Merge pull request #21728 from drodil/signals_plugin2

feat: signals plugins
This commit is contained in:
Patrik Oldsberg
2024-01-17 16:40:09 +01:00
committed by GitHub
57 changed files with 2270 additions and 6 deletions
+8
View File
@@ -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
+1
View File
@@ -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:^",
+1
View File
@@ -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';
+2
View File
@@ -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:^",
+12 -4
View File
@@ -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);
+28
View File
@@ -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<Router> {
return await createRouter({
logger: env.logger,
eventBroker: env.eventBroker,
identity: env.identity,
});
}
+2
View File
@@ -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;
};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+45
View File
@@ -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<Router> {
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());
// ...
}
```
+30
View File
@@ -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<express.Router>;
// @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)
```
@@ -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
+51
View File
@@ -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"
]
}
+17
View File
@@ -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';
+48
View File
@@ -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,
}),
);
},
});
},
});
+32
View File
@@ -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);
});
@@ -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<string | symbol, (this: WebSocket, ...args: any[]) => 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' } }),
);
});
});
@@ -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<string>;
};
/**
* @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<string, SignalConnection> = 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<SignalPayload>) =>
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<string>(),
};
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<SignalPayload>,
): Promise<void> {
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}`);
}
});
});
}
}
@@ -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<EventBroker> = {
subscribe: jest.fn(),
publish: jest.fn(),
};
const identityApiMock: jest.Mocked<IdentityApi> = {
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' });
});
});
});
@@ -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<express.Router> {
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;
}
@@ -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<Server> {
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<void> {
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<Server>;
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();
+16
View File
@@ -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 {};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+86
View File
@@ -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',
},
});
```
+38
View File
@@ -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<void>;
}
// @public (undocumented)
export type SignalPayload = {
recipients: string[] | null;
channel: string;
message: JsonObject;
};
// @public (undocumented)
export type SignalService = {
publish(signal: SignalPayload): Promise<void>;
};
// @public (undocumented)
export const signalService: ServiceRef<SignalService, 'plugin'>;
// @public (undocumented)
export type SignalServiceOptions = {
eventBroker?: EventBroker;
};
// (No @packageDocumentation comment for this package)
```
+10
View File
@@ -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
+42
View File
@@ -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"
}
}
@@ -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,
},
});
}
}
+24
View File
@@ -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<void>;
};
+20
View File
@@ -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';
+38
View File
@@ -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<SignalService>({
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({});
},
}),
});
+16
View File
@@ -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 {};
+31
View File
@@ -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;
};
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+52
View File
@@ -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();
```
+28
View File
@@ -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<SignalApi>;
// @public (undocumented)
export const useSignal: (channel: string) => {
lastSignal: JsonObject | null;
};
// (No @packageDocumentation comment for this package)
```
+10
View File
@@ -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
+43
View File
@@ -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"
]
}
@@ -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<SignalApi>({
id: 'plugin.signal.service',
});
/** @public */
export type SignalApi = {
subscribe(
channel: string,
onMessage: (message: JsonObject) => void,
): { unsubscribe: () => void };
};
+16
View File
@@ -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';
+16
View File
@@ -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';
@@ -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<JsonObject | null>(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 };
};
+18
View File
@@ -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';
+16
View File
@@ -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';
+1
View File
@@ -0,0 +1 @@
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
+44
View File
@@ -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();
```
+38
View File
@@ -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)
```
+9
View File
@@ -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
+35
View File
@@ -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: (
<Page themeId="home">
<Header title="Signals" />
<Content>
<Typography>TODO</Typography>
</Content>
</Page>
),
})
.render();
+54
View File
@@ -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"
]
}
+217
View File
@@ -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<string, Subscription> = 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);
}
}
@@ -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',
});
});
});
+16
View File
@@ -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';
+17
View File
@@ -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';
+22
View File
@@ -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();
});
});
+43
View File
@@ -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,
});
},
}),
],
});
+16
View File
@@ -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';
+94 -2
View File
@@ -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: