diff --git a/.changeset/real-eggs-sip.md b/.changeset/real-eggs-sip.md new file mode 100644 index 0000000000..ca23d792f8 --- /dev/null +++ b/.changeset/real-eggs-sip.md @@ -0,0 +1,8 @@ +--- +'@backstage/plugin-signals-backend': patch +'@backstage/plugin-signals': 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/app/package.json b/packages/app/package.json index fbbe6ce09d..53f25883d0 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/packages/backend/package.json b/packages/backend/package.json index 4bcf627366..cdd0a548c8 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..bade028597 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'; @@ -72,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 { 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 @@ -98,6 +100,9 @@ function makeCreateEnv(config: Config) { }); const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' })); + const signalService = DefaultSignalService.create({ + eventBroker, + }); root.info(`Created UrlReader ${reader}`); @@ -119,6 +124,7 @@ function makeCreateEnv(config: Config) { permissions, scheduler, identity, + signalService, }; }; } @@ -172,6 +178,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 +205,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..477cea1938 --- /dev/null +++ b/packages/backend/src/plugins/signals.ts @@ -0,0 +1,28 @@ +/* + * 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 { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + eventBroker: env.eventBroker, + identity: env.identity, + }); +} diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index ab1baf0c95..3dad2f739b 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 { DefaultSignalService } from '@backstage/plugin-signals-node'; export type PluginEnvironment = { logger: Logger; @@ -40,4 +41,5 @@ export type PluginEnvironment = { scheduler: PluginTaskScheduler; identity: IdentityApi; eventBroker: EventBroker; + signalService: DefaultSignalService; }; 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..e30a0836e0 --- /dev/null +++ b/plugins/signals-backend/README.md @@ -0,0 +1,45 @@ +# signals + +Welcome to the signals backend plugin! + +Signals plugin allows backend plugins to publish messages to frontend plugins. + +## Getting started + +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 { PluginEnvironment } from '../types'; + +export default async function createPlugin( + env: PluginEnvironment, +): Promise { + return await createRouter({ + logger: env.logger, + eventBroker: env.eventBroker, + identity: env.identity, + }); +} +``` + +Now add the signals to `packages/backend/src/index.ts`: + +```ts +// ... +import signals from './plugins/signals'; + +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 new file mode 100644 index 0000000000..dd46778e5d --- /dev/null +++ b/plugins/signals-backend/api-report.md @@ -0,0 +1,30 @@ +## 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 { 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'; + +// @public (undocumented) +export function createRouter(options: RouterOptions): Promise; + +// @public (undocumented) +export interface RouterOptions { + // (undocumented) + eventBroker?: EventBroker; + // (undocumented) + identity: IdentityApi; + // (undocumented) + logger: LoggerService; +} + +// @public +const signalsPlugin: () => BackendFeature; +export default signalsPlugin; + +// (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..2fe6d86480 --- /dev/null +++ b/plugins/signals-backend/package.json @@ -0,0 +1,51 @@ +{ + "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/backend-plugin-api": "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..05e1f941b2 --- /dev/null +++ b/plugins/signals-backend/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 * 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..4a9cef028f --- /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 { + coreServices, + createBackendPlugin, +} from '@backstage/backend-plugin-api'; +import { createRouter } from './service/router'; + +/** + * Signals backend plugin + * + * @public + */ +export const signalsPlugin = createBackendPlugin({ + pluginId: 'signals', + register(env) { + env.registerInit({ + deps: { + httpRouter: coreServices.httpRouter, + logger: coreServices.logger, + 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, identity }) { + httpRouter.use( + await createRouter({ + logger, + identity, + }), + ); + }, + }); + }, +}); 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/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 new file mode 100644 index 0000000000..983496b96b --- /dev/null +++ b/plugins/signals-backend/src/service/SignalManager.ts @@ -0,0 +1,162 @@ +/* + * 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 } from 'ws'; +import { v4 as uuid } from 'uuid'; +import { JsonObject } from '@backstage/types'; +import { BackstageIdentityResponse } from '@backstage/plugin-auth-node'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +/** + * @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; +}; + +/** @internal */ +export class SignalManager { + private connections: Map = new Map< + string, + SignalConnection + >(); + private eventBroker?: EventBroker; + private logger: LoggerService; + + static create(options: SignalManagerOptions) { + return new SignalManager(options); + } + + private constructor(options: SignalManagerOptions) { + ({ eventBroker: this.eventBroker, logger: this.logger } = options); + + this.eventBroker?.subscribe({ + supportsEventTopics: () => ['signals'], + onEvent: (params: EventParams) => + this.onEventBrokerEvent(params), + }); + } + + 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, err => { + if (err) { + this.logger.error(`Failed to send message to ${conn.id}: ${err}`); + } + }); + }); + } +} 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..ecf2ad4aa6 --- /dev/null +++ b/plugins/signals-backend/src/service/router.test.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 { getVoidLogger } from '@backstage/backend-common'; +import express from 'express'; +import request from 'supertest'; + +import { createRouter } from './router'; +import { EventBroker } from '@backstage/plugin-events-node'; +import { IdentityApi } from '@backstage/plugin-auth-node'; + +const eventBrokerMock: jest.Mocked = { + subscribe: jest.fn(), + publish: jest.fn(), +}; + +const identityApiMock: jest.Mocked = { + getIdentity: jest.fn(), +}; + +describe('createRouter', () => { + let app: express.Express; + + beforeAll(async () => { + const router = await createRouter({ + logger: getVoidLogger(), + identity: identityApiMock, + eventBroker: eventBrokerMock, + }); + 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..4394a9f480 --- /dev/null +++ b/plugins/signals-backend/src/service/router.ts @@ -0,0 +1,110 @@ +/* + * 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, { 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, { IncomingMessage } from 'http'; +import { SignalManager } from './SignalManager'; +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 { + logger: LoggerService; + eventBroker?: EventBroker; + identity: IdentityApi; +} + +/** @public */ +export async function createRouter( + options: RouterOptions, +): Promise { + const { logger, identity } = options; + const manager = SignalManager.create(options); + let subscribedToUpgradeRequests = false; + + 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 || + req.headers.upgrade.toLowerCase() !== 'websocket' + ) { + next(); + return; + } + + 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(); + router.use(express.json()); + router.use(upgradeMiddleware); + + router.get('/health', (_, response) => { + logger.info('PONG!'); + response.json({ status: 'ok' }); + }); + + 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..278de8621e --- /dev/null +++ b/plugins/signals-backend/src/service/standaloneServer.ts @@ -0,0 +1,98 @@ +/* + * 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 { 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; + 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 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({ + eventBroker, + }); + + const router = await createRouter({ + logger, + identity, + eventBroker, + }); + + let service = createServiceBuilder(module) + .setPort(options.port) + .addRouter('/signals', router); + if (options.enableCors) { + service = service.enableCors({ origin: 'http://localhost:3000' }); + } + + 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-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..5e738a849d --- /dev/null +++ b/plugins/signals-node/README.md @@ -0,0 +1,86 @@ +# @backstage/plugin-signals-node + +Welcome to the Node.js library package for the signals plugin! + +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 = DefaultSignalService.create({ + eventBroker, + }); + + 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 `null` as `recipients` parameter. + +```ts +// Periodic sending example +setInterval(async () => { + 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 new file mode 100644 index 0000000000..00d099f7db --- /dev/null +++ b/plugins/signals-node/api-report.md @@ -0,0 +1,38 @@ +## 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 { JsonObject } from '@backstage/types'; +import { ServiceRef } from '@backstage/backend-plugin-api'; + +// @public (undocumented) +export class DefaultSignalService implements SignalService { + // (undocumented) + static create(options: SignalServiceOptions): DefaultSignalService; + publish(signal: SignalPayload): Promise; +} + +// @public (undocumented) +export type SignalPayload = { + recipients: string[] | null; + channel: string; + message: JsonObject; +}; + +// @public (undocumented) +export type SignalService = { + publish(signal: SignalPayload): Promise; +}; + +// @public (undocumented) +export const signalService: ServiceRef; + +// @public (undocumented) +export type SignalServiceOptions = { + eventBroker?: EventBroker; +}; + +// (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..d3230432f1 --- /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/backend-plugin-api": "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", + "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..1fba96b8bc --- /dev/null +++ b/plugins/signals-node/src/DefaultSignalService.ts @@ -0,0 +1,50 @@ +/* + * 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 } from '@backstage/plugin-events-node'; +import { SignalPayload, SignalServiceOptions } from './types'; +import { SignalService } from './SignalService'; + +/** @public */ +export class DefaultSignalService implements SignalService { + // TODO: Remove this to be optional when events-backend has eventBroker as service + private eventBroker?: EventBroker; + + static create(options: SignalServiceOptions) { + return new DefaultSignalService(options); + } + + private constructor(options: SignalServiceOptions) { + ({ eventBroker: this.eventBroker } = options); + } + + /** + * Publishes a message to user refs to specific topic + * @param recipients - string or array of user ref strings to publish message to + * @param topic - message topic + * @param message - message to publish + */ + async publish(signal: SignalPayload) { + const { recipients, channel, message } = signal; + await this.eventBroker?.publish({ + topic: 'signals', + eventPayload: { + recipients, + message, + channel, + }, + }); + } +} diff --git a/plugins/signals-node/src/SignalService.ts b/plugins/signals-node/src/SignalService.ts new file mode 100644 index 0000000000..f08a12661f --- /dev/null +++ b/plugins/signals-node/src/SignalService.ts @@ -0,0 +1,24 @@ +/* + * 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 { SignalPayload } from './types'; + +/** @public */ +export type SignalService = { + /** + * Publishes a message to user refs to specific topic + */ + publish(signal: SignalPayload): Promise; +}; diff --git a/plugins/signals-node/src/index.ts b/plugins/signals-node/src/index.ts new file mode 100644 index 0000000000..f9c220b1e9 --- /dev/null +++ b/plugins/signals-node/src/index.ts @@ -0,0 +1,20 @@ +/* + * 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 './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 new file mode 100644 index 0000000000..095a2f085d --- /dev/null +++ b/plugins/signals-node/src/lib.ts @@ -0,0 +1,38 @@ +/* + * 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 { + createServiceFactory, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { DefaultSignalService } from './DefaultSignalService'; +import { SignalService } from './SignalService'; + +/** @public */ +export const signalService = createServiceRef({ + id: 'signals.service', + scope: 'plugin', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + // 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({}) { + return DefaultSignalService.create({}); + }, + }), +}); 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..0220a196aa --- /dev/null +++ b/plugins/signals-node/src/types.ts @@ -0,0 +1,31 @@ +/* + * 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 } from '@backstage/plugin-events-node'; +import { JsonObject } from '@backstage/types'; + +/** + * @public + */ +export type SignalServiceOptions = { + eventBroker?: EventBroker; +}; + +/** @public */ +export type SignalPayload = { + recipients: string[] | null; + channel: string; + message: JsonObject; +}; 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..4da9338866 --- /dev/null +++ b/plugins/signals-react/README.md @@ -0,0 +1,52 @@ +# @backstage/plugin-signals-react + +Welcome to the web library package for the signals plugin! + +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 { useSignal } from '@backstage/plugin-signals-react'; + +const { lastSignal } = useSignal('myplugin:channel'); + +useEffect(() => { + console.log(lastSignal); +}, [lastSignal]); +``` + +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 + +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); +const { unsubscribe } = signals.subscribe( + 'myplugin:channel', + (message: JsonObject) => { + console.log(message); + }, +); +// Remember to unsubscribe +unsubscribe(); +``` diff --git a/plugins/signals-react/api-report.md b/plugins/signals-react/api-report.md new file mode 100644 index 0000000000..2df2e4f1ce --- /dev/null +++ b/plugins/signals-react/api-report.md @@ -0,0 +1,28 @@ +## 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 { JsonObject } from '@backstage/types'; + +// @public (undocumented) +export type SignalApi = { + subscribe( + channel: string, + onMessage: (message: JsonObject) => void, + ): { + unsubscribe: () => void; + }; +}; + +// @public (undocumented) +export const signalApiRef: ApiRef; + +// @public (undocumented) +export const useSignal: (channel: string) => { + lastSignal: JsonObject | null; +}; + +// (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..06df050ccd --- /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.12.4" + }, + "peerDependencies": { + "react": "^16.13.1 || ^17.0.0" + }, + "devDependencies": { + "@backstage/cli": "workspace:^", + "@backstage/test-utils": "workspace:^", + "@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 new file mode 100644 index 0000000000..b37b3ae2f5 --- /dev/null +++ b/plugins/signals-react/src/api/SignalApi.ts @@ -0,0 +1,30 @@ +/* + * 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 signalApiRef = createApiRef({ + id: 'plugin.signal.service', +}); + +/** @public */ +export type SignalApi = { + subscribe( + channel: string, + onMessage: (message: JsonObject) => void, + ): { unsubscribe: () => void }; +}; diff --git a/plugins/signals-react/src/api/index.ts b/plugins/signals-react/src/api/index.ts new file mode 100644 index 0000000000..8d728dbd0f --- /dev/null +++ b/plugins/signals-react/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 './SignalApi'; diff --git a/plugins/signals-react/src/hooks/index.ts b/plugins/signals-react/src/hooks/index.ts new file mode 100644 index 0000000000..a7bf7ebad2 --- /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 './useSignal'; diff --git a/plugins/signals-react/src/hooks/useSignal.ts b/plugins/signals-react/src/hooks/useSignal.ts new file mode 100644 index 0000000000..a7d50fbe3b --- /dev/null +++ b/plugins/signals-react/src/hooks/useSignal.ts @@ -0,0 +1,44 @@ +/* + * 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 { signalApiRef } from '../api'; +import { useApiHolder } from '@backstage/core-plugin-api'; +import { JsonObject } from '@backstage/types'; +import { useEffect, useState } from 'react'; + +/** @public */ +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 + const signals = apiHolder.get(signalApiRef); + const [lastSignal, setLastSignal] = useState(null); + useEffect(() => { + let unsub: null | (() => void) = null; + if (signals) { + const { unsubscribe } = signals.subscribe(channel, (msg: JsonObject) => { + setLastSignal(msg); + }); + unsub = unsubscribe; + } + return () => { + if (signals && unsub) { + unsub(); + } + }; + }, [signals, channel]); + + return { lastSignal }; +}; 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/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..7b96da32f1 --- /dev/null +++ b/plugins/signals/README.md @@ -0,0 +1,44 @@ +# signals + +Welcome to the signals plugin! + +Signals plugin allows backend plugins to publish messages to frontend plugins. + +## Getting started + +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`. + +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); +const { unsubscribe } = signals.subscribe( + 'myplugin:topic', + (message: JsonObject) => { + console.log(message); + }, +); +// Remember to unsubscribe +unsubscribe(); +``` diff --git a/plugins/signals/api-report.md b/plugins/signals/api-report.md new file mode 100644 index 0000000000..398d5fbfe6 --- /dev/null +++ b/plugins/signals/api-report.md @@ -0,0 +1,38 @@ +## 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 { IdentityApi } from '@backstage/core-plugin-api'; +import { JsonObject } from '@backstage/types'; +import { SignalApi } from '@backstage/plugin-signals-react'; + +// @public (undocumented) +export class SignalClient implements SignalApi { + // (undocumented) + static create(options: { + identity: IdentityApi; + discoveryApi: DiscoveryApi; + connectTimeout?: number; + reconnectTimeout?: number; + }): SignalClient; + // (undocumented) + static readonly DEFAULT_CONNECT_TIMEOUT_MS: number; + // (undocumented) + static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number; + // (undocumented) + subscribe( + channel: string, + onMessage: (message: JsonObject) => void, + ): { + unsubscribe: () => 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..cec95280c1 --- /dev/null +++ b/plugins/signals/package.json @@ -0,0 +1,54 @@ +{ + "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.12.4", + "@material-ui/icons": "^4.9.1", + "@material-ui/lab": "^4.0.0-alpha.61", + "react-use": "^17.2.4", + "uuid": "^8.0.0" + }, + "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": "^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" + }, + "files": [ + "dist" + ] +} diff --git a/plugins/signals/src/api/SignalClient.ts b/plugins/signals/src/api/SignalClient.ts new file mode 100644 index 0000000000..ee4ed8756b --- /dev/null +++ b/plugins/signals/src/api/SignalClient.ts @@ -0,0 +1,217 @@ +/* + * 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 { 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'; + +type Subscription = { + channel: string; + callback: (message: JsonObject) => void; +}; + +const WS_CLOSE_NORMAL = 1000; +const WS_CLOSE_GOING_AWAY = 1001; + +/** @public */ +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; + private subscriptions: Map = new Map(); + private messageQueue: string[] = []; + private reconnectTo: any; + + static create(options: { + identity: IdentityApi; + discoveryApi: DiscoveryApi; + connectTimeout?: number; + reconnectTimeout?: number; + }) { + const { + identity, + discoveryApi, + connectTimeout = SignalClient.DEFAULT_CONNECT_TIMEOUT_MS, + reconnectTimeout = SignalClient.DEFAULT_RECONNECT_TIMEOUT_MS, + } = options; + return new SignalClient( + identity, + discoveryApi, + connectTimeout, + reconnectTimeout, + ); + } + + private constructor( + private identity: IdentityApi, + private discoveryApi: DiscoveryApi, + private connectTimeout: number, + private reconnectTimeout: number, + ) {} + + subscribe( + channel: string, + onMessage: (message: JsonObject) => void, + ): { unsubscribe: () => void } { + const subscriptionId = uuid(); + const exists = [...this.subscriptions.values()].find( + sub => sub.channel === channel, + ); + this.subscriptions.set(subscriptionId, { + channel: channel, + callback: onMessage, + }); + + this.connect() + .then(() => { + // Do not subscribe twice to same channel even there is multiple callbacks + if (!exists) { + this.send({ action: 'subscribe', channel }); + } + }) + .catch(() => { + this.reconnect(); + }); + + const unsubscribe = () => { + const sub = this.subscriptions.get(subscriptionId); + if (!sub) { + return; + } + this.subscriptions.delete(subscriptionId); + const multipleExists = [...this.subscriptions.values()].find( + s => s.channel === channel, + ); + // If there are subscriptions still listening to this channel, do not + // unsubscribe from the server + if (!multipleExists) { + this.send({ action: 'unsubscribe', channel: sub.channel }); + } + + // 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 { + const jsonMessage = JSON.stringify(data); + if (jsonMessage.length === 0) { + return; + } + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + if (data) { + this.messageQueue.unshift(jsonMessage); + } + return; + } + + // First send queue + for (const msg of this.messageQueue) { + this.ws!.send(msg); + } + this.messageQueue = []; + if (data) { + this.ws!.send(jsonMessage); + } + } + + private async connect() { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + return; + } + + 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(), token); + + this.ws.onmessage = (data: MessageEvent) => { + this.handleMessage(data); + }; + + this.ws.onerror = () => { + this.reconnect(); + }; + + this.ws.onclose = (ev: CloseEvent) => { + if (ev.code !== WS_CLOSE_NORMAL && ev.code !== WS_CLOSE_GOING_AWAY) { + this.reconnect(); + } + }; + + // Wait until connection is open + let connectSleep = 0; + while ( + this.ws && + this.ws.readyState !== WebSocket.OPEN && + connectSleep < this.connectTimeout + ) { + await new Promise(r => setTimeout(r, 100)); + connectSleep += 100; + } + + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error('Connect timeout'); + } + } + + private handleMessage(data: MessageEvent) { + try { + const json = JSON.parse(data.data) as JsonObject; + if (json.channel) { + for (const sub of this.subscriptions.values()) { + if (sub.channel === json.channel) { + sub.callback(json.message as JsonObject); + } + } + } + } catch (e) { + // NOOP + } + } + + private reconnect() { + if (this.reconnectTo) { + clearTimeout(this.reconnectTo); + } + + this.reconnectTo = setTimeout(() => { + this.reconnectTo = null; + if (this.ws) { + this.ws.close(); + } + this.ws = null; + this.connect() + .then(() => { + // Resubscribe to existing channels in case we lost connection + for (const sub of this.subscriptions.values()) { + this.send({ action: 'subscribe', channel: sub.channel }); + } + }) + .catch(() => { + this.reconnect(); + }); + }, this.reconnectTimeout); + } +} diff --git a/plugins/signals/src/api/SignalsClient.test.ts b/plugins/signals/src/api/SignalsClient.test.ts new file mode 100644 index 0000000000..2e93cd69f1 --- /dev/null +++ b/plugins/signals/src/api/SignalsClient.test.ts @@ -0,0 +1,126 @@ +/* + * 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 { SignalClient } from './SignalClient'; + +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 = SignalClient.create({ discoveryApi, identity }); + const { unsubscribe } = client.subscribe('channel', messageMock); + await server.connected; + + await expect(server).toReceiveMessage({ + action: 'subscribe', + channel: 'channel', + }); + server.send({ channel: 'channel', message: { hello: 'world' } }); + expect(messageMock).toHaveBeenCalledWith({ hello: 'world' }); + + await unsubscribe(); + + await expect(server).toReceiveMessage({ + action: 'unsubscribe', + channel: 'channel', + }); + }); + + it('should handle multiple subscription correctly', async () => { + const messageMock1 = jest.fn(); + const messageMock2 = jest.fn(); + const client1 = SignalClient.create({ discoveryApi, identity }); + const client2 = SignalClient.create({ discoveryApi, identity }); + const { unsubscribe: unsubscribe1 } = client1.subscribe( + 'channel', + messageMock1, + ); + const { unsubscribe: unsubscribe2 } = client2.subscribe( + 'channel', + messageMock2, + ); + + await server.connected; + + await expect(server).toReceiveMessage({ + action: 'subscribe', + channel: 'channel', + }); + 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', + channel: 'channel', + }); + + await unsubscribe2(); + await expect(server).toReceiveMessage({ + action: 'unsubscribe', + channel: 'channel', + }); + }); + + it('should reconnect on error', async () => { + const messageMock = jest.fn(); + const client = SignalClient.create({ + discoveryApi, + identity, + reconnectTimeout: 10, + connectTimeout: 100, + }); + + client.subscribe('channel', messageMock); + await server.connected; + await expect(server).toReceiveMessage({ + action: 'subscribe', + channel: 'channel', + }); + + await server.server.emit('error', null); + + await new Promise(r => setTimeout(r, 50)); + await expect(server).toReceiveMessage({ + action: 'subscribe', + channel: 'channel', + }); + }); +}); diff --git a/plugins/signals/src/api/index.ts b/plugins/signals/src/api/index.ts new file mode 100644 index 0000000000..c76ae196ed --- /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 './SignalClient'; 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..eb3f9126e2 --- /dev/null +++ b/plugins/signals/src/plugin.ts @@ -0,0 +1,43 @@ +/* + * 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, + identityApiRef, +} from '@backstage/core-plugin-api'; +import { signalApiRef } from '@backstage/plugin-signals-react'; +import { SignalClient } from './api/SignalClient'; + +/** @public */ +export const signalsPlugin = createPlugin({ + id: 'signals', + apis: [ + createApiFactory({ + api: signalApiRef, + deps: { + identity: identityApiRef, + discoveryApi: discoveryApiRef, + }, + factory: ({ identity, discoveryApi }) => { + return SignalClient.create({ + identity, + 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 ccfd1ec70b..41218e601c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8700,6 +8700,95 @@ __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/backend-plugin-api": "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/backend-plugin-api": "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 + ws: ^8.14.2 + languageName: unknown + linkType: soft + +"@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: + "@backstage/cli": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" + "@backstage/test-utils": "workspace:^" + "@backstage/types": "workspace:^" + "@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 + 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.12.4 + "@material-ui/icons": ^4.9.1 + "@material-ui/lab": ^4.0.0-alpha.61 + "@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 + react-use: ^17.2.4 + uuid: ^8.0.0 + 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" @@ -17758,7 +17847,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: @@ -26330,6 +26419,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:^" @@ -26469,6 +26559,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:^" @@ -44433,7 +44525,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: