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: