From 047beadd9ddebbf2b9b44d27055d1b30a8af3e7b Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 4 Dec 2023 12:36:50 +0200 Subject: [PATCH 01/20] feat: signals plugins next try after #18153 without any external dependencies and only supporting websocket. missing tests and necessary documentation but will work on those after initial comments if this would be proper way to go forward. already planning for the notification plugins on top of this. Signed-off-by: Heikki Hellgren --- .changeset/real-eggs-sip.md | 7 + packages/backend/package.json | 2 + packages/backend/src/index.ts | 11 +- packages/backend/src/plugins/signals.ts | 39 +++ .../Content/InfoContent/InfoContent.tsx | 13 + plugins/signals-backend/.eslintrc.js | 1 + plugins/signals-backend/README.md | 31 +++ plugins/signals-backend/api-report.md | 22 ++ plugins/signals-backend/catalog-info.yaml | 9 + plugins/signals-backend/package.json | 50 ++++ plugins/signals-backend/src/index.ts | 16 ++ plugins/signals-backend/src/run.ts | 32 +++ .../src/service/router.test.ts | 48 ++++ plugins/signals-backend/src/service/router.ts | 57 +++++ .../src/service/standaloneServer.ts | 69 ++++++ plugins/signals-backend/src/setupTests.ts | 16 ++ plugins/signals-node/.eslintrc.js | 1 + plugins/signals-node/README.md | 5 + plugins/signals-node/api-report.md | 47 ++++ plugins/signals-node/catalog-info.yaml | 10 + plugins/signals-node/package.json | 42 ++++ plugins/signals-node/src/SignalsService.ts | 226 ++++++++++++++++++ plugins/signals-node/src/index.ts | 18 ++ plugins/signals-node/src/setupTests.ts | 16 ++ plugins/signals-node/src/types.ts | 39 +++ plugins/signals-react/.eslintrc.js | 1 + plugins/signals-react/README.md | 5 + plugins/signals-react/api-report.md | 45 ++++ plugins/signals-react/catalog-info.yaml | 10 + plugins/signals-react/package.json | 43 ++++ plugins/signals-react/src/api/SignalsApi.ts | 32 +++ .../signals-react/src/api/SignalsClient.ts | 133 +++++++++++ plugins/signals-react/src/api/index.ts | 17 ++ plugins/signals-react/src/hooks/index.ts | 16 ++ .../signals-react/src/hooks/useSignalsApi.ts | 37 +++ plugins/signals-react/src/index.ts | 18 ++ plugins/signals-react/src/setupTests.ts | 16 ++ yarn.lock | 115 ++++++++- 38 files changed, 1308 insertions(+), 7 deletions(-) create mode 100644 .changeset/real-eggs-sip.md create mode 100644 packages/backend/src/plugins/signals.ts create mode 100644 plugins/signals-backend/.eslintrc.js create mode 100644 plugins/signals-backend/README.md create mode 100644 plugins/signals-backend/api-report.md create mode 100644 plugins/signals-backend/catalog-info.yaml create mode 100644 plugins/signals-backend/package.json create mode 100644 plugins/signals-backend/src/index.ts create mode 100644 plugins/signals-backend/src/run.ts create mode 100644 plugins/signals-backend/src/service/router.test.ts create mode 100644 plugins/signals-backend/src/service/router.ts create mode 100644 plugins/signals-backend/src/service/standaloneServer.ts create mode 100644 plugins/signals-backend/src/setupTests.ts create mode 100644 plugins/signals-node/.eslintrc.js create mode 100644 plugins/signals-node/README.md create mode 100644 plugins/signals-node/api-report.md create mode 100644 plugins/signals-node/catalog-info.yaml create mode 100644 plugins/signals-node/package.json create mode 100644 plugins/signals-node/src/SignalsService.ts create mode 100644 plugins/signals-node/src/index.ts create mode 100644 plugins/signals-node/src/setupTests.ts create mode 100644 plugins/signals-node/src/types.ts create mode 100644 plugins/signals-react/.eslintrc.js create mode 100644 plugins/signals-react/README.md create mode 100644 plugins/signals-react/api-report.md create mode 100644 plugins/signals-react/catalog-info.yaml create mode 100644 plugins/signals-react/package.json create mode 100644 plugins/signals-react/src/api/SignalsApi.ts create mode 100644 plugins/signals-react/src/api/SignalsClient.ts create mode 100644 plugins/signals-react/src/api/index.ts create mode 100644 plugins/signals-react/src/hooks/index.ts create mode 100644 plugins/signals-react/src/hooks/useSignalsApi.ts create mode 100644 plugins/signals-react/src/index.ts create mode 100644 plugins/signals-react/src/setupTests.ts diff --git a/.changeset/real-eggs-sip.md b/.changeset/real-eggs-sip.md new file mode 100644 index 0000000000..ca05205083 --- /dev/null +++ b/.changeset/real-eggs-sip.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-signals-backend': patch +'@backstage/plugin-signals-react': patch +'@backstage/plugin-signals-node': patch +--- + +Add support to subscribe and publish messages through signals plugins diff --git a/packages/backend/package.json b/packages/backend/package.json index a70e5b5d3b..8899115c17 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -72,6 +72,8 @@ "@backstage/plugin-search-backend-module-techdocs": "workspace:^", "@backstage/plugin-search-backend-node": "workspace:^", "@backstage/plugin-search-common": "workspace:^", + "@backstage/plugin-signals-backend": "workspace:^", + "@backstage/plugin-signals-node": "workspace:^", "@backstage/plugin-tech-insights-backend": "workspace:^", "@backstage/plugin-tech-insights-backend-module-jsonfc": "workspace:^", "@backstage/plugin-tech-insights-node": "workspace:^", diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 8df01b050e..cefc272aa7 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -26,19 +26,19 @@ import Router from 'express-promise-router'; import { CacheManager, createServiceBuilder, + DatabaseManager, getRootLogger, + HostDiscovery, loadBackendConfig, notFoundHandler, - DatabaseManager, - HostDiscovery, + ServerTokenManager, UrlReaders, useHotMemoize, - ServerTokenManager, } from '@backstage/backend-common'; import { TaskScheduler } from '@backstage/backend-tasks'; import { Config } from '@backstage/config'; import healthcheck from './plugins/healthcheck'; -import { metricsInit, metricsHandler } from './metrics'; +import { metricsHandler, metricsInit } from './metrics'; import auth from './plugins/auth'; import azureDevOps from './plugins/azure-devops'; import catalog from './plugins/catalog'; @@ -65,6 +65,7 @@ import lighthouse from './plugins/lighthouse'; import linguist from './plugins/linguist'; import devTools from './plugins/devtools'; import nomad from './plugins/nomad'; +import signals from './plugins/signals'; import { PluginEnvironment } from './types'; import { ServerPermissionClient } from '@backstage/plugin-permission-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; @@ -172,6 +173,7 @@ async function main() { const linguistEnv = useHotMemoize(module, () => createEnv('linguist')); const devToolsEnv = useHotMemoize(module, () => createEnv('devtools')); const nomadEnv = useHotMemoize(module, () => createEnv('nomad')); + const signalsEnv = useHotMemoize(module, () => createEnv('signals')); const apiRouter = Router(); apiRouter.use('/catalog', await catalog(catalogEnv)); @@ -198,6 +200,7 @@ async function main() { apiRouter.use('/linguist', await linguist(linguistEnv)); apiRouter.use('/devtools', await devTools(devToolsEnv)); apiRouter.use('/nomad', await nomad(nomadEnv)); + apiRouter.use('/signals', await signals(signalsEnv)); apiRouter.use(notFoundHandler()); await lighthouse(lighthouseEnv); diff --git a/packages/backend/src/plugins/signals.ts b/packages/backend/src/plugins/signals.ts new file mode 100644 index 0000000000..8e2d0b2d3f --- /dev/null +++ b/packages/backend/src/plugins/signals.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Router } from 'express'; +import { createRouter } from '@backstage/plugin-signals-backend'; +import { SignalsService } from '@backstage/plugin-signals-node'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const service = SignalsService.create({ + logger: env.logger, + identity: env.identity, + eventBroker: env.eventBroker, + }); + + setInterval(() => { + console.log('publishing'); + service.publish('*', { hello: 'world' }); + }, 5000); + + return await createRouter({ + logger: env.logger, + service, + }); +} diff --git a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx index 0ed43d2fee..810a823027 100644 --- a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx +++ b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx @@ -27,6 +27,7 @@ import { makeStyles, Paper, Theme, + Typography, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; import React from 'react'; @@ -38,6 +39,7 @@ import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard'; import { BackstageLogoIcon } from './BackstageLogoIcon'; import FileCopyIcon from '@material-ui/icons/FileCopy'; import { DevToolsInfo } from '@backstage/plugin-devtools-common'; +import { useSignalsApi } from '@backstage/plugin-signals-react'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -73,6 +75,12 @@ const copyToClipboard = ({ about }: { about: DevToolsInfo | undefined }) => { export const InfoContent = () => { const classes = useStyles(); const { about, loading, error } = useInfo(); + // Just testing for signals + const [messages, setMessages] = React.useState([]); + useSignalsApi(message => { + messages.push(JSON.stringify(message)); + setMessages([...messages]); + }); if (loading) { return ; @@ -81,6 +89,11 @@ export const InfoContent = () => { } return ( + + {messages.map((msg, i) => { + return {msg}; + })} + diff --git a/plugins/signals-backend/.eslintrc.js b/plugins/signals-backend/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/signals-backend/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/signals-backend/README.md b/plugins/signals-backend/README.md new file mode 100644 index 0000000000..d194b852f2 --- /dev/null +++ b/plugins/signals-backend/README.md @@ -0,0 +1,31 @@ +# signals + +Welcome to the signals backend plugin! + +Signals plugin allows backend plugins to publish messages to frontend plugins. + +## Getting started + +Add Signals router to your backend in `packages/backend/src/plugins/signals.ts`: + +```ts +import { Router } from 'express'; +import { createRouter } from '@backstage/plugin-signals-backend'; +import { SignalsService } from '@backstage/plugin-signals-node'; +import { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + const service = SignalsService.create({ + logger: env.logger, + identity: env.identity, + eventBroker: env.eventBroker, + }); + + return await createRouter({ + logger: env.logger, + service, + }); +} +``` diff --git a/plugins/signals-backend/api-report.md b/plugins/signals-backend/api-report.md new file mode 100644 index 0000000000..0ea56c707a --- /dev/null +++ b/plugins/signals-backend/api-report.md @@ -0,0 +1,22 @@ +## API Report File for "@backstage/plugin-signals-backend" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import express from 'express'; +import { Logger } from 'winston'; +import { SignalsService } from '@backstage/plugin-signals-node'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + logger: Logger; + // (undocumented) + service: SignalsService; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/signals-backend/catalog-info.yaml b/plugins/signals-backend/catalog-info.yaml new file mode 100644 index 0000000000..0a025b77b5 --- /dev/null +++ b/plugins/signals-backend/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-signals-backend + title: '@backstage/plugin-signals-backend' +spec: + lifecycle: experimental + type: backstage-backend-plugin + owner: maintainers diff --git a/plugins/signals-backend/package.json b/plugins/signals-backend/package.json new file mode 100644 index 0000000000..ee303cdf3b --- /dev/null +++ b/plugins/signals-backend/package.json @@ -0,0 +1,50 @@ +{ + "name": "@backstage/plugin-signals-backend", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "backend-plugin" + }, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-events-node": "workspace:^", + "@backstage/plugin-signals-node": "workspace:^", + "@backstage/types": "workspace:^", + "@types/express": "*", + "express": "^4.17.1", + "express-promise-router": "^4.1.0", + "http-proxy-middleware": "^2.0.0", + "node-fetch": "^2.6.7", + "uuid": "^8.0.0", + "winston": "^3.2.1", + "ws": "^8.14.2", + "yn": "^4.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/supertest": "^2.0.8", + "msw": "^1.0.0", + "supertest": "^6.2.4" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/signals-backend/src/index.ts b/plugins/signals-backend/src/index.ts new file mode 100644 index 0000000000..d2e8d61bad --- /dev/null +++ b/plugins/signals-backend/src/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './service/router'; diff --git a/plugins/signals-backend/src/run.ts b/plugins/signals-backend/src/run.ts new file mode 100644 index 0000000000..d299ed23e9 --- /dev/null +++ b/plugins/signals-backend/src/run.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getRootLogger } from '@backstage/backend-common'; +import yn from 'yn'; +import { startStandaloneServer } from './service/standaloneServer'; + +const port = process.env.PLUGIN_PORT ? Number(process.env.PLUGIN_PORT) : 7007; +const enableCors = yn(process.env.PLUGIN_CORS, { default: false }); +const logger = getRootLogger(); + +startStandaloneServer({ port, enableCors, logger }).catch(err => { + logger.error(err); + process.exit(1); +}); + +process.on('SIGINT', () => { + logger.info('CTRL+C pressed; exiting.'); + process.exit(0); +}); diff --git a/plugins/signals-backend/src/service/router.test.ts b/plugins/signals-backend/src/service/router.test.ts new file mode 100644 index 0000000000..d7091827f9 --- /dev/null +++ b/plugins/signals-backend/src/service/router.test.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; +import { SignalsService } from '@backstage/plugin-signals-node'; + +const signalsServiceMock: jest.Mocked = {} as any; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + service: signalsServiceMock, + }); + app = express().use(router); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('GET /health', () => { + it('returns ok', async () => { + const response = await request(app).get('/health'); + + expect(response.status).toEqual(200); + expect(response.body).toEqual({ status: 'ok' }); + }); + }); +}); diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts new file mode 100644 index 0000000000..0027acef30 --- /dev/null +++ b/plugins/signals-backend/src/service/router.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { errorHandler } from '@backstage/backend-common'; +import express from 'express'; +import Router from 'express-promise-router'; +import { Logger } from 'winston'; +import { SignalsService } from '@backstage/plugin-signals-node'; + +/** @public */ +export interface RouterOptions { + logger: Logger; + service: SignalsService; +} + +/** @public */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, service } = options; + + const router = Router(); + router.use(express.json()); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); + }); + + router.get('/', async (req, _, next) => { + if ( + !req.headers || + req.headers.upgrade === undefined || + req.headers.upgrade.toLowerCase() !== 'websocket' + ) { + next(); + return; + } + + await service.handleUpgrade(req); + }); + + router.use(errorHandler()); + return router; +} diff --git a/plugins/signals-backend/src/service/standaloneServer.ts b/plugins/signals-backend/src/service/standaloneServer.ts new file mode 100644 index 0000000000..c13d718734 --- /dev/null +++ b/plugins/signals-backend/src/service/standaloneServer.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createServiceBuilder, + HostDiscovery, + loadBackendConfig, +} from '@backstage/backend-common'; +import { Server } from 'http'; +import { Logger } from 'winston'; +import { createRouter } from './router'; +import { SignalsService } from '@backstage/plugin-signals-node'; +import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; + +export interface ServerOptions { + port: number; + enableCors: boolean; + logger: Logger; +} + +export async function startStandaloneServer( + options: ServerOptions, +): Promise { + const logger = options.logger.child({ service: 'signals-backend' }); + logger.debug('Starting application server...'); + const config = await loadBackendConfig({ logger, argv: process.argv }); + const discovery = HostDiscovery.fromConfig(config); + + const identity = DefaultIdentityClient.create({ + discovery, + issuer: await discovery.getExternalBaseUrl('auth'), + }); + + const signals = SignalsService.create({ + logger: logger, + identity, + }); + + const router = await createRouter({ + logger, + service: signals, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/signals', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + return await service.start().catch(err => { + logger.error(err); + process.exit(1); + }); +} + +module.hot?.accept(); diff --git a/plugins/signals-backend/src/setupTests.ts b/plugins/signals-backend/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/signals-backend/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/signals-node/.eslintrc.js b/plugins/signals-node/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/signals-node/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/signals-node/README.md b/plugins/signals-node/README.md new file mode 100644 index 0000000000..55d766f9e0 --- /dev/null +++ b/plugins/signals-node/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-signals-node + +Welcome to the Node.js library package for the signals plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md new file mode 100644 index 0000000000..e8c9900908 --- /dev/null +++ b/plugins/signals-node/api-report.md @@ -0,0 +1,47 @@ +## API Report File for "@backstage/plugin-signals-node" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { EventBroker } from '@backstage/plugin-events-node'; +import { EventParams } from '@backstage/plugin-events-node'; +import { EventSubscriber } from '@backstage/plugin-events-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { JsonObject } from '@backstage/types'; +import { Logger } from 'winston'; +import { Request as Request_2 } from 'express'; + +// @public (undocumented) +export type ServiceOptions = { + eventBroker?: EventBroker; + logger: Logger; + identity: IdentityApi; +}; + +// @public (undocumented) +export type SignalsEventBrokerPayload = { + recipients?: string[]; + topic?: string; + message?: JsonObject; +}; + +// @public (undocumented) +export class SignalsService implements EventSubscriber { + // (undocumented) + static create(options: ServiceOptions): SignalsService; + // (undocumented) + handleUpgrade: (req: Request_2) => Promise; + // (undocumented) + onEvent(params: EventParams): Promise; + // (undocumented) + publish( + to: string | string[], + message: JsonObject, + topic?: string, + ): Promise; + // (undocumented) + supportsEventTopics(): string[]; +} + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/signals-node/catalog-info.yaml b/plugins/signals-node/catalog-info.yaml new file mode 100644 index 0000000000..5e3af0acde --- /dev/null +++ b/plugins/signals-node/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-signals-node + title: '@backstage/plugin-signals-node' + description: Node.js library for the signals plugin +spec: + lifecycle: experimental + type: backstage-node-library + owner: maintainers diff --git a/plugins/signals-node/package.json b/plugins/signals-node/package.json new file mode 100644 index 0000000000..66e4528678 --- /dev/null +++ b/plugins/signals-node/package.json @@ -0,0 +1,42 @@ +{ + "name": "@backstage/plugin-signals-node", + "description": "Node.js library for the signals plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "node-library" + }, + "scripts": { + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@types/express": "^4.17.21" + }, + "files": [ + "dist" + ], + "dependencies": { + "@backstage/backend-common": "workspace:^", + "@backstage/config": "workspace:^", + "@backstage/plugin-auth-node": "workspace:^", + "@backstage/plugin-events-node": "workspace:^", + "@backstage/types": "workspace:^", + "express": "^4.17.1", + "uuid": "^8.0.0", + "winston": "^3.2.1", + "ws": "^8.14.2" + } +} diff --git a/plugins/signals-node/src/SignalsService.ts b/plugins/signals-node/src/SignalsService.ts new file mode 100644 index 0000000000..6404386129 --- /dev/null +++ b/plugins/signals-node/src/SignalsService.ts @@ -0,0 +1,226 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + EventBroker, + EventParams, + EventSubscriber, +} from '@backstage/plugin-events-node'; +import { Logger } from 'winston'; +import { ServiceOptions, SignalConnection } from './types'; +import { RawData, WebSocket, WebSocketServer } from 'ws'; +import { IncomingMessage } from 'http'; +import { v4 as uuid } from 'uuid'; +import { Request } from 'express'; +import { JsonObject } from '@backstage/types'; +import { + BackstageIdentityResponse, + IdentityApi, +} from '@backstage/plugin-auth-node'; + +/** @public */ +export type SignalsEventBrokerPayload = { + recipients?: string[]; + topic?: string; + message?: JsonObject; +}; + +/** @public */ +export class SignalsService implements EventSubscriber { + private readonly serverId: string; + private connections: Map = new Map< + string, + SignalConnection + >(); + private eventBroker?: EventBroker; + private logger: Logger; + private identity: IdentityApi; + private server: WebSocketServer; + + static create(options: ServiceOptions) { + return new SignalsService(options); + } + + private constructor(options: ServiceOptions) { + ({ + eventBroker: this.eventBroker, + logger: this.logger, + identity: this.identity, + } = options); + + this.serverId = uuid(); + this.server = new WebSocketServer({ + noServer: true, + }); + + this.server.on('close', () => { + this.logger.info('Closing signals server'); + this.connections.forEach(conn => { + conn.ws.close(); + }); + this.connections = new Map(); + }); + + this.eventBroker?.subscribe(this); + } + + handleUpgrade = async (req: Request) => { + const identity = await this.identity.getIdentity({ + request: req, + }); + + this.server.handleUpgrade( + req, + req.socket, + Buffer.from(''), + (ws: WebSocket, __: IncomingMessage) => { + this.addConnection(ws, identity); + }, + ); + }; + + private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) { + const id = uuid(); + + const conn = { + id, + user: identity?.identity.userEntityRef ?? 'user:default/guest', + ws, + ownershipEntityRefs: identity?.identity.ownershipEntityRefs ?? [], + subscriptions: new Set(), + }; + + this.connections.set(id, conn); + + ws.on('error', (err: Error) => { + this.logger.info( + `Error occurred with connection ${id}: ${err}, closing connection`, + ); + ws.close(); + this.connections.delete(id); + }); + + ws.on('close', (code: number, reason: Buffer) => { + this.logger.info( + `Connection ${id} closed with code ${code}, reason: ${reason}`, + ); + this.connections.delete(id); + }); + + ws.on('ping', () => { + this.logger.debug(`Ping from connection ${id}`); + ws.pong(); + }); + + ws.on('message', (data: RawData, isBinary: boolean) => { + this.logger.debug(`Received message from connection ${id}: ${data}`); + if (isBinary) { + return; + } + + try { + const json = JSON.parse(data.toString()) as JsonObject; + this.handleMessage(conn, json); + } catch (err: any) { + this.logger.error( + `Invalid message received from connection ${id}: ${err}`, + ); + } + }); + } + + private handleMessage(connection: SignalConnection, message: JsonObject) { + if (message.action === 'subscribe' && message.topic) { + this.logger.info( + `Connection ${connection.id} subscribed to ${message.topic}`, + ); + connection.subscriptions.add(message.topic as string); + } + + if (message.action === 'unsubscribe' && message.topic) { + this.logger.info( + `Connection ${connection.id} unsubscribed from ${message.topic}`, + ); + connection.subscriptions.delete(message.topic as string); + } + } + + async publish(to: string | string[], message: JsonObject, topic?: string) { + await this.publishInternal( + Array.isArray(to) ? to : [to], + message, + false, + topic, + ); + } + + private async publishInternal( + recipients: string[], + message: JsonObject, + brokedEvent: boolean, + topic?: string, + ) { + this.connections.forEach(conn => { + if (topic && !conn.subscriptions.has(topic)) { + return; + } + // Sending to all users can be done with '*' + if ( + !recipients.includes('*') && + !conn.ownershipEntityRefs.some(ref => recipients.includes(ref)) + ) { + return; + } + conn.ws.send(JSON.stringify({ topic, message })); + }); + + // If this event has not been broadcasted to all servers, then use + // EventBroker to do that + if (this.eventBroker && !brokedEvent) { + await this.eventBroker.publish({ + topic: 'signals', + eventPayload: { + recipients, + message, + topic, + }, + metadata: { server: this.serverId }, + }); + } + } + + async onEvent(params: EventParams): Promise { + const { eventPayload, metadata } = params; + // Discard message from same server to prevent duplicate messages + if (!metadata?.server || metadata.server === this.serverId) { + return; + } + + if (!eventPayload?.recipients || !eventPayload.message) { + return; + } + + await this.publishInternal( + eventPayload.recipients, + eventPayload.message, + true, + eventPayload.topic, + ); + } + + supportsEventTopics(): string[] { + return ['signals']; + } +} diff --git a/plugins/signals-node/src/index.ts b/plugins/signals-node/src/index.ts new file mode 100644 index 0000000000..5427711cbe --- /dev/null +++ b/plugins/signals-node/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './SignalsService'; +export * from './types'; diff --git a/plugins/signals-node/src/setupTests.ts b/plugins/signals-node/src/setupTests.ts new file mode 100644 index 0000000000..4b9026cde5 --- /dev/null +++ b/plugins/signals-node/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export {}; diff --git a/plugins/signals-node/src/types.ts b/plugins/signals-node/src/types.ts new file mode 100644 index 0000000000..c53bfdb20e --- /dev/null +++ b/plugins/signals-node/src/types.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { EventBroker } from '@backstage/plugin-events-node'; +import { Logger } from 'winston'; +import { WebSocket } from 'ws'; + +/** + * @public + */ +export type ServiceOptions = { + eventBroker?: EventBroker; + logger: Logger; + identity: IdentityApi; +}; + +/** + * @internal + */ +export type SignalConnection = { + id: string; + user: string; + ws: WebSocket; + ownershipEntityRefs: string[]; + subscriptions: Set; +}; diff --git a/plugins/signals-react/.eslintrc.js b/plugins/signals-react/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/signals-react/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/signals-react/README.md b/plugins/signals-react/README.md new file mode 100644 index 0000000000..4b6b4e00bc --- /dev/null +++ b/plugins/signals-react/README.md @@ -0,0 +1,5 @@ +# @backstage/plugin-signals-react + +Welcome to the web library package for the signals plugin! + +_This plugin was created through the Backstage CLI_ diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md new file mode 100644 index 0000000000..14d78d4517 --- /dev/null +++ b/plugins/signals-react/api-report.md @@ -0,0 +1,45 @@ +## API Report File for "@backstage/plugin-signals-react" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { ApiRef } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { JSONObject } from '@apollo/explorer/src/helpers/types'; +import { JsonObject } from '@backstage/types'; + +// @public (undocumented) +export type SignalsApi = { + subscribe( + onMessage: (message: JsonObject, topic?: string) => void, + topic?: string, + ): void; + unsubscribe(topic?: string): void; +}; + +// @public (undocumented) +export const signalsApiRef: ApiRef; + +// @public (undocumented) +export class SignalsClient implements SignalsApi { + // (undocumented) + static create(options: { discoveryApi: DiscoveryApi }): SignalsClient; + // (undocumented) + static instance: SignalsClient | null; + // (undocumented) + subscribe( + onMessage: (message: JsonObject, topic?: string) => void, + topic?: string, + ): void; + // (undocumented) + unsubscribe(topic?: string): void; +} + +// @public (undocumented) +export const useSignalsApi: ( + onMessage: (message: JSONObject) => void, + topic?: string, +) => void; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/signals-react/catalog-info.yaml b/plugins/signals-react/catalog-info.yaml new file mode 100644 index 0000000000..ac8093ed0c --- /dev/null +++ b/plugins/signals-react/catalog-info.yaml @@ -0,0 +1,10 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-signals-react + title: '@backstage/plugin-signals-react' + description: Web library for the signals plugin +spec: + lifecycle: experimental + type: backstage-web-library + owner: maintainers diff --git a/plugins/signals-react/package.json b/plugins/signals-react/package.json new file mode 100644 index 0000000000..148bc27cb3 --- /dev/null +++ b/plugins/signals-react/package.json @@ -0,0 +1,43 @@ +{ + "name": "@backstage/plugin-signals-react", + "description": "Web library for the signals plugin", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "web-library" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-plugin-api": "workspace:^", + "@backstage/types": "workspace:^", + "@material-ui/core": "^4.9.13" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/signals-react/src/api/SignalsApi.ts b/plugins/signals-react/src/api/SignalsApi.ts new file mode 100644 index 0000000000..685cd560aa --- /dev/null +++ b/plugins/signals-react/src/api/SignalsApi.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createApiRef } from '@backstage/core-plugin-api'; +import { JsonObject } from '@backstage/types'; + +/** @public */ +export const signalsApiRef = createApiRef({ + id: 'plugin.signals.service', +}); + +/** @public */ +export type SignalsApi = { + subscribe( + onMessage: (message: JsonObject, topic?: string) => void, + topic?: string, + ): void; + + unsubscribe(topic?: string): void; +}; diff --git a/plugins/signals-react/src/api/SignalsClient.ts b/plugins/signals-react/src/api/SignalsClient.ts new file mode 100644 index 0000000000..71984a5c23 --- /dev/null +++ b/plugins/signals-react/src/api/SignalsClient.ts @@ -0,0 +1,133 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SignalsApi } from './SignalsApi'; +import { JsonObject } from '@backstage/types'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; + +/** @public */ +export class SignalsClient implements SignalsApi { + static instance: SignalsClient | null = null; + private ws: WebSocket | null = null; + private discoveryApi: DiscoveryApi; + private cbs: Map void> = + new Map(); + private queue: JsonObject[] = []; + private reconnectTimeout: any; + + static create(options: { discoveryApi: DiscoveryApi }) { + if (!SignalsClient.instance) { + SignalsClient.instance = new SignalsClient(options); + } + return SignalsClient.instance; + } + + private constructor(options: { discoveryApi: DiscoveryApi }) { + this.discoveryApi = options.discoveryApi; + } + + subscribe( + onMessage: (message: JsonObject, topic?: string) => void, + topic?: string, + ): void { + const subscriptionTopic = topic ?? '*'; + // Do not allow to subscribe to same topic multiple times + if (this.cbs.has(subscriptionTopic)) { + return; + } + + this.cbs.set(subscriptionTopic, onMessage); + this.connect().then(() => { + this.send({ action: 'subscribe', topic }); + }); + } + + unsubscribe(topic?: string): void { + const subscriptionTopic = topic ?? '*'; + this.cbs.delete(subscriptionTopic); + this.send({ action: 'unsubscribe', topic }); + } + + private send(data?: JsonObject): void { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + if (data) { + this.queue.push(data); + } + return; + } + + // First send queue + for (const msg of this.queue) { + this.ws!.send(JSON.stringify(msg)); + } + this.queue = []; + if (data) { + this.ws!.send(JSON.stringify(data)); + } + } + + private async connect() { + if (this.ws) { + return; + } + + const apiUrl = `${await this.discoveryApi.getBaseUrl('signals')}`; + const url = new URL(apiUrl); + url.protocol = url.protocol === 'http:' ? 'ws' : 'wss'; + this.ws = new WebSocket(url.toString()); + + this.ws.onmessage = (data: MessageEvent) => { + try { + const json = JSON.parse(data.data) as JsonObject; + let cb = this.cbs.get('*'); + if (json.topic) { + cb = this.cbs.get(json.topic as string); + } + if (cb) { + cb(json.message as JsonObject, json.topic as string); + } + } catch (e) { + // NOOP + } + }; + + this.ws.onerror = () => { + this.reconnect(); + }; + + this.ws.onclose = () => { + this.reconnect(); + }; + + while (this.ws.readyState !== WebSocket.OPEN) { + await new Promise(r => setTimeout(r, 10)); + } + this.send(); + } + + private reconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + + this.reconnectTimeout = setTimeout(() => { + if (this.ws) { + this.ws.close(); + } + this.ws = null; + this.connect(); + }, 5000); + } +} diff --git a/plugins/signals-react/src/api/index.ts b/plugins/signals-react/src/api/index.ts new file mode 100644 index 0000000000..b8dea2af34 --- /dev/null +++ b/plugins/signals-react/src/api/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './SignalsApi'; +export * from './SignalsClient'; diff --git a/plugins/signals-react/src/hooks/index.ts b/plugins/signals-react/src/hooks/index.ts new file mode 100644 index 0000000000..0d5967f9eb --- /dev/null +++ b/plugins/signals-react/src/hooks/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './useSignalsApi'; diff --git a/plugins/signals-react/src/hooks/useSignalsApi.ts b/plugins/signals-react/src/hooks/useSignalsApi.ts new file mode 100644 index 0000000000..3bfbba5d71 --- /dev/null +++ b/plugins/signals-react/src/hooks/useSignalsApi.ts @@ -0,0 +1,37 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SignalsClient } from '../api'; +import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; +import { JSONObject } from '@apollo/explorer/src/helpers/types'; +import { useEffect } from 'react'; + +/** @public */ +export const useSignalsApi = ( + onMessage: (message: JSONObject) => void, + topic?: string, +) => { + const discovery = useApi(discoveryApiRef); + const signals = SignalsClient.create({ discoveryApi: discovery }); + useEffect(() => { + signals.subscribe(onMessage, topic); + }, [signals, onMessage, topic]); + + useEffect(() => { + return () => { + signals.unsubscribe(topic); + }; + }, [signals, topic]); +}; diff --git a/plugins/signals-react/src/index.ts b/plugins/signals-react/src/index.ts new file mode 100644 index 0000000000..0f44901352 --- /dev/null +++ b/plugins/signals-react/src/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export * from './api'; +export * from './hooks'; diff --git a/plugins/signals-react/src/setupTests.ts b/plugins/signals-react/src/setupTests.ts new file mode 100644 index 0000000000..865308e634 --- /dev/null +++ b/plugins/signals-react/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index 2d59eecaac..cdd232b103 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"@adobe/css-tools@npm:^4.3.2": +"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.2": version: 4.3.2 resolution: "@adobe/css-tools@npm:4.3.2" checksum: 9667d61d55dc3b0a315c530ae84e016ce5267c4dd8ac00abb40108dc98e07b98e3090ce8b87acd51a41a68d9e84dcccb08cdf21c902572a9cf9dcaf830da4ae3 @@ -8833,6 +8833,66 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-signals-backend@workspace:^, @backstage/plugin-signals-backend@workspace:plugins/signals-backend": + version: 0.0.0-use.local + resolution: "@backstage/plugin-signals-backend@workspace:plugins/signals-backend" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-events-node": "workspace:^" + "@backstage/plugin-signals-node": "workspace:^" + "@backstage/types": "workspace:^" + "@types/express": "*" + "@types/supertest": ^2.0.8 + express: ^4.17.1 + express-promise-router: ^4.1.0 + http-proxy-middleware: ^2.0.0 + msw: ^1.0.0 + node-fetch: ^2.6.7 + supertest: ^6.2.4 + uuid: ^8.0.0 + winston: ^3.2.1 + ws: ^8.14.2 + yn: ^4.0.0 + languageName: unknown + linkType: soft + +"@backstage/plugin-signals-node@workspace:^, @backstage/plugin-signals-node@workspace:plugins/signals-node": + version: 0.0.0-use.local + resolution: "@backstage/plugin-signals-node@workspace:plugins/signals-node" + dependencies: + "@backstage/backend-common": "workspace:^" + "@backstage/cli": "workspace:^" + "@backstage/config": "workspace:^" + "@backstage/plugin-auth-node": "workspace:^" + "@backstage/plugin-events-node": "workspace:^" + "@backstage/types": "workspace:^" + "@types/express": ^4.17.21 + express: ^4.17.1 + uuid: ^8.0.0 + winston: ^3.2.1 + ws: ^8.14.2 + languageName: unknown + linkType: soft + +"@backstage/plugin-signals-react@workspace:plugins/signals-react": + version: 0.0.0-use.local + resolution: "@backstage/plugin-signals-react@workspace:plugins/signals-react" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@material-ui/core": ^4.9.13 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-sonarqube-backend@workspace:^, @backstage/plugin-sonarqube-backend@workspace:plugins/sonarqube-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-sonarqube-backend@workspace:plugins/sonarqube-backend" @@ -17125,6 +17185,22 @@ __metadata: languageName: unknown linkType: soft +"@testing-library/dom@npm:^8.0.0": + version: 8.20.1 + resolution: "@testing-library/dom@npm:8.20.1" + dependencies: + "@babel/code-frame": ^7.10.4 + "@babel/runtime": ^7.12.5 + "@types/aria-query": ^5.0.1 + aria-query: 5.1.3 + chalk: ^4.1.0 + dom-accessibility-api: ^0.5.9 + lz-string: ^1.5.0 + pretty-format: ^27.0.2 + checksum: 06fc8dc67849aadb726cbbad0e7546afdf8923bd39acb64c576d706249bd7d0d05f08e08a31913fb621162e3b9c2bd0dce15964437f030f9fa4476326fdd3007 + languageName: node + linkType: hard + "@testing-library/dom@npm:^9.0.0": version: 9.3.3 resolution: "@testing-library/dom@npm:9.3.3" @@ -17141,6 +17217,23 @@ __metadata: languageName: node linkType: hard +"@testing-library/jest-dom@npm:^5.10.1": + version: 5.17.0 + resolution: "@testing-library/jest-dom@npm:5.17.0" + dependencies: + "@adobe/css-tools": ^4.0.1 + "@babel/runtime": ^7.9.2 + "@types/testing-library__jest-dom": ^5.9.1 + aria-query: ^5.0.0 + chalk: ^3.0.0 + css.escape: ^1.5.1 + dom-accessibility-api: ^0.5.6 + lodash: ^4.17.15 + redent: ^3.0.0 + checksum: 9f28dbca8b50d7c306aae40c3aa8e06f0e115f740360004bd87d57f95acf7ab4b4f4122a7399a76dbf2bdaaafb15c99cc137fdcb0ae457a92e2de0f3fbf9b03b + languageName: node + linkType: hard + "@testing-library/jest-dom@npm:^6.0.0": version: 6.1.6 resolution: "@testing-library/jest-dom@npm:6.1.6" @@ -17193,6 +17286,20 @@ __metadata: languageName: node linkType: hard +"@testing-library/react@npm:^12.1.3": + version: 12.1.5 + resolution: "@testing-library/react@npm:12.1.5" + dependencies: + "@babel/runtime": ^7.12.5 + "@testing-library/dom": ^8.0.0 + "@types/react-dom": <18.0.0 + peerDependencies: + react: <18.0.0 + react-dom: <18.0.0 + checksum: 4abd0490405e709a7df584a0db604e508a4612398bb1326e8fa32dd9393b15badc826dcf6d2f7525437886d507871f719f127b9860ed69ddd204d1fa834f576a + languageName: node + linkType: hard + "@testing-library/react@npm:^14.0.0": version: 14.1.2 resolution: "@testing-library/react@npm:14.1.2" @@ -17884,7 +17991,7 @@ __metadata: languageName: node linkType: hard -"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.17.17, @types/express@npm:^4.17.6": +"@types/express@npm:*, @types/express@npm:^4.17.13, @types/express@npm:^4.17.17, @types/express@npm:^4.17.21, @types/express@npm:^4.17.6": version: 4.17.21 resolution: "@types/express@npm:4.17.21" dependencies: @@ -26588,6 +26695,8 @@ __metadata: "@backstage/plugin-search-backend-module-techdocs": "workspace:^" "@backstage/plugin-search-backend-node": "workspace:^" "@backstage/plugin-search-common": "workspace:^" + "@backstage/plugin-signals-backend": "workspace:^" + "@backstage/plugin-signals-node": "workspace:^" "@backstage/plugin-tech-insights-backend": "workspace:^" "@backstage/plugin-tech-insights-backend-module-jsonfc": "workspace:^" "@backstage/plugin-tech-insights-node": "workspace:^" @@ -44545,7 +44654,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:*, ws@npm:8.14.2, ws@npm:^8.11.0, ws@npm:^8.12.0, ws@npm:^8.13.0, ws@npm:^8.8.0": +"ws@npm:*, ws@npm:8.14.2, ws@npm:^8.11.0, ws@npm:^8.12.0, ws@npm:^8.13.0, ws@npm:^8.14.2, ws@npm:^8.8.0": version: 8.14.2 resolution: "ws@npm:8.14.2" peerDependencies: From 01d02b0d3b08f28fe69bfbe6b77a46cc234e24a3 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 4 Dec 2023 14:21:40 +0200 Subject: [PATCH 02/20] chore: make topic mandatory for signals Signed-off-by: Heikki Hellgren --- packages/backend/src/plugins/signals.ts | 2 +- .../Content/InfoContent/InfoContent.tsx | 2 +- plugins/signals-node/api-report.md | 4 +-- plugins/signals-node/src/SignalsService.ts | 27 +++++++++++---- plugins/signals-react/api-report.md | 13 +++---- plugins/signals-react/src/api/SignalsApi.ts | 4 +-- .../signals-react/src/api/SignalsClient.ts | 34 +++++++++---------- .../signals-react/src/hooks/useSignalsApi.ts | 2 +- 8 files changed, 48 insertions(+), 40 deletions(-) diff --git a/packages/backend/src/plugins/signals.ts b/packages/backend/src/plugins/signals.ts index 8e2d0b2d3f..dae2731c52 100644 --- a/packages/backend/src/plugins/signals.ts +++ b/packages/backend/src/plugins/signals.ts @@ -29,7 +29,7 @@ export default async function createPlugin( setInterval(() => { console.log('publishing'); - service.publish('*', { hello: 'world' }); + service.publish('*', 'devtools:info', { now: new Date().toISOString() }); }, 5000); return await createRouter({ diff --git a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx index 810a823027..adca500fad 100644 --- a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx +++ b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx @@ -77,7 +77,7 @@ export const InfoContent = () => { const { about, loading, error } = useInfo(); // Just testing for signals const [messages, setMessages] = React.useState([]); - useSignalsApi(message => { + useSignalsApi('devtools:info', message => { messages.push(JSON.stringify(message)); setMessages([...messages]); }); diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index e8c9900908..15a6f67d87 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -29,15 +29,13 @@ export type SignalsEventBrokerPayload = { export class SignalsService implements EventSubscriber { // (undocumented) static create(options: ServiceOptions): SignalsService; - // (undocumented) handleUpgrade: (req: Request_2) => Promise; // (undocumented) onEvent(params: EventParams): Promise; - // (undocumented) publish( to: string | string[], + topic: string, message: JsonObject, - topic?: string, ): Promise; // (undocumented) supportsEventTopics(): string[]; diff --git a/plugins/signals-node/src/SignalsService.ts b/plugins/signals-node/src/SignalsService.ts index 6404386129..15a63e15d7 100644 --- a/plugins/signals-node/src/SignalsService.ts +++ b/plugins/signals-node/src/SignalsService.ts @@ -76,6 +76,11 @@ export class SignalsService implements EventSubscriber { this.eventBroker?.subscribe(this); } + /** + * Handles request upgradce to websocket and adds the connection to internal + * list for publish/subscribe functionality + * @param req - Request + */ handleUpgrade = async (req: Request) => { const identity = await this.identity.getIdentity({ request: req, @@ -157,23 +162,29 @@ export class SignalsService implements EventSubscriber { } } - async publish(to: string | string[], message: JsonObject, topic?: string) { + /** + * Publishes a message to user refs to specific topic + * @param to - string or array of user ref strings to publish message to + * @param topic - message topic + * @param message - message to publish + */ + async publish(to: string | string[], topic: string, message: JsonObject) { await this.publishInternal( Array.isArray(to) ? to : [to], + topic, message, false, - topic, ); } private async publishInternal( recipients: string[], + topic: string, message: JsonObject, brokedEvent: boolean, - topic?: string, ) { this.connections.forEach(conn => { - if (topic && !conn.subscriptions.has(topic)) { + if (!conn.subscriptions.has(topic)) { return; } // Sending to all users can be done with '*' @@ -208,15 +219,19 @@ export class SignalsService implements EventSubscriber { return; } - if (!eventPayload?.recipients || !eventPayload.message) { + if ( + !eventPayload?.recipients || + !eventPayload.topic || + !eventPayload.message + ) { return; } await this.publishInternal( eventPayload.recipients, + eventPayload.topic, eventPayload.message, true, - eventPayload.topic, ); } diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 14d78d4517..22e9306c14 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -12,9 +12,9 @@ import { JsonObject } from '@backstage/types'; export type SignalsApi = { subscribe( onMessage: (message: JsonObject, topic?: string) => void, - topic?: string, + topic: string, ): void; - unsubscribe(topic?: string): void; + unsubscribe(topic: string): void; }; // @public (undocumented) @@ -27,18 +27,15 @@ export class SignalsClient implements SignalsApi { // (undocumented) static instance: SignalsClient | null; // (undocumented) - subscribe( - onMessage: (message: JsonObject, topic?: string) => void, - topic?: string, - ): void; + subscribe(onMessage: (message: JsonObject) => void, topic: string): void; // (undocumented) - unsubscribe(topic?: string): void; + unsubscribe(topic: string): void; } // @public (undocumented) export const useSignalsApi: ( + topic: string, onMessage: (message: JSONObject) => void, - topic?: string, ) => void; // (No @packageDocumentation comment for this package) diff --git a/plugins/signals-react/src/api/SignalsApi.ts b/plugins/signals-react/src/api/SignalsApi.ts index 685cd560aa..28f9da39dc 100644 --- a/plugins/signals-react/src/api/SignalsApi.ts +++ b/plugins/signals-react/src/api/SignalsApi.ts @@ -25,8 +25,8 @@ export const signalsApiRef = createApiRef({ export type SignalsApi = { subscribe( onMessage: (message: JsonObject, topic?: string) => void, - topic?: string, + topic: string, ): void; - unsubscribe(topic?: string): void; + unsubscribe(topic: string): void; }; diff --git a/plugins/signals-react/src/api/SignalsClient.ts b/plugins/signals-react/src/api/SignalsClient.ts index 71984a5c23..cdfa7e5606 100644 --- a/plugins/signals-react/src/api/SignalsClient.ts +++ b/plugins/signals-react/src/api/SignalsClient.ts @@ -22,8 +22,7 @@ export class SignalsClient implements SignalsApi { static instance: SignalsClient | null = null; private ws: WebSocket | null = null; private discoveryApi: DiscoveryApi; - private cbs: Map void> = - new Map(); + private cbs: Map void> = new Map(); private queue: JsonObject[] = []; private reconnectTimeout: any; @@ -38,25 +37,20 @@ export class SignalsClient implements SignalsApi { this.discoveryApi = options.discoveryApi; } - subscribe( - onMessage: (message: JsonObject, topic?: string) => void, - topic?: string, - ): void { - const subscriptionTopic = topic ?? '*'; + subscribe(onMessage: (message: JsonObject) => void, topic: string): void { // Do not allow to subscribe to same topic multiple times - if (this.cbs.has(subscriptionTopic)) { + if (this.cbs.has(topic)) { return; } - this.cbs.set(subscriptionTopic, onMessage); + this.cbs.set(topic, onMessage); this.connect().then(() => { this.send({ action: 'subscribe', topic }); }); } - unsubscribe(topic?: string): void { - const subscriptionTopic = topic ?? '*'; - this.cbs.delete(subscriptionTopic); + unsubscribe(topic: string): void { + this.cbs.delete(topic); this.send({ action: 'unsubscribe', topic }); } @@ -91,12 +85,11 @@ export class SignalsClient implements SignalsApi { this.ws.onmessage = (data: MessageEvent) => { try { const json = JSON.parse(data.data) as JsonObject; - let cb = this.cbs.get('*'); if (json.topic) { - cb = this.cbs.get(json.topic as string); - } - if (cb) { - cb(json.message as JsonObject, json.topic as string); + const cb = this.cbs.get(json.topic as string); + if (cb) { + cb(json.message as JsonObject); + } } } catch (e) { // NOOP @@ -127,7 +120,12 @@ export class SignalsClient implements SignalsApi { this.ws.close(); } this.ws = null; - this.connect(); + this.connect().then(() => { + // Resubscribe to existing topics in case we lost connection + for (const topic of this.cbs.keys()) { + this.send({ action: 'subscribe', topic }); + } + }); }, 5000); } } diff --git a/plugins/signals-react/src/hooks/useSignalsApi.ts b/plugins/signals-react/src/hooks/useSignalsApi.ts index 3bfbba5d71..88112cebc7 100644 --- a/plugins/signals-react/src/hooks/useSignalsApi.ts +++ b/plugins/signals-react/src/hooks/useSignalsApi.ts @@ -20,8 +20,8 @@ import { useEffect } from 'react'; /** @public */ export const useSignalsApi = ( + topic: string, onMessage: (message: JSONObject) => void, - topic?: string, ) => { const discovery = useApi(discoveryApiRef); const signals = SignalsClient.create({ discoveryApi: discovery }); From 5e1a90daa2175e2ff44518b4bb8185c889f5563e Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 4 Dec 2023 15:00:02 +0200 Subject: [PATCH 03/20] chore: move signals client to fronend plugin Signed-off-by: Heikki Hellgren --- .changeset/real-eggs-sip.md | 1 + packages/app/package.json | 1 + packages/app/src/plugins.ts | 1 + plugins/signals-node/package.json | 4 +- plugins/signals-react/api-report.md | 16 +----- plugins/signals-react/src/api/index.ts | 1 - .../signals-react/src/hooks/useSignalsApi.ts | 11 ++-- plugins/signals/.eslintrc.js | 1 + plugins/signals/README.md | 13 +++++ plugins/signals/api-report.md | 27 ++++++++++ plugins/signals/catalog-info.yaml | 9 ++++ plugins/signals/dev/index.tsx | 35 +++++++++++++ plugins/signals/package.json | 52 +++++++++++++++++++ .../src/api/SignalsClient.ts | 2 +- plugins/signals/src/api/index.ts | 16 ++++++ plugins/signals/src/index.ts | 17 ++++++ plugins/signals/src/plugin.test.ts | 22 ++++++++ plugins/signals/src/plugin.ts | 39 ++++++++++++++ plugins/signals/src/setupTests.ts | 16 ++++++ yarn.lock | 27 ++++++++++ 20 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 plugins/signals/.eslintrc.js create mode 100644 plugins/signals/README.md create mode 100644 plugins/signals/api-report.md create mode 100644 plugins/signals/catalog-info.yaml create mode 100644 plugins/signals/dev/index.tsx create mode 100644 plugins/signals/package.json rename plugins/{signals-react => signals}/src/api/SignalsClient.ts (98%) create mode 100644 plugins/signals/src/api/index.ts create mode 100644 plugins/signals/src/index.ts create mode 100644 plugins/signals/src/plugin.test.ts create mode 100644 plugins/signals/src/plugin.ts create mode 100644 plugins/signals/src/setupTests.ts diff --git a/.changeset/real-eggs-sip.md b/.changeset/real-eggs-sip.md index ca05205083..ca23d792f8 100644 --- a/.changeset/real-eggs-sip.md +++ b/.changeset/real-eggs-sip.md @@ -1,5 +1,6 @@ --- '@backstage/plugin-signals-backend': patch +'@backstage/plugin-signals': patch '@backstage/plugin-signals-react': patch '@backstage/plugin-signals-node': patch --- diff --git a/packages/app/package.json b/packages/app/package.json index c6ceafcf04..53a2b60a52 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -73,6 +73,7 @@ "@backstage/plugin-search-react": "workspace:^", "@backstage/plugin-sentry": "workspace:^", "@backstage/plugin-shortcuts": "workspace:^", + "@backstage/plugin-signals": "workspace:^", "@backstage/plugin-stack-overflow": "workspace:^", "@backstage/plugin-stackstorm": "workspace:^", "@backstage/plugin-tech-insights": "workspace:^", diff --git a/packages/app/src/plugins.ts b/packages/app/src/plugins.ts index 36f7a86acc..ddd12bea2c 100644 --- a/packages/app/src/plugins.ts +++ b/packages/app/src/plugins.ts @@ -19,3 +19,4 @@ export { badgesPlugin } from '@backstage/plugin-badges'; export { shortcutsPlugin } from '@backstage/plugin-shortcuts'; export { homePlugin } from '@backstage/plugin-home'; +export { signalsPlugin } from '@backstage/plugin-signals'; diff --git a/plugins/signals-node/package.json b/plugins/signals-node/package.json index 66e4528678..2dc17f5e54 100644 --- a/plugins/signals-node/package.json +++ b/plugins/signals-node/package.json @@ -22,8 +22,7 @@ "postpack": "backstage-cli package postpack" }, "devDependencies": { - "@backstage/cli": "workspace:^", - "@types/express": "^4.17.21" + "@backstage/cli": "workspace:^" }, "files": [ "dist" @@ -34,6 +33,7 @@ "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-events-node": "workspace:^", "@backstage/types": "workspace:^", + "@types/express": "^4.17.21", "express": "^4.17.1", "uuid": "^8.0.0", "winston": "^3.2.1", diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 22e9306c14..0ab752be60 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -4,8 +4,6 @@ ```ts import { ApiRef } from '@backstage/core-plugin-api'; -import { DiscoveryApi } from '@backstage/core-plugin-api'; -import { JSONObject } from '@apollo/explorer/src/helpers/types'; import { JsonObject } from '@backstage/types'; // @public (undocumented) @@ -20,22 +18,10 @@ export type SignalsApi = { // @public (undocumented) export const signalsApiRef: ApiRef; -// @public (undocumented) -export class SignalsClient implements SignalsApi { - // (undocumented) - static create(options: { discoveryApi: DiscoveryApi }): SignalsClient; - // (undocumented) - static instance: SignalsClient | null; - // (undocumented) - subscribe(onMessage: (message: JsonObject) => void, topic: string): void; - // (undocumented) - unsubscribe(topic: string): void; -} - // @public (undocumented) export const useSignalsApi: ( topic: string, - onMessage: (message: JSONObject) => void, + onMessage: (message: JsonObject) => void, ) => void; // (No @packageDocumentation comment for this package) diff --git a/plugins/signals-react/src/api/index.ts b/plugins/signals-react/src/api/index.ts index b8dea2af34..9d5c4c5656 100644 --- a/plugins/signals-react/src/api/index.ts +++ b/plugins/signals-react/src/api/index.ts @@ -14,4 +14,3 @@ * limitations under the License. */ export * from './SignalsApi'; -export * from './SignalsClient'; diff --git a/plugins/signals-react/src/hooks/useSignalsApi.ts b/plugins/signals-react/src/hooks/useSignalsApi.ts index 88112cebc7..d97d80ed76 100644 --- a/plugins/signals-react/src/hooks/useSignalsApi.ts +++ b/plugins/signals-react/src/hooks/useSignalsApi.ts @@ -13,18 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SignalsClient } from '../api'; -import { discoveryApiRef, useApi } from '@backstage/core-plugin-api'; -import { JSONObject } from '@apollo/explorer/src/helpers/types'; +import { signalsApiRef } from '../api'; +import { useApi } from '@backstage/core-plugin-api'; +import { JsonObject } from '@backstage/types'; import { useEffect } from 'react'; /** @public */ export const useSignalsApi = ( topic: string, - onMessage: (message: JSONObject) => void, + onMessage: (message: JsonObject) => void, ) => { - const discovery = useApi(discoveryApiRef); - const signals = SignalsClient.create({ discoveryApi: discovery }); + const signals = useApi(signalsApiRef); useEffect(() => { signals.subscribe(onMessage, topic); }, [signals, onMessage, topic]); diff --git a/plugins/signals/.eslintrc.js b/plugins/signals/.eslintrc.js new file mode 100644 index 0000000000..e2a53a6ad2 --- /dev/null +++ b/plugins/signals/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/signals/README.md b/plugins/signals/README.md new file mode 100644 index 0000000000..604fa98a33 --- /dev/null +++ b/plugins/signals/README.md @@ -0,0 +1,13 @@ +# signals + +Welcome to the signals plugin! + +_This plugin was created through the Backstage CLI_ + +## Getting started + +Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/signals](http://localhost:3000/signals). + +You can also serve the plugin in isolation by running `yarn start` in the plugin directory. +This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. +It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. diff --git a/plugins/signals/api-report.md b/plugins/signals/api-report.md new file mode 100644 index 0000000000..d2dd111fe6 --- /dev/null +++ b/plugins/signals/api-report.md @@ -0,0 +1,27 @@ +## API Report File for "@backstage/plugin-signals" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts +import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { JsonObject } from '@backstage/types'; +import { SignalsApi } from '@backstage/plugin-signals-react'; + +// @public (undocumented) +export class SignalsClient implements SignalsApi { + // (undocumented) + static create(options: { discoveryApi: DiscoveryApi }): SignalsClient; + // (undocumented) + static instance: SignalsClient | null; + // (undocumented) + subscribe(onMessage: (message: JsonObject) => void, topic: string): void; + // (undocumented) + unsubscribe(topic: string): void; +} + +// @public (undocumented) +export const signalsPlugin: BackstagePlugin<{}, {}>; + +// (No @packageDocumentation comment for this package) +``` diff --git a/plugins/signals/catalog-info.yaml b/plugins/signals/catalog-info.yaml new file mode 100644 index 0000000000..ab52477147 --- /dev/null +++ b/plugins/signals/catalog-info.yaml @@ -0,0 +1,9 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: backstage-plugin-signals + title: '@backstage/plugin-signals' +spec: + lifecycle: experimental + type: backstage-frontend-plugin + owner: maintainers diff --git a/plugins/signals/dev/index.tsx b/plugins/signals/dev/index.tsx new file mode 100644 index 0000000000..e8c7928f57 --- /dev/null +++ b/plugins/signals/dev/index.tsx @@ -0,0 +1,35 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import React from 'react'; +import { createDevApp } from '@backstage/dev-utils'; +import { signalsPlugin } from '../src/plugin'; +import { Content, Header, Page } from '@backstage/core-components'; +import { Typography } from '@material-ui/core'; + +createDevApp() + .registerPlugin(signalsPlugin) + .addPage({ + title: 'Debug', + element: ( + +
+ + TODO + + + ), + }) + .render(); diff --git a/plugins/signals/package.json b/plugins/signals/package.json new file mode 100644 index 0000000000..9c0bcb5c13 --- /dev/null +++ b/plugins/signals/package.json @@ -0,0 +1,52 @@ +{ + "name": "@backstage/plugin-signals", + "version": "0.0.0", + "main": "src/index.ts", + "types": "src/index.ts", + "license": "Apache-2.0", + "publishConfig": { + "access": "public", + "main": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "frontend-plugin" + }, + "sideEffects": false, + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/core-components": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", + "@backstage/plugin-signals-react": "workspace:^", + "@backstage/theme": "workspace:^", + "@backstage/types": "workspace:^", + "@material-ui/core": "^4.9.13", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.61", + "react-use": "^17.2.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/core-app-api": "workspace:^", + "@backstage/dev-utils": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@testing-library/jest-dom": "^5.10.1", + "@testing-library/react": "^12.1.3", + "@testing-library/user-event": "^14.0.0", + "msw": "^1.0.0" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/signals-react/src/api/SignalsClient.ts b/plugins/signals/src/api/SignalsClient.ts similarity index 98% rename from plugins/signals-react/src/api/SignalsClient.ts rename to plugins/signals/src/api/SignalsClient.ts index cdfa7e5606..34ef557f31 100644 --- a/plugins/signals-react/src/api/SignalsClient.ts +++ b/plugins/signals/src/api/SignalsClient.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SignalsApi } from './SignalsApi'; +import { SignalsApi } from '@backstage/plugin-signals-react'; import { JsonObject } from '@backstage/types'; import { DiscoveryApi } from '@backstage/core-plugin-api'; diff --git a/plugins/signals/src/api/index.ts b/plugins/signals/src/api/index.ts new file mode 100644 index 0000000000..80e69259af --- /dev/null +++ b/plugins/signals/src/api/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export * from './SignalsClient'; diff --git a/plugins/signals/src/index.ts b/plugins/signals/src/index.ts new file mode 100644 index 0000000000..ddf8afb9c6 --- /dev/null +++ b/plugins/signals/src/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +export { signalsPlugin } from './plugin'; +export * from './api'; diff --git a/plugins/signals/src/plugin.test.ts b/plugins/signals/src/plugin.test.ts new file mode 100644 index 0000000000..cc8ecf44ed --- /dev/null +++ b/plugins/signals/src/plugin.test.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { signalsPlugin } from './plugin'; + +describe('signals', () => { + it('should export plugin', () => { + expect(signalsPlugin).toBeDefined(); + }); +}); diff --git a/plugins/signals/src/plugin.ts b/plugins/signals/src/plugin.ts new file mode 100644 index 0000000000..457b3d9925 --- /dev/null +++ b/plugins/signals/src/plugin.ts @@ -0,0 +1,39 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + createApiFactory, + createPlugin, + discoveryApiRef, +} from '@backstage/core-plugin-api'; +import { signalsApiRef } from '@backstage/plugin-signals-react'; +import { SignalsClient } from './api/SignalsClient'; + +/** @public */ +export const signalsPlugin = createPlugin({ + id: 'signals', + apis: [ + createApiFactory({ + api: signalsApiRef, + deps: { + discoveryApi: discoveryApiRef, + }, + factory: ({ discoveryApi }) => + SignalsClient.create({ + discoveryApi, + }), + }), + ], +}); diff --git a/plugins/signals/src/setupTests.ts b/plugins/signals/src/setupTests.ts new file mode 100644 index 0000000000..865308e634 --- /dev/null +++ b/plugins/signals/src/setupTests.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import '@testing-library/jest-dom'; diff --git a/yarn.lock b/yarn.lock index cdd232b103..a6e69edc89 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8893,6 +8893,32 @@ __metadata: languageName: unknown linkType: soft +"@backstage/plugin-signals@workspace:^, @backstage/plugin-signals@workspace:plugins/signals": + version: 0.0.0-use.local + resolution: "@backstage/plugin-signals@workspace:plugins/signals" + dependencies: + "@backstage/cli": "workspace:^" + "@backstage/core-app-api": "workspace:^" + "@backstage/core-components": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/dev-utils": "workspace:^" + "@backstage/plugin-signals-react": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/theme": "workspace:^" + "@backstage/types": "workspace:^" + "@material-ui/core": ^4.9.13 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.61 + "@testing-library/jest-dom": ^5.10.1 + "@testing-library/react": ^12.1.3 + "@testing-library/user-event": ^14.0.0 + msw: ^1.0.0 + react-use: ^17.2.4 + peerDependencies: + react: ^16.13.1 || ^17.0.0 + languageName: unknown + linkType: soft + "@backstage/plugin-sonarqube-backend@workspace:^, @backstage/plugin-sonarqube-backend@workspace:plugins/sonarqube-backend": version: 0.0.0-use.local resolution: "@backstage/plugin-sonarqube-backend@workspace:plugins/sonarqube-backend" @@ -26556,6 +26582,7 @@ __metadata: "@backstage/plugin-search-react": "workspace:^" "@backstage/plugin-sentry": "workspace:^" "@backstage/plugin-shortcuts": "workspace:^" + "@backstage/plugin-signals": "workspace:^" "@backstage/plugin-stack-overflow": "workspace:^" "@backstage/plugin-stackstorm": "workspace:^" "@backstage/plugin-tech-insights": "workspace:^" From d05a6f566fde13de901659ffdcc232ce15bde3ef Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 5 Dec 2023 09:01:37 +0200 Subject: [PATCH 04/20] feat: allow multiple subscriptions to single topic + auth Signed-off-by: Heikki Hellgren --- plugins/signals-node/src/SignalsService.ts | 25 ++- plugins/signals-react/api-report.md | 4 +- plugins/signals-react/src/api/SignalsApi.ts | 4 +- .../signals-react/src/hooks/useSignalsApi.ts | 16 +- plugins/signals/api-report.md | 14 +- plugins/signals/package.json | 3 +- plugins/signals/src/api/SignalsClient.ts | 183 +++++++++++++----- plugins/signals/src/plugin.ts | 5 +- yarn.lock | 1 + 9 files changed, 187 insertions(+), 68 deletions(-) diff --git a/plugins/signals-node/src/SignalsService.ts b/plugins/signals-node/src/SignalsService.ts index 15a63e15d7..9cbe4b1565 100644 --- a/plugins/signals-node/src/SignalsService.ts +++ b/plugins/signals-node/src/SignalsService.ts @@ -28,6 +28,7 @@ import { JsonObject } from '@backstage/types'; import { BackstageIdentityResponse, IdentityApi, + IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; /** @public */ @@ -160,6 +161,23 @@ export class SignalsService implements EventSubscriber { ); connection.subscriptions.delete(message.topic as string); } + + if (message.action === 'authenticate' && message.token) { + this.logger.info(`Connection ${connection.id} authenticated`); + this.identity + .getIdentity({ + request: { + headers: { authorization: message.token }, + }, + } as IdentityApiGetIdentityRequest) + .then(identity => { + if (identity) { + connection.user = identity.identity.userEntityRef; + connection.ownershipEntityRefs = + identity.identity.ownershipEntityRefs; + } + }); + } } /** @@ -183,6 +201,11 @@ export class SignalsService implements EventSubscriber { message: JsonObject, brokedEvent: boolean, ) { + const jsonMessage = JSON.stringify({ topic, message }); + if (jsonMessage.length === 0) { + return; + } + this.connections.forEach(conn => { if (!conn.subscriptions.has(topic)) { return; @@ -194,7 +217,7 @@ export class SignalsService implements EventSubscriber { ) { return; } - conn.ws.send(JSON.stringify({ topic, message })); + conn.ws.send(jsonMessage); }); // If this event has not been broadcasted to all servers, then use diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 0ab752be60..75aeb5d77d 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -11,8 +11,8 @@ export type SignalsApi = { subscribe( onMessage: (message: JsonObject, topic?: string) => void, topic: string, - ): void; - unsubscribe(topic: string): void; + ): string; + unsubscribe(subscription: string): void; }; // @public (undocumented) diff --git a/plugins/signals-react/src/api/SignalsApi.ts b/plugins/signals-react/src/api/SignalsApi.ts index 28f9da39dc..e592b56df0 100644 --- a/plugins/signals-react/src/api/SignalsApi.ts +++ b/plugins/signals-react/src/api/SignalsApi.ts @@ -26,7 +26,7 @@ export type SignalsApi = { subscribe( onMessage: (message: JsonObject, topic?: string) => void, topic: string, - ): void; + ): string; - unsubscribe(topic: string): void; + unsubscribe(subscription: string): void; }; diff --git a/plugins/signals-react/src/hooks/useSignalsApi.ts b/plugins/signals-react/src/hooks/useSignalsApi.ts index d97d80ed76..327ca08ff9 100644 --- a/plugins/signals-react/src/hooks/useSignalsApi.ts +++ b/plugins/signals-react/src/hooks/useSignalsApi.ts @@ -16,7 +16,7 @@ import { signalsApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; /** @public */ export const useSignalsApi = ( @@ -24,13 +24,19 @@ export const useSignalsApi = ( onMessage: (message: JsonObject) => void, ) => { const signals = useApi(signalsApiRef); + const [subscription, setSubscription] = useState(null); useEffect(() => { - signals.subscribe(onMessage, topic); - }, [signals, onMessage, topic]); + if (!subscription) { + const sub = signals.subscribe(onMessage, topic); + setSubscription(sub); + } + }, [subscription, signals, onMessage, topic]); useEffect(() => { return () => { - signals.unsubscribe(topic); + if (subscription) { + signals.unsubscribe(subscription); + } }; - }, [signals, topic]); + }, [subscription, signals, topic]); }; diff --git a/plugins/signals/api-report.md b/plugins/signals/api-report.md index d2dd111fe6..2c9777a5c0 100644 --- a/plugins/signals/api-report.md +++ b/plugins/signals/api-report.md @@ -5,19 +5,25 @@ ```ts import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { IdentityApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { SignalsApi } from '@backstage/plugin-signals-react'; // @public (undocumented) export class SignalsClient implements SignalsApi { // (undocumented) - static create(options: { discoveryApi: DiscoveryApi }): SignalsClient; + static readonly CONNECT_TIMEOUT_MS: number; // (undocumented) - static instance: SignalsClient | null; + static create(options: { + identity: IdentityApi; + discoveryApi: DiscoveryApi; + }): SignalsClient; // (undocumented) - subscribe(onMessage: (message: JsonObject) => void, topic: string): void; + static readonly RECONNECT_TIMEOUT_MS: number; // (undocumented) - unsubscribe(topic: string): void; + subscribe(onMessage: (message: JsonObject) => void, topic: string): string; + // (undocumented) + unsubscribe(subscription: string): void; } // @public (undocumented) diff --git a/plugins/signals/package.json b/plugins/signals/package.json index 9c0bcb5c13..35ffc8e212 100644 --- a/plugins/signals/package.json +++ b/plugins/signals/package.json @@ -31,7 +31,8 @@ "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", - "react-use": "^17.2.4" + "react-use": "^17.2.4", + "uuid": "^8.0.0" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0" diff --git a/plugins/signals/src/api/SignalsClient.ts b/plugins/signals/src/api/SignalsClient.ts index 34ef557f31..cc1254578f 100644 --- a/plugins/signals/src/api/SignalsClient.ts +++ b/plugins/signals/src/api/SignalsClient.ts @@ -15,60 +15,105 @@ */ import { SignalsApi } from '@backstage/plugin-signals-react'; import { JsonObject } from '@backstage/types'; -import { DiscoveryApi } from '@backstage/core-plugin-api'; +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import { v4 as uuid } from 'uuid'; + +/** @internal */ +type Subscription = { + topic: string; + callback: (message: JsonObject) => void; +}; + +/** @internal */ +const WS_CLOSE_NORMAL = 1000; +/** @internal */ +const WS_CLOSE_GOING_AWAY = 1001; /** @public */ export class SignalsClient implements SignalsApi { - static instance: SignalsClient | null = null; + static readonly CONNECT_TIMEOUT_MS: number = 1000; + static readonly RECONNECT_TIMEOUT_MS: number = 5000; private ws: WebSocket | null = null; - private discoveryApi: DiscoveryApi; - private cbs: Map void> = new Map(); - private queue: JsonObject[] = []; + private subscriptions: Map = new Map(); + private messageQueue: string[] = []; private reconnectTimeout: any; - static create(options: { discoveryApi: DiscoveryApi }) { - if (!SignalsClient.instance) { - SignalsClient.instance = new SignalsClient(options); - } - return SignalsClient.instance; + static create(options: { + identity: IdentityApi; + discoveryApi: DiscoveryApi; + }) { + const { identity, discoveryApi } = options; + return new SignalsClient(identity, discoveryApi); } - private constructor(options: { discoveryApi: DiscoveryApi }) { - this.discoveryApi = options.discoveryApi; + private constructor( + private identity: IdentityApi, + private discoveryApi: DiscoveryApi, + ) {} + + subscribe(onMessage: (message: JsonObject) => void, topic: string): string { + const subscriptionId = uuid(); + const exists = [...this.subscriptions.values()].find( + sub => sub.topic === topic, + ); + this.subscriptions.set(subscriptionId, { topic, callback: onMessage }); + + this.connect() + .then(() => { + // Do not subscribe twice to same topic even there is multiple callbacks + if (!exists) { + this.send({ action: 'subscribe', topic }); + } + }) + .catch(() => { + this.reconnect(); + }); + return subscriptionId; } - subscribe(onMessage: (message: JsonObject) => void, topic: string): void { - // Do not allow to subscribe to same topic multiple times - if (this.cbs.has(topic)) { + unsubscribe(subscription: string): void { + const sub = this.subscriptions.get(subscription); + if (!sub) { return; } + const topic = sub.topic; + this.subscriptions.delete(subscription); + const exists = [...this.subscriptions.values()].find( + s => s.topic === topic, + ); + // If there are subscriptions still listening to this topic, do not + // unsubscribe from the server + if (!exists) { + this.send({ action: 'unsubscribe', topic: sub.topic }); + } - this.cbs.set(topic, onMessage); - this.connect().then(() => { - this.send({ action: 'subscribe', topic }); - }); - } - - unsubscribe(topic: string): void { - this.cbs.delete(topic); - this.send({ action: 'unsubscribe', topic }); + // If there are no subscriptions, close the connection + if (this.subscriptions.size === 0) { + this.ws?.close(WS_CLOSE_NORMAL); + this.ws = null; + } } private send(data?: JsonObject): void { + const jsonMessage = JSON.stringify(data); + if (jsonMessage.length === 0) { + return; + } + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { if (data) { - this.queue.push(data); + this.messageQueue.unshift(jsonMessage); } return; } // First send queue - for (const msg of this.queue) { - this.ws!.send(JSON.stringify(msg)); + for (const msg of this.messageQueue) { + this.ws!.send(msg); } - this.queue = []; + this.messageQueue = []; if (data) { - this.ws!.send(JSON.stringify(data)); + this.ws!.send(jsonMessage); } } @@ -83,31 +128,60 @@ export class SignalsClient implements SignalsApi { this.ws = new WebSocket(url.toString()); this.ws.onmessage = (data: MessageEvent) => { - try { - const json = JSON.parse(data.data) as JsonObject; - if (json.topic) { - const cb = this.cbs.get(json.topic as string); - if (cb) { - cb(json.message as JsonObject); - } - } - } catch (e) { - // NOOP - } + this.handleMessage(data); }; this.ws.onerror = () => { this.reconnect(); }; - this.ws.onclose = () => { - this.reconnect(); + this.ws.onclose = (ev: CloseEvent) => { + if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { + this.reconnect(); + } }; - while (this.ws.readyState !== WebSocket.OPEN) { - await new Promise(r => setTimeout(r, 10)); + // Wait until connection is open + let connectSleep = 0; + while ( + this.ws && + this.ws.readyState !== WebSocket.OPEN && + connectSleep < SignalsClient.CONNECT_TIMEOUT_MS + ) { + await new Promise(r => setTimeout(r, 100)); + connectSleep += 100; + } + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('Connect timeout'); + } + + // Authenticate + await this.authenticate(); + } + + private handleMessage(data: MessageEvent) { + try { + const json = JSON.parse(data.data) as JsonObject; + if (json.topic) { + for (const sub of this.subscriptions.values()) { + if (sub.topic === json.topic) { + sub.callback(json.message as JsonObject); + } + } + } + } catch (e) { + // NOOP + } + } + + private async authenticate() { + const { token } = await this.identity.getCredentials(); + if (token) { + // Authentication is done with websocket message to server as the plain + // websocket does not allow sending headers during connection upgrade + this.send({ action: 'authenticate', token: token }); } - this.send(); } private reconnect() { @@ -116,16 +190,21 @@ export class SignalsClient implements SignalsApi { } this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null; if (this.ws) { this.ws.close(); } this.ws = null; - this.connect().then(() => { - // Resubscribe to existing topics in case we lost connection - for (const topic of this.cbs.keys()) { - this.send({ action: 'subscribe', topic }); - } - }); - }, 5000); + this.connect() + .then(() => { + // Resubscribe to existing topics in case we lost connection + for (const topic of this.subscriptions.keys()) { + this.send({ action: 'subscribe', topic }); + } + }) + .catch(() => { + this.reconnect(); + }); + }, SignalsClient.RECONNECT_TIMEOUT_MS); } } diff --git a/plugins/signals/src/plugin.ts b/plugins/signals/src/plugin.ts index 457b3d9925..d1b0311116 100644 --- a/plugins/signals/src/plugin.ts +++ b/plugins/signals/src/plugin.ts @@ -17,6 +17,7 @@ import { createApiFactory, createPlugin, discoveryApiRef, + identityApiRef, } from '@backstage/core-plugin-api'; import { signalsApiRef } from '@backstage/plugin-signals-react'; import { SignalsClient } from './api/SignalsClient'; @@ -28,10 +29,12 @@ export const signalsPlugin = createPlugin({ createApiFactory({ api: signalsApiRef, deps: { + identity: identityApiRef, discoveryApi: discoveryApiRef, }, - factory: ({ discoveryApi }) => + factory: ({ identity, discoveryApi }) => SignalsClient.create({ + identity, discoveryApi, }), }), diff --git a/yarn.lock b/yarn.lock index a6e69edc89..c89f106c57 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8914,6 +8914,7 @@ __metadata: "@testing-library/user-event": ^14.0.0 msw: ^1.0.0 react-use: ^17.2.4 + uuid: ^8.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 languageName: unknown From 7af9750ea8f9d7bf815ffe21565f50f993700368 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 5 Dec 2023 09:19:50 +0200 Subject: [PATCH 05/20] feat: use websocket header to pass the auth token Signed-off-by: Heikki Hellgren --- plugins/signals-node/src/SignalsService.ts | 45 ++++++++-------------- plugins/signals-node/src/types.ts | 8 ++++ plugins/signals/src/api/SignalsClient.ts | 16 ++------ 3 files changed, 28 insertions(+), 41 deletions(-) diff --git a/plugins/signals-node/src/SignalsService.ts b/plugins/signals-node/src/SignalsService.ts index 9cbe4b1565..6a1547a6bb 100644 --- a/plugins/signals-node/src/SignalsService.ts +++ b/plugins/signals-node/src/SignalsService.ts @@ -19,7 +19,11 @@ import { EventSubscriber, } from '@backstage/plugin-events-node'; import { Logger } from 'winston'; -import { ServiceOptions, SignalConnection } from './types'; +import { + ServiceOptions, + SignalConnection, + SignalsEventBrokerPayload, +} from './types'; import { RawData, WebSocket, WebSocketServer } from 'ws'; import { IncomingMessage } from 'http'; import { v4 as uuid } from 'uuid'; @@ -31,13 +35,6 @@ import { IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; -/** @public */ -export type SignalsEventBrokerPayload = { - recipients?: string[]; - topic?: string; - message?: JsonObject; -}; - /** @public */ export class SignalsService implements EventSubscriber { private readonly serverId: string; @@ -83,9 +80,18 @@ export class SignalsService implements EventSubscriber { * @param req - Request */ handleUpgrade = async (req: Request) => { - const identity = await this.identity.getIdentity({ - request: req, - }); + let identity: BackstageIdentityResponse | undefined = undefined; + + // Authentication token is passed in Sec-WebSocket-Protocol header as there + // is no other way to pass the token with plain websockets + const token = req.headers['sec-websocket-protocol']; + if (token) { + identity = await this.identity.getIdentity({ + request: { + headers: { authorization: token }, + }, + } as IdentityApiGetIdentityRequest); + } this.server.handleUpgrade( req, @@ -161,23 +167,6 @@ export class SignalsService implements EventSubscriber { ); connection.subscriptions.delete(message.topic as string); } - - if (message.action === 'authenticate' && message.token) { - this.logger.info(`Connection ${connection.id} authenticated`); - this.identity - .getIdentity({ - request: { - headers: { authorization: message.token }, - }, - } as IdentityApiGetIdentityRequest) - .then(identity => { - if (identity) { - connection.user = identity.identity.userEntityRef; - connection.ownershipEntityRefs = - identity.identity.ownershipEntityRefs; - } - }); - } } /** diff --git a/plugins/signals-node/src/types.ts b/plugins/signals-node/src/types.ts index c53bfdb20e..090e4b2ff1 100644 --- a/plugins/signals-node/src/types.ts +++ b/plugins/signals-node/src/types.ts @@ -17,6 +17,7 @@ import { IdentityApi } from '@backstage/plugin-auth-node'; import { EventBroker } from '@backstage/plugin-events-node'; import { Logger } from 'winston'; import { WebSocket } from 'ws'; +import { JsonObject } from '@backstage/types'; /** * @public @@ -27,6 +28,13 @@ export type ServiceOptions = { identity: IdentityApi; }; +/** @public */ +export type SignalsEventBrokerPayload = { + recipients?: string[]; + topic?: string; + message?: JsonObject; +}; + /** * @internal */ diff --git a/plugins/signals/src/api/SignalsClient.ts b/plugins/signals/src/api/SignalsClient.ts index cc1254578f..d8d3328411 100644 --- a/plugins/signals/src/api/SignalsClient.ts +++ b/plugins/signals/src/api/SignalsClient.ts @@ -123,9 +123,11 @@ export class SignalsClient implements SignalsApi { } const apiUrl = `${await this.discoveryApi.getBaseUrl('signals')}`; + const { token } = await this.identity.getCredentials(); + const url = new URL(apiUrl); url.protocol = url.protocol === 'http:' ? 'ws' : 'wss'; - this.ws = new WebSocket(url.toString()); + this.ws = new WebSocket(url.toString(), token); this.ws.onmessage = (data: MessageEvent) => { this.handleMessage(data); @@ -155,9 +157,6 @@ export class SignalsClient implements SignalsApi { if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { throw new Error('Connect timeout'); } - - // Authenticate - await this.authenticate(); } private handleMessage(data: MessageEvent) { @@ -175,15 +174,6 @@ export class SignalsClient implements SignalsApi { } } - private async authenticate() { - const { token } = await this.identity.getCredentials(); - if (token) { - // Authentication is done with websocket message to server as the plain - // websocket does not allow sending headers during connection upgrade - this.send({ action: 'authenticate', token: token }); - } - } - private reconnect() { if (this.reconnectTimeout) { clearTimeout(this.reconnectTimeout); From 8fbabdc2cbb5cfe5c0777fb8d9cac9e9739f00f8 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 5 Dec 2023 09:31:46 +0200 Subject: [PATCH 06/20] chore: add check for connection status before sending Signed-off-by: Heikki Hellgren --- plugins/signals-node/src/SignalsService.ts | 5 +++++ plugins/signals/src/api/SignalsClient.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/plugins/signals-node/src/SignalsService.ts b/plugins/signals-node/src/SignalsService.ts index 6a1547a6bb..c743c958dd 100644 --- a/plugins/signals-node/src/SignalsService.ts +++ b/plugins/signals-node/src/SignalsService.ts @@ -206,6 +206,11 @@ export class SignalsService implements EventSubscriber { ) { return; } + + if (conn.ws.readyState !== WebSocket.OPEN) { + return; + } + conn.ws.send(jsonMessage); }); diff --git a/plugins/signals/src/api/SignalsClient.ts b/plugins/signals/src/api/SignalsClient.ts index d8d3328411..1fbe374ad9 100644 --- a/plugins/signals/src/api/SignalsClient.ts +++ b/plugins/signals/src/api/SignalsClient.ts @@ -126,7 +126,7 @@ export class SignalsClient implements SignalsApi { const { token } = await this.identity.getCredentials(); const url = new URL(apiUrl); - url.protocol = url.protocol === 'http:' ? 'ws' : 'wss'; + url.protocol = url.protocol === 'http:' ? 'ws:' : 'wss:'; this.ws = new WebSocket(url.toString(), token); this.ws.onmessage = (data: MessageEvent) => { From db84649e81ce0b6e8128314afcca6435d0b69d97 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 5 Dec 2023 10:40:48 +0200 Subject: [PATCH 07/20] test: add tests for signals client Signed-off-by: Heikki Hellgren --- plugins/signals-react/api-report.md | 5 +- plugins/signals-react/src/api/SignalsApi.ts | 5 +- .../signals-react/src/hooks/useSignalsApi.ts | 2 +- plugins/signals/api-report.md | 10 +- plugins/signals/package.json | 1 + plugins/signals/src/api/SignalsClient.test.ts | 119 ++++++++++++++++++ plugins/signals/src/api/SignalsClient.ts | 46 ++++--- yarn.lock | 1 + 8 files changed, 160 insertions(+), 29 deletions(-) create mode 100644 plugins/signals/src/api/SignalsClient.test.ts diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 75aeb5d77d..21f070c35d 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -8,10 +8,7 @@ import { JsonObject } from '@backstage/types'; // @public (undocumented) export type SignalsApi = { - subscribe( - onMessage: (message: JsonObject, topic?: string) => void, - topic: string, - ): string; + subscribe(topic: string, onMessage: (message: JsonObject) => void): string; unsubscribe(subscription: string): void; }; diff --git a/plugins/signals-react/src/api/SignalsApi.ts b/plugins/signals-react/src/api/SignalsApi.ts index e592b56df0..2aefa5d166 100644 --- a/plugins/signals-react/src/api/SignalsApi.ts +++ b/plugins/signals-react/src/api/SignalsApi.ts @@ -23,10 +23,7 @@ export const signalsApiRef = createApiRef({ /** @public */ export type SignalsApi = { - subscribe( - onMessage: (message: JsonObject, topic?: string) => void, - topic: string, - ): string; + subscribe(topic: string, onMessage: (message: JsonObject) => void): string; unsubscribe(subscription: string): void; }; diff --git a/plugins/signals-react/src/hooks/useSignalsApi.ts b/plugins/signals-react/src/hooks/useSignalsApi.ts index 327ca08ff9..0fcc69efed 100644 --- a/plugins/signals-react/src/hooks/useSignalsApi.ts +++ b/plugins/signals-react/src/hooks/useSignalsApi.ts @@ -27,7 +27,7 @@ export const useSignalsApi = ( const [subscription, setSubscription] = useState(null); useEffect(() => { if (!subscription) { - const sub = signals.subscribe(onMessage, topic); + const sub = signals.subscribe(topic, onMessage); setSubscription(sub); } }, [subscription, signals, onMessage, topic]); diff --git a/plugins/signals/api-report.md b/plugins/signals/api-report.md index 2c9777a5c0..08c52ef418 100644 --- a/plugins/signals/api-report.md +++ b/plugins/signals/api-report.md @@ -11,17 +11,19 @@ import { SignalsApi } from '@backstage/plugin-signals-react'; // @public (undocumented) export class SignalsClient implements SignalsApi { - // (undocumented) - static readonly CONNECT_TIMEOUT_MS: number; // (undocumented) static create(options: { identity: IdentityApi; discoveryApi: DiscoveryApi; + connectTimeout?: number; + reconnectTimeout?: number; }): SignalsClient; // (undocumented) - static readonly RECONNECT_TIMEOUT_MS: number; + static readonly DEFAULT_CONNECT_TIMEOUT_MS: number; // (undocumented) - subscribe(onMessage: (message: JsonObject) => void, topic: string): string; + static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number; + // (undocumented) + subscribe(topic: string, onMessage: (message: JsonObject) => void): string; // (undocumented) unsubscribe(subscription: string): void; } diff --git a/plugins/signals/package.json b/plugins/signals/package.json index 35ffc8e212..e708d46329 100644 --- a/plugins/signals/package.json +++ b/plugins/signals/package.json @@ -45,6 +45,7 @@ "@testing-library/jest-dom": "^5.10.1", "@testing-library/react": "^12.1.3", "@testing-library/user-event": "^14.0.0", + "jest-websocket-mock": "^2.5.0", "msw": "^1.0.0" }, "files": [ diff --git a/plugins/signals/src/api/SignalsClient.test.ts b/plugins/signals/src/api/SignalsClient.test.ts new file mode 100644 index 0000000000..03b25d580d --- /dev/null +++ b/plugins/signals/src/api/SignalsClient.test.ts @@ -0,0 +1,119 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; +import WS from 'jest-websocket-mock'; +import { SignalsClient } from './SignalsClient'; + +describe('SignalsClient', () => { + const tokenFunction = jest.fn(); + const baseUrlFunction = jest.fn(); + const identity = { + getCredentials: tokenFunction, + } as unknown as IdentityApi; + const discoveryApi = { + getBaseUrl: baseUrlFunction, + } as unknown as DiscoveryApi; + + let server: WS; + + beforeEach(async () => { + jest.resetAllMocks(); + tokenFunction.mockResolvedValue({ token: '12345' }); + baseUrlFunction.mockResolvedValue('http://localhost:1234'); + server = new WS('ws://localhost:1234', { jsonProtocol: true }); + }); + + afterEach(() => { + WS.clean(); + }); + + it('should handle single subscription correctly', async () => { + const messageMock = jest.fn(); + const client = SignalsClient.create({ discoveryApi, identity }); + const sub = client.subscribe('topic', messageMock); + await server.connected; + + await expect(server).toReceiveMessage({ + action: 'subscribe', + topic: 'topic', + }); + server.send({ topic: 'topic', message: { hello: 'world' } }); + expect(messageMock).toHaveBeenCalledWith({ hello: 'world' }); + + client.unsubscribe(sub); + await expect(server).toReceiveMessage({ + action: 'unsubscribe', + topic: 'topic', + }); + }); + + it('should handle multiple subscription correctly', async () => { + const messageMock1 = jest.fn(); + const messageMock2 = jest.fn(); + const client1 = SignalsClient.create({ discoveryApi, identity }); + const client2 = SignalsClient.create({ discoveryApi, identity }); + const sub1 = client1.subscribe('topic', messageMock1); + const sub2 = client2.subscribe('topic', messageMock2); + + await server.connected; + + await expect(server).toReceiveMessage({ + action: 'subscribe', + topic: 'topic', + }); + server.send({ topic: 'topic', message: { hello: 'world' } }); + expect(messageMock1).toHaveBeenCalledWith({ hello: 'world' }); + expect(messageMock2).toHaveBeenCalledWith({ hello: 'world' }); + + client1.unsubscribe(sub1); + await expect(server).not.toReceiveMessage({ + action: 'unsubscribe', + topic: 'topic', + }); + + client2.unsubscribe(sub2); + await expect(server).toReceiveMessage({ + action: 'unsubscribe', + topic: 'topic', + }); + }); + + it('should reconnect on error', async () => { + const messageMock = jest.fn(); + const client = SignalsClient.create({ + discoveryApi, + identity, + reconnectTimeout: 10, + connectTimeout: 100, + }); + + client.subscribe('topic', messageMock); + await server.connected; + await expect(server).toReceiveMessage({ + action: 'subscribe', + topic: 'topic', + }); + + await server.server.emit('error', null); + + await new Promise(r => setTimeout(r, 50)); + await expect(server).toReceiveMessage({ + action: 'subscribe', + topic: 'topic', + }); + }); +}); diff --git a/plugins/signals/src/api/SignalsClient.ts b/plugins/signals/src/api/SignalsClient.ts index 1fbe374ad9..4051468a9e 100644 --- a/plugins/signals/src/api/SignalsClient.ts +++ b/plugins/signals/src/api/SignalsClient.ts @@ -31,27 +31,41 @@ const WS_CLOSE_GOING_AWAY = 1001; /** @public */ export class SignalsClient implements SignalsApi { - static readonly CONNECT_TIMEOUT_MS: number = 1000; - static readonly RECONNECT_TIMEOUT_MS: number = 5000; + static readonly DEFAULT_CONNECT_TIMEOUT_MS: number = 1000; + static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number = 5000; private ws: WebSocket | null = null; private subscriptions: Map = new Map(); private messageQueue: string[] = []; - private reconnectTimeout: any; + private reconnectTo: any; static create(options: { identity: IdentityApi; discoveryApi: DiscoveryApi; + connectTimeout?: number; + reconnectTimeout?: number; }) { - const { identity, discoveryApi } = options; - return new SignalsClient(identity, discoveryApi); + const { + identity, + discoveryApi, + connectTimeout = SignalsClient.DEFAULT_CONNECT_TIMEOUT_MS, + reconnectTimeout = SignalsClient.DEFAULT_RECONNECT_TIMEOUT_MS, + } = options; + return new SignalsClient( + identity, + discoveryApi, + connectTimeout, + reconnectTimeout, + ); } private constructor( private identity: IdentityApi, private discoveryApi: DiscoveryApi, + private connectTimeout: number, + private reconnectTimeout: number, ) {} - subscribe(onMessage: (message: JsonObject) => void, topic: string): string { + subscribe(topic: string, onMessage: (message: JsonObject) => void): string { const subscriptionId = uuid(); const exists = [...this.subscriptions.values()].find( sub => sub.topic === topic, @@ -118,11 +132,11 @@ export class SignalsClient implements SignalsApi { } private async connect() { - if (this.ws) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { return; } - const apiUrl = `${await this.discoveryApi.getBaseUrl('signals')}`; + const apiUrl = await this.discoveryApi.getBaseUrl('signals'); const { token } = await this.identity.getCredentials(); const url = new URL(apiUrl); @@ -148,7 +162,7 @@ export class SignalsClient implements SignalsApi { while ( this.ws && this.ws.readyState !== WebSocket.OPEN && - connectSleep < SignalsClient.CONNECT_TIMEOUT_MS + connectSleep < this.connectTimeout ) { await new Promise(r => setTimeout(r, 100)); connectSleep += 100; @@ -175,12 +189,12 @@ export class SignalsClient implements SignalsApi { } private reconnect() { - if (this.reconnectTimeout) { - clearTimeout(this.reconnectTimeout); + if (this.reconnectTo) { + clearTimeout(this.reconnectTo); } - this.reconnectTimeout = setTimeout(() => { - this.reconnectTimeout = null; + this.reconnectTo = setTimeout(() => { + this.reconnectTo = null; if (this.ws) { this.ws.close(); } @@ -188,13 +202,13 @@ export class SignalsClient implements SignalsApi { this.connect() .then(() => { // Resubscribe to existing topics in case we lost connection - for (const topic of this.subscriptions.keys()) { - this.send({ action: 'subscribe', topic }); + for (const sub of this.subscriptions.values()) { + this.send({ action: 'subscribe', topic: sub.topic }); } }) .catch(() => { this.reconnect(); }); - }, SignalsClient.RECONNECT_TIMEOUT_MS); + }, this.reconnectTimeout); } } diff --git a/yarn.lock b/yarn.lock index c89f106c57..d44cfeab72 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8912,6 +8912,7 @@ __metadata: "@testing-library/jest-dom": ^5.10.1 "@testing-library/react": ^12.1.3 "@testing-library/user-event": ^14.0.0 + jest-websocket-mock: ^2.5.0 msw: ^1.0.0 react-use: ^17.2.4 uuid: ^8.0.0 From 59a4508efeceed5f3e821053a1305b60ea725328 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 5 Dec 2023 15:35:36 +0200 Subject: [PATCH 08/20] feat: update devtools almost real time using signals Signed-off-by: Heikki Hellgren --- .changeset/poor-sheep-tease.md | 6 ++++ packages/backend/src/index.ts | 7 ++++ packages/backend/src/plugins/devtools.ts | 1 + packages/backend/src/plugins/signals.ts | 14 +------- packages/backend/src/types.ts | 2 ++ plugins/devtools-backend/api-report.md | 3 ++ plugins/devtools-backend/package.json | 1 + .../devtools-backend/src/service/router.ts | 14 +++++++- .../Content/InfoContent/InfoContent.tsx | 36 +++++++++---------- plugins/signals-backend/api-report.md | 4 +-- .../src/service/router.test.ts | 4 +-- plugins/signals-backend/src/service/router.ts | 4 +-- .../src/service/standaloneServer.ts | 4 +-- plugins/signals-node/api-report.md | 9 ++--- .../{SignalsService.ts => SignalService.ts} | 23 +++++++++--- plugins/signals-node/src/index.ts | 2 +- plugins/signals-node/src/types.ts | 2 +- plugins/signals-react/api-report.md | 6 ++-- .../src/api/{SignalsApi.ts => SignalApi.ts} | 6 ++-- plugins/signals-react/src/api/index.ts | 2 +- plugins/signals-react/src/hooks/index.ts | 2 +- .../{useSignalsApi.ts => useSignalApi.ts} | 6 ++-- plugins/signals/api-report.md | 6 ++-- .../api/{SignalsClient.ts => SignalClient.ts} | 13 +++---- plugins/signals/src/api/SignalsClient.test.ts | 10 +++--- plugins/signals/src/api/index.ts | 2 +- plugins/signals/src/plugin.ts | 8 ++--- yarn.lock | 1 + 28 files changed, 114 insertions(+), 84 deletions(-) create mode 100644 .changeset/poor-sheep-tease.md rename plugins/signals-node/src/{SignalsService.ts => SignalService.ts} (90%) rename plugins/signals-react/src/api/{SignalsApi.ts => SignalApi.ts} (88%) rename plugins/signals-react/src/hooks/{useSignalsApi.ts => useSignalApi.ts} (91%) rename plugins/signals/src/api/{SignalsClient.ts => SignalClient.ts} (94%) diff --git a/.changeset/poor-sheep-tease.md b/.changeset/poor-sheep-tease.md new file mode 100644 index 0000000000..fa89ac4749 --- /dev/null +++ b/.changeset/poor-sheep-tease.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-devtools-backend': patch +'@backstage/plugin-devtools': patch +--- + +Update devtools information almost real time using signals plugin diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index cefc272aa7..fc6b814c00 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -73,6 +73,7 @@ import { DefaultEventBroker } from '@backstage/plugin-events-backend'; import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; +import { SignalService } from '@backstage/plugin-signals-node'; // Expose opentelemetry metrics using a Prometheus exporter on // http://localhost:9464/metrics . See prometheus.yml in packages/backend for @@ -99,6 +100,11 @@ function makeCreateEnv(config: Config) { }); const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); + const signalService = SignalService.create({ + logger: root, + eventBroker, + identity, + }); root.info(`Created UrlReader ${reader}`); @@ -120,6 +126,7 @@ function makeCreateEnv(config: Config) { permissions, scheduler, identity, + signalService, }; }; } diff --git a/packages/backend/src/plugins/devtools.ts b/packages/backend/src/plugins/devtools.ts index 8e1767ddb1..5488033682 100644 --- a/packages/backend/src/plugins/devtools.ts +++ b/packages/backend/src/plugins/devtools.ts @@ -25,5 +25,6 @@ export default async function createPlugin( logger: env.logger, config: env.config, permissions: env.permissions, + signalService: env.signalService, }); } diff --git a/packages/backend/src/plugins/signals.ts b/packages/backend/src/plugins/signals.ts index dae2731c52..687fcfaa33 100644 --- a/packages/backend/src/plugins/signals.ts +++ b/packages/backend/src/plugins/signals.ts @@ -15,25 +15,13 @@ */ import { Router } from 'express'; import { createRouter } from '@backstage/plugin-signals-backend'; -import { SignalsService } from '@backstage/plugin-signals-node'; import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { - const service = SignalsService.create({ - logger: env.logger, - identity: env.identity, - eventBroker: env.eventBroker, - }); - - setInterval(() => { - console.log('publishing'); - service.publish('*', 'devtools:info', { now: new Date().toISOString() }); - }, 5000); - return await createRouter({ logger: env.logger, - service, + service: env.signalService, }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index ab1baf0c95..d76e68c1c9 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -27,6 +27,7 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { EventBroker } from '@backstage/plugin-events-node'; +import { SignalService } from '@backstage/plugin-signals-node'; export type PluginEnvironment = { logger: Logger; @@ -40,4 +41,5 @@ export type PluginEnvironment = { scheduler: PluginTaskScheduler; identity: IdentityApi; eventBroker: EventBroker; + signalService: SignalService; }; diff --git a/plugins/devtools-backend/api-report.md b/plugins/devtools-backend/api-report.md index b9eb7b5a0c..85b2bf6efa 100644 --- a/plugins/devtools-backend/api-report.md +++ b/plugins/devtools-backend/api-report.md @@ -11,6 +11,7 @@ import express from 'express'; import { ExternalDependency } from '@backstage/plugin-devtools-common'; import { Logger } from 'winston'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { SignalService } from '@backstage/plugin-signals-node'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -40,5 +41,7 @@ export interface RouterOptions { logger: Logger; // (undocumented) permissions: PermissionEvaluator; + // (undocumented) + signalService?: SignalService; } ``` diff --git a/plugins/devtools-backend/package.json b/plugins/devtools-backend/package.json index 18013bc52f..8de7da1b39 100644 --- a/plugins/devtools-backend/package.json +++ b/plugins/devtools-backend/package.json @@ -38,6 +38,7 @@ "@backstage/plugin-devtools-common": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", + "@backstage/plugin-signals-node": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", "@types/express": "*", diff --git a/plugins/devtools-backend/src/service/router.ts b/plugins/devtools-backend/src/service/router.ts index fbeeb5af84..fb8b106337 100644 --- a/plugins/devtools-backend/src/service/router.ts +++ b/plugins/devtools-backend/src/service/router.ts @@ -33,6 +33,7 @@ import { errorHandler } from '@backstage/backend-common'; import express from 'express'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; +import { SignalService } from '@backstage/plugin-signals-node'; /** @public */ export interface RouterOptions { @@ -40,17 +41,28 @@ export interface RouterOptions { logger: Logger; config: Config; permissions: PermissionEvaluator; + signalService?: SignalService; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, config, permissions } = options; + const { logger, config, permissions, signalService } = options; const devToolsBackendApi = options.devToolsBackendApi || new DevToolsBackendApi(logger, config); + if (signalService) { + // Publish info periodically using the signal service + setInterval(async () => { + if (signalService.hasSubscribers('devtools:info')) { + const info = await devToolsBackendApi.listInfo(); + await signalService.publish('*', 'devtools:info', info); + } + }, 5000); + } + const router = Router(); router.use(express.json()); router.use( diff --git a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx index adca500fad..74587d3e57 100644 --- a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx +++ b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx @@ -27,10 +27,9 @@ import { makeStyles, Paper, Theme, - Typography, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { useInfo } from '../../../hooks'; import { InfoDependenciesTable } from './InfoDependenciesTable'; import DescriptionIcon from '@material-ui/icons/Description'; @@ -39,7 +38,7 @@ import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard'; import { BackstageLogoIcon } from './BackstageLogoIcon'; import FileCopyIcon from '@material-ui/icons/FileCopy'; import { DevToolsInfo } from '@backstage/plugin-devtools-common'; -import { useSignalsApi } from '@backstage/plugin-signals-react'; +import { useSignalApi } from '@backstage/plugin-signals-react'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -74,14 +73,18 @@ const copyToClipboard = ({ about }: { about: DevToolsInfo | undefined }) => { /** @public */ export const InfoContent = () => { const classes = useStyles(); + const [info, setInfo] = useState(undefined); const { about, loading, error } = useInfo(); - // Just testing for signals - const [messages, setMessages] = React.useState([]); - useSignalsApi('devtools:info', message => { - messages.push(JSON.stringify(message)); - setMessages([...messages]); + useSignalApi('devtools:info', message => { + setInfo(message as DevToolsInfo); }); + useEffect(() => { + if (!loading && !error && about) { + setInfo(about); + } + }, [about, loading, error]); + if (loading) { return ; } else if (error) { @@ -89,11 +92,6 @@ export const InfoContent = () => { } return ( - - {messages.map((msg, i) => { - return {msg}; - })} - @@ -104,7 +102,7 @@ export const InfoContent = () => { @@ -115,7 +113,7 @@ export const InfoContent = () => { @@ -126,7 +124,7 @@ export const InfoContent = () => { @@ -137,14 +135,14 @@ export const InfoContent = () => { { - copyToClipboard({ about }); + copyToClipboard({ about: info }); }} className={classes.copyButton} > @@ -157,7 +155,7 @@ export const InfoContent = () => { - + ); }; diff --git a/plugins/signals-backend/api-report.md b/plugins/signals-backend/api-report.md index 0ea56c707a..309bd22ce3 100644 --- a/plugins/signals-backend/api-report.md +++ b/plugins/signals-backend/api-report.md @@ -5,7 +5,7 @@ ```ts import express from 'express'; import { Logger } from 'winston'; -import { SignalsService } from '@backstage/plugin-signals-node'; +import { SignalService } from '@backstage/plugin-signals-node'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -15,7 +15,7 @@ export interface RouterOptions { // (undocumented) logger: Logger; // (undocumented) - service: SignalsService; + service: SignalService; } // (No @packageDocumentation comment for this package) diff --git a/plugins/signals-backend/src/service/router.test.ts b/plugins/signals-backend/src/service/router.test.ts index d7091827f9..b41704e008 100644 --- a/plugins/signals-backend/src/service/router.test.ts +++ b/plugins/signals-backend/src/service/router.test.ts @@ -18,9 +18,9 @@ import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; -import { SignalsService } from '@backstage/plugin-signals-node'; +import { SignalService } from '@backstage/plugin-signals-node'; -const signalsServiceMock: jest.Mocked = {} as any; +const signalsServiceMock: jest.Mocked = {} as any; describe('createRouter', () => { let app: express.Express; diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts index 0027acef30..c47517e06c 100644 --- a/plugins/signals-backend/src/service/router.ts +++ b/plugins/signals-backend/src/service/router.ts @@ -17,12 +17,12 @@ import { errorHandler } from '@backstage/backend-common'; import express from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; -import { SignalsService } from '@backstage/plugin-signals-node'; +import { SignalService } from '@backstage/plugin-signals-node'; /** @public */ export interface RouterOptions { logger: Logger; - service: SignalsService; + service: SignalService; } /** @public */ diff --git a/plugins/signals-backend/src/service/standaloneServer.ts b/plugins/signals-backend/src/service/standaloneServer.ts index c13d718734..0728b2fde5 100644 --- a/plugins/signals-backend/src/service/standaloneServer.ts +++ b/plugins/signals-backend/src/service/standaloneServer.ts @@ -21,7 +21,7 @@ import { import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; -import { SignalsService } from '@backstage/plugin-signals-node'; +import { SignalService } from '@backstage/plugin-signals-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; export interface ServerOptions { @@ -43,7 +43,7 @@ export async function startStandaloneServer( issuer: await discovery.getExternalBaseUrl('auth'), }); - const signals = SignalsService.create({ + const signals = SignalService.create({ logger: logger, identity, }); diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index 15a6f67d87..5c9f2fad54 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -19,19 +19,20 @@ export type ServiceOptions = { }; // @public (undocumented) -export type SignalsEventBrokerPayload = { +export type SignalEventBrokerPayload = { recipients?: string[]; topic?: string; message?: JsonObject; }; // @public (undocumented) -export class SignalsService implements EventSubscriber { +export class SignalService implements EventSubscriber { // (undocumented) - static create(options: ServiceOptions): SignalsService; + static create(options: ServiceOptions): SignalService; handleUpgrade: (req: Request_2) => Promise; + hasSubscribers(topic: string): boolean; // (undocumented) - onEvent(params: EventParams): Promise; + onEvent(params: EventParams): Promise; publish( to: string | string[], topic: string, diff --git a/plugins/signals-node/src/SignalsService.ts b/plugins/signals-node/src/SignalService.ts similarity index 90% rename from plugins/signals-node/src/SignalsService.ts rename to plugins/signals-node/src/SignalService.ts index c743c958dd..4846fe7b6f 100644 --- a/plugins/signals-node/src/SignalsService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -22,7 +22,7 @@ import { Logger } from 'winston'; import { ServiceOptions, SignalConnection, - SignalsEventBrokerPayload, + SignalEventBrokerPayload, } from './types'; import { RawData, WebSocket, WebSocketServer } from 'ws'; import { IncomingMessage } from 'http'; @@ -36,7 +36,7 @@ import { } from '@backstage/plugin-auth-node'; /** @public */ -export class SignalsService implements EventSubscriber { +export class SignalService implements EventSubscriber { private readonly serverId: string; private connections: Map = new Map< string, @@ -48,7 +48,7 @@ export class SignalsService implements EventSubscriber { private server: WebSocketServer; static create(options: ServiceOptions) { - return new SignalsService(options); + return new SignalService(options); } private constructor(options: ServiceOptions) { @@ -75,7 +75,7 @@ export class SignalsService implements EventSubscriber { } /** - * Handles request upgradce to websocket and adds the connection to internal + * Handles request upgrade to websocket and adds the connection to internal * list for publish/subscribe functionality * @param req - Request */ @@ -184,6 +184,19 @@ export class SignalsService implements EventSubscriber { ); } + /** + * Checks if there is active subscriptions to specific topic. + * This can be useful to skip heavy processing before publishing messages if there are no subscriptions. + * @param topic - topic to check for subscriptions + */ + hasSubscribers(topic: string): boolean { + return ( + [...this.connections.values()].find(conn => + conn.subscriptions.has(topic), + ) !== undefined + ); + } + private async publishInternal( recipients: string[], topic: string, @@ -229,7 +242,7 @@ export class SignalsService implements EventSubscriber { } } - async onEvent(params: EventParams): Promise { + async onEvent(params: EventParams): Promise { const { eventPayload, metadata } = params; // Discard message from same server to prevent duplicate messages if (!metadata?.server || metadata.server === this.serverId) { diff --git a/plugins/signals-node/src/index.ts b/plugins/signals-node/src/index.ts index 5427711cbe..289efade07 100644 --- a/plugins/signals-node/src/index.ts +++ b/plugins/signals-node/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export * from './SignalsService'; +export * from './SignalService'; export * from './types'; diff --git a/plugins/signals-node/src/types.ts b/plugins/signals-node/src/types.ts index 090e4b2ff1..99821f641d 100644 --- a/plugins/signals-node/src/types.ts +++ b/plugins/signals-node/src/types.ts @@ -29,7 +29,7 @@ export type ServiceOptions = { }; /** @public */ -export type SignalsEventBrokerPayload = { +export type SignalEventBrokerPayload = { recipients?: string[]; topic?: string; message?: JsonObject; diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 21f070c35d..d28b806103 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -7,16 +7,16 @@ import { ApiRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; // @public (undocumented) -export type SignalsApi = { +export type SignalApi = { subscribe(topic: string, onMessage: (message: JsonObject) => void): string; unsubscribe(subscription: string): void; }; // @public (undocumented) -export const signalsApiRef: ApiRef; +export const signalApiRef: ApiRef; // @public (undocumented) -export const useSignalsApi: ( +export const useSignalApi: ( topic: string, onMessage: (message: JsonObject) => void, ) => void; diff --git a/plugins/signals-react/src/api/SignalsApi.ts b/plugins/signals-react/src/api/SignalApi.ts similarity index 88% rename from plugins/signals-react/src/api/SignalsApi.ts rename to plugins/signals-react/src/api/SignalApi.ts index 2aefa5d166..f63023bf5f 100644 --- a/plugins/signals-react/src/api/SignalsApi.ts +++ b/plugins/signals-react/src/api/SignalApi.ts @@ -17,12 +17,12 @@ import { createApiRef } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; /** @public */ -export const signalsApiRef = createApiRef({ - id: 'plugin.signals.service', +export const signalApiRef = createApiRef({ + id: 'plugin.signal.service', }); /** @public */ -export type SignalsApi = { +export type SignalApi = { subscribe(topic: string, onMessage: (message: JsonObject) => void): string; unsubscribe(subscription: string): void; diff --git a/plugins/signals-react/src/api/index.ts b/plugins/signals-react/src/api/index.ts index 9d5c4c5656..8d728dbd0f 100644 --- a/plugins/signals-react/src/api/index.ts +++ b/plugins/signals-react/src/api/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './SignalsApi'; +export * from './SignalApi'; diff --git a/plugins/signals-react/src/hooks/index.ts b/plugins/signals-react/src/hooks/index.ts index 0d5967f9eb..921eca8d30 100644 --- a/plugins/signals-react/src/hooks/index.ts +++ b/plugins/signals-react/src/hooks/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './useSignalsApi'; +export * from './useSignalApi'; diff --git a/plugins/signals-react/src/hooks/useSignalsApi.ts b/plugins/signals-react/src/hooks/useSignalApi.ts similarity index 91% rename from plugins/signals-react/src/hooks/useSignalsApi.ts rename to plugins/signals-react/src/hooks/useSignalApi.ts index 0fcc69efed..bb6f47a5fe 100644 --- a/plugins/signals-react/src/hooks/useSignalsApi.ts +++ b/plugins/signals-react/src/hooks/useSignalApi.ts @@ -13,17 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { signalsApiRef } from '../api'; +import { signalApiRef } from '../api'; import { useApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { useEffect, useState } from 'react'; /** @public */ -export const useSignalsApi = ( +export const useSignalApi = ( topic: string, onMessage: (message: JsonObject) => void, ) => { - const signals = useApi(signalsApiRef); + const signals = useApi(signalApiRef); const [subscription, setSubscription] = useState(null); useEffect(() => { if (!subscription) { diff --git a/plugins/signals/api-report.md b/plugins/signals/api-report.md index 08c52ef418..28a70a339c 100644 --- a/plugins/signals/api-report.md +++ b/plugins/signals/api-report.md @@ -7,17 +7,17 @@ import { BackstagePlugin } from '@backstage/core-plugin-api'; import { DiscoveryApi } from '@backstage/core-plugin-api'; import { IdentityApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; -import { SignalsApi } from '@backstage/plugin-signals-react'; +import { SignalApi } from '@backstage/plugin-signals-react'; // @public (undocumented) -export class SignalsClient implements SignalsApi { +export class SignalClient implements SignalApi { // (undocumented) static create(options: { identity: IdentityApi; discoveryApi: DiscoveryApi; connectTimeout?: number; reconnectTimeout?: number; - }): SignalsClient; + }): SignalClient; // (undocumented) static readonly DEFAULT_CONNECT_TIMEOUT_MS: number; // (undocumented) diff --git a/plugins/signals/src/api/SignalsClient.ts b/plugins/signals/src/api/SignalClient.ts similarity index 94% rename from plugins/signals/src/api/SignalsClient.ts rename to plugins/signals/src/api/SignalClient.ts index 4051468a9e..64c1866fab 100644 --- a/plugins/signals/src/api/SignalsClient.ts +++ b/plugins/signals/src/api/SignalClient.ts @@ -13,24 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { SignalsApi } from '@backstage/plugin-signals-react'; +import { SignalApi } from '@backstage/plugin-signals-react'; import { JsonObject } from '@backstage/types'; import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { v4 as uuid } from 'uuid'; -/** @internal */ type Subscription = { topic: string; callback: (message: JsonObject) => void; }; -/** @internal */ const WS_CLOSE_NORMAL = 1000; -/** @internal */ const WS_CLOSE_GOING_AWAY = 1001; /** @public */ -export class SignalsClient implements SignalsApi { +export class SignalClient implements SignalApi { static readonly DEFAULT_CONNECT_TIMEOUT_MS: number = 1000; static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number = 5000; private ws: WebSocket | null = null; @@ -47,10 +44,10 @@ export class SignalsClient implements SignalsApi { const { identity, discoveryApi, - connectTimeout = SignalsClient.DEFAULT_CONNECT_TIMEOUT_MS, - reconnectTimeout = SignalsClient.DEFAULT_RECONNECT_TIMEOUT_MS, + connectTimeout = SignalClient.DEFAULT_CONNECT_TIMEOUT_MS, + reconnectTimeout = SignalClient.DEFAULT_RECONNECT_TIMEOUT_MS, } = options; - return new SignalsClient( + return new SignalClient( identity, discoveryApi, connectTimeout, diff --git a/plugins/signals/src/api/SignalsClient.test.ts b/plugins/signals/src/api/SignalsClient.test.ts index 03b25d580d..60d67e6c4b 100644 --- a/plugins/signals/src/api/SignalsClient.test.ts +++ b/plugins/signals/src/api/SignalsClient.test.ts @@ -16,7 +16,7 @@ import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import WS from 'jest-websocket-mock'; -import { SignalsClient } from './SignalsClient'; +import { SignalClient } from './SignalClient'; describe('SignalsClient', () => { const tokenFunction = jest.fn(); @@ -43,7 +43,7 @@ describe('SignalsClient', () => { it('should handle single subscription correctly', async () => { const messageMock = jest.fn(); - const client = SignalsClient.create({ discoveryApi, identity }); + const client = SignalClient.create({ discoveryApi, identity }); const sub = client.subscribe('topic', messageMock); await server.connected; @@ -64,8 +64,8 @@ describe('SignalsClient', () => { it('should handle multiple subscription correctly', async () => { const messageMock1 = jest.fn(); const messageMock2 = jest.fn(); - const client1 = SignalsClient.create({ discoveryApi, identity }); - const client2 = SignalsClient.create({ discoveryApi, identity }); + const client1 = SignalClient.create({ discoveryApi, identity }); + const client2 = SignalClient.create({ discoveryApi, identity }); const sub1 = client1.subscribe('topic', messageMock1); const sub2 = client2.subscribe('topic', messageMock2); @@ -94,7 +94,7 @@ describe('SignalsClient', () => { it('should reconnect on error', async () => { const messageMock = jest.fn(); - const client = SignalsClient.create({ + const client = SignalClient.create({ discoveryApi, identity, reconnectTimeout: 10, diff --git a/plugins/signals/src/api/index.ts b/plugins/signals/src/api/index.ts index 80e69259af..c76ae196ed 100644 --- a/plugins/signals/src/api/index.ts +++ b/plugins/signals/src/api/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './SignalsClient'; +export * from './SignalClient'; diff --git a/plugins/signals/src/plugin.ts b/plugins/signals/src/plugin.ts index d1b0311116..946e9aaa71 100644 --- a/plugins/signals/src/plugin.ts +++ b/plugins/signals/src/plugin.ts @@ -19,21 +19,21 @@ import { discoveryApiRef, identityApiRef, } from '@backstage/core-plugin-api'; -import { signalsApiRef } from '@backstage/plugin-signals-react'; -import { SignalsClient } from './api/SignalsClient'; +import { signalApiRef } from '@backstage/plugin-signals-react'; +import { SignalClient } from './api/SignalClient'; /** @public */ export const signalsPlugin = createPlugin({ id: 'signals', apis: [ createApiFactory({ - api: signalsApiRef, + api: signalApiRef, deps: { identity: identityApiRef, discoveryApi: discoveryApiRef, }, factory: ({ identity, discoveryApi }) => - SignalsClient.create({ + SignalClient.create({ identity, discoveryApi, }), diff --git a/yarn.lock b/yarn.lock index d44cfeab72..de28fd808d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6203,6 +6203,7 @@ __metadata: "@backstage/plugin-devtools-common": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" + "@backstage/plugin-signals-node": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": ^1.1.3 "@types/express": "*" From a219469cf33391312dc26a23291c782f3bd1e5ab Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 5 Dec 2023 16:03:41 +0200 Subject: [PATCH 09/20] docs: add signals plugin docs Signed-off-by: Heikki Hellgren --- plugins/signals-backend/README.md | 13 ++---- plugins/signals-node/README.md | 67 ++++++++++++++++++++++++++++++- plugins/signals-react/README.md | 44 +++++++++++++++++++- plugins/signals/README.md | 38 +++++++++++++++--- 4 files changed, 146 insertions(+), 16 deletions(-) diff --git a/plugins/signals-backend/README.md b/plugins/signals-backend/README.md index d194b852f2..1f58c1195a 100644 --- a/plugins/signals-backend/README.md +++ b/plugins/signals-backend/README.md @@ -6,26 +6,21 @@ Signals plugin allows backend plugins to publish messages to frontend plugins. ## Getting started -Add Signals router to your backend in `packages/backend/src/plugins/signals.ts`: +First install the `@backstage/plugin-signals-node` plugin to get the `SignalService` set up. + +Next, add Signals router to your backend in `packages/backend/src/plugins/signals.ts`: ```ts import { Router } from 'express'; import { createRouter } from '@backstage/plugin-signals-backend'; -import { SignalsService } from '@backstage/plugin-signals-node'; import { PluginEnvironment } from '../types'; export default async function createPlugin( env: PluginEnvironment, ): Promise { - const service = SignalsService.create({ - logger: env.logger, - identity: env.identity, - eventBroker: env.eventBroker, - }); - return await createRouter({ logger: env.logger, - service, + service: env.signalsService, }); } ``` diff --git a/plugins/signals-node/README.md b/plugins/signals-node/README.md index 55d766f9e0..abea6c6d22 100644 --- a/plugins/signals-node/README.md +++ b/plugins/signals-node/README.md @@ -2,4 +2,69 @@ Welcome to the Node.js library package for the signals plugin! -_This plugin was created through the Backstage CLI_ +Signals plugin allows backend plugins to publish messages to frontend plugins. + +## Getting started + +Add SignalService to your plugin environment in `packages/backend/src/types.ts`: + +```ts +import { SignalService } from '@backstage/plugin-signals-node'; + +export type PluginEnvironment = { + // ... + signalService: SignalService; +}; +``` + +Add it also to your `makeCreateEnv` to allow access from the other plugins: + +```ts +import { SignalService } from '@backstage/plugin-signals-node'; +import { DefaultEventBroker } from '@backstage/plugin-events-backend'; + +function makeCreateEnv(config: Config) { + // ... + + const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); + const signalService = SignalService.create({ + logger: root, + eventBroker, // EventBroker is optional + identity, + }); + + return (plugin: string): PluginEnvironment => { + const logger = root.child({ type: 'plugin', plugin }); + return { + logger, + eventBroker, + signalService, + // ... + }; + }; +} +``` + +To allow connections from the frontend, you should also install the `@backstage/plugin-signals-backend`. + +## Using the service + +Once you have both of the backend plugins installed, you can utilize the signal service by calling the +`publish` method. This will publish the message to all subscribers in the frontend. To send message to +all subscribers, you can use `*` as `to` parameter. + +```ts +// Periodic sending example +setInterval(async () => { + // You can use hasSubscribers to check if the message will be sent to anyone + // in case you need some heavy processing before that + if (signalService.hasSubscribers('plugin:topic')) { + await signalService.publish('*', 'plugin:topic', { + message: 'hello world', + }); + } +}, 5000); +``` + +To receive this message in the frontend, check the documentation of `@backstage/plugin-signals` and +`@backstage/plugin-signals-react`. diff --git a/plugins/signals-react/README.md b/plugins/signals-react/README.md index 4b6b4e00bc..9bdf5e964f 100644 --- a/plugins/signals-react/README.md +++ b/plugins/signals-react/README.md @@ -2,4 +2,46 @@ Welcome to the web library package for the signals plugin! -_This plugin was created through the Backstage CLI_ +Signals plugin allows backend plugins to publish messages to frontend plugins. + +## Getting started + +This plugin contains functionalities that help utilize the signals plugin. To get started, +see installation instructions from `@backstage/plugin-signals-node`, `@backstage/plugin-signals-backend`, and +`@backstage/plugin-signals`. + +There are two ways to utilize the signals plugin; either by using the hook or by directly using the API. + +## Using the hook + +By using the hook, unsubscribe is automatically taken care of. This helps to maintain only necessary amount +of connections to the backend and also to allow multiple subscriptions using the same connection. + +Example of using the hook: + +```ts +import { useSignalApi } from '@backstage/plugin-signals-react'; + +const [message, setMessage] = useState(undefined); +useSignalApi('myplugin:topic', message => { + setMessage(message); +}); +``` + +Whenever backend publishes new message to the topic `myplugin:topic`, the state of this component is changed. + +## Using API directly + +You can also use the signal API directly. This allows more fine-grained control over the state of the connections and +subscriptions. + +```ts +import { signalsApiRef } from '@backstage/plugin-signals-react'; + +const signals = useApi(signalsApiRef); +signals.subscribe('myplugin:topic', (message: JsonObject) => { + console.log(message); +}); +// Remember to unsubscribe +signals.unsubscribe('myplugin:topic'); +``` diff --git a/plugins/signals/README.md b/plugins/signals/README.md index 604fa98a33..e70c1ef7d8 100644 --- a/plugins/signals/README.md +++ b/plugins/signals/README.md @@ -2,12 +2,40 @@ Welcome to the signals plugin! -_This plugin was created through the Backstage CLI_ +Signals plugin allows backend plugins to publish messages to frontend plugins. ## Getting started -Your plugin has been added to the example app in this repository, meaning you'll be able to access it by running `yarn start` in the root directory, and then navigating to [/signals](http://localhost:3000/signals). +This plugin contains client that can receive messages from the backend. To get started, +see installation instructions from `@backstage/plugin-signals-node`, `@backstage/plugin-signals-backend`. -You can also serve the plugin in isolation by running `yarn start` in the plugin directory. -This method of serving the plugin provides quicker iteration speed and a faster startup and hot reloads. -It is only meant for local development, and the setup for it can be found inside the [/dev](./dev) directory. +To install the plugin, you have to add the following to your `packages/app/src/plugins.ts`: + +```ts +export { signalsPlugin } from '@backstage/plugin-signals'; +``` + +And make sure that your `packages/app/src/App.tsx` contains: + +```ts +import * as plugins from './plugins'; + +const app = createApp({ + // ... + plugins: Object.values(plugins), + // ... +}); +``` + +Now you can utilize the API from other plugins using the `@backstage/plugin-signals-react` package or simply by: + +```ts +import { signalsApiRef } from '@backstage/plugin-signals-react'; + +const signals = useApi(signalsApiRef); +signals.subscribe('myplugin:topic', (message: JsonObject) => { + console.log(message); +}); +// Remember to unsubscribe +signals.unsubscribe('myplugin:topic'); +``` From 6a730a43104dff559ca1535957edf2c80c60dcaa Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 8 Dec 2023 14:28:22 +0200 Subject: [PATCH 10/20] fix: handle upgrade properly in signal router Signed-off-by: Heikki Hellgren --- plugins/devtools-backend/api-report.md | 2 ++ .../src/api/DevToolsBackendApi.ts | 16 +++++---- .../devtools-backend/src/service/router.ts | 8 +++-- .../Content/InfoContent/InfoContent.tsx | 20 +++++------ plugins/signals-backend/src/service/router.ts | 35 +++++++++++++------ plugins/signals-node/api-report.md | 11 ++++-- plugins/signals-node/src/SignalService.ts | 27 +++++--------- 7 files changed, 69 insertions(+), 50 deletions(-) diff --git a/plugins/devtools-backend/api-report.md b/plugins/devtools-backend/api-report.md index 85b2bf6efa..7049285c02 100644 --- a/plugins/devtools-backend/api-report.md +++ b/plugins/devtools-backend/api-report.md @@ -25,6 +25,8 @@ export class DevToolsBackendApi { listExternalDependencyDetails(): Promise; // (undocumented) listInfo(): Promise; + // (undocumented) + listResourceUtilization(): Promise; } // @public diff --git a/plugins/devtools-backend/src/api/DevToolsBackendApi.ts b/plugins/devtools-backend/src/api/DevToolsBackendApi.ts index b71edb3391..da9a1ca0dd 100644 --- a/plugins/devtools-backend/src/api/DevToolsBackendApi.ts +++ b/plugins/devtools-backend/src/api/DevToolsBackendApi.ts @@ -203,17 +203,21 @@ export class DevToolsBackendApi { return configInfo; } - public async listInfo(): Promise { - const operatingSystem = `${os.hostname()}: ${os.type} ${os.release} - ${ - os.platform - }/${os.arch}`; + public async listResourceUtilization(): Promise { const usedMem = Math.floor((os.totalmem() - os.freemem()) / (1024 * 1024)); - const resources = `Memory: ${usedMem}/${Math.floor( + return `Memory: ${usedMem}/${Math.floor( os.totalmem() / (1024 * 1024), )}MB - Load: ${os .loadavg() .map(v => v.toFixed(2)) .join('/')}`; + } + + public async listInfo(): Promise { + const operatingSystem = `${os.hostname()}: ${os.type} ${os.release} - ${ + os.platform + }/${os.arch}`; + const nodeJsVersion = process.version; /* eslint-disable-next-line no-restricted-syntax */ @@ -247,7 +251,7 @@ export class DevToolsBackendApi { const info: DevToolsInfo = { operatingSystem: operatingSystem ?? 'N/A', - resourceUtilization: resources ?? 'N/A', + resourceUtilization: (await this.listResourceUtilization()) ?? 'N/A', nodeJsVersion: nodeJsVersion ?? 'N/A', backstageVersion: backstageJson && backstageJson.version ? backstageJson.version : 'N/A', diff --git a/plugins/devtools-backend/src/service/router.ts b/plugins/devtools-backend/src/service/router.ts index fb8b106337..106eb44309 100644 --- a/plugins/devtools-backend/src/service/router.ts +++ b/plugins/devtools-backend/src/service/router.ts @@ -56,9 +56,11 @@ export async function createRouter( if (signalService) { // Publish info periodically using the signal service setInterval(async () => { - if (signalService.hasSubscribers('devtools:info')) { - const info = await devToolsBackendApi.listInfo(); - await signalService.publish('*', 'devtools:info', info); + if (signalService.hasSubscribers('devtools:resources')) { + const info = await devToolsBackendApi.listResourceUtilization(); + await signalService.publish('*', 'devtools:resources', { + resources: info, + }); } }, 5000); } diff --git a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx index 74587d3e57..2480bf5233 100644 --- a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx +++ b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx @@ -73,15 +73,15 @@ const copyToClipboard = ({ about }: { about: DevToolsInfo | undefined }) => { /** @public */ export const InfoContent = () => { const classes = useStyles(); - const [info, setInfo] = useState(undefined); + const [resources, setResources] = useState(undefined); const { about, loading, error } = useInfo(); - useSignalApi('devtools:info', message => { - setInfo(message as DevToolsInfo); + useSignalApi('devtools:resources', message => { + setResources(message.resources as string); }); useEffect(() => { if (!loading && !error && about) { - setInfo(about); + setResources(about.resourceUtilization); } }, [about, loading, error]); @@ -102,7 +102,7 @@ export const InfoContent = () => { @@ -113,7 +113,7 @@ export const InfoContent = () => { @@ -124,7 +124,7 @@ export const InfoContent = () => { @@ -135,14 +135,14 @@ export const InfoContent = () => { { - copyToClipboard({ about: info }); + copyToClipboard({ about }); }} className={classes.copyButton} > @@ -155,7 +155,7 @@ export const InfoContent = () => { - + ); }; diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts index c47517e06c..acffde19c7 100644 --- a/plugins/signals-backend/src/service/router.ts +++ b/plugins/signals-backend/src/service/router.ts @@ -14,10 +14,12 @@ * limitations under the License. */ import { errorHandler } from '@backstage/backend-common'; -import express from 'express'; +import express, { NextFunction, Request, Response } from 'express'; import Router from 'express-promise-router'; import { Logger } from 'winston'; import { SignalService } from '@backstage/plugin-signals-node'; +import * as https from 'https'; +import http from 'http'; /** @public */ export interface RouterOptions { @@ -30,17 +32,14 @@ export async function createRouter( options: RouterOptions, ): Promise { const { logger, service } = options; + let subscribed = false; - const router = Router(); - router.use(express.json()); - - router.get('/health', (_, response) => { - logger.info('PONG!'); - response.json({ status: 'ok' }); - }); - - router.get('/', async (req, _, next) => { + const upgradeMiddleware = (req: Request, _: Response, next: NextFunction) => { + const server: https.Server | http.Server = ( + (req.socket ?? req.connection) as any + )?.server; if ( + !server || !req.headers || req.headers.upgrade === undefined || req.headers.upgrade.toLowerCase() !== 'websocket' @@ -49,7 +48,21 @@ export async function createRouter( return; } - await service.handleUpgrade(req); + if (!subscribed) { + server.on('upgrade', async (request, socket, head) => { + await service.handleUpgrade(request, socket, head); + }); + subscribed = true; + } + }; + + const router = Router(); + router.use(express.json()); + router.use(upgradeMiddleware); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); }); router.use(errorHandler()); diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index 5c9f2fad54..3ad4da11ee 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -3,13 +3,16 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +/// + +import { Duplex } from 'stream'; import { EventBroker } from '@backstage/plugin-events-node'; import { EventParams } from '@backstage/plugin-events-node'; import { EventSubscriber } from '@backstage/plugin-events-node'; import { IdentityApi } from '@backstage/plugin-auth-node'; +import { IncomingMessage } from 'http'; import { JsonObject } from '@backstage/types'; import { Logger } from 'winston'; -import { Request as Request_2 } from 'express'; // @public (undocumented) export type ServiceOptions = { @@ -29,7 +32,11 @@ export type SignalEventBrokerPayload = { export class SignalService implements EventSubscriber { // (undocumented) static create(options: ServiceOptions): SignalService; - handleUpgrade: (req: Request_2) => Promise; + handleUpgrade: ( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + ) => Promise; hasSubscribers(topic: string): boolean; // (undocumented) onEvent(params: EventParams): Promise; diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index 4846fe7b6f..5ae7b63deb 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -27,13 +27,13 @@ import { import { RawData, WebSocket, WebSocketServer } from 'ws'; import { IncomingMessage } from 'http'; import { v4 as uuid } from 'uuid'; -import { Request } from 'express'; import { JsonObject } from '@backstage/types'; import { BackstageIdentityResponse, IdentityApi, IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; +import { Duplex } from 'stream'; /** @public */ export class SignalService implements EventSubscriber { @@ -61,14 +61,7 @@ export class SignalService implements EventSubscriber { this.serverId = uuid(); this.server = new WebSocketServer({ noServer: true, - }); - - this.server.on('close', () => { - this.logger.info('Closing signals server'); - this.connections.forEach(conn => { - conn.ws.close(); - }); - this.connections = new Map(); + clientTracking: false, }); this.eventBroker?.subscribe(this); @@ -79,7 +72,11 @@ export class SignalService implements EventSubscriber { * list for publish/subscribe functionality * @param req - Request */ - handleUpgrade = async (req: Request) => { + handleUpgrade = async ( + req: IncomingMessage, + socket: Duplex, + head: Buffer, + ) => { let identity: BackstageIdentityResponse | undefined = undefined; // Authentication token is passed in Sec-WebSocket-Protocol header as there @@ -95,8 +92,8 @@ export class SignalService implements EventSubscriber { this.server.handleUpgrade( req, - req.socket, - Buffer.from(''), + socket, + head, (ws: WebSocket, __: IncomingMessage) => { this.addConnection(ws, identity); }, @@ -131,17 +128,11 @@ export class SignalService implements EventSubscriber { this.connections.delete(id); }); - ws.on('ping', () => { - this.logger.debug(`Ping from connection ${id}`); - ws.pong(); - }); - ws.on('message', (data: RawData, isBinary: boolean) => { this.logger.debug(`Received message from connection ${id}: ${data}`); if (isBinary) { return; } - try { const json = JSON.parse(data.toString()) as JsonObject; this.handleMessage(conn, json); From ec8c5fcb707164b6269d221beee09a321b023273 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 8 Dec 2023 14:38:34 +0200 Subject: [PATCH 11/20] fix: only allow one signal client instance at once Signed-off-by: Heikki Hellgren --- plugins/signals/src/plugin.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/plugins/signals/src/plugin.ts b/plugins/signals/src/plugin.ts index 946e9aaa71..1482d7dae1 100644 --- a/plugins/signals/src/plugin.ts +++ b/plugins/signals/src/plugin.ts @@ -22,6 +22,8 @@ import { import { signalApiRef } from '@backstage/plugin-signals-react'; import { SignalClient } from './api/SignalClient'; +let clientInstance: SignalClient | undefined = undefined; + /** @public */ export const signalsPlugin = createPlugin({ id: 'signals', @@ -32,11 +34,15 @@ export const signalsPlugin = createPlugin({ identity: identityApiRef, discoveryApi: discoveryApiRef, }, - factory: ({ identity, discoveryApi }) => - SignalClient.create({ - identity, - discoveryApi, - }), + factory: ({ identity, discoveryApi }) => { + if (!clientInstance) { + clientInstance = SignalClient.create({ + identity, + discoveryApi, + }); + } + return clientInstance; + }, }), ], }); From aaa4e910e29e9cdc30df26d988d7db54e29ff678 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 8 Dec 2023 14:53:46 +0200 Subject: [PATCH 12/20] deps: move express types to dev deps Signed-off-by: Heikki Hellgren --- plugins/signals-node/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/signals-node/package.json b/plugins/signals-node/package.json index 2dc17f5e54..66e4528678 100644 --- a/plugins/signals-node/package.json +++ b/plugins/signals-node/package.json @@ -22,7 +22,8 @@ "postpack": "backstage-cli package postpack" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@types/express": "^4.17.21" }, "files": [ "dist" @@ -33,7 +34,6 @@ "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-events-node": "workspace:^", "@backstage/types": "workspace:^", - "@types/express": "^4.17.21", "express": "^4.17.1", "uuid": "^8.0.0", "winston": "^3.2.1", From f3575bc34c2f53fb20362ca7e871dfd6c7cd0fcb Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 8 Dec 2023 15:33:42 +0200 Subject: [PATCH 13/20] fix: initial review finding fixes Signed-off-by: Heikki Hellgren --- plugins/devtools-backend/src/plugin.ts | 5 +- plugins/signals-backend/README.md | 18 +++++++ plugins/signals-backend/api-report.md | 5 ++ plugins/signals-backend/package.json | 1 + plugins/signals-backend/src/index.ts | 1 + plugins/signals-backend/src/plugin.ts | 48 +++++++++++++++++++ plugins/signals-backend/src/service/router.ts | 3 +- plugins/signals-node/api-report.md | 4 ++ plugins/signals-node/package.json | 1 + plugins/signals-node/src/index.ts | 1 + plugins/signals-node/src/lib.ts | 21 ++++++++ yarn.lock | 2 + 12 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 plugins/signals-backend/src/plugin.ts create mode 100644 plugins/signals-node/src/lib.ts diff --git a/plugins/devtools-backend/src/plugin.ts b/plugins/devtools-backend/src/plugin.ts index 685522557e..02c3831128 100644 --- a/plugins/devtools-backend/src/plugin.ts +++ b/plugins/devtools-backend/src/plugin.ts @@ -20,6 +20,7 @@ import { createBackendPlugin, } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; +import { signalService } from '@backstage/plugin-signals-node'; /** * DevTools backend plugin @@ -35,13 +36,15 @@ export const devtoolsPlugin = createBackendPlugin({ logger: coreServices.logger, permissions: coreServices.permissions, httpRouter: coreServices.httpRouter, + signals: signalService, }, - async init({ config, logger, permissions, httpRouter }) { + async init({ config, logger, permissions, httpRouter, signals }) { httpRouter.use( await createRouter({ config, logger: loggerToWinstonLogger(logger), permissions, + signalService: signals, }), ); }, diff --git a/plugins/signals-backend/README.md b/plugins/signals-backend/README.md index 1f58c1195a..f9ebfb80c7 100644 --- a/plugins/signals-backend/README.md +++ b/plugins/signals-backend/README.md @@ -24,3 +24,21 @@ export default async function createPlugin( }); } ``` + +Now add the signals to `packages/backend/src/index.ts`: + +```ts +// ... +import signals from './plugins/sonarqube'; + +async function main() { + // ... + const signalsEnv = useHotMemoize(module, () => createEnv('signals')); + + const apiRouter = Router(); + // ... + apiRouter.use('/signals', await signals(signalsEnv)); + apiRouter.use(notFoundHandler()); + // ... +} +``` diff --git a/plugins/signals-backend/api-report.md b/plugins/signals-backend/api-report.md index 309bd22ce3..afbc63abf2 100644 --- a/plugins/signals-backend/api-report.md +++ b/plugins/signals-backend/api-report.md @@ -3,6 +3,7 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts +import { BackendFeature } from '@backstage/backend-plugin-api'; import express from 'express'; import { Logger } from 'winston'; import { SignalService } from '@backstage/plugin-signals-node'; @@ -18,5 +19,9 @@ export interface RouterOptions { service: SignalService; } +// @public +const signalsPlugin: () => BackendFeature; +export default signalsPlugin; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/signals-backend/package.json b/plugins/signals-backend/package.json index ee303cdf3b..2fe6d86480 100644 --- a/plugins/signals-backend/package.json +++ b/plugins/signals-backend/package.json @@ -23,6 +23,7 @@ }, "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-events-node": "workspace:^", diff --git a/plugins/signals-backend/src/index.ts b/plugins/signals-backend/src/index.ts index d2e8d61bad..05e1f941b2 100644 --- a/plugins/signals-backend/src/index.ts +++ b/plugins/signals-backend/src/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './service/router'; +export { signalsPlugin as default } from './plugin'; diff --git a/plugins/signals-backend/src/plugin.ts b/plugins/signals-backend/src/plugin.ts new file mode 100644 index 0000000000..b4a0a3e095 --- /dev/null +++ b/plugins/signals-backend/src/plugin.ts @@ -0,0 +1,48 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { loggerToWinstonLogger } from '@backstage/backend-common'; +import { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; +import { signalService } from '@backstage/plugin-signals-node'; + +/** + * Signals backend plugin + * + * @public + */ +export const signalsPlugin = createBackendPlugin({ + pluginId: 'devtools', + register(env) { + env.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + logger: coreServices.logger, + service: signalService, + }, + async init({ httpRouter, logger, service }) { + httpRouter.use( + await createRouter({ + logger: loggerToWinstonLogger(logger), + service, + }), + ); + }, + }); + }, +}); diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts index acffde19c7..7b0cc02a1d 100644 --- a/plugins/signals-backend/src/service/router.ts +++ b/plugins/signals-backend/src/service/router.ts @@ -33,7 +33,6 @@ export async function createRouter( ): Promise { const { logger, service } = options; let subscribed = false; - const upgradeMiddleware = (req: Request, _: Response, next: NextFunction) => { const server: https.Server | http.Server = ( (req.socket ?? req.connection) as any @@ -49,10 +48,10 @@ export async function createRouter( } if (!subscribed) { + subscribed = true; server.on('upgrade', async (request, socket, head) => { await service.handleUpgrade(request, socket, head); }); - subscribed = true; } }; diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index 3ad4da11ee..750dd1774a 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -13,6 +13,7 @@ import { IdentityApi } from '@backstage/plugin-auth-node'; import { IncomingMessage } from 'http'; import { JsonObject } from '@backstage/types'; import { Logger } from 'winston'; +import { ServiceRef } from '@backstage/backend-plugin-api'; // @public (undocumented) export type ServiceOptions = { @@ -49,5 +50,8 @@ export class SignalService implements EventSubscriber { supportsEventTopics(): string[]; } +// @public (undocumented) +export const signalService: ServiceRef; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/signals-node/package.json b/plugins/signals-node/package.json index 66e4528678..d5e980d857 100644 --- a/plugins/signals-node/package.json +++ b/plugins/signals-node/package.json @@ -30,6 +30,7 @@ ], "dependencies": { "@backstage/backend-common": "workspace:^", + "@backstage/backend-plugin-api": "workspace:^", "@backstage/config": "workspace:^", "@backstage/plugin-auth-node": "workspace:^", "@backstage/plugin-events-node": "workspace:^", diff --git a/plugins/signals-node/src/index.ts b/plugins/signals-node/src/index.ts index 289efade07..43a3a88ac0 100644 --- a/plugins/signals-node/src/index.ts +++ b/plugins/signals-node/src/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export * from './lib'; export * from './SignalService'; export * from './types'; diff --git a/plugins/signals-node/src/lib.ts b/plugins/signals-node/src/lib.ts new file mode 100644 index 0000000000..d303b4d3cc --- /dev/null +++ b/plugins/signals-node/src/lib.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createServiceRef } from '@backstage/backend-plugin-api'; + +/** @public */ +export const signalService = createServiceRef< + import('./SignalService').SignalService +>({ id: 'signals.service', scope: 'plugin' }); diff --git a/yarn.lock b/yarn.lock index de28fd808d..7a3fbb3902 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8839,6 +8839,7 @@ __metadata: resolution: "@backstage/plugin-signals-backend@workspace:plugins/signals-backend" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" @@ -8865,6 +8866,7 @@ __metadata: resolution: "@backstage/plugin-signals-node@workspace:plugins/signals-node" dependencies: "@backstage/backend-common": "workspace:^" + "@backstage/backend-plugin-api": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/plugin-auth-node": "workspace:^" From 5368d38e9d0dbfb03745db0de44277a9bae2a219 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 8 Dec 2023 15:50:41 +0200 Subject: [PATCH 14/20] fix: add check for request path to upgrade Signed-off-by: Heikki Hellgren --- plugins/signals-backend/src/service/router.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts index 7b0cc02a1d..8926910544 100644 --- a/plugins/signals-backend/src/service/router.ts +++ b/plugins/signals-backend/src/service/router.ts @@ -34,9 +34,7 @@ export async function createRouter( const { logger, service } = options; let subscribed = false; const upgradeMiddleware = (req: Request, _: Response, next: NextFunction) => { - const server: https.Server | http.Server = ( - (req.socket ?? req.connection) as any - )?.server; + const server: https.Server | http.Server = (req.socket as any)?.server; if ( !server || !req.headers || @@ -50,7 +48,10 @@ export async function createRouter( if (!subscribed) { subscribed = true; server.on('upgrade', async (request, socket, head) => { - await service.handleUpgrade(request, socket, head); + // Only upgrade if request to root of the signals plugin + if (request.url === '/api/signals') { + await service.handleUpgrade(request, socket, head); + } }); } }; From 3b6b645d93125a1a88dfe6edc973047f2f44353a Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 8 Dec 2023 16:42:16 +0200 Subject: [PATCH 15/20] feat: add service factory for signal service Signed-off-by: Heikki Hellgren --- plugins/signals-backend/src/plugin.ts | 2 +- plugins/signals-node/api-report.md | 4 ++-- plugins/signals-node/src/SignalService.ts | 4 ++-- plugins/signals-node/src/lib.ts | 26 +++++++++++++++++++---- plugins/signals-node/src/types.ts | 4 ++-- 5 files changed, 29 insertions(+), 11 deletions(-) diff --git a/plugins/signals-backend/src/plugin.ts b/plugins/signals-backend/src/plugin.ts index b4a0a3e095..8ade4e85b5 100644 --- a/plugins/signals-backend/src/plugin.ts +++ b/plugins/signals-backend/src/plugin.ts @@ -27,7 +27,7 @@ import { signalService } from '@backstage/plugin-signals-node'; * @public */ export const signalsPlugin = createBackendPlugin({ - pluginId: 'devtools', + pluginId: 'signals', register(env) { env.registerInit({ deps: { diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index 750dd1774a..7bfffc2540 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -12,13 +12,13 @@ import { EventSubscriber } from '@backstage/plugin-events-node'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { IncomingMessage } from 'http'; import { JsonObject } from '@backstage/types'; -import { Logger } from 'winston'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; // @public (undocumented) export type ServiceOptions = { eventBroker?: EventBroker; - logger: Logger; + logger: LoggerService; identity: IdentityApi; }; diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index 5ae7b63deb..54f09f583a 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -18,7 +18,6 @@ import { EventParams, EventSubscriber, } from '@backstage/plugin-events-node'; -import { Logger } from 'winston'; import { ServiceOptions, SignalConnection, @@ -34,6 +33,7 @@ import { IdentityApiGetIdentityRequest, } from '@backstage/plugin-auth-node'; import { Duplex } from 'stream'; +import { LoggerService } from '@backstage/backend-plugin-api'; /** @public */ export class SignalService implements EventSubscriber { @@ -43,7 +43,7 @@ export class SignalService implements EventSubscriber { SignalConnection >(); private eventBroker?: EventBroker; - private logger: Logger; + private logger: LoggerService; private identity: IdentityApi; private server: WebSocketServer; diff --git a/plugins/signals-node/src/lib.ts b/plugins/signals-node/src/lib.ts index d303b4d3cc..b95d4a9231 100644 --- a/plugins/signals-node/src/lib.ts +++ b/plugins/signals-node/src/lib.ts @@ -13,9 +13,27 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { createServiceRef } from '@backstage/backend-plugin-api'; +import { + coreServices, + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { SignalService } from './SignalService'; /** @public */ -export const signalService = createServiceRef< - import('./SignalService').SignalService ->({ id: 'signals.service', scope: 'plugin' }); +export const signalService = createServiceRef({ + id: 'signals.service', + scope: 'plugin', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + logger: coreServices.logger, + identity: coreServices.identity, + // TODO: EventBroker + }, + factory({ logger, identity }) { + return SignalService.create({ identity, logger }); + }, + }), +}); diff --git a/plugins/signals-node/src/types.ts b/plugins/signals-node/src/types.ts index 99821f641d..6bfafae0b2 100644 --- a/plugins/signals-node/src/types.ts +++ b/plugins/signals-node/src/types.ts @@ -15,16 +15,16 @@ */ import { IdentityApi } from '@backstage/plugin-auth-node'; import { EventBroker } from '@backstage/plugin-events-node'; -import { Logger } from 'winston'; import { WebSocket } from 'ws'; import { JsonObject } from '@backstage/types'; +import { LoggerService } from '@backstage/backend-plugin-api'; /** * @public */ export type ServiceOptions = { eventBroker?: EventBroker; - logger: Logger; + logger: LoggerService; identity: IdentityApi; }; From 0b2142260417015d1865f4381e0057e9827b6cad Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Thu, 14 Dec 2023 15:47:29 +0200 Subject: [PATCH 16/20] chore: simplify events backend and signal hook Signed-off-by: Heikki Hellgren --- plugins/signals-node/src/SignalService.ts | 38 ++++++++----------- .../signals-react/src/hooks/useSignalApi.ts | 11 ++++-- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index 54f09f583a..9c621d5cbd 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -37,7 +37,6 @@ import { LoggerService } from '@backstage/backend-plugin-api'; /** @public */ export class SignalService implements EventSubscriber { - private readonly serverId: string; private connections: Map = new Map< string, SignalConnection @@ -58,7 +57,6 @@ export class SignalService implements EventSubscriber { identity: this.identity, } = options); - this.serverId = uuid(); this.server = new WebSocketServer({ noServer: true, clientTracking: false, @@ -199,6 +197,21 @@ export class SignalService implements EventSubscriber { return; } + // If there is event broker, use that to publish the message to + // all signal services, including this one. + if (this.eventBroker && !brokedEvent) { + await this.eventBroker.publish({ + topic: 'signals', + eventPayload: { + recipients, + message, + topic, + }, + }); + return; + } + + // Actual websocket message sending this.connections.forEach(conn => { if (!conn.subscriptions.has(topic)) { return; @@ -217,29 +230,10 @@ export class SignalService implements EventSubscriber { conn.ws.send(jsonMessage); }); - - // If this event has not been broadcasted to all servers, then use - // EventBroker to do that - if (this.eventBroker && !brokedEvent) { - await this.eventBroker.publish({ - topic: 'signals', - eventPayload: { - recipients, - message, - topic, - }, - metadata: { server: this.serverId }, - }); - } } async onEvent(params: EventParams): Promise { - const { eventPayload, metadata } = params; - // Discard message from same server to prevent duplicate messages - if (!metadata?.server || metadata.server === this.serverId) { - return; - } - + const { eventPayload } = params; if ( !eventPayload?.recipients || !eventPayload.topic || diff --git a/plugins/signals-react/src/hooks/useSignalApi.ts b/plugins/signals-react/src/hooks/useSignalApi.ts index bb6f47a5fe..8534732264 100644 --- a/plugins/signals-react/src/hooks/useSignalApi.ts +++ b/plugins/signals-react/src/hooks/useSignalApi.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { signalApiRef } from '../api'; -import { useApi } from '@backstage/core-plugin-api'; +import { useApiHolder } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { useEffect, useState } from 'react'; @@ -23,10 +23,13 @@ export const useSignalApi = ( topic: string, onMessage: (message: JsonObject) => void, ) => { - const signals = useApi(signalApiRef); + const apiHolder = useApiHolder(); + // Use apiHolder instead useApi in case signalApi is not available in the + // backstage instance this is used + const signals = apiHolder.get(signalApiRef); const [subscription, setSubscription] = useState(null); useEffect(() => { - if (!subscription) { + if (signals && !subscription) { const sub = signals.subscribe(topic, onMessage); setSubscription(sub); } @@ -34,7 +37,7 @@ export const useSignalApi = ( useEffect(() => { return () => { - if (subscription) { + if (signals && subscription) { signals.unsubscribe(subscription); } }; From da5c3fb8188051edd4069ee24520865737b10f3e Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Tue, 2 Jan 2024 09:57:19 +0200 Subject: [PATCH 17/20] fix: address review comments Signed-off-by: Heikki Hellgren --- .changeset/poor-sheep-tease.md | 6 - packages/backend/src/index.ts | 4 +- packages/backend/src/plugins/devtools.ts | 1 - packages/backend/src/types.ts | 4 +- plugins/devtools-backend/api-report.md | 5 - plugins/devtools-backend/package.json | 1 - .../src/api/DevToolsBackendApi.ts | 16 +- plugins/devtools-backend/src/plugin.ts | 5 +- .../devtools-backend/src/service/router.ts | 16 +- .../Content/InfoContent/InfoContent.tsx | 15 +- plugins/signals-backend/api-report.md | 4 +- plugins/signals-backend/src/plugin.ts | 3 +- .../src/service/router.test.ts | 4 +- plugins/signals-backend/src/service/router.ts | 8 +- .../src/service/standaloneServer.ts | 4 +- plugins/signals-node/README.md | 10 +- plugins/signals-node/api-report.md | 41 +-- plugins/signals-node/package.json | 1 - .../signals-node/src/DefaultSignalService.ts | 238 +++++++++++++++++ plugins/signals-node/src/SignalService.ts | 250 ++---------------- plugins/signals-node/src/index.ts | 1 + plugins/signals-node/src/lib.ts | 3 +- plugins/signals-react/README.md | 20 +- plugins/signals-react/api-report.md | 15 +- plugins/signals-react/package.json | 6 +- plugins/signals-react/src/api/SignalApi.ts | 7 +- plugins/signals-react/src/hooks/index.ts | 2 +- .../hooks/{useSignalApi.ts => useSignal.ts} | 27 +- plugins/signals/api-report.md | 9 +- plugins/signals/package.json | 6 +- plugins/signals/src/api/SignalClient.ts | 49 ++-- plugins/signals/src/api/SignalsClient.test.ts | 19 +- plugins/signals/src/plugin.ts | 13 +- yarn.lock | 65 +---- 34 files changed, 413 insertions(+), 465 deletions(-) delete mode 100644 .changeset/poor-sheep-tease.md create mode 100644 plugins/signals-node/src/DefaultSignalService.ts rename plugins/signals-react/src/hooks/{useSignalApi.ts => useSignal.ts} (69%) diff --git a/.changeset/poor-sheep-tease.md b/.changeset/poor-sheep-tease.md deleted file mode 100644 index fa89ac4749..0000000000 --- a/.changeset/poor-sheep-tease.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -'@backstage/plugin-devtools-backend': patch -'@backstage/plugin-devtools': patch ---- - -Update devtools information almost real time using signals plugin diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index fc6b814c00..0e92698230 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -73,7 +73,7 @@ import { DefaultEventBroker } from '@backstage/plugin-events-backend'; import { PrometheusExporter } from '@opentelemetry/exporter-prometheus'; import { MeterProvider } from '@opentelemetry/sdk-metrics'; import { metrics } from '@opentelemetry/api'; -import { SignalService } from '@backstage/plugin-signals-node'; +import { DefaultSignalService } from '@backstage/plugin-signals-node'; // Expose opentelemetry metrics using a Prometheus exporter on // http://localhost:9464/metrics . See prometheus.yml in packages/backend for @@ -100,7 +100,7 @@ function makeCreateEnv(config: Config) { }); const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); - const signalService = SignalService.create({ + const signalService = DefaultSignalService.create({ logger: root, eventBroker, identity, diff --git a/packages/backend/src/plugins/devtools.ts b/packages/backend/src/plugins/devtools.ts index 5488033682..8e1767ddb1 100644 --- a/packages/backend/src/plugins/devtools.ts +++ b/packages/backend/src/plugins/devtools.ts @@ -25,6 +25,5 @@ export default async function createPlugin( logger: env.logger, config: env.config, permissions: env.permissions, - signalService: env.signalService, }); } diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index d76e68c1c9..3dad2f739b 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -27,7 +27,7 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; import { EventBroker } from '@backstage/plugin-events-node'; -import { SignalService } from '@backstage/plugin-signals-node'; +import { DefaultSignalService } from '@backstage/plugin-signals-node'; export type PluginEnvironment = { logger: Logger; @@ -41,5 +41,5 @@ export type PluginEnvironment = { scheduler: PluginTaskScheduler; identity: IdentityApi; eventBroker: EventBroker; - signalService: SignalService; + signalService: DefaultSignalService; }; diff --git a/plugins/devtools-backend/api-report.md b/plugins/devtools-backend/api-report.md index 7049285c02..b9eb7b5a0c 100644 --- a/plugins/devtools-backend/api-report.md +++ b/plugins/devtools-backend/api-report.md @@ -11,7 +11,6 @@ import express from 'express'; import { ExternalDependency } from '@backstage/plugin-devtools-common'; import { Logger } from 'winston'; import { PermissionEvaluator } from '@backstage/plugin-permission-common'; -import { SignalService } from '@backstage/plugin-signals-node'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -25,8 +24,6 @@ export class DevToolsBackendApi { listExternalDependencyDetails(): Promise; // (undocumented) listInfo(): Promise; - // (undocumented) - listResourceUtilization(): Promise; } // @public @@ -43,7 +40,5 @@ export interface RouterOptions { logger: Logger; // (undocumented) permissions: PermissionEvaluator; - // (undocumented) - signalService?: SignalService; } ``` diff --git a/plugins/devtools-backend/package.json b/plugins/devtools-backend/package.json index 8de7da1b39..18013bc52f 100644 --- a/plugins/devtools-backend/package.json +++ b/plugins/devtools-backend/package.json @@ -38,7 +38,6 @@ "@backstage/plugin-devtools-common": "workspace:^", "@backstage/plugin-permission-common": "workspace:^", "@backstage/plugin-permission-node": "workspace:^", - "@backstage/plugin-signals-node": "workspace:^", "@backstage/types": "workspace:^", "@manypkg/get-packages": "^1.1.3", "@types/express": "*", diff --git a/plugins/devtools-backend/src/api/DevToolsBackendApi.ts b/plugins/devtools-backend/src/api/DevToolsBackendApi.ts index da9a1ca0dd..b71edb3391 100644 --- a/plugins/devtools-backend/src/api/DevToolsBackendApi.ts +++ b/plugins/devtools-backend/src/api/DevToolsBackendApi.ts @@ -203,21 +203,17 @@ export class DevToolsBackendApi { return configInfo; } - public async listResourceUtilization(): Promise { + public async listInfo(): Promise { + const operatingSystem = `${os.hostname()}: ${os.type} ${os.release} - ${ + os.platform + }/${os.arch}`; const usedMem = Math.floor((os.totalmem() - os.freemem()) / (1024 * 1024)); - return `Memory: ${usedMem}/${Math.floor( + const resources = `Memory: ${usedMem}/${Math.floor( os.totalmem() / (1024 * 1024), )}MB - Load: ${os .loadavg() .map(v => v.toFixed(2)) .join('/')}`; - } - - public async listInfo(): Promise { - const operatingSystem = `${os.hostname()}: ${os.type} ${os.release} - ${ - os.platform - }/${os.arch}`; - const nodeJsVersion = process.version; /* eslint-disable-next-line no-restricted-syntax */ @@ -251,7 +247,7 @@ export class DevToolsBackendApi { const info: DevToolsInfo = { operatingSystem: operatingSystem ?? 'N/A', - resourceUtilization: (await this.listResourceUtilization()) ?? 'N/A', + resourceUtilization: resources ?? 'N/A', nodeJsVersion: nodeJsVersion ?? 'N/A', backstageVersion: backstageJson && backstageJson.version ? backstageJson.version : 'N/A', diff --git a/plugins/devtools-backend/src/plugin.ts b/plugins/devtools-backend/src/plugin.ts index 02c3831128..685522557e 100644 --- a/plugins/devtools-backend/src/plugin.ts +++ b/plugins/devtools-backend/src/plugin.ts @@ -20,7 +20,6 @@ import { createBackendPlugin, } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; -import { signalService } from '@backstage/plugin-signals-node'; /** * DevTools backend plugin @@ -36,15 +35,13 @@ export const devtoolsPlugin = createBackendPlugin({ logger: coreServices.logger, permissions: coreServices.permissions, httpRouter: coreServices.httpRouter, - signals: signalService, }, - async init({ config, logger, permissions, httpRouter, signals }) { + async init({ config, logger, permissions, httpRouter }) { httpRouter.use( await createRouter({ config, logger: loggerToWinstonLogger(logger), permissions, - signalService: signals, }), ); }, diff --git a/plugins/devtools-backend/src/service/router.ts b/plugins/devtools-backend/src/service/router.ts index 106eb44309..fbeeb5af84 100644 --- a/plugins/devtools-backend/src/service/router.ts +++ b/plugins/devtools-backend/src/service/router.ts @@ -33,7 +33,6 @@ import { errorHandler } from '@backstage/backend-common'; import express from 'express'; import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node'; import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node'; -import { SignalService } from '@backstage/plugin-signals-node'; /** @public */ export interface RouterOptions { @@ -41,30 +40,17 @@ export interface RouterOptions { logger: Logger; config: Config; permissions: PermissionEvaluator; - signalService?: SignalService; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, config, permissions, signalService } = options; + const { logger, config, permissions } = options; const devToolsBackendApi = options.devToolsBackendApi || new DevToolsBackendApi(logger, config); - if (signalService) { - // Publish info periodically using the signal service - setInterval(async () => { - if (signalService.hasSubscribers('devtools:resources')) { - const info = await devToolsBackendApi.listResourceUtilization(); - await signalService.publish('*', 'devtools:resources', { - resources: info, - }); - } - }, 5000); - } - const router = Router(); router.use(express.json()); router.use( diff --git a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx index 2480bf5233..0ed43d2fee 100644 --- a/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx +++ b/plugins/devtools/src/components/Content/InfoContent/InfoContent.tsx @@ -29,7 +29,7 @@ import { Theme, } from '@material-ui/core'; import { Alert } from '@material-ui/lab'; -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useInfo } from '../../../hooks'; import { InfoDependenciesTable } from './InfoDependenciesTable'; import DescriptionIcon from '@material-ui/icons/Description'; @@ -38,7 +38,6 @@ import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard'; import { BackstageLogoIcon } from './BackstageLogoIcon'; import FileCopyIcon from '@material-ui/icons/FileCopy'; import { DevToolsInfo } from '@backstage/plugin-devtools-common'; -import { useSignalApi } from '@backstage/plugin-signals-react'; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -73,17 +72,7 @@ const copyToClipboard = ({ about }: { about: DevToolsInfo | undefined }) => { /** @public */ export const InfoContent = () => { const classes = useStyles(); - const [resources, setResources] = useState(undefined); const { about, loading, error } = useInfo(); - useSignalApi('devtools:resources', message => { - setResources(message.resources as string); - }); - - useEffect(() => { - if (!loading && !error && about) { - setResources(about.resourceUtilization); - } - }, [about, loading, error]); if (loading) { return ; @@ -113,7 +102,7 @@ export const InfoContent = () => { diff --git a/plugins/signals-backend/api-report.md b/plugins/signals-backend/api-report.md index afbc63abf2..e96dd2e09c 100644 --- a/plugins/signals-backend/api-report.md +++ b/plugins/signals-backend/api-report.md @@ -5,7 +5,7 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; import express from 'express'; -import { Logger } from 'winston'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { SignalService } from '@backstage/plugin-signals-node'; // @public (undocumented) @@ -14,7 +14,7 @@ export function createRouter(options: RouterOptions): Promise; // @public (undocumented) export interface RouterOptions { // (undocumented) - logger: Logger; + logger: LoggerService; // (undocumented) service: SignalService; } diff --git a/plugins/signals-backend/src/plugin.ts b/plugins/signals-backend/src/plugin.ts index 8ade4e85b5..68bbf27377 100644 --- a/plugins/signals-backend/src/plugin.ts +++ b/plugins/signals-backend/src/plugin.ts @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { loggerToWinstonLogger } from '@backstage/backend-common'; import { coreServices, createBackendPlugin, @@ -38,7 +37,7 @@ export const signalsPlugin = createBackendPlugin({ async init({ httpRouter, logger, service }) { httpRouter.use( await createRouter({ - logger: loggerToWinstonLogger(logger), + logger, service, }), ); diff --git a/plugins/signals-backend/src/service/router.test.ts b/plugins/signals-backend/src/service/router.test.ts index b41704e008..281d6ed4a5 100644 --- a/plugins/signals-backend/src/service/router.test.ts +++ b/plugins/signals-backend/src/service/router.test.ts @@ -18,9 +18,9 @@ import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; -import { SignalService } from '@backstage/plugin-signals-node'; +import { DefaultSignalService } from '@backstage/plugin-signals-node'; -const signalsServiceMock: jest.Mocked = {} as any; +const signalsServiceMock: jest.Mocked = {} as any; describe('createRouter', () => { let app: express.Express; diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts index 8926910544..28bafce14a 100644 --- a/plugins/signals-backend/src/service/router.ts +++ b/plugins/signals-backend/src/service/router.ts @@ -16,14 +16,14 @@ import { errorHandler } from '@backstage/backend-common'; import express, { NextFunction, Request, Response } from 'express'; import Router from 'express-promise-router'; -import { Logger } from 'winston'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { SignalService } from '@backstage/plugin-signals-node'; import * as https from 'https'; import http from 'http'; /** @public */ export interface RouterOptions { - logger: Logger; + logger: LoggerService; service: SignalService; } @@ -48,9 +48,9 @@ export async function createRouter( if (!subscribed) { subscribed = true; server.on('upgrade', async (request, socket, head) => { - // Only upgrade if request to root of the signals plugin + // TODO: Find a way to make this more generic if (request.url === '/api/signals') { - await service.handleUpgrade(request, socket, head); + await service.handleUpgrade({ server, request, socket, head }); } }); } diff --git a/plugins/signals-backend/src/service/standaloneServer.ts b/plugins/signals-backend/src/service/standaloneServer.ts index 0728b2fde5..e1ce27aee4 100644 --- a/plugins/signals-backend/src/service/standaloneServer.ts +++ b/plugins/signals-backend/src/service/standaloneServer.ts @@ -21,7 +21,7 @@ import { import { Server } from 'http'; import { Logger } from 'winston'; import { createRouter } from './router'; -import { SignalService } from '@backstage/plugin-signals-node'; +import { DefaultSignalService } from '@backstage/plugin-signals-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; export interface ServerOptions { @@ -43,7 +43,7 @@ export async function startStandaloneServer( issuer: await discovery.getExternalBaseUrl('auth'), }); - const signals = SignalService.create({ + const signals = DefaultSignalService.create({ logger: logger, identity, }); diff --git a/plugins/signals-node/README.md b/plugins/signals-node/README.md index abea6c6d22..14c9332dc1 100644 --- a/plugins/signals-node/README.md +++ b/plugins/signals-node/README.md @@ -56,13 +56,9 @@ all subscribers, you can use `*` as `to` parameter. ```ts // Periodic sending example setInterval(async () => { - // You can use hasSubscribers to check if the message will be sent to anyone - // in case you need some heavy processing before that - if (signalService.hasSubscribers('plugin:topic')) { - await signalService.publish('*', 'plugin:topic', { - message: 'hello world', - }); - } + await signalService.publish('*', 'plugin:topic', { + message: 'hello world', + }); }, 5000); ``` diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index 7bfffc2540..d85163040b 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -7,14 +7,26 @@ import { Duplex } from 'stream'; import { EventBroker } from '@backstage/plugin-events-node'; -import { EventParams } from '@backstage/plugin-events-node'; -import { EventSubscriber } from '@backstage/plugin-events-node'; +import http from 'http'; +import https from 'https'; import { IdentityApi } from '@backstage/plugin-auth-node'; import { IncomingMessage } from 'http'; import { JsonObject } from '@backstage/types'; import { LoggerService } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; +// @public (undocumented) +export class DefaultSignalService implements SignalService { + // (undocumented) + static create(options: ServiceOptions): DefaultSignalService; + handleUpgrade(options: SignalServiceUpgradeOptions): Promise; + publish( + to: string | string[], + topic: string, + message: JsonObject, + ): Promise; +} + // @public (undocumented) export type ServiceOptions = { eventBroker?: EventBroker; @@ -30,28 +42,25 @@ export type SignalEventBrokerPayload = { }; // @public (undocumented) -export class SignalService implements EventSubscriber { - // (undocumented) - static create(options: ServiceOptions): SignalService; - handleUpgrade: ( - req: IncomingMessage, - socket: Duplex, - head: Buffer, - ) => Promise; - hasSubscribers(topic: string): boolean; - // (undocumented) - onEvent(params: EventParams): Promise; +export type SignalService = { publish( to: string | string[], topic: string, message: JsonObject, ): Promise; - // (undocumented) - supportsEventTopics(): string[]; -} + handleUpgrade(options: SignalServiceUpgradeOptions): Promise; +}; // @public (undocumented) export const signalService: ServiceRef; +// @public (undocumented) +export type SignalServiceUpgradeOptions = { + server: https.Server | http.Server; + request: IncomingMessage; + socket: Duplex; + head: Buffer; +}; + // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/signals-node/package.json b/plugins/signals-node/package.json index d5e980d857..d3230432f1 100644 --- a/plugins/signals-node/package.json +++ b/plugins/signals-node/package.json @@ -37,7 +37,6 @@ "@backstage/types": "workspace:^", "express": "^4.17.1", "uuid": "^8.0.0", - "winston": "^3.2.1", "ws": "^8.14.2" } } diff --git a/plugins/signals-node/src/DefaultSignalService.ts b/plugins/signals-node/src/DefaultSignalService.ts new file mode 100644 index 0000000000..cab6e83c81 --- /dev/null +++ b/plugins/signals-node/src/DefaultSignalService.ts @@ -0,0 +1,238 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EventBroker, EventParams } from '@backstage/plugin-events-node'; +import { + ServiceOptions, + SignalConnection, + SignalEventBrokerPayload, +} from './types'; +import { RawData, WebSocket, WebSocketServer } from 'ws'; +import { IncomingMessage } from 'http'; +import { v4 as uuid } from 'uuid'; +import { JsonObject } from '@backstage/types'; +import { + BackstageIdentityResponse, + IdentityApi, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { SignalService, SignalServiceUpgradeOptions } from './SignalService'; + +/** @public */ +export class DefaultSignalService implements SignalService { + private connections: Map = new Map< + string, + SignalConnection + >(); + private eventBroker?: EventBroker; + private logger: LoggerService; + private identity: IdentityApi; + private server: WebSocketServer; + + static create(options: ServiceOptions) { + return new DefaultSignalService(options); + } + + private constructor(options: ServiceOptions) { + ({ + eventBroker: this.eventBroker, + logger: this.logger, + identity: this.identity, + } = options); + + this.server = new WebSocketServer({ + noServer: true, + clientTracking: false, + }); + + this.eventBroker?.subscribe({ + supportsEventTopics: () => ['signals'], + onEvent: (params: EventParams) => + this.onEventBrokerEvent(params), + }); + } + + /** + * Handles request upgrade to websocket and adds the connection to internal + * list for publish/subscribe functionality + * @param req - Request + */ + async handleUpgrade(options: SignalServiceUpgradeOptions) { + const { request, socket, head } = options; + let identity: BackstageIdentityResponse | undefined = undefined; + + // Authentication token is passed in Sec-WebSocket-Protocol header as there + // is no other way to pass the token with plain websockets + const token = request.headers['sec-websocket-protocol']; + if (token) { + identity = await this.identity.getIdentity({ + request: { + headers: { authorization: token }, + }, + } as IdentityApiGetIdentityRequest); + } + + this.server.handleUpgrade( + request, + socket, + head, + (ws: WebSocket, __: IncomingMessage) => { + this.addConnection(ws, identity); + }, + ); + } + + private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) { + const id = uuid(); + + const conn = { + id, + user: identity?.identity.userEntityRef ?? 'user:default/guest', + ws, + ownershipEntityRefs: identity?.identity.ownershipEntityRefs ?? [], + subscriptions: new Set(), + }; + + this.connections.set(id, conn); + + ws.on('error', (err: Error) => { + this.logger.info( + `Error occurred with connection ${id}: ${err}, closing connection`, + ); + ws.close(); + this.connections.delete(id); + }); + + ws.on('close', (code: number, reason: Buffer) => { + this.logger.info( + `Connection ${id} closed with code ${code}, reason: ${reason}`, + ); + this.connections.delete(id); + }); + + ws.on('message', (data: RawData, isBinary: boolean) => { + this.logger.debug(`Received message from connection ${id}: ${data}`); + if (isBinary) { + return; + } + try { + const json = JSON.parse(data.toString()) as JsonObject; + this.handleMessage(conn, json); + } catch (err: any) { + this.logger.error( + `Invalid message received from connection ${id}: ${err}`, + ); + } + }); + } + + private handleMessage(connection: SignalConnection, message: JsonObject) { + if (message.action === 'subscribe' && message.topic) { + this.logger.info( + `Connection ${connection.id} subscribed to ${message.topic}`, + ); + connection.subscriptions.add(message.topic as string); + } + + if (message.action === 'unsubscribe' && message.topic) { + this.logger.info( + `Connection ${connection.id} unsubscribed from ${message.topic}`, + ); + connection.subscriptions.delete(message.topic as string); + } + } + + /** + * Publishes a message to user refs to specific topic + * @param to - string or array of user ref strings to publish message to + * @param topic - message topic + * @param message - message to publish + */ + async publish(to: string | string[], topic: string, message: JsonObject) { + await this.publishInternal( + Array.isArray(to) ? to : [to], + topic, + message, + false, + ); + } + + private async publishInternal( + recipients: string[], + topic: string, + message: JsonObject, + brokedEvent: boolean, + ) { + const jsonMessage = JSON.stringify({ topic, message }); + if (jsonMessage.length === 0) { + return; + } + + // If there is event broker, use that to publish the message to + // all signal services, including this one. + if (this.eventBroker && !brokedEvent) { + await this.eventBroker.publish({ + topic: 'signals', + eventPayload: { + recipients, + message, + topic, + }, + }); + return; + } + + // Actual websocket message sending + this.connections.forEach(conn => { + if (!conn.subscriptions.has(topic)) { + return; + } + // Sending to all users can be done with '*' + if ( + !recipients.includes('*') && + !conn.ownershipEntityRefs.some(ref => recipients.includes(ref)) + ) { + return; + } + + if (conn.ws.readyState !== WebSocket.OPEN) { + return; + } + + conn.ws.send(jsonMessage); + }); + } + + private async onEventBrokerEvent( + params: EventParams, + ): Promise { + const { eventPayload } = params; + if ( + !eventPayload?.recipients || + !eventPayload.topic || + !eventPayload.message + ) { + return; + } + + await this.publishInternal( + eventPayload.recipients, + eventPayload.topic, + eventPayload.message, + true, + ); + } +} diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index 9c621d5cbd..7095c29858 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * Copyright 2024 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,244 +13,32 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { - EventBroker, - EventParams, - EventSubscriber, -} from '@backstage/plugin-events-node'; -import { - ServiceOptions, - SignalConnection, - SignalEventBrokerPayload, -} from './types'; -import { RawData, WebSocket, WebSocketServer } from 'ws'; -import { IncomingMessage } from 'http'; -import { v4 as uuid } from 'uuid'; import { JsonObject } from '@backstage/types'; -import { - BackstageIdentityResponse, - IdentityApi, - IdentityApiGetIdentityRequest, -} from '@backstage/plugin-auth-node'; +import http, { IncomingMessage } from 'http'; import { Duplex } from 'stream'; -import { LoggerService } from '@backstage/backend-plugin-api'; +import https from 'https'; /** @public */ -export class SignalService implements EventSubscriber { - private connections: Map = new Map< - string, - SignalConnection - >(); - private eventBroker?: EventBroker; - private logger: LoggerService; - private identity: IdentityApi; - private server: WebSocketServer; - - static create(options: ServiceOptions) { - return new SignalService(options); - } - - private constructor(options: ServiceOptions) { - ({ - eventBroker: this.eventBroker, - logger: this.logger, - identity: this.identity, - } = options); - - this.server = new WebSocketServer({ - noServer: true, - clientTracking: false, - }); - - this.eventBroker?.subscribe(this); - } - - /** - * Handles request upgrade to websocket and adds the connection to internal - * list for publish/subscribe functionality - * @param req - Request - */ - handleUpgrade = async ( - req: IncomingMessage, - socket: Duplex, - head: Buffer, - ) => { - let identity: BackstageIdentityResponse | undefined = undefined; - - // Authentication token is passed in Sec-WebSocket-Protocol header as there - // is no other way to pass the token with plain websockets - const token = req.headers['sec-websocket-protocol']; - if (token) { - identity = await this.identity.getIdentity({ - request: { - headers: { authorization: token }, - }, - } as IdentityApiGetIdentityRequest); - } - - this.server.handleUpgrade( - req, - socket, - head, - (ws: WebSocket, __: IncomingMessage) => { - this.addConnection(ws, identity); - }, - ); - }; - - private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) { - const id = uuid(); - - const conn = { - id, - user: identity?.identity.userEntityRef ?? 'user:default/guest', - ws, - ownershipEntityRefs: identity?.identity.ownershipEntityRefs ?? [], - subscriptions: new Set(), - }; - - this.connections.set(id, conn); - - ws.on('error', (err: Error) => { - this.logger.info( - `Error occurred with connection ${id}: ${err}, closing connection`, - ); - ws.close(); - this.connections.delete(id); - }); - - ws.on('close', (code: number, reason: Buffer) => { - this.logger.info( - `Connection ${id} closed with code ${code}, reason: ${reason}`, - ); - this.connections.delete(id); - }); - - ws.on('message', (data: RawData, isBinary: boolean) => { - this.logger.debug(`Received message from connection ${id}: ${data}`); - if (isBinary) { - return; - } - try { - const json = JSON.parse(data.toString()) as JsonObject; - this.handleMessage(conn, json); - } catch (err: any) { - this.logger.error( - `Invalid message received from connection ${id}: ${err}`, - ); - } - }); - } - - private handleMessage(connection: SignalConnection, message: JsonObject) { - if (message.action === 'subscribe' && message.topic) { - this.logger.info( - `Connection ${connection.id} subscribed to ${message.topic}`, - ); - connection.subscriptions.add(message.topic as string); - } - - if (message.action === 'unsubscribe' && message.topic) { - this.logger.info( - `Connection ${connection.id} unsubscribed from ${message.topic}`, - ); - connection.subscriptions.delete(message.topic as string); - } - } +export type SignalServiceUpgradeOptions = { + server: https.Server | http.Server; + request: IncomingMessage; + socket: Duplex; + head: Buffer; +}; +/** @public */ +export type SignalService = { /** * Publishes a message to user refs to specific topic - * @param to - string or array of user ref strings to publish message to - * @param topic - message topic - * @param message - message to publish */ - async publish(to: string | string[], topic: string, message: JsonObject) { - await this.publishInternal( - Array.isArray(to) ? to : [to], - topic, - message, - false, - ); - } - - /** - * Checks if there is active subscriptions to specific topic. - * This can be useful to skip heavy processing before publishing messages if there are no subscriptions. - * @param topic - topic to check for subscriptions - */ - hasSubscribers(topic: string): boolean { - return ( - [...this.connections.values()].find(conn => - conn.subscriptions.has(topic), - ) !== undefined - ); - } - - private async publishInternal( - recipients: string[], + publish( + to: string | string[], topic: string, message: JsonObject, - brokedEvent: boolean, - ) { - const jsonMessage = JSON.stringify({ topic, message }); - if (jsonMessage.length === 0) { - return; - } + ): Promise; - // If there is event broker, use that to publish the message to - // all signal services, including this one. - if (this.eventBroker && !brokedEvent) { - await this.eventBroker.publish({ - topic: 'signals', - eventPayload: { - recipients, - message, - topic, - }, - }); - return; - } - - // Actual websocket message sending - this.connections.forEach(conn => { - if (!conn.subscriptions.has(topic)) { - return; - } - // Sending to all users can be done with '*' - if ( - !recipients.includes('*') && - !conn.ownershipEntityRefs.some(ref => recipients.includes(ref)) - ) { - return; - } - - if (conn.ws.readyState !== WebSocket.OPEN) { - return; - } - - conn.ws.send(jsonMessage); - }); - } - - async onEvent(params: EventParams): Promise { - const { eventPayload } = params; - if ( - !eventPayload?.recipients || - !eventPayload.topic || - !eventPayload.message - ) { - return; - } - - await this.publishInternal( - eventPayload.recipients, - eventPayload.topic, - eventPayload.message, - true, - ); - } - - supportsEventTopics(): string[] { - return ['signals']; - } -} + /** + * Handles request upgrade + */ + handleUpgrade(options: SignalServiceUpgradeOptions): Promise; +}; diff --git a/plugins/signals-node/src/index.ts b/plugins/signals-node/src/index.ts index 43a3a88ac0..f9c220b1e9 100644 --- a/plugins/signals-node/src/index.ts +++ b/plugins/signals-node/src/index.ts @@ -15,5 +15,6 @@ */ export * from './lib'; +export * from './DefaultSignalService'; export * from './SignalService'; export * from './types'; diff --git a/plugins/signals-node/src/lib.ts b/plugins/signals-node/src/lib.ts index b95d4a9231..c7e7c4a6ae 100644 --- a/plugins/signals-node/src/lib.ts +++ b/plugins/signals-node/src/lib.ts @@ -18,6 +18,7 @@ import { createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; +import { DefaultSignalService } from './DefaultSignalService'; import { SignalService } from './SignalService'; /** @public */ @@ -33,7 +34,7 @@ export const signalService = createServiceRef({ // TODO: EventBroker }, factory({ logger, identity }) { - return SignalService.create({ identity, logger }); + return DefaultSignalService.create({ identity, logger }); }, }), }); diff --git a/plugins/signals-react/README.md b/plugins/signals-react/README.md index 9bdf5e964f..244a4ec43d 100644 --- a/plugins/signals-react/README.md +++ b/plugins/signals-react/README.md @@ -20,15 +20,12 @@ of connections to the backend and also to allow multiple subscriptions using the Example of using the hook: ```ts -import { useSignalApi } from '@backstage/plugin-signals-react'; +import { useSignal } from '@backstage/plugin-signals-react'; -const [message, setMessage] = useState(undefined); -useSignalApi('myplugin:topic', message => { - setMessage(message); -}); +const { lastSignal } = useSignal('myplugin:topic'); ``` -Whenever backend publishes new message to the topic `myplugin:topic`, the state of this component is changed. +Whenever backend publishes new message to the topic `myplugin:topic`, the lastSignal is changed. ## Using API directly @@ -39,9 +36,12 @@ subscriptions. import { signalsApiRef } from '@backstage/plugin-signals-react'; const signals = useApi(signalsApiRef); -signals.subscribe('myplugin:topic', (message: JsonObject) => { - console.log(message); -}); +const { unsubscribe } = signals.subscribe( + 'myplugin:topic', + (message: JsonObject) => { + console.log(message); + }, +); // Remember to unsubscribe -signals.unsubscribe('myplugin:topic'); +unsubscribe(); ``` diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index d28b806103..8ad0c06fbe 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -8,18 +8,21 @@ import { JsonObject } from '@backstage/types'; // @public (undocumented) export type SignalApi = { - subscribe(topic: string, onMessage: (message: JsonObject) => void): string; - unsubscribe(subscription: string): void; + subscribe( + topic: string, + onMessage: (message: JsonObject) => void, + ): { + unsubscribe: () => void; + }; }; // @public (undocumented) export const signalApiRef: ApiRef; // @public (undocumented) -export const useSignalApi: ( - topic: string, - onMessage: (message: JsonObject) => void, -) => void; +export const useSignal: (topic: string) => { + lastSignal: JsonObject | null; +}; // (No @packageDocumentation comment for this package) ``` diff --git a/plugins/signals-react/package.json b/plugins/signals-react/package.json index 148bc27cb3..06df050ccd 100644 --- a/plugins/signals-react/package.json +++ b/plugins/signals-react/package.json @@ -26,7 +26,7 @@ "dependencies": { "@backstage/core-plugin-api": "workspace:^", "@backstage/types": "workspace:^", - "@material-ui/core": "^4.9.13" + "@material-ui/core": "^4.12.4" }, "peerDependencies": { "react": "^16.13.1 || ^17.0.0" @@ -34,8 +34,8 @@ "devDependencies": { "@backstage/cli": "workspace:^", "@backstage/test-utils": "workspace:^", - "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^12.1.3" + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0" }, "files": [ "dist" diff --git a/plugins/signals-react/src/api/SignalApi.ts b/plugins/signals-react/src/api/SignalApi.ts index f63023bf5f..09eacafa4d 100644 --- a/plugins/signals-react/src/api/SignalApi.ts +++ b/plugins/signals-react/src/api/SignalApi.ts @@ -23,7 +23,8 @@ export const signalApiRef = createApiRef({ /** @public */ export type SignalApi = { - subscribe(topic: string, onMessage: (message: JsonObject) => void): string; - - unsubscribe(subscription: string): void; + subscribe( + topic: string, + onMessage: (message: JsonObject) => void, + ): { unsubscribe: () => void }; }; diff --git a/plugins/signals-react/src/hooks/index.ts b/plugins/signals-react/src/hooks/index.ts index 921eca8d30..a7bf7ebad2 100644 --- a/plugins/signals-react/src/hooks/index.ts +++ b/plugins/signals-react/src/hooks/index.ts @@ -13,4 +13,4 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export * from './useSignalApi'; +export * from './useSignal'; diff --git a/plugins/signals-react/src/hooks/useSignalApi.ts b/plugins/signals-react/src/hooks/useSignal.ts similarity index 69% rename from plugins/signals-react/src/hooks/useSignalApi.ts rename to plugins/signals-react/src/hooks/useSignal.ts index 8534732264..1176769a06 100644 --- a/plugins/signals-react/src/hooks/useSignalApi.ts +++ b/plugins/signals-react/src/hooks/useSignal.ts @@ -19,27 +19,26 @@ import { JsonObject } from '@backstage/types'; import { useEffect, useState } from 'react'; /** @public */ -export const useSignalApi = ( - topic: string, - onMessage: (message: JsonObject) => void, -) => { +export const useSignal = (topic: string) => { const apiHolder = useApiHolder(); // Use apiHolder instead useApi in case signalApi is not available in the // backstage instance this is used const signals = apiHolder.get(signalApiRef); - const [subscription, setSubscription] = useState(null); + const [lastSignal, setLastSignal] = useState(null); useEffect(() => { - if (signals && !subscription) { - const sub = signals.subscribe(topic, onMessage); - setSubscription(sub); + let unsub: null | (() => void) = null; + if (signals) { + const { unsubscribe } = signals.subscribe(topic, (msg: JsonObject) => { + setLastSignal(msg); + }); + unsub = unsubscribe; } - }, [subscription, signals, onMessage, topic]); - - useEffect(() => { return () => { - if (signals && subscription) { - signals.unsubscribe(subscription); + if (signals && unsub) { + unsub(); } }; - }, [subscription, signals, topic]); + }, [signals, topic]); + + return { lastSignal }; }; diff --git a/plugins/signals/api-report.md b/plugins/signals/api-report.md index 28a70a339c..97c882dda4 100644 --- a/plugins/signals/api-report.md +++ b/plugins/signals/api-report.md @@ -23,9 +23,12 @@ export class SignalClient implements SignalApi { // (undocumented) static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number; // (undocumented) - subscribe(topic: string, onMessage: (message: JsonObject) => void): string; - // (undocumented) - unsubscribe(subscription: string): void; + subscribe( + topic: string, + onMessage: (message: JsonObject) => void, + ): { + unsubscribe: () => void; + }; } // @public (undocumented) diff --git a/plugins/signals/package.json b/plugins/signals/package.json index e708d46329..cec95280c1 100644 --- a/plugins/signals/package.json +++ b/plugins/signals/package.json @@ -28,7 +28,7 @@ "@backstage/plugin-signals-react": "workspace:^", "@backstage/theme": "workspace:^", "@backstage/types": "workspace:^", - "@material-ui/core": "^4.9.13", + "@material-ui/core": "^4.12.4", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.61", "react-use": "^17.2.4", @@ -42,8 +42,8 @@ "@backstage/core-app-api": "workspace:^", "@backstage/dev-utils": "workspace:^", "@backstage/test-utils": "workspace:^", - "@testing-library/jest-dom": "^5.10.1", - "@testing-library/react": "^12.1.3", + "@testing-library/jest-dom": "^6.0.0", + "@testing-library/react": "^14.0.0", "@testing-library/user-event": "^14.0.0", "jest-websocket-mock": "^2.5.0", "msw": "^1.0.0" diff --git a/plugins/signals/src/api/SignalClient.ts b/plugins/signals/src/api/SignalClient.ts index 64c1866fab..24a38a49e3 100644 --- a/plugins/signals/src/api/SignalClient.ts +++ b/plugins/signals/src/api/SignalClient.ts @@ -62,7 +62,10 @@ export class SignalClient implements SignalApi { private reconnectTimeout: number, ) {} - subscribe(topic: string, onMessage: (message: JsonObject) => void): string { + subscribe( + topic: string, + onMessage: (message: JsonObject) => void, + ): { unsubscribe: () => void } { const subscriptionId = uuid(); const exists = [...this.subscriptions.values()].find( sub => sub.topic === topic, @@ -79,30 +82,30 @@ export class SignalClient implements SignalApi { .catch(() => { this.reconnect(); }); - return subscriptionId; - } - unsubscribe(subscription: string): void { - const sub = this.subscriptions.get(subscription); - if (!sub) { - return; - } - const topic = sub.topic; - this.subscriptions.delete(subscription); - const exists = [...this.subscriptions.values()].find( - s => s.topic === topic, - ); - // If there are subscriptions still listening to this topic, do not - // unsubscribe from the server - if (!exists) { - this.send({ action: 'unsubscribe', topic: sub.topic }); - } + const unsubscribe = () => { + const sub = this.subscriptions.get(subscriptionId); + if (!sub) { + return; + } + this.subscriptions.delete(subscriptionId); + const multipleExists = [...this.subscriptions.values()].find( + s => s.topic === topic, + ); + // If there are subscriptions still listening to this topic, do not + // unsubscribe from the server + if (!multipleExists) { + this.send({ action: 'unsubscribe', topic: sub.topic }); + } - // If there are no subscriptions, close the connection - if (this.subscriptions.size === 0) { - this.ws?.close(WS_CLOSE_NORMAL); - this.ws = null; - } + // If there are no subscriptions, close the connection + if (this.subscriptions.size === 0) { + this.ws?.close(WS_CLOSE_NORMAL); + this.ws = null; + } + }; + + return { unsubscribe }; } private send(data?: JsonObject): void { diff --git a/plugins/signals/src/api/SignalsClient.test.ts b/plugins/signals/src/api/SignalsClient.test.ts index 60d67e6c4b..ec202e2703 100644 --- a/plugins/signals/src/api/SignalsClient.test.ts +++ b/plugins/signals/src/api/SignalsClient.test.ts @@ -44,7 +44,7 @@ describe('SignalsClient', () => { it('should handle single subscription correctly', async () => { const messageMock = jest.fn(); const client = SignalClient.create({ discoveryApi, identity }); - const sub = client.subscribe('topic', messageMock); + const { unsubscribe } = client.subscribe('topic', messageMock); await server.connected; await expect(server).toReceiveMessage({ @@ -54,7 +54,8 @@ describe('SignalsClient', () => { server.send({ topic: 'topic', message: { hello: 'world' } }); expect(messageMock).toHaveBeenCalledWith({ hello: 'world' }); - client.unsubscribe(sub); + await unsubscribe(); + await expect(server).toReceiveMessage({ action: 'unsubscribe', topic: 'topic', @@ -66,8 +67,14 @@ describe('SignalsClient', () => { const messageMock2 = jest.fn(); const client1 = SignalClient.create({ discoveryApi, identity }); const client2 = SignalClient.create({ discoveryApi, identity }); - const sub1 = client1.subscribe('topic', messageMock1); - const sub2 = client2.subscribe('topic', messageMock2); + const { unsubscribe: unsubscribe1 } = client1.subscribe( + 'topic', + messageMock1, + ); + const { unsubscribe: unsubscribe2 } = client2.subscribe( + 'topic', + messageMock2, + ); await server.connected; @@ -79,13 +86,13 @@ describe('SignalsClient', () => { expect(messageMock1).toHaveBeenCalledWith({ hello: 'world' }); expect(messageMock2).toHaveBeenCalledWith({ hello: 'world' }); - client1.unsubscribe(sub1); + await unsubscribe1(); await expect(server).not.toReceiveMessage({ action: 'unsubscribe', topic: 'topic', }); - client2.unsubscribe(sub2); + await unsubscribe2(); await expect(server).toReceiveMessage({ action: 'unsubscribe', topic: 'topic', diff --git a/plugins/signals/src/plugin.ts b/plugins/signals/src/plugin.ts index 1482d7dae1..eb3f9126e2 100644 --- a/plugins/signals/src/plugin.ts +++ b/plugins/signals/src/plugin.ts @@ -22,8 +22,6 @@ import { import { signalApiRef } from '@backstage/plugin-signals-react'; import { SignalClient } from './api/SignalClient'; -let clientInstance: SignalClient | undefined = undefined; - /** @public */ export const signalsPlugin = createPlugin({ id: 'signals', @@ -35,13 +33,10 @@ export const signalsPlugin = createPlugin({ discoveryApi: discoveryApiRef, }, factory: ({ identity, discoveryApi }) => { - if (!clientInstance) { - clientInstance = SignalClient.create({ - identity, - discoveryApi, - }); - } - return clientInstance; + return SignalClient.create({ + identity, + discoveryApi, + }); }, }), ], diff --git a/yarn.lock b/yarn.lock index 7a3fbb3902..72e4e43d02 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12,7 +12,7 @@ __metadata: languageName: node linkType: hard -"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.2": +"@adobe/css-tools@npm:^4.3.2": version: 4.3.2 resolution: "@adobe/css-tools@npm:4.3.2" checksum: 9667d61d55dc3b0a315c530ae84e016ce5267c4dd8ac00abb40108dc98e07b98e3090ce8b87acd51a41a68d9e84dcccb08cdf21c902572a9cf9dcaf830da4ae3 @@ -6203,7 +6203,6 @@ __metadata: "@backstage/plugin-devtools-common": "workspace:^" "@backstage/plugin-permission-common": "workspace:^" "@backstage/plugin-permission-node": "workspace:^" - "@backstage/plugin-signals-node": "workspace:^" "@backstage/types": "workspace:^" "@manypkg/get-packages": ^1.1.3 "@types/express": "*" @@ -8875,12 +8874,11 @@ __metadata: "@types/express": ^4.17.21 express: ^4.17.1 uuid: ^8.0.0 - winston: ^3.2.1 ws: ^8.14.2 languageName: unknown linkType: soft -"@backstage/plugin-signals-react@workspace:plugins/signals-react": +"@backstage/plugin-signals-react@workspace:^, @backstage/plugin-signals-react@workspace:plugins/signals-react": version: 0.0.0-use.local resolution: "@backstage/plugin-signals-react@workspace:plugins/signals-react" dependencies: @@ -8888,9 +8886,9 @@ __metadata: "@backstage/core-plugin-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" - "@material-ui/core": ^4.9.13 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 + "@material-ui/core": ^4.12.4 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^14.0.0 peerDependencies: react: ^16.13.1 || ^17.0.0 languageName: unknown @@ -8909,11 +8907,11 @@ __metadata: "@backstage/test-utils": "workspace:^" "@backstage/theme": "workspace:^" "@backstage/types": "workspace:^" - "@material-ui/core": ^4.9.13 + "@material-ui/core": ^4.12.4 "@material-ui/icons": ^4.9.1 "@material-ui/lab": ^4.0.0-alpha.61 - "@testing-library/jest-dom": ^5.10.1 - "@testing-library/react": ^12.1.3 + "@testing-library/jest-dom": ^6.0.0 + "@testing-library/react": ^14.0.0 "@testing-library/user-event": ^14.0.0 jest-websocket-mock: ^2.5.0 msw: ^1.0.0 @@ -17216,22 +17214,6 @@ __metadata: languageName: unknown linkType: soft -"@testing-library/dom@npm:^8.0.0": - version: 8.20.1 - resolution: "@testing-library/dom@npm:8.20.1" - dependencies: - "@babel/code-frame": ^7.10.4 - "@babel/runtime": ^7.12.5 - "@types/aria-query": ^5.0.1 - aria-query: 5.1.3 - chalk: ^4.1.0 - dom-accessibility-api: ^0.5.9 - lz-string: ^1.5.0 - pretty-format: ^27.0.2 - checksum: 06fc8dc67849aadb726cbbad0e7546afdf8923bd39acb64c576d706249bd7d0d05f08e08a31913fb621162e3b9c2bd0dce15964437f030f9fa4476326fdd3007 - languageName: node - linkType: hard - "@testing-library/dom@npm:^9.0.0": version: 9.3.3 resolution: "@testing-library/dom@npm:9.3.3" @@ -17248,23 +17230,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/jest-dom@npm:^5.10.1": - version: 5.17.0 - resolution: "@testing-library/jest-dom@npm:5.17.0" - dependencies: - "@adobe/css-tools": ^4.0.1 - "@babel/runtime": ^7.9.2 - "@types/testing-library__jest-dom": ^5.9.1 - aria-query: ^5.0.0 - chalk: ^3.0.0 - css.escape: ^1.5.1 - dom-accessibility-api: ^0.5.6 - lodash: ^4.17.15 - redent: ^3.0.0 - checksum: 9f28dbca8b50d7c306aae40c3aa8e06f0e115f740360004bd87d57f95acf7ab4b4f4122a7399a76dbf2bdaaafb15c99cc137fdcb0ae457a92e2de0f3fbf9b03b - languageName: node - linkType: hard - "@testing-library/jest-dom@npm:^6.0.0": version: 6.1.6 resolution: "@testing-library/jest-dom@npm:6.1.6" @@ -17317,20 +17282,6 @@ __metadata: languageName: node linkType: hard -"@testing-library/react@npm:^12.1.3": - version: 12.1.5 - resolution: "@testing-library/react@npm:12.1.5" - dependencies: - "@babel/runtime": ^7.12.5 - "@testing-library/dom": ^8.0.0 - "@types/react-dom": <18.0.0 - peerDependencies: - react: <18.0.0 - react-dom: <18.0.0 - checksum: 4abd0490405e709a7df584a0db604e508a4612398bb1326e8fa32dd9393b15badc826dcf6d2f7525437886d507871f719f127b9860ed69ddd204d1fa834f576a - languageName: node - linkType: hard - "@testing-library/react@npm:^14.0.0": version: 14.1.2 resolution: "@testing-library/react@npm:14.1.2" From 67ddf8d6d79e2d2e214411f2be8f2b2114039b02 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Wed, 3 Jan 2024 13:03:53 +0200 Subject: [PATCH 18/20] docs: fix example of signal api in README Signed-off-by: Heikki Hellgren --- plugins/signals/README.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/signals/README.md b/plugins/signals/README.md index e70c1ef7d8..7b96da32f1 100644 --- a/plugins/signals/README.md +++ b/plugins/signals/README.md @@ -33,9 +33,12 @@ Now you can utilize the API from other plugins using the `@backstage/plugin-sign import { signalsApiRef } from '@backstage/plugin-signals-react'; const signals = useApi(signalsApiRef); -signals.subscribe('myplugin:topic', (message: JsonObject) => { - console.log(message); -}); +const { unsubscribe } = signals.subscribe( + 'myplugin:topic', + (message: JsonObject) => { + console.log(message); + }, +); // Remember to unsubscribe -signals.unsubscribe('myplugin:topic'); +unsubscribe(); ``` From 169e3ffc1fcdec3d2eef1b5f84cf488db814c4cc Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Fri, 12 Jan 2024 13:38:34 +0200 Subject: [PATCH 19/20] feat: move all signal handling to backend plugin Signed-off-by: Heikki Hellgren --- packages/backend/src/index.ts | 2 - packages/backend/src/plugins/signals.ts | 3 +- plugins/signals-backend/README.md | 5 +- plugins/signals-backend/api-report.md | 9 +- plugins/signals-backend/src/plugin.ts | 9 +- .../src/service/SignalManager.ts | 213 +++++++++++++++++ .../src/service/router.test.ts | 15 +- plugins/signals-backend/src/service/router.ts | 13 +- .../src/service/standaloneServer.ts | 39 ++- plugins/signals-node/README.md | 34 ++- plugins/signals-node/api-report.md | 46 +--- .../signals-node/src/DefaultSignalService.ts | 222 ++---------------- plugins/signals-node/src/SignalService.ts | 24 +- plugins/signals-node/src/lib.ts | 10 +- plugins/signals-node/src/types.ts | 26 +- plugins/signals-react/README.md | 11 +- plugins/signals-react/api-report.md | 4 +- plugins/signals-react/src/api/SignalApi.ts | 2 +- plugins/signals-react/src/hooks/useSignal.ts | 6 +- plugins/signals/api-report.md | 2 +- plugins/signals/src/api/SignalClient.ts | 29 ++- plugins/signals/src/api/SignalsClient.test.ts | 26 +- 22 files changed, 392 insertions(+), 358 deletions(-) create mode 100644 plugins/signals-backend/src/service/SignalManager.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 0e92698230..bade028597 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -101,9 +101,7 @@ function makeCreateEnv(config: Config) { const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); const signalService = DefaultSignalService.create({ - logger: root, eventBroker, - identity, }); root.info(`Created UrlReader ${reader}`); diff --git a/packages/backend/src/plugins/signals.ts b/packages/backend/src/plugins/signals.ts index 687fcfaa33..477cea1938 100644 --- a/packages/backend/src/plugins/signals.ts +++ b/packages/backend/src/plugins/signals.ts @@ -22,6 +22,7 @@ export default async function createPlugin( ): Promise { return await createRouter({ logger: env.logger, - service: env.signalService, + eventBroker: env.eventBroker, + identity: env.identity, }); } diff --git a/plugins/signals-backend/README.md b/plugins/signals-backend/README.md index f9ebfb80c7..e30a0836e0 100644 --- a/plugins/signals-backend/README.md +++ b/plugins/signals-backend/README.md @@ -20,7 +20,8 @@ export default async function createPlugin( ): Promise { return await createRouter({ logger: env.logger, - service: env.signalsService, + eventBroker: env.eventBroker, + identity: env.identity, }); } ``` @@ -29,7 +30,7 @@ Now add the signals to `packages/backend/src/index.ts`: ```ts // ... -import signals from './plugins/sonarqube'; +import signals from './plugins/signals'; async function main() { // ... diff --git a/plugins/signals-backend/api-report.md b/plugins/signals-backend/api-report.md index e96dd2e09c..dd46778e5d 100644 --- a/plugins/signals-backend/api-report.md +++ b/plugins/signals-backend/api-report.md @@ -4,9 +4,10 @@ ```ts import { BackendFeature } from '@backstage/backend-plugin-api'; +import { EventBroker } from '@backstage/plugin-events-node'; import express from 'express'; +import { IdentityApi } from '@backstage/plugin-auth-node'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { SignalService } from '@backstage/plugin-signals-node'; // @public (undocumented) export function createRouter(options: RouterOptions): Promise; @@ -14,9 +15,11 @@ export function createRouter(options: RouterOptions): Promise; // @public (undocumented) export interface RouterOptions { // (undocumented) - logger: LoggerService; + eventBroker?: EventBroker; // (undocumented) - service: SignalService; + identity: IdentityApi; + // (undocumented) + logger: LoggerService; } // @public diff --git a/plugins/signals-backend/src/plugin.ts b/plugins/signals-backend/src/plugin.ts index 68bbf27377..4a9cef028f 100644 --- a/plugins/signals-backend/src/plugin.ts +++ b/plugins/signals-backend/src/plugin.ts @@ -18,7 +18,6 @@ import { createBackendPlugin, } from '@backstage/backend-plugin-api'; import { createRouter } from './service/router'; -import { signalService } from '@backstage/plugin-signals-node'; /** * Signals backend plugin @@ -32,13 +31,15 @@ export const signalsPlugin = createBackendPlugin({ deps: { httpRouter: coreServices.httpRouter, logger: coreServices.logger, - service: signalService, + identity: coreServices.identity, + // TODO: EventBroker. It is optional for now but it's actually required so waiting for the new backend system + // for the events-backend for this to work. }, - async init({ httpRouter, logger, service }) { + async init({ httpRouter, logger, identity }) { httpRouter.use( await createRouter({ logger, - service, + identity, }), ); }, diff --git a/plugins/signals-backend/src/service/SignalManager.ts b/plugins/signals-backend/src/service/SignalManager.ts new file mode 100644 index 0000000000..988db47465 --- /dev/null +++ b/plugins/signals-backend/src/service/SignalManager.ts @@ -0,0 +1,213 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { EventBroker, EventParams } from '@backstage/plugin-events-node'; +import { SignalPayload } from '@backstage/plugin-signals-node'; +import { RawData, WebSocket, WebSocketServer } from 'ws'; +import { IncomingMessage } from 'http'; +import { v4 as uuid } from 'uuid'; +import { JsonObject } from '@backstage/types'; +import { + BackstageIdentityResponse, + IdentityApi, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { Duplex } from 'stream'; + +/** @internal */ +export type ConnectionUpgradeOptions = { + request: IncomingMessage; + socket: Duplex; + head: Buffer; +}; + +/** + * @internal + */ +export type SignalConnection = { + id: string; + user: string; + ws: WebSocket; + ownershipEntityRefs: string[]; + subscriptions: Set; +}; + +/** + * @internal + */ +export type SignalManagerOptions = { + // TODO: Remove optional when events-backend can offer this service + eventBroker?: EventBroker; + logger: LoggerService; + identity: IdentityApi; +}; + +/** @internal */ +export class SignalManager { + private connections: Map = new Map< + string, + SignalConnection + >(); + private eventBroker?: EventBroker; + private logger: LoggerService; + private identity: IdentityApi; + private server: WebSocketServer; + + static create(options: SignalManagerOptions) { + return new SignalManager(options); + } + + private constructor(options: SignalManagerOptions) { + ({ + eventBroker: this.eventBroker, + logger: this.logger, + identity: this.identity, + } = options); + + this.server = new WebSocketServer({ + noServer: true, + clientTracking: false, + }); + + this.eventBroker?.subscribe({ + supportsEventTopics: () => ['signals'], + onEvent: (params: EventParams) => + this.onEventBrokerEvent(params), + }); + } + + /** + * Handles request upgrade to websocket and adds the connection to internal + * list for publish/subscribe functionality + * @param req - Request + */ + async handleUpgrade(options: ConnectionUpgradeOptions) { + const { request, socket, head } = options; + let identity: BackstageIdentityResponse | undefined = undefined; + + // Authentication token is passed in Sec-WebSocket-Protocol header as there + // is no other way to pass the token with plain websockets + const token = request.headers['sec-websocket-protocol']; + if (token) { + identity = await this.identity.getIdentity({ + request: { + headers: { authorization: token }, + }, + } as IdentityApiGetIdentityRequest); + } + + this.server.handleUpgrade( + request, + socket, + head, + (ws: WebSocket, __: IncomingMessage) => { + this.addConnection(ws, identity); + }, + ); + } + + private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) { + const id = uuid(); + + const conn = { + id, + user: identity?.identity.userEntityRef ?? 'user:default/guest', + ws, + ownershipEntityRefs: identity?.identity.ownershipEntityRefs ?? [], + subscriptions: new Set(), + }; + + this.connections.set(id, conn); + + ws.on('error', (err: Error) => { + this.logger.info( + `Error occurred with connection ${id}: ${err}, closing connection`, + ); + ws.close(); + this.connections.delete(id); + }); + + ws.on('close', (code: number, reason: Buffer) => { + this.logger.info( + `Connection ${id} closed with code ${code}, reason: ${reason}`, + ); + this.connections.delete(id); + }); + + ws.on('message', (data: RawData, isBinary: boolean) => { + this.logger.debug(`Received message from connection ${id}: ${data}`); + if (isBinary) { + return; + } + try { + const json = JSON.parse(data.toString()) as JsonObject; + this.handleMessage(conn, json); + } catch (err: any) { + this.logger.error( + `Invalid message received from connection ${id}: ${err}`, + ); + } + }); + } + + private handleMessage(connection: SignalConnection, message: JsonObject) { + if (message.action === 'subscribe' && message.channel) { + this.logger.info( + `Connection ${connection.id} subscribed to ${message.channel}`, + ); + connection.subscriptions.add(message.channel as string); + } else if (message.action === 'unsubscribe' && message.channel) { + this.logger.info( + `Connection ${connection.id} unsubscribed from ${message.channel}`, + ); + connection.subscriptions.delete(message.channel as string); + } + } + + private async onEventBrokerEvent( + params: EventParams, + ): Promise { + const { eventPayload } = params; + if (!eventPayload.channel || !eventPayload.message) { + return; + } + + const { channel, recipients, message } = eventPayload; + const jsonMessage = JSON.stringify({ channel, message }); + + // Actual websocket message sending + this.connections.forEach(conn => { + if (!conn.subscriptions.has(channel)) { + return; + } + // Sending to all users can be done with null + if ( + recipients !== null && + !conn.ownershipEntityRefs.some((ref: string) => + recipients.includes(ref), + ) + ) { + return; + } + + if (conn.ws.readyState !== WebSocket.OPEN) { + return; + } + + conn.ws.send(jsonMessage); + }); + } +} diff --git a/plugins/signals-backend/src/service/router.test.ts b/plugins/signals-backend/src/service/router.test.ts index 281d6ed4a5..ecf2ad4aa6 100644 --- a/plugins/signals-backend/src/service/router.test.ts +++ b/plugins/signals-backend/src/service/router.test.ts @@ -18,9 +18,17 @@ import express from 'express'; import request from 'supertest'; import { createRouter } from './router'; -import { DefaultSignalService } from '@backstage/plugin-signals-node'; +import { EventBroker } from '@backstage/plugin-events-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; -const signalsServiceMock: jest.Mocked = {} as any; +const eventBrokerMock: jest.Mocked = { + subscribe: jest.fn(), + publish: jest.fn(), +}; + +const identityApiMock: jest.Mocked = { + getIdentity: jest.fn(), +}; describe('createRouter', () => { let app: express.Express; @@ -28,7 +36,8 @@ describe('createRouter', () => { beforeAll(async () => { const router = await createRouter({ logger: getVoidLogger(), - service: signalsServiceMock, + identity: identityApiMock, + eventBroker: eventBrokerMock, }); app = express().use(router); }); diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts index 28bafce14a..ec56860bf3 100644 --- a/plugins/signals-backend/src/service/router.ts +++ b/plugins/signals-backend/src/service/router.ts @@ -17,22 +17,27 @@ import { errorHandler } from '@backstage/backend-common'; import express, { NextFunction, Request, Response } from 'express'; import Router from 'express-promise-router'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { SignalService } from '@backstage/plugin-signals-node'; import * as https from 'https'; import http from 'http'; +import { SignalManager } from './SignalManager'; +import { IdentityApi } from '@backstage/plugin-auth-node'; +import { EventBroker } from '@backstage/plugin-events-node'; /** @public */ export interface RouterOptions { logger: LoggerService; - service: SignalService; + eventBroker?: EventBroker; + identity: IdentityApi; } /** @public */ export async function createRouter( options: RouterOptions, ): Promise { - const { logger, service } = options; + const { logger } = options; + const manager = SignalManager.create(options); let subscribed = false; + const upgradeMiddleware = (req: Request, _: Response, next: NextFunction) => { const server: https.Server | http.Server = (req.socket as any)?.server; if ( @@ -50,7 +55,7 @@ export async function createRouter( server.on('upgrade', async (request, socket, head) => { // TODO: Find a way to make this more generic if (request.url === '/api/signals') { - await service.handleUpgrade({ server, request, socket, head }); + await manager.handleUpgrade({ request, socket, head }); } }); } diff --git a/plugins/signals-backend/src/service/standaloneServer.ts b/plugins/signals-backend/src/service/standaloneServer.ts index e1ce27aee4..278de8621e 100644 --- a/plugins/signals-backend/src/service/standaloneServer.ts +++ b/plugins/signals-backend/src/service/standaloneServer.ts @@ -23,6 +23,11 @@ import { Logger } from 'winston'; import { createRouter } from './router'; import { DefaultSignalService } from '@backstage/plugin-signals-node'; import { DefaultIdentityClient } from '@backstage/plugin-auth-node'; +import { + EventBroker, + EventParams, + EventSubscriber, +} from '@backstage/plugin-events-node'; export interface ServerOptions { port: number; @@ -43,14 +48,26 @@ export async function startStandaloneServer( issuer: await discovery.getExternalBaseUrl('auth'), }); + const mockSubscribers: EventSubscriber[] = []; + const eventBroker: EventBroker = { + async publish(params: EventParams): Promise { + mockSubscribers.forEach(sub => sub.onEvent(params)); + }, + subscribe(...subscribers: EventSubscriber[]) { + subscribers.flat().forEach(subscriber => { + mockSubscribers.push(subscriber); + }); + }, + }; + const signals = DefaultSignalService.create({ - logger: logger, - identity, + eventBroker, }); const router = await createRouter({ logger, - service: signals, + identity, + eventBroker, }); let service = createServiceBuilder(module) @@ -60,10 +77,22 @@ export async function startStandaloneServer( service = service.enableCors({ origin: 'http://localhost:3000' }); } - return await service.start().catch(err => { + let server: Promise; + try { + server = service.start(); + + setInterval(() => { + signals.publish({ + recipients: null, + channel: 'test', + message: { hello: 'world' }, + }); + }, 5000); + } catch (err) { logger.error(err); process.exit(1); - }); + } + return server; } module.hot?.accept(); diff --git a/plugins/signals-node/README.md b/plugins/signals-node/README.md index 14c9332dc1..5e738a849d 100644 --- a/plugins/signals-node/README.md +++ b/plugins/signals-node/README.md @@ -27,10 +27,8 @@ function makeCreateEnv(config: Config) { // ... const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); - const signalService = SignalService.create({ - logger: root, - eventBroker, // EventBroker is optional - identity, + const signalService = DefaultSignalService.create({ + eventBroker, }); return (plugin: string): PluginEnvironment => { @@ -51,16 +49,38 @@ To allow connections from the frontend, you should also install the `@backstage/ Once you have both of the backend plugins installed, you can utilize the signal service by calling the `publish` method. This will publish the message to all subscribers in the frontend. To send message to -all subscribers, you can use `*` as `to` parameter. +all subscribers, you can use `null` as `recipients` parameter. ```ts // Periodic sending example setInterval(async () => { - await signalService.publish('*', 'plugin:topic', { - message: 'hello world', + await signalService.publish({ + recipients: null, + channel: 'my_plugin', + message: { + message: 'hello world', + }, }); }, 5000); ``` To receive this message in the frontend, check the documentation of `@backstage/plugin-signals` and `@backstage/plugin-signals-react`. + +## Using event broker directly + +Other way to send signals is to utilize the `EventBroker` directly. This requires that the payload is correct for it +to work: + +```ts +eventBroker.publish({ + topic: 'signals', + eventPayload: { + recipients: ['user:default/user1'], + message: { + message: 'hello world', + }, + channel: 'my_plugin', + }, +}); +``` diff --git a/plugins/signals-node/api-report.md b/plugins/signals-node/api-report.md index d85163040b..00d099f7db 100644 --- a/plugins/signals-node/api-report.md +++ b/plugins/signals-node/api-report.md @@ -3,63 +3,35 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -/// - -import { Duplex } from 'stream'; import { EventBroker } from '@backstage/plugin-events-node'; -import http from 'http'; -import https from 'https'; -import { IdentityApi } from '@backstage/plugin-auth-node'; -import { IncomingMessage } from 'http'; import { JsonObject } from '@backstage/types'; -import { LoggerService } from '@backstage/backend-plugin-api'; import { ServiceRef } from '@backstage/backend-plugin-api'; // @public (undocumented) export class DefaultSignalService implements SignalService { // (undocumented) - static create(options: ServiceOptions): DefaultSignalService; - handleUpgrade(options: SignalServiceUpgradeOptions): Promise; - publish( - to: string | string[], - topic: string, - message: JsonObject, - ): Promise; + static create(options: SignalServiceOptions): DefaultSignalService; + publish(signal: SignalPayload): Promise; } // @public (undocumented) -export type ServiceOptions = { - eventBroker?: EventBroker; - logger: LoggerService; - identity: IdentityApi; -}; - -// @public (undocumented) -export type SignalEventBrokerPayload = { - recipients?: string[]; - topic?: string; - message?: JsonObject; +export type SignalPayload = { + recipients: string[] | null; + channel: string; + message: JsonObject; }; // @public (undocumented) export type SignalService = { - publish( - to: string | string[], - topic: string, - message: JsonObject, - ): Promise; - handleUpgrade(options: SignalServiceUpgradeOptions): Promise; + publish(signal: SignalPayload): Promise; }; // @public (undocumented) export const signalService: ServiceRef; // @public (undocumented) -export type SignalServiceUpgradeOptions = { - server: https.Server | http.Server; - request: IncomingMessage; - socket: Duplex; - head: Buffer; +export type SignalServiceOptions = { + eventBroker?: EventBroker; }; // (No @packageDocumentation comment for this package) diff --git a/plugins/signals-node/src/DefaultSignalService.ts b/plugins/signals-node/src/DefaultSignalService.ts index cab6e83c81..1fba96b8bc 100644 --- a/plugins/signals-node/src/DefaultSignalService.ts +++ b/plugins/signals-node/src/DefaultSignalService.ts @@ -13,226 +13,38 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { EventBroker, EventParams } from '@backstage/plugin-events-node'; -import { - ServiceOptions, - SignalConnection, - SignalEventBrokerPayload, -} from './types'; -import { RawData, WebSocket, WebSocketServer } from 'ws'; -import { IncomingMessage } from 'http'; -import { v4 as uuid } from 'uuid'; -import { JsonObject } from '@backstage/types'; -import { - BackstageIdentityResponse, - IdentityApi, - IdentityApiGetIdentityRequest, -} from '@backstage/plugin-auth-node'; -import { LoggerService } from '@backstage/backend-plugin-api'; -import { SignalService, SignalServiceUpgradeOptions } from './SignalService'; +import { EventBroker } from '@backstage/plugin-events-node'; +import { SignalPayload, SignalServiceOptions } from './types'; +import { SignalService } from './SignalService'; /** @public */ export class DefaultSignalService implements SignalService { - private connections: Map = new Map< - string, - SignalConnection - >(); + // TODO: Remove this to be optional when events-backend has eventBroker as service private eventBroker?: EventBroker; - private logger: LoggerService; - private identity: IdentityApi; - private server: WebSocketServer; - static create(options: ServiceOptions) { + static create(options: SignalServiceOptions) { return new DefaultSignalService(options); } - private constructor(options: ServiceOptions) { - ({ - eventBroker: this.eventBroker, - logger: this.logger, - identity: this.identity, - } = options); - - this.server = new WebSocketServer({ - noServer: true, - clientTracking: false, - }); - - this.eventBroker?.subscribe({ - supportsEventTopics: () => ['signals'], - onEvent: (params: EventParams) => - this.onEventBrokerEvent(params), - }); - } - - /** - * Handles request upgrade to websocket and adds the connection to internal - * list for publish/subscribe functionality - * @param req - Request - */ - async handleUpgrade(options: SignalServiceUpgradeOptions) { - const { request, socket, head } = options; - let identity: BackstageIdentityResponse | undefined = undefined; - - // Authentication token is passed in Sec-WebSocket-Protocol header as there - // is no other way to pass the token with plain websockets - const token = request.headers['sec-websocket-protocol']; - if (token) { - identity = await this.identity.getIdentity({ - request: { - headers: { authorization: token }, - }, - } as IdentityApiGetIdentityRequest); - } - - this.server.handleUpgrade( - request, - socket, - head, - (ws: WebSocket, __: IncomingMessage) => { - this.addConnection(ws, identity); - }, - ); - } - - private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) { - const id = uuid(); - - const conn = { - id, - user: identity?.identity.userEntityRef ?? 'user:default/guest', - ws, - ownershipEntityRefs: identity?.identity.ownershipEntityRefs ?? [], - subscriptions: new Set(), - }; - - this.connections.set(id, conn); - - ws.on('error', (err: Error) => { - this.logger.info( - `Error occurred with connection ${id}: ${err}, closing connection`, - ); - ws.close(); - this.connections.delete(id); - }); - - ws.on('close', (code: number, reason: Buffer) => { - this.logger.info( - `Connection ${id} closed with code ${code}, reason: ${reason}`, - ); - this.connections.delete(id); - }); - - ws.on('message', (data: RawData, isBinary: boolean) => { - this.logger.debug(`Received message from connection ${id}: ${data}`); - if (isBinary) { - return; - } - try { - const json = JSON.parse(data.toString()) as JsonObject; - this.handleMessage(conn, json); - } catch (err: any) { - this.logger.error( - `Invalid message received from connection ${id}: ${err}`, - ); - } - }); - } - - private handleMessage(connection: SignalConnection, message: JsonObject) { - if (message.action === 'subscribe' && message.topic) { - this.logger.info( - `Connection ${connection.id} subscribed to ${message.topic}`, - ); - connection.subscriptions.add(message.topic as string); - } - - if (message.action === 'unsubscribe' && message.topic) { - this.logger.info( - `Connection ${connection.id} unsubscribed from ${message.topic}`, - ); - connection.subscriptions.delete(message.topic as string); - } + private constructor(options: SignalServiceOptions) { + ({ eventBroker: this.eventBroker } = options); } /** * Publishes a message to user refs to specific topic - * @param to - string or array of user ref strings to publish message to + * @param recipients - string or array of user ref strings to publish message to * @param topic - message topic * @param message - message to publish */ - async publish(to: string | string[], topic: string, message: JsonObject) { - await this.publishInternal( - Array.isArray(to) ? to : [to], - topic, - message, - false, - ); - } - - private async publishInternal( - recipients: string[], - topic: string, - message: JsonObject, - brokedEvent: boolean, - ) { - const jsonMessage = JSON.stringify({ topic, message }); - if (jsonMessage.length === 0) { - return; - } - - // If there is event broker, use that to publish the message to - // all signal services, including this one. - if (this.eventBroker && !brokedEvent) { - await this.eventBroker.publish({ - topic: 'signals', - eventPayload: { - recipients, - message, - topic, - }, - }); - return; - } - - // Actual websocket message sending - this.connections.forEach(conn => { - if (!conn.subscriptions.has(topic)) { - return; - } - // Sending to all users can be done with '*' - if ( - !recipients.includes('*') && - !conn.ownershipEntityRefs.some(ref => recipients.includes(ref)) - ) { - return; - } - - if (conn.ws.readyState !== WebSocket.OPEN) { - return; - } - - conn.ws.send(jsonMessage); + async publish(signal: SignalPayload) { + const { recipients, channel, message } = signal; + await this.eventBroker?.publish({ + topic: 'signals', + eventPayload: { + recipients, + message, + channel, + }, }); } - - private async onEventBrokerEvent( - params: EventParams, - ): Promise { - const { eventPayload } = params; - if ( - !eventPayload?.recipients || - !eventPayload.topic || - !eventPayload.message - ) { - return; - } - - await this.publishInternal( - eventPayload.recipients, - eventPayload.topic, - eventPayload.message, - true, - ); - } } diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts index 7095c29858..f08a12661f 100644 --- a/plugins/signals-node/src/SignalService.ts +++ b/plugins/signals-node/src/SignalService.ts @@ -13,32 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JsonObject } from '@backstage/types'; -import http, { IncomingMessage } from 'http'; -import { Duplex } from 'stream'; -import https from 'https'; - -/** @public */ -export type SignalServiceUpgradeOptions = { - server: https.Server | http.Server; - request: IncomingMessage; - socket: Duplex; - head: Buffer; -}; +import { SignalPayload } from './types'; /** @public */ export type SignalService = { /** * Publishes a message to user refs to specific topic */ - publish( - to: string | string[], - topic: string, - message: JsonObject, - ): Promise; - - /** - * Handles request upgrade - */ - handleUpgrade(options: SignalServiceUpgradeOptions): Promise; + publish(signal: SignalPayload): Promise; }; diff --git a/plugins/signals-node/src/lib.ts b/plugins/signals-node/src/lib.ts index c7e7c4a6ae..095a2f085d 100644 --- a/plugins/signals-node/src/lib.ts +++ b/plugins/signals-node/src/lib.ts @@ -14,7 +14,6 @@ * limitations under the License. */ import { - coreServices, createServiceFactory, createServiceRef, } from '@backstage/backend-plugin-api'; @@ -29,12 +28,11 @@ export const signalService = createServiceRef({ createServiceFactory({ service, deps: { - logger: coreServices.logger, - identity: coreServices.identity, - // TODO: EventBroker + // TODO: EventBroker. It is optional for now but it's actually required so waiting for the new backend system + // for the events-backend for this to work. }, - factory({ logger, identity }) { - return DefaultSignalService.create({ identity, logger }); + factory({}) { + return DefaultSignalService.create({}); }, }), }); diff --git a/plugins/signals-node/src/types.ts b/plugins/signals-node/src/types.ts index 6bfafae0b2..0220a196aa 100644 --- a/plugins/signals-node/src/types.ts +++ b/plugins/signals-node/src/types.ts @@ -13,35 +13,19 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { IdentityApi } from '@backstage/plugin-auth-node'; import { EventBroker } from '@backstage/plugin-events-node'; -import { WebSocket } from 'ws'; import { JsonObject } from '@backstage/types'; -import { LoggerService } from '@backstage/backend-plugin-api'; /** * @public */ -export type ServiceOptions = { +export type SignalServiceOptions = { eventBroker?: EventBroker; - logger: LoggerService; - identity: IdentityApi; }; /** @public */ -export type SignalEventBrokerPayload = { - recipients?: string[]; - topic?: string; - message?: JsonObject; -}; - -/** - * @internal - */ -export type SignalConnection = { - id: string; - user: string; - ws: WebSocket; - ownershipEntityRefs: string[]; - subscriptions: Set; +export type SignalPayload = { + recipients: string[] | null; + channel: string; + message: JsonObject; }; diff --git a/plugins/signals-react/README.md b/plugins/signals-react/README.md index 244a4ec43d..4da9338866 100644 --- a/plugins/signals-react/README.md +++ b/plugins/signals-react/README.md @@ -22,10 +22,15 @@ Example of using the hook: ```ts import { useSignal } from '@backstage/plugin-signals-react'; -const { lastSignal } = useSignal('myplugin:topic'); +const { lastSignal } = useSignal('myplugin:channel'); + +useEffect(() => { + console.log(lastSignal); +}, [lastSignal]); ``` -Whenever backend publishes new message to the topic `myplugin:topic`, the lastSignal is changed. +Whenever backend publishes new message to the channel `myplugin:channel`, the `lastSignal` is changed. The `lastSignal` +is always initiated with null value before any messages are received from the backend. ## Using API directly @@ -37,7 +42,7 @@ import { signalsApiRef } from '@backstage/plugin-signals-react'; const signals = useApi(signalsApiRef); const { unsubscribe } = signals.subscribe( - 'myplugin:topic', + 'myplugin:channel', (message: JsonObject) => { console.log(message); }, diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md index 8ad0c06fbe..2df2e4f1ce 100644 --- a/plugins/signals-react/api-report.md +++ b/plugins/signals-react/api-report.md @@ -9,7 +9,7 @@ import { JsonObject } from '@backstage/types'; // @public (undocumented) export type SignalApi = { subscribe( - topic: string, + channel: string, onMessage: (message: JsonObject) => void, ): { unsubscribe: () => void; @@ -20,7 +20,7 @@ export type SignalApi = { export const signalApiRef: ApiRef; // @public (undocumented) -export const useSignal: (topic: string) => { +export const useSignal: (channel: string) => { lastSignal: JsonObject | null; }; diff --git a/plugins/signals-react/src/api/SignalApi.ts b/plugins/signals-react/src/api/SignalApi.ts index 09eacafa4d..b37b3ae2f5 100644 --- a/plugins/signals-react/src/api/SignalApi.ts +++ b/plugins/signals-react/src/api/SignalApi.ts @@ -24,7 +24,7 @@ export const signalApiRef = createApiRef({ /** @public */ export type SignalApi = { subscribe( - topic: string, + channel: string, onMessage: (message: JsonObject) => void, ): { unsubscribe: () => void }; }; diff --git a/plugins/signals-react/src/hooks/useSignal.ts b/plugins/signals-react/src/hooks/useSignal.ts index 1176769a06..a7d50fbe3b 100644 --- a/plugins/signals-react/src/hooks/useSignal.ts +++ b/plugins/signals-react/src/hooks/useSignal.ts @@ -19,7 +19,7 @@ import { JsonObject } from '@backstage/types'; import { useEffect, useState } from 'react'; /** @public */ -export const useSignal = (topic: string) => { +export const useSignal = (channel: string) => { const apiHolder = useApiHolder(); // Use apiHolder instead useApi in case signalApi is not available in the // backstage instance this is used @@ -28,7 +28,7 @@ export const useSignal = (topic: string) => { useEffect(() => { let unsub: null | (() => void) = null; if (signals) { - const { unsubscribe } = signals.subscribe(topic, (msg: JsonObject) => { + const { unsubscribe } = signals.subscribe(channel, (msg: JsonObject) => { setLastSignal(msg); }); unsub = unsubscribe; @@ -38,7 +38,7 @@ export const useSignal = (topic: string) => { unsub(); } }; - }, [signals, topic]); + }, [signals, channel]); return { lastSignal }; }; diff --git a/plugins/signals/api-report.md b/plugins/signals/api-report.md index 97c882dda4..398d5fbfe6 100644 --- a/plugins/signals/api-report.md +++ b/plugins/signals/api-report.md @@ -24,7 +24,7 @@ export class SignalClient implements SignalApi { static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number; // (undocumented) subscribe( - topic: string, + channel: string, onMessage: (message: JsonObject) => void, ): { unsubscribe: () => void; diff --git a/plugins/signals/src/api/SignalClient.ts b/plugins/signals/src/api/SignalClient.ts index 24a38a49e3..ee4ed8756b 100644 --- a/plugins/signals/src/api/SignalClient.ts +++ b/plugins/signals/src/api/SignalClient.ts @@ -19,7 +19,7 @@ import { DiscoveryApi, IdentityApi } from '@backstage/core-plugin-api'; import { v4 as uuid } from 'uuid'; type Subscription = { - topic: string; + channel: string; callback: (message: JsonObject) => void; }; @@ -63,20 +63,23 @@ export class SignalClient implements SignalApi { ) {} subscribe( - topic: string, + channel: string, onMessage: (message: JsonObject) => void, ): { unsubscribe: () => void } { const subscriptionId = uuid(); const exists = [...this.subscriptions.values()].find( - sub => sub.topic === topic, + sub => sub.channel === channel, ); - this.subscriptions.set(subscriptionId, { topic, callback: onMessage }); + this.subscriptions.set(subscriptionId, { + channel: channel, + callback: onMessage, + }); this.connect() .then(() => { - // Do not subscribe twice to same topic even there is multiple callbacks + // Do not subscribe twice to same channel even there is multiple callbacks if (!exists) { - this.send({ action: 'subscribe', topic }); + this.send({ action: 'subscribe', channel }); } }) .catch(() => { @@ -90,12 +93,12 @@ export class SignalClient implements SignalApi { } this.subscriptions.delete(subscriptionId); const multipleExists = [...this.subscriptions.values()].find( - s => s.topic === topic, + s => s.channel === channel, ); - // If there are subscriptions still listening to this topic, do not + // If there are subscriptions still listening to this channel, do not // unsubscribe from the server if (!multipleExists) { - this.send({ action: 'unsubscribe', topic: sub.topic }); + this.send({ action: 'unsubscribe', channel: sub.channel }); } // If there are no subscriptions, close the connection @@ -176,9 +179,9 @@ export class SignalClient implements SignalApi { private handleMessage(data: MessageEvent) { try { const json = JSON.parse(data.data) as JsonObject; - if (json.topic) { + if (json.channel) { for (const sub of this.subscriptions.values()) { - if (sub.topic === json.topic) { + if (sub.channel === json.channel) { sub.callback(json.message as JsonObject); } } @@ -201,9 +204,9 @@ export class SignalClient implements SignalApi { this.ws = null; this.connect() .then(() => { - // Resubscribe to existing topics in case we lost connection + // Resubscribe to existing channels in case we lost connection for (const sub of this.subscriptions.values()) { - this.send({ action: 'subscribe', topic: sub.topic }); + this.send({ action: 'subscribe', channel: sub.channel }); } }) .catch(() => { diff --git a/plugins/signals/src/api/SignalsClient.test.ts b/plugins/signals/src/api/SignalsClient.test.ts index ec202e2703..2e93cd69f1 100644 --- a/plugins/signals/src/api/SignalsClient.test.ts +++ b/plugins/signals/src/api/SignalsClient.test.ts @@ -44,21 +44,21 @@ describe('SignalsClient', () => { it('should handle single subscription correctly', async () => { const messageMock = jest.fn(); const client = SignalClient.create({ discoveryApi, identity }); - const { unsubscribe } = client.subscribe('topic', messageMock); + const { unsubscribe } = client.subscribe('channel', messageMock); await server.connected; await expect(server).toReceiveMessage({ action: 'subscribe', - topic: 'topic', + channel: 'channel', }); - server.send({ topic: 'topic', message: { hello: 'world' } }); + server.send({ channel: 'channel', message: { hello: 'world' } }); expect(messageMock).toHaveBeenCalledWith({ hello: 'world' }); await unsubscribe(); await expect(server).toReceiveMessage({ action: 'unsubscribe', - topic: 'topic', + channel: 'channel', }); }); @@ -68,11 +68,11 @@ describe('SignalsClient', () => { const client1 = SignalClient.create({ discoveryApi, identity }); const client2 = SignalClient.create({ discoveryApi, identity }); const { unsubscribe: unsubscribe1 } = client1.subscribe( - 'topic', + 'channel', messageMock1, ); const { unsubscribe: unsubscribe2 } = client2.subscribe( - 'topic', + 'channel', messageMock2, ); @@ -80,22 +80,22 @@ describe('SignalsClient', () => { await expect(server).toReceiveMessage({ action: 'subscribe', - topic: 'topic', + channel: 'channel', }); - server.send({ topic: 'topic', message: { hello: 'world' } }); + server.send({ channel: 'channel', message: { hello: 'world' } }); expect(messageMock1).toHaveBeenCalledWith({ hello: 'world' }); expect(messageMock2).toHaveBeenCalledWith({ hello: 'world' }); await unsubscribe1(); await expect(server).not.toReceiveMessage({ action: 'unsubscribe', - topic: 'topic', + channel: 'channel', }); await unsubscribe2(); await expect(server).toReceiveMessage({ action: 'unsubscribe', - topic: 'topic', + channel: 'channel', }); }); @@ -108,11 +108,11 @@ describe('SignalsClient', () => { connectTimeout: 100, }); - client.subscribe('topic', messageMock); + client.subscribe('channel', messageMock); await server.connected; await expect(server).toReceiveMessage({ action: 'subscribe', - topic: 'topic', + channel: 'channel', }); await server.server.emit('error', null); @@ -120,7 +120,7 @@ describe('SignalsClient', () => { await new Promise(r => setTimeout(r, 50)); await expect(server).toReceiveMessage({ action: 'subscribe', - topic: 'topic', + channel: 'channel', }); }); }); From 9ac1ceb4b68bb2f5b9b50522f4ce281d424ec401 Mon Sep 17 00:00:00 2001 From: Heikki Hellgren Date: Mon, 15 Jan 2024 09:16:28 +0200 Subject: [PATCH 20/20] chore: move connection upgrade to router, add tests for manager Signed-off-by: Heikki Hellgren --- .../src/service/SignalManager.test.ts | 178 ++++++++++++++++++ .../src/service/SignalManager.ts | 69 +------ plugins/signals-backend/src/service/router.ts | 63 +++++-- 3 files changed, 236 insertions(+), 74 deletions(-) create mode 100644 plugins/signals-backend/src/service/SignalManager.test.ts diff --git a/plugins/signals-backend/src/service/SignalManager.test.ts b/plugins/signals-backend/src/service/SignalManager.test.ts new file mode 100644 index 0000000000..5720d1ff52 --- /dev/null +++ b/plugins/signals-backend/src/service/SignalManager.test.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { WebSocket } from 'ws'; +import { EventSubscriber } from '@backstage/plugin-events-node'; +import { SignalManager } from './SignalManager'; +import { getVoidLogger } from '@backstage/backend-common'; + +class MockWebSocket { + closed: boolean = false; + readyState: number = WebSocket.OPEN; + callbacks: Map void> = + new Map(); + data: any[] = []; + + close(_: number, __: string | Buffer): void { + this.readyState = WebSocket.CLOSED; + this.closed = true; + } + + on( + event: string | symbol, + listener: (this: WebSocket, ...args: any[]) => void, + ) { + this.callbacks.set(event, listener); + return this; + } + + // @ts-ignore + send(data: any, _?: (err?: Error) => void): void { + this.data.push(data); + } + + trigger(event: string | symbol, ...args: any[]): void { + const cb = this.callbacks.get(event); + if (!cb) { + throw new Error(`No callback for ${event.toString()}`); + } + // @ts-ignore + cb(...args); + } +} + +describe('SignalManager', () => { + let onEvent: Function; + + const mockEventBroker = { + publish: async () => {}, + subscribe: (subscriber: EventSubscriber) => { + onEvent = subscriber.onEvent; + }, + }; + + const manager = SignalManager.create({ + eventBroker: mockEventBroker, + logger: getVoidLogger(), + }); + + it('should close connection on error', () => { + const ws = new MockWebSocket(); + manager.addConnection(ws as unknown as WebSocket); + + ws.trigger('error', new Error('error')); + expect(ws.closed).toBeTruthy(); + }); + + it('should allow subscribing and unsubscribing to events', async () => { + const ws = new MockWebSocket(); + manager.addConnection(ws as unknown as WebSocket); + + ws.trigger( + 'message', + JSON.stringify({ action: 'subscribe', channel: 'test' }), + false, + ); + + await onEvent({ + topic: 'signals', + eventPayload: { + recipients: null, + channel: 'test', + message: { msg: 'test' }, + }, + }); + + expect(ws.data.length).toEqual(1); + expect(ws.data[0]).toEqual( + JSON.stringify({ channel: 'test', message: { msg: 'test' } }), + ); + + ws.trigger( + 'message', + JSON.stringify({ action: 'unsubscribe', channel: 'test' }), + false, + ); + + await onEvent({ + topic: 'signals', + eventPayload: { + recipients: null, + channel: 'test', + message: { msg: 'test' }, + }, + }); + + expect(ws.data.length).toEqual(1); + }); + + it('should only send to users from identity', async () => { + // Connection without identity + const ws1 = new MockWebSocket(); + manager.addConnection(ws1 as unknown as WebSocket); + + // Connection with identity and subscription + const ws2 = new MockWebSocket(); + manager.addConnection(ws2 as unknown as WebSocket, { + identity: { + type: 'user', + ownershipEntityRefs: ['user:default/john.doe'], + userEntityRef: 'user:default/john.doe', + }, + expiresInSeconds: 3600, + token: '1234', + }); + + // Connection without subscription + const ws3 = new MockWebSocket(); + manager.addConnection(ws3 as unknown as WebSocket, { + identity: { + type: 'user', + ownershipEntityRefs: ['user:default/john.doe'], + userEntityRef: 'user:default/john.doe', + }, + expiresInSeconds: 3600, + token: '1234', + }); + + ws1.trigger( + 'message', + JSON.stringify({ action: 'subscribe', channel: 'test' }), + false, + ); + + ws2.trigger( + 'message', + JSON.stringify({ action: 'subscribe', channel: 'test' }), + false, + ); + + await onEvent({ + topic: 'signals', + eventPayload: { + recipients: 'user:default/john.doe', + channel: 'test', + message: { msg: 'test' }, + }, + }); + + expect(ws1.data.length).toEqual(0); + expect(ws3.data.length).toEqual(0); + expect(ws2.data.length).toEqual(1); + expect(ws2.data[0]).toEqual( + JSON.stringify({ channel: 'test', message: { msg: 'test' } }), + ); + }); +}); diff --git a/plugins/signals-backend/src/service/SignalManager.ts b/plugins/signals-backend/src/service/SignalManager.ts index 988db47465..983496b96b 100644 --- a/plugins/signals-backend/src/service/SignalManager.ts +++ b/plugins/signals-backend/src/service/SignalManager.ts @@ -15,24 +15,11 @@ */ import { EventBroker, EventParams } from '@backstage/plugin-events-node'; import { SignalPayload } from '@backstage/plugin-signals-node'; -import { RawData, WebSocket, WebSocketServer } from 'ws'; -import { IncomingMessage } from 'http'; +import { RawData, WebSocket } from 'ws'; import { v4 as uuid } from 'uuid'; import { JsonObject } from '@backstage/types'; -import { - BackstageIdentityResponse, - IdentityApi, - IdentityApiGetIdentityRequest, -} from '@backstage/plugin-auth-node'; +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; import { LoggerService } from '@backstage/backend-plugin-api'; -import { Duplex } from 'stream'; - -/** @internal */ -export type ConnectionUpgradeOptions = { - request: IncomingMessage; - socket: Duplex; - head: Buffer; -}; /** * @internal @@ -52,7 +39,6 @@ export type SignalManagerOptions = { // TODO: Remove optional when events-backend can offer this service eventBroker?: EventBroker; logger: LoggerService; - identity: IdentityApi; }; /** @internal */ @@ -63,24 +49,13 @@ export class SignalManager { >(); private eventBroker?: EventBroker; private logger: LoggerService; - private identity: IdentityApi; - private server: WebSocketServer; static create(options: SignalManagerOptions) { return new SignalManager(options); } private constructor(options: SignalManagerOptions) { - ({ - eventBroker: this.eventBroker, - logger: this.logger, - identity: this.identity, - } = options); - - this.server = new WebSocketServer({ - noServer: true, - clientTracking: false, - }); + ({ eventBroker: this.eventBroker, logger: this.logger } = options); this.eventBroker?.subscribe({ supportsEventTopics: () => ['signals'], @@ -89,37 +64,7 @@ export class SignalManager { }); } - /** - * Handles request upgrade to websocket and adds the connection to internal - * list for publish/subscribe functionality - * @param req - Request - */ - async handleUpgrade(options: ConnectionUpgradeOptions) { - const { request, socket, head } = options; - let identity: BackstageIdentityResponse | undefined = undefined; - - // Authentication token is passed in Sec-WebSocket-Protocol header as there - // is no other way to pass the token with plain websockets - const token = request.headers['sec-websocket-protocol']; - if (token) { - identity = await this.identity.getIdentity({ - request: { - headers: { authorization: token }, - }, - } as IdentityApiGetIdentityRequest); - } - - this.server.handleUpgrade( - request, - socket, - head, - (ws: WebSocket, __: IncomingMessage) => { - this.addConnection(ws, identity); - }, - ); - } - - private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) { + addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) { const id = uuid(); const conn = { @@ -207,7 +152,11 @@ export class SignalManager { return; } - conn.ws.send(jsonMessage); + conn.ws.send(jsonMessage, err => { + if (err) { + this.logger.error(`Failed to send message to ${conn.id}: ${err}`); + } + }); }); } } diff --git a/plugins/signals-backend/src/service/router.ts b/plugins/signals-backend/src/service/router.ts index ec56860bf3..4394a9f480 100644 --- a/plugins/signals-backend/src/service/router.ts +++ b/plugins/signals-backend/src/service/router.ts @@ -18,10 +18,15 @@ import express, { NextFunction, Request, Response } from 'express'; import Router from 'express-promise-router'; import { LoggerService } from '@backstage/backend-plugin-api'; import * as https from 'https'; -import http from 'http'; +import http, { IncomingMessage } from 'http'; import { SignalManager } from './SignalManager'; -import { IdentityApi } from '@backstage/plugin-auth-node'; +import { + BackstageIdentityResponse, + IdentityApi, + IdentityApiGetIdentityRequest, +} from '@backstage/plugin-auth-node'; import { EventBroker } from '@backstage/plugin-events-node'; +import { WebSocket, WebSocketServer } from 'ws'; /** @public */ export interface RouterOptions { @@ -34,13 +39,23 @@ export interface RouterOptions { export async function createRouter( options: RouterOptions, ): Promise { - const { logger } = options; + const { logger, identity } = options; const manager = SignalManager.create(options); - let subscribed = false; + let subscribedToUpgradeRequests = false; - const upgradeMiddleware = (req: Request, _: Response, next: NextFunction) => { + const webSocketServer = new WebSocketServer({ + noServer: true, + clientTracking: false, + }); + + const upgradeMiddleware = async ( + req: Request, + _: Response, + next: NextFunction, + ) => { const server: https.Server | http.Server = (req.socket as any)?.server; if ( + subscribedToUpgradeRequests || !server || !req.headers || req.headers.upgrade === undefined || @@ -50,15 +65,35 @@ export async function createRouter( return; } - if (!subscribed) { - subscribed = true; - server.on('upgrade', async (request, socket, head) => { - // TODO: Find a way to make this more generic - if (request.url === '/api/signals') { - await manager.handleUpgrade({ request, socket, head }); - } - }); - } + subscribedToUpgradeRequests = true; + server.on('upgrade', async (request, socket, head) => { + // TODO: Find a way to make this more generic + if (request.url !== '/api/signals') { + return; + } + + let userIdentity: BackstageIdentityResponse | undefined = undefined; + + // Authentication token is passed in Sec-WebSocket-Protocol header as there + // is no other way to pass the token with plain websockets + const token = req.headers['sec-websocket-protocol']; + if (token) { + userIdentity = await identity.getIdentity({ + request: { + headers: { authorization: token }, + }, + } as IdentityApiGetIdentityRequest); + } + + webSocketServer.handleUpgrade( + request, + socket, + head, + (ws: WebSocket, __: IncomingMessage) => { + manager.addConnection(ws, userIdentity); + }, + ); + }); }; const router = Router();