fix: address review comments

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-01-02 09:57:19 +02:00
parent 0b21422604
commit da5c3fb818
34 changed files with 413 additions and 465 deletions
-6
View File
@@ -1,6 +0,0 @@
---
'@backstage/plugin-devtools-backend': patch
'@backstage/plugin-devtools': patch
---
Update devtools information almost real time using signals plugin
+2 -2
View File
@@ -73,7 +73,7 @@ import { DefaultEventBroker } from '@backstage/plugin-events-backend';
import { PrometheusExporter } from '@opentelemetry/exporter-prometheus';
import { MeterProvider } from '@opentelemetry/sdk-metrics';
import { metrics } from '@opentelemetry/api';
import { SignalService } from '@backstage/plugin-signals-node';
import { DefaultSignalService } from '@backstage/plugin-signals-node';
// Expose opentelemetry metrics using a Prometheus exporter on
// http://localhost:9464/metrics . See prometheus.yml in packages/backend for
@@ -100,7 +100,7 @@ function makeCreateEnv(config: Config) {
});
const eventBroker = new DefaultEventBroker(root.child({ type: 'plugin' }));
const signalService = SignalService.create({
const signalService = DefaultSignalService.create({
logger: root,
eventBroker,
identity,
-1
View File
@@ -25,6 +25,5 @@ export default async function createPlugin(
logger: env.logger,
config: env.config,
permissions: env.permissions,
signalService: env.signalService,
});
}
+2 -2
View File
@@ -27,7 +27,7 @@ import { PluginTaskScheduler } from '@backstage/backend-tasks';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { EventBroker } from '@backstage/plugin-events-node';
import { SignalService } from '@backstage/plugin-signals-node';
import { DefaultSignalService } from '@backstage/plugin-signals-node';
export type PluginEnvironment = {
logger: Logger;
@@ -41,5 +41,5 @@ export type PluginEnvironment = {
scheduler: PluginTaskScheduler;
identity: IdentityApi;
eventBroker: EventBroker;
signalService: SignalService;
signalService: DefaultSignalService;
};
-5
View File
@@ -11,7 +11,6 @@ import express from 'express';
import { ExternalDependency } from '@backstage/plugin-devtools-common';
import { Logger } from 'winston';
import { PermissionEvaluator } from '@backstage/plugin-permission-common';
import { SignalService } from '@backstage/plugin-signals-node';
// @public (undocumented)
export function createRouter(options: RouterOptions): Promise<express.Router>;
@@ -25,8 +24,6 @@ export class DevToolsBackendApi {
listExternalDependencyDetails(): Promise<ExternalDependency[]>;
// (undocumented)
listInfo(): Promise<DevToolsInfo>;
// (undocumented)
listResourceUtilization(): Promise<string>;
}
// @public
@@ -43,7 +40,5 @@ export interface RouterOptions {
logger: Logger;
// (undocumented)
permissions: PermissionEvaluator;
// (undocumented)
signalService?: SignalService;
}
```
-1
View File
@@ -38,7 +38,6 @@
"@backstage/plugin-devtools-common": "workspace:^",
"@backstage/plugin-permission-common": "workspace:^",
"@backstage/plugin-permission-node": "workspace:^",
"@backstage/plugin-signals-node": "workspace:^",
"@backstage/types": "workspace:^",
"@manypkg/get-packages": "^1.1.3",
"@types/express": "*",
@@ -203,21 +203,17 @@ export class DevToolsBackendApi {
return configInfo;
}
public async listResourceUtilization(): Promise<string> {
public async listInfo(): Promise<DevToolsInfo> {
const operatingSystem = `${os.hostname()}: ${os.type} ${os.release} - ${
os.platform
}/${os.arch}`;
const usedMem = Math.floor((os.totalmem() - os.freemem()) / (1024 * 1024));
return `Memory: ${usedMem}/${Math.floor(
const resources = `Memory: ${usedMem}/${Math.floor(
os.totalmem() / (1024 * 1024),
)}MB - Load: ${os
.loadavg()
.map(v => v.toFixed(2))
.join('/')}`;
}
public async listInfo(): Promise<DevToolsInfo> {
const operatingSystem = `${os.hostname()}: ${os.type} ${os.release} - ${
os.platform
}/${os.arch}`;
const nodeJsVersion = process.version;
/* eslint-disable-next-line no-restricted-syntax */
@@ -251,7 +247,7 @@ export class DevToolsBackendApi {
const info: DevToolsInfo = {
operatingSystem: operatingSystem ?? 'N/A',
resourceUtilization: (await this.listResourceUtilization()) ?? 'N/A',
resourceUtilization: resources ?? 'N/A',
nodeJsVersion: nodeJsVersion ?? 'N/A',
backstageVersion:
backstageJson && backstageJson.version ? backstageJson.version : 'N/A',
+1 -4
View File
@@ -20,7 +20,6 @@ import {
createBackendPlugin,
} from '@backstage/backend-plugin-api';
import { createRouter } from './service/router';
import { signalService } from '@backstage/plugin-signals-node';
/**
* DevTools backend plugin
@@ -36,15 +35,13 @@ export const devtoolsPlugin = createBackendPlugin({
logger: coreServices.logger,
permissions: coreServices.permissions,
httpRouter: coreServices.httpRouter,
signals: signalService,
},
async init({ config, logger, permissions, httpRouter, signals }) {
async init({ config, logger, permissions, httpRouter }) {
httpRouter.use(
await createRouter({
config,
logger: loggerToWinstonLogger(logger),
permissions,
signalService: signals,
}),
);
},
+1 -15
View File
@@ -33,7 +33,6 @@ import { errorHandler } from '@backstage/backend-common';
import express from 'express';
import { getBearerTokenFromAuthorizationHeader } from '@backstage/plugin-auth-node';
import { createPermissionIntegrationRouter } from '@backstage/plugin-permission-node';
import { SignalService } from '@backstage/plugin-signals-node';
/** @public */
export interface RouterOptions {
@@ -41,30 +40,17 @@ export interface RouterOptions {
logger: Logger;
config: Config;
permissions: PermissionEvaluator;
signalService?: SignalService;
}
/** @public */
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const { logger, config, permissions, signalService } = options;
const { logger, config, permissions } = options;
const devToolsBackendApi =
options.devToolsBackendApi || new DevToolsBackendApi(logger, config);
if (signalService) {
// Publish info periodically using the signal service
setInterval(async () => {
if (signalService.hasSubscribers('devtools:resources')) {
const info = await devToolsBackendApi.listResourceUtilization();
await signalService.publish('*', 'devtools:resources', {
resources: info,
});
}
}, 5000);
}
const router = Router();
router.use(express.json());
router.use(
@@ -29,7 +29,7 @@ import {
Theme,
} from '@material-ui/core';
import { Alert } from '@material-ui/lab';
import React, { useEffect, useState } from 'react';
import React from 'react';
import { useInfo } from '../../../hooks';
import { InfoDependenciesTable } from './InfoDependenciesTable';
import DescriptionIcon from '@material-ui/icons/Description';
@@ -38,7 +38,6 @@ import DeveloperBoardIcon from '@material-ui/icons/DeveloperBoard';
import { BackstageLogoIcon } from './BackstageLogoIcon';
import FileCopyIcon from '@material-ui/icons/FileCopy';
import { DevToolsInfo } from '@backstage/plugin-devtools-common';
import { useSignalApi } from '@backstage/plugin-signals-react';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
@@ -73,17 +72,7 @@ const copyToClipboard = ({ about }: { about: DevToolsInfo | undefined }) => {
/** @public */
export const InfoContent = () => {
const classes = useStyles();
const [resources, setResources] = useState<string | undefined>(undefined);
const { about, loading, error } = useInfo();
useSignalApi('devtools:resources', message => {
setResources(message.resources as string);
});
useEffect(() => {
if (!loading && !error && about) {
setResources(about.resourceUtilization);
}
}, [about, loading, error]);
if (loading) {
return <Progress />;
@@ -113,7 +102,7 @@ export const InfoContent = () => {
</ListItemAvatar>
<ListItemText
primary="Resource utilization"
secondary={resources}
secondary={about?.resourceUtilization}
/>
</ListItem>
<ListItem>
+2 -2
View File
@@ -5,7 +5,7 @@
```ts
import { BackendFeature } from '@backstage/backend-plugin-api';
import express from 'express';
import { Logger } from 'winston';
import { LoggerService } from '@backstage/backend-plugin-api';
import { SignalService } from '@backstage/plugin-signals-node';
// @public (undocumented)
@@ -14,7 +14,7 @@ export function createRouter(options: RouterOptions): Promise<express.Router>;
// @public (undocumented)
export interface RouterOptions {
// (undocumented)
logger: Logger;
logger: LoggerService;
// (undocumented)
service: SignalService;
}
+1 -2
View File
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { loggerToWinstonLogger } from '@backstage/backend-common';
import {
coreServices,
createBackendPlugin,
@@ -38,7 +37,7 @@ export const signalsPlugin = createBackendPlugin({
async init({ httpRouter, logger, service }) {
httpRouter.use(
await createRouter({
logger: loggerToWinstonLogger(logger),
logger,
service,
}),
);
@@ -18,9 +18,9 @@ import express from 'express';
import request from 'supertest';
import { createRouter } from './router';
import { SignalService } from '@backstage/plugin-signals-node';
import { DefaultSignalService } from '@backstage/plugin-signals-node';
const signalsServiceMock: jest.Mocked<SignalService> = {} as any;
const signalsServiceMock: jest.Mocked<DefaultSignalService> = {} as any;
describe('createRouter', () => {
let app: express.Express;
@@ -16,14 +16,14 @@
import { errorHandler } from '@backstage/backend-common';
import express, { NextFunction, Request, Response } from 'express';
import Router from 'express-promise-router';
import { Logger } from 'winston';
import { LoggerService } from '@backstage/backend-plugin-api';
import { SignalService } from '@backstage/plugin-signals-node';
import * as https from 'https';
import http from 'http';
/** @public */
export interface RouterOptions {
logger: Logger;
logger: LoggerService;
service: SignalService;
}
@@ -48,9 +48,9 @@ export async function createRouter(
if (!subscribed) {
subscribed = true;
server.on('upgrade', async (request, socket, head) => {
// Only upgrade if request to root of the signals plugin
// TODO: Find a way to make this more generic
if (request.url === '/api/signals') {
await service.handleUpgrade(request, socket, head);
await service.handleUpgrade({ server, request, socket, head });
}
});
}
@@ -21,7 +21,7 @@ import {
import { Server } from 'http';
import { Logger } from 'winston';
import { createRouter } from './router';
import { SignalService } from '@backstage/plugin-signals-node';
import { DefaultSignalService } from '@backstage/plugin-signals-node';
import { DefaultIdentityClient } from '@backstage/plugin-auth-node';
export interface ServerOptions {
@@ -43,7 +43,7 @@ export async function startStandaloneServer(
issuer: await discovery.getExternalBaseUrl('auth'),
});
const signals = SignalService.create({
const signals = DefaultSignalService.create({
logger: logger,
identity,
});
+3 -7
View File
@@ -56,13 +56,9 @@ all subscribers, you can use `*` as `to` parameter.
```ts
// Periodic sending example
setInterval(async () => {
// You can use hasSubscribers to check if the message will be sent to anyone
// in case you need some heavy processing before that
if (signalService.hasSubscribers('plugin:topic')) {
await signalService.publish('*', 'plugin:topic', {
message: 'hello world',
});
}
await signalService.publish('*', 'plugin:topic', {
message: 'hello world',
});
}, 5000);
```
+25 -16
View File
@@ -7,14 +7,26 @@
import { Duplex } from 'stream';
import { EventBroker } from '@backstage/plugin-events-node';
import { EventParams } from '@backstage/plugin-events-node';
import { EventSubscriber } from '@backstage/plugin-events-node';
import http from 'http';
import https from 'https';
import { IdentityApi } from '@backstage/plugin-auth-node';
import { IncomingMessage } from 'http';
import { JsonObject } from '@backstage/types';
import { LoggerService } from '@backstage/backend-plugin-api';
import { ServiceRef } from '@backstage/backend-plugin-api';
// @public (undocumented)
export class DefaultSignalService implements SignalService {
// (undocumented)
static create(options: ServiceOptions): DefaultSignalService;
handleUpgrade(options: SignalServiceUpgradeOptions): Promise<void>;
publish(
to: string | string[],
topic: string,
message: JsonObject,
): Promise<void>;
}
// @public (undocumented)
export type ServiceOptions = {
eventBroker?: EventBroker;
@@ -30,28 +42,25 @@ export type SignalEventBrokerPayload = {
};
// @public (undocumented)
export class SignalService implements EventSubscriber {
// (undocumented)
static create(options: ServiceOptions): SignalService;
handleUpgrade: (
req: IncomingMessage,
socket: Duplex,
head: Buffer,
) => Promise<void>;
hasSubscribers(topic: string): boolean;
// (undocumented)
onEvent(params: EventParams<SignalEventBrokerPayload>): Promise<void>;
export type SignalService = {
publish(
to: string | string[],
topic: string,
message: JsonObject,
): Promise<void>;
// (undocumented)
supportsEventTopics(): string[];
}
handleUpgrade(options: SignalServiceUpgradeOptions): Promise<void>;
};
// @public (undocumented)
export const signalService: ServiceRef<SignalService, 'plugin'>;
// @public (undocumented)
export type SignalServiceUpgradeOptions = {
server: https.Server | http.Server;
request: IncomingMessage;
socket: Duplex;
head: Buffer;
};
// (No @packageDocumentation comment for this package)
```
-1
View File
@@ -37,7 +37,6 @@
"@backstage/types": "workspace:^",
"express": "^4.17.1",
"uuid": "^8.0.0",
"winston": "^3.2.1",
"ws": "^8.14.2"
}
}
@@ -0,0 +1,238 @@
/*
* Copyright 2023 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventBroker, EventParams } from '@backstage/plugin-events-node';
import {
ServiceOptions,
SignalConnection,
SignalEventBrokerPayload,
} from './types';
import { RawData, WebSocket, WebSocketServer } from 'ws';
import { IncomingMessage } from 'http';
import { v4 as uuid } from 'uuid';
import { JsonObject } from '@backstage/types';
import {
BackstageIdentityResponse,
IdentityApi,
IdentityApiGetIdentityRequest,
} from '@backstage/plugin-auth-node';
import { LoggerService } from '@backstage/backend-plugin-api';
import { SignalService, SignalServiceUpgradeOptions } from './SignalService';
/** @public */
export class DefaultSignalService implements SignalService {
private connections: Map<string, SignalConnection> = new Map<
string,
SignalConnection
>();
private eventBroker?: EventBroker;
private logger: LoggerService;
private identity: IdentityApi;
private server: WebSocketServer;
static create(options: ServiceOptions) {
return new DefaultSignalService(options);
}
private constructor(options: ServiceOptions) {
({
eventBroker: this.eventBroker,
logger: this.logger,
identity: this.identity,
} = options);
this.server = new WebSocketServer({
noServer: true,
clientTracking: false,
});
this.eventBroker?.subscribe({
supportsEventTopics: () => ['signals'],
onEvent: (params: EventParams<SignalEventBrokerPayload>) =>
this.onEventBrokerEvent(params),
});
}
/**
* Handles request upgrade to websocket and adds the connection to internal
* list for publish/subscribe functionality
* @param req - Request
*/
async handleUpgrade(options: SignalServiceUpgradeOptions) {
const { request, socket, head } = options;
let identity: BackstageIdentityResponse | undefined = undefined;
// Authentication token is passed in Sec-WebSocket-Protocol header as there
// is no other way to pass the token with plain websockets
const token = request.headers['sec-websocket-protocol'];
if (token) {
identity = await this.identity.getIdentity({
request: {
headers: { authorization: token },
},
} as IdentityApiGetIdentityRequest);
}
this.server.handleUpgrade(
request,
socket,
head,
(ws: WebSocket, __: IncomingMessage) => {
this.addConnection(ws, identity);
},
);
}
private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) {
const id = uuid();
const conn = {
id,
user: identity?.identity.userEntityRef ?? 'user:default/guest',
ws,
ownershipEntityRefs: identity?.identity.ownershipEntityRefs ?? [],
subscriptions: new Set<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.topic) {
this.logger.info(
`Connection ${connection.id} subscribed to ${message.topic}`,
);
connection.subscriptions.add(message.topic as string);
}
if (message.action === 'unsubscribe' && message.topic) {
this.logger.info(
`Connection ${connection.id} unsubscribed from ${message.topic}`,
);
connection.subscriptions.delete(message.topic as string);
}
}
/**
* Publishes a message to user refs to specific topic
* @param to - string or array of user ref strings to publish message to
* @param topic - message topic
* @param message - message to publish
*/
async publish(to: string | string[], topic: string, message: JsonObject) {
await this.publishInternal(
Array.isArray(to) ? to : [to],
topic,
message,
false,
);
}
private async publishInternal(
recipients: string[],
topic: string,
message: JsonObject,
brokedEvent: boolean,
) {
const jsonMessage = JSON.stringify({ topic, message });
if (jsonMessage.length === 0) {
return;
}
// If there is event broker, use that to publish the message to
// all signal services, including this one.
if (this.eventBroker && !brokedEvent) {
await this.eventBroker.publish({
topic: 'signals',
eventPayload: {
recipients,
message,
topic,
},
});
return;
}
// Actual websocket message sending
this.connections.forEach(conn => {
if (!conn.subscriptions.has(topic)) {
return;
}
// Sending to all users can be done with '*'
if (
!recipients.includes('*') &&
!conn.ownershipEntityRefs.some(ref => recipients.includes(ref))
) {
return;
}
if (conn.ws.readyState !== WebSocket.OPEN) {
return;
}
conn.ws.send(jsonMessage);
});
}
private async onEventBrokerEvent(
params: EventParams<SignalEventBrokerPayload>,
): Promise<void> {
const { eventPayload } = params;
if (
!eventPayload?.recipients ||
!eventPayload.topic ||
!eventPayload.message
) {
return;
}
await this.publishInternal(
eventPayload.recipients,
eventPayload.topic,
eventPayload.message,
true,
);
}
}
+19 -231
View File
@@ -1,5 +1,5 @@
/*
* Copyright 2023 The Backstage Authors
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@@ -13,244 +13,32 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
EventBroker,
EventParams,
EventSubscriber,
} from '@backstage/plugin-events-node';
import {
ServiceOptions,
SignalConnection,
SignalEventBrokerPayload,
} from './types';
import { RawData, WebSocket, WebSocketServer } from 'ws';
import { IncomingMessage } from 'http';
import { v4 as uuid } from 'uuid';
import { JsonObject } from '@backstage/types';
import {
BackstageIdentityResponse,
IdentityApi,
IdentityApiGetIdentityRequest,
} from '@backstage/plugin-auth-node';
import http, { IncomingMessage } from 'http';
import { Duplex } from 'stream';
import { LoggerService } from '@backstage/backend-plugin-api';
import https from 'https';
/** @public */
export class SignalService implements EventSubscriber {
private connections: Map<string, SignalConnection> = new Map<
string,
SignalConnection
>();
private eventBroker?: EventBroker;
private logger: LoggerService;
private identity: IdentityApi;
private server: WebSocketServer;
static create(options: ServiceOptions) {
return new SignalService(options);
}
private constructor(options: ServiceOptions) {
({
eventBroker: this.eventBroker,
logger: this.logger,
identity: this.identity,
} = options);
this.server = new WebSocketServer({
noServer: true,
clientTracking: false,
});
this.eventBroker?.subscribe(this);
}
/**
* Handles request upgrade to websocket and adds the connection to internal
* list for publish/subscribe functionality
* @param req - Request
*/
handleUpgrade = async (
req: IncomingMessage,
socket: Duplex,
head: Buffer,
) => {
let identity: BackstageIdentityResponse | undefined = undefined;
// Authentication token is passed in Sec-WebSocket-Protocol header as there
// is no other way to pass the token with plain websockets
const token = req.headers['sec-websocket-protocol'];
if (token) {
identity = await this.identity.getIdentity({
request: {
headers: { authorization: token },
},
} as IdentityApiGetIdentityRequest);
}
this.server.handleUpgrade(
req,
socket,
head,
(ws: WebSocket, __: IncomingMessage) => {
this.addConnection(ws, identity);
},
);
};
private addConnection(ws: WebSocket, identity?: BackstageIdentityResponse) {
const id = uuid();
const conn = {
id,
user: identity?.identity.userEntityRef ?? 'user:default/guest',
ws,
ownershipEntityRefs: identity?.identity.ownershipEntityRefs ?? [],
subscriptions: new Set<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.topic) {
this.logger.info(
`Connection ${connection.id} subscribed to ${message.topic}`,
);
connection.subscriptions.add(message.topic as string);
}
if (message.action === 'unsubscribe' && message.topic) {
this.logger.info(
`Connection ${connection.id} unsubscribed from ${message.topic}`,
);
connection.subscriptions.delete(message.topic as string);
}
}
export type SignalServiceUpgradeOptions = {
server: https.Server | http.Server;
request: IncomingMessage;
socket: Duplex;
head: Buffer;
};
/** @public */
export type SignalService = {
/**
* Publishes a message to user refs to specific topic
* @param to - string or array of user ref strings to publish message to
* @param topic - message topic
* @param message - message to publish
*/
async publish(to: string | string[], topic: string, message: JsonObject) {
await this.publishInternal(
Array.isArray(to) ? to : [to],
topic,
message,
false,
);
}
/**
* Checks if there is active subscriptions to specific topic.
* This can be useful to skip heavy processing before publishing messages if there are no subscriptions.
* @param topic - topic to check for subscriptions
*/
hasSubscribers(topic: string): boolean {
return (
[...this.connections.values()].find(conn =>
conn.subscriptions.has(topic),
) !== undefined
);
}
private async publishInternal(
recipients: string[],
publish(
to: string | string[],
topic: string,
message: JsonObject,
brokedEvent: boolean,
) {
const jsonMessage = JSON.stringify({ topic, message });
if (jsonMessage.length === 0) {
return;
}
): Promise<void>;
// If there is event broker, use that to publish the message to
// all signal services, including this one.
if (this.eventBroker && !brokedEvent) {
await this.eventBroker.publish({
topic: 'signals',
eventPayload: {
recipients,
message,
topic,
},
});
return;
}
// Actual websocket message sending
this.connections.forEach(conn => {
if (!conn.subscriptions.has(topic)) {
return;
}
// Sending to all users can be done with '*'
if (
!recipients.includes('*') &&
!conn.ownershipEntityRefs.some(ref => recipients.includes(ref))
) {
return;
}
if (conn.ws.readyState !== WebSocket.OPEN) {
return;
}
conn.ws.send(jsonMessage);
});
}
async onEvent(params: EventParams<SignalEventBrokerPayload>): Promise<void> {
const { eventPayload } = params;
if (
!eventPayload?.recipients ||
!eventPayload.topic ||
!eventPayload.message
) {
return;
}
await this.publishInternal(
eventPayload.recipients,
eventPayload.topic,
eventPayload.message,
true,
);
}
supportsEventTopics(): string[] {
return ['signals'];
}
}
/**
* Handles request upgrade
*/
handleUpgrade(options: SignalServiceUpgradeOptions): Promise<void>;
};
+1
View File
@@ -15,5 +15,6 @@
*/
export * from './lib';
export * from './DefaultSignalService';
export * from './SignalService';
export * from './types';
+2 -1
View File
@@ -18,6 +18,7 @@ import {
createServiceFactory,
createServiceRef,
} from '@backstage/backend-plugin-api';
import { DefaultSignalService } from './DefaultSignalService';
import { SignalService } from './SignalService';
/** @public */
@@ -33,7 +34,7 @@ export const signalService = createServiceRef<SignalService>({
// TODO: EventBroker
},
factory({ logger, identity }) {
return SignalService.create({ identity, logger });
return DefaultSignalService.create({ identity, logger });
},
}),
});
+10 -10
View File
@@ -20,15 +20,12 @@ of connections to the backend and also to allow multiple subscriptions using the
Example of using the hook:
```ts
import { useSignalApi } from '@backstage/plugin-signals-react';
import { useSignal } from '@backstage/plugin-signals-react';
const [message, setMessage] = useState<JsonObject | undefined>(undefined);
useSignalApi('myplugin:topic', message => {
setMessage(message);
});
const { lastSignal } = useSignal('myplugin:topic');
```
Whenever backend publishes new message to the topic `myplugin:topic`, the state of this component is changed.
Whenever backend publishes new message to the topic `myplugin:topic`, the lastSignal is changed.
## Using API directly
@@ -39,9 +36,12 @@ subscriptions.
import { signalsApiRef } from '@backstage/plugin-signals-react';
const signals = useApi(signalsApiRef);
signals.subscribe('myplugin:topic', (message: JsonObject) => {
console.log(message);
});
const { unsubscribe } = signals.subscribe(
'myplugin:topic',
(message: JsonObject) => {
console.log(message);
},
);
// Remember to unsubscribe
signals.unsubscribe('myplugin:topic');
unsubscribe();
```
+9 -6
View File
@@ -8,18 +8,21 @@ import { JsonObject } from '@backstage/types';
// @public (undocumented)
export type SignalApi = {
subscribe(topic: string, onMessage: (message: JsonObject) => void): string;
unsubscribe(subscription: string): void;
subscribe(
topic: string,
onMessage: (message: JsonObject) => void,
): {
unsubscribe: () => void;
};
};
// @public (undocumented)
export const signalApiRef: ApiRef<SignalApi>;
// @public (undocumented)
export const useSignalApi: (
topic: string,
onMessage: (message: JsonObject) => void,
) => void;
export const useSignal: (topic: string) => {
lastSignal: JsonObject | null;
};
// (No @packageDocumentation comment for this package)
```
+3 -3
View File
@@ -26,7 +26,7 @@
"dependencies": {
"@backstage/core-plugin-api": "workspace:^",
"@backstage/types": "workspace:^",
"@material-ui/core": "^4.9.13"
"@material-ui/core": "^4.12.4"
},
"peerDependencies": {
"react": "^16.13.1 || ^17.0.0"
@@ -34,8 +34,8 @@
"devDependencies": {
"@backstage/cli": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3"
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0"
},
"files": [
"dist"
+4 -3
View File
@@ -23,7 +23,8 @@ export const signalApiRef = createApiRef<SignalApi>({
/** @public */
export type SignalApi = {
subscribe(topic: string, onMessage: (message: JsonObject) => void): string;
unsubscribe(subscription: string): void;
subscribe(
topic: string,
onMessage: (message: JsonObject) => void,
): { unsubscribe: () => void };
};
+1 -1
View File
@@ -13,4 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export * from './useSignalApi';
export * from './useSignal';
@@ -19,27 +19,26 @@ import { JsonObject } from '@backstage/types';
import { useEffect, useState } from 'react';
/** @public */
export const useSignalApi = (
topic: string,
onMessage: (message: JsonObject) => void,
) => {
export const useSignal = (topic: string) => {
const apiHolder = useApiHolder();
// Use apiHolder instead useApi in case signalApi is not available in the
// backstage instance this is used
const signals = apiHolder.get(signalApiRef);
const [subscription, setSubscription] = useState<null | string>(null);
const [lastSignal, setLastSignal] = useState<JsonObject | null>(null);
useEffect(() => {
if (signals && !subscription) {
const sub = signals.subscribe(topic, onMessage);
setSubscription(sub);
let unsub: null | (() => void) = null;
if (signals) {
const { unsubscribe } = signals.subscribe(topic, (msg: JsonObject) => {
setLastSignal(msg);
});
unsub = unsubscribe;
}
}, [subscription, signals, onMessage, topic]);
useEffect(() => {
return () => {
if (signals && subscription) {
signals.unsubscribe(subscription);
if (signals && unsub) {
unsub();
}
};
}, [subscription, signals, topic]);
}, [signals, topic]);
return { lastSignal };
};
+6 -3
View File
@@ -23,9 +23,12 @@ export class SignalClient implements SignalApi {
// (undocumented)
static readonly DEFAULT_RECONNECT_TIMEOUT_MS: number;
// (undocumented)
subscribe(topic: string, onMessage: (message: JsonObject) => void): string;
// (undocumented)
unsubscribe(subscription: string): void;
subscribe(
topic: string,
onMessage: (message: JsonObject) => void,
): {
unsubscribe: () => void;
};
}
// @public (undocumented)
+3 -3
View File
@@ -28,7 +28,7 @@
"@backstage/plugin-signals-react": "workspace:^",
"@backstage/theme": "workspace:^",
"@backstage/types": "workspace:^",
"@material-ui/core": "^4.9.13",
"@material-ui/core": "^4.12.4",
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.61",
"react-use": "^17.2.4",
@@ -42,8 +42,8 @@
"@backstage/core-app-api": "workspace:^",
"@backstage/dev-utils": "workspace:^",
"@backstage/test-utils": "workspace:^",
"@testing-library/jest-dom": "^5.10.1",
"@testing-library/react": "^12.1.3",
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.0.0",
"jest-websocket-mock": "^2.5.0",
"msw": "^1.0.0"
+26 -23
View File
@@ -62,7 +62,10 @@ export class SignalClient implements SignalApi {
private reconnectTimeout: number,
) {}
subscribe(topic: string, onMessage: (message: JsonObject) => void): string {
subscribe(
topic: string,
onMessage: (message: JsonObject) => void,
): { unsubscribe: () => void } {
const subscriptionId = uuid();
const exists = [...this.subscriptions.values()].find(
sub => sub.topic === topic,
@@ -79,30 +82,30 @@ export class SignalClient implements SignalApi {
.catch(() => {
this.reconnect();
});
return subscriptionId;
}
unsubscribe(subscription: string): void {
const sub = this.subscriptions.get(subscription);
if (!sub) {
return;
}
const topic = sub.topic;
this.subscriptions.delete(subscription);
const exists = [...this.subscriptions.values()].find(
s => s.topic === topic,
);
// If there are subscriptions still listening to this topic, do not
// unsubscribe from the server
if (!exists) {
this.send({ action: 'unsubscribe', topic: sub.topic });
}
const unsubscribe = () => {
const sub = this.subscriptions.get(subscriptionId);
if (!sub) {
return;
}
this.subscriptions.delete(subscriptionId);
const multipleExists = [...this.subscriptions.values()].find(
s => s.topic === topic,
);
// If there are subscriptions still listening to this topic, do not
// unsubscribe from the server
if (!multipleExists) {
this.send({ action: 'unsubscribe', topic: sub.topic });
}
// If there are no subscriptions, close the connection
if (this.subscriptions.size === 0) {
this.ws?.close(WS_CLOSE_NORMAL);
this.ws = null;
}
// If there are no subscriptions, close the connection
if (this.subscriptions.size === 0) {
this.ws?.close(WS_CLOSE_NORMAL);
this.ws = null;
}
};
return { unsubscribe };
}
private send(data?: JsonObject): void {
+13 -6
View File
@@ -44,7 +44,7 @@ describe('SignalsClient', () => {
it('should handle single subscription correctly', async () => {
const messageMock = jest.fn();
const client = SignalClient.create({ discoveryApi, identity });
const sub = client.subscribe('topic', messageMock);
const { unsubscribe } = client.subscribe('topic', messageMock);
await server.connected;
await expect(server).toReceiveMessage({
@@ -54,7 +54,8 @@ describe('SignalsClient', () => {
server.send({ topic: 'topic', message: { hello: 'world' } });
expect(messageMock).toHaveBeenCalledWith({ hello: 'world' });
client.unsubscribe(sub);
await unsubscribe();
await expect(server).toReceiveMessage({
action: 'unsubscribe',
topic: 'topic',
@@ -66,8 +67,14 @@ describe('SignalsClient', () => {
const messageMock2 = jest.fn();
const client1 = SignalClient.create({ discoveryApi, identity });
const client2 = SignalClient.create({ discoveryApi, identity });
const sub1 = client1.subscribe('topic', messageMock1);
const sub2 = client2.subscribe('topic', messageMock2);
const { unsubscribe: unsubscribe1 } = client1.subscribe(
'topic',
messageMock1,
);
const { unsubscribe: unsubscribe2 } = client2.subscribe(
'topic',
messageMock2,
);
await server.connected;
@@ -79,13 +86,13 @@ describe('SignalsClient', () => {
expect(messageMock1).toHaveBeenCalledWith({ hello: 'world' });
expect(messageMock2).toHaveBeenCalledWith({ hello: 'world' });
client1.unsubscribe(sub1);
await unsubscribe1();
await expect(server).not.toReceiveMessage({
action: 'unsubscribe',
topic: 'topic',
});
client2.unsubscribe(sub2);
await unsubscribe2();
await expect(server).toReceiveMessage({
action: 'unsubscribe',
topic: 'topic',
+4 -9
View File
@@ -22,8 +22,6 @@ import {
import { signalApiRef } from '@backstage/plugin-signals-react';
import { SignalClient } from './api/SignalClient';
let clientInstance: SignalClient | undefined = undefined;
/** @public */
export const signalsPlugin = createPlugin({
id: 'signals',
@@ -35,13 +33,10 @@ export const signalsPlugin = createPlugin({
discoveryApi: discoveryApiRef,
},
factory: ({ identity, discoveryApi }) => {
if (!clientInstance) {
clientInstance = SignalClient.create({
identity,
discoveryApi,
});
}
return clientInstance;
return SignalClient.create({
identity,
discoveryApi,
});
},
}),
],
+8 -57
View File
@@ -12,7 +12,7 @@ __metadata:
languageName: node
linkType: hard
"@adobe/css-tools@npm:^4.0.1, @adobe/css-tools@npm:^4.3.2":
"@adobe/css-tools@npm:^4.3.2":
version: 4.3.2
resolution: "@adobe/css-tools@npm:4.3.2"
checksum: 9667d61d55dc3b0a315c530ae84e016ce5267c4dd8ac00abb40108dc98e07b98e3090ce8b87acd51a41a68d9e84dcccb08cdf21c902572a9cf9dcaf830da4ae3
@@ -6203,7 +6203,6 @@ __metadata:
"@backstage/plugin-devtools-common": "workspace:^"
"@backstage/plugin-permission-common": "workspace:^"
"@backstage/plugin-permission-node": "workspace:^"
"@backstage/plugin-signals-node": "workspace:^"
"@backstage/types": "workspace:^"
"@manypkg/get-packages": ^1.1.3
"@types/express": "*"
@@ -8875,12 +8874,11 @@ __metadata:
"@types/express": ^4.17.21
express: ^4.17.1
uuid: ^8.0.0
winston: ^3.2.1
ws: ^8.14.2
languageName: unknown
linkType: soft
"@backstage/plugin-signals-react@workspace:plugins/signals-react":
"@backstage/plugin-signals-react@workspace:^, @backstage/plugin-signals-react@workspace:plugins/signals-react":
version: 0.0.0-use.local
resolution: "@backstage/plugin-signals-react@workspace:plugins/signals-react"
dependencies:
@@ -8888,9 +8886,9 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@material-ui/core": ^4.9.13
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@material-ui/core": ^4.12.4
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^14.0.0
peerDependencies:
react: ^16.13.1 || ^17.0.0
languageName: unknown
@@ -8909,11 +8907,11 @@ __metadata:
"@backstage/test-utils": "workspace:^"
"@backstage/theme": "workspace:^"
"@backstage/types": "workspace:^"
"@material-ui/core": ^4.9.13
"@material-ui/core": ^4.12.4
"@material-ui/icons": ^4.9.1
"@material-ui/lab": ^4.0.0-alpha.61
"@testing-library/jest-dom": ^5.10.1
"@testing-library/react": ^12.1.3
"@testing-library/jest-dom": ^6.0.0
"@testing-library/react": ^14.0.0
"@testing-library/user-event": ^14.0.0
jest-websocket-mock: ^2.5.0
msw: ^1.0.0
@@ -17216,22 +17214,6 @@ __metadata:
languageName: unknown
linkType: soft
"@testing-library/dom@npm:^8.0.0":
version: 8.20.1
resolution: "@testing-library/dom@npm:8.20.1"
dependencies:
"@babel/code-frame": ^7.10.4
"@babel/runtime": ^7.12.5
"@types/aria-query": ^5.0.1
aria-query: 5.1.3
chalk: ^4.1.0
dom-accessibility-api: ^0.5.9
lz-string: ^1.5.0
pretty-format: ^27.0.2
checksum: 06fc8dc67849aadb726cbbad0e7546afdf8923bd39acb64c576d706249bd7d0d05f08e08a31913fb621162e3b9c2bd0dce15964437f030f9fa4476326fdd3007
languageName: node
linkType: hard
"@testing-library/dom@npm:^9.0.0":
version: 9.3.3
resolution: "@testing-library/dom@npm:9.3.3"
@@ -17248,23 +17230,6 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/jest-dom@npm:^5.10.1":
version: 5.17.0
resolution: "@testing-library/jest-dom@npm:5.17.0"
dependencies:
"@adobe/css-tools": ^4.0.1
"@babel/runtime": ^7.9.2
"@types/testing-library__jest-dom": ^5.9.1
aria-query: ^5.0.0
chalk: ^3.0.0
css.escape: ^1.5.1
dom-accessibility-api: ^0.5.6
lodash: ^4.17.15
redent: ^3.0.0
checksum: 9f28dbca8b50d7c306aae40c3aa8e06f0e115f740360004bd87d57f95acf7ab4b4f4122a7399a76dbf2bdaaafb15c99cc137fdcb0ae457a92e2de0f3fbf9b03b
languageName: node
linkType: hard
"@testing-library/jest-dom@npm:^6.0.0":
version: 6.1.6
resolution: "@testing-library/jest-dom@npm:6.1.6"
@@ -17317,20 +17282,6 @@ __metadata:
languageName: node
linkType: hard
"@testing-library/react@npm:^12.1.3":
version: 12.1.5
resolution: "@testing-library/react@npm:12.1.5"
dependencies:
"@babel/runtime": ^7.12.5
"@testing-library/dom": ^8.0.0
"@types/react-dom": <18.0.0
peerDependencies:
react: <18.0.0
react-dom: <18.0.0
checksum: 4abd0490405e709a7df584a0db604e508a4612398bb1326e8fa32dd9393b15badc826dcf6d2f7525437886d507871f719f127b9860ed69ddd204d1fa834f576a
languageName: node
linkType: hard
"@testing-library/react@npm:^14.0.0":
version: 14.1.2
resolution: "@testing-library/react@npm:14.1.2"