Merge pull request #21728 from drodil/signals_plugin2
feat: signals plugins
This commit is contained in:
@@ -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
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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());
|
||||
// ...
|
||||
}
|
||||
```
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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 {};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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({});
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -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 {};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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();
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
module.exports = require('@backstage/cli/config/eslint-factory')(__dirname);
|
||||
@@ -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();
|
||||
```
|
||||
@@ -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)
|
||||
```
|
||||
@@ -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
|
||||
@@ -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();
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user