diff --git a/common/changes/@microsoft/rush/rush-serve-socket_2023-09-14-20-58.json b/common/changes/@microsoft/rush/rush-serve-socket_2023-09-14-20-58.json new file mode 100644 index 00000000000..974e08e052f --- /dev/null +++ b/common/changes/@microsoft/rush/rush-serve-socket_2023-09-14-20-58.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "Add support for optional build status notifications over a web socket connection to `@rushstack/rush-serve-plugin`.", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/common/config/rush/nonbrowser-approved-packages.json b/common/config/rush/nonbrowser-approved-packages.json index fdbf7174814..b34f3601442 100644 --- a/common/config/rush/nonbrowser-approved-packages.json +++ b/common/config/rush/nonbrowser-approved-packages.json @@ -406,14 +406,14 @@ "name": "compression", "allowedCategories": [ "libraries" ] }, - { - "name": "cors", - "allowedCategories": [ "libraries" ] - }, { "name": "constructs", "allowedCategories": [ "tests" ] }, + { + "name": "cors", + "allowedCategories": [ "libraries" ] + }, { "name": "css-loader", "allowedCategories": [ "libraries", "tests" ] @@ -826,6 +826,10 @@ "name": "wordwrap", "allowedCategories": [ "libraries" ] }, + { + "name": "ws", + "allowedCategories": [ "libraries" ] + }, { "name": "xmldoc", "allowedCategories": [ "libraries" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 098b87a70a7..2c0a1be0dfe 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1271,7 +1271,7 @@ importers: '@rushstack/heft-lint-plugin': link:../../heft-plugins/heft-lint-plugin '@rushstack/heft-typescript-plugin': link:../../heft-plugins/heft-typescript-plugin '@types/jest': 29.5.4 - '@types/node': 20.5.9 + '@types/node': 20.6.0 eslint: 8.7.0 tslint: 5.20.1_typescript@4.9.5 tslint-microsoft-contrib: 6.2.0_uwqr5pcif4g7c56scrk6kqzf7i @@ -2870,10 +2870,12 @@ importers: '@types/express': 4.17.13 '@types/heft-jest': 1.0.1 '@types/node': 14.18.36 + '@types/ws': 8.5.5 compression: ~1.7.4 cors: ~2.8.5 express: 4.18.1 http2-express-bridge: ~1.0.7 + ws: ~8.14.1 dependencies: '@rushstack/debug-certificate-manager': link:../../libraries/debug-certificate-manager '@rushstack/heft-config-file': link:../../libraries/heft-config-file @@ -2885,6 +2887,7 @@ importers: cors: 2.8.5 express: 4.18.1 http2-express-bridge: 1.0.7_@types+express@4.17.13 + ws: 8.14.1 devDependencies: '@rushstack/eslint-config': link:../../eslint/eslint-config '@rushstack/heft': link:../../apps/heft @@ -2894,6 +2897,7 @@ importers: '@types/express': 4.17.13 '@types/heft-jest': 1.0.1 '@types/node': 14.18.36 + '@types/ws': 8.5.5 ../../vscode-extensions/rush-vscode-command-webview: specifiers: @@ -9715,7 +9719,7 @@ packages: util-deprecate: 1.0.2 watchpack: 2.4.0 webpack: 4.47.0 - ws: 8.13.0 + ws: 8.14.1 transitivePeerDependencies: - '@types/react' - bufferutil @@ -9788,7 +9792,7 @@ packages: util-deprecate: 1.0.2 watchpack: 2.4.0 webpack: 4.47.0 - ws: 8.13.0 + ws: 8.14.1 transitivePeerDependencies: - '@types/react' - bufferutil @@ -10731,8 +10735,8 @@ packages: resolution: {integrity: sha512-xA6drNNeqb5YyV5fO3OAEsnXLfO7uF0whiOfPTz5AeDo8KeZFmODKnvwPymMNO8qE/an8pVY/O50tig2SQCrGw==} dev: true - /@types/node/20.5.9: - resolution: {integrity: sha512-PcGNd//40kHAS3sTlzKB9C9XL4K0sTup8nbG5lC14kzEteTNuAFh9u5nA0o5TWnSG2r/JNPRXFVcHJIIeRlmqQ==} + /@types/node/20.6.0: + resolution: {integrity: sha512-najjVq5KN2vsH2U/xyh2opaSEz6cZMR2SetLIlxlj08nOcmPOemJmUK2o4kUzfLqfrWE0PIrNeE16XhYDd3nqg==} dev: true /@types/normalize-package-data/2.4.1: @@ -10964,7 +10968,6 @@ packages: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: '@types/node': 14.18.36 - dev: false /@types/xmldoc/1.1.4: resolution: {integrity: sha512-a/ONNCf9itbmzEz1ohx0Fv5TLJzXIPQTapxFu+DlYlDtn9UcAa1OhnrOOMwbU8125hFjrkJKL3qllD7vO5Bivw==} @@ -18378,7 +18381,7 @@ packages: whatwg-encoding: 2.0.0 whatwg-mimetype: 3.0.0 whatwg-url: 11.0.0 - ws: 8.13.0 + ws: 8.14.1 xml-name-validator: 4.0.0 transitivePeerDependencies: - bufferutil @@ -24709,7 +24712,7 @@ packages: webpack: 4.47.0_webpack-cli@3.3.12 webpack-cli: 3.3.12_webpack@4.47.0 webpack-dev-middleware: 5.3.3_webpack@4.47.0 - ws: 8.13.0 + ws: 8.14.1 transitivePeerDependencies: - bufferutil - debug @@ -24763,7 +24766,7 @@ packages: spdy: 4.0.2 webpack: 4.47.0 webpack-dev-middleware: 5.3.3_bs575e6uz6qqyriedrrkqiwy2m - ws: 8.13.0 + ws: 8.14.1 transitivePeerDependencies: - bufferutil - debug @@ -24816,7 +24819,7 @@ packages: spdy: 4.0.2 webpack: 5.82.1 webpack-dev-middleware: 5.3.3_webpack@5.82.1 - ws: 8.13.0 + ws: 8.14.1 transitivePeerDependencies: - bufferutil - debug @@ -25205,8 +25208,8 @@ packages: utf-8-validate: optional: true - /ws/8.13.0: - resolution: {integrity: sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA==} + /ws/8.14.1: + resolution: {integrity: sha512-4OOseMUq8AzRBI/7SLMUwO+FEDnguetSk7KMb1sHwvF2w2Wv5Hoj0nlifx8vtGsftE/jWHojPy8sMMzYLJ2G/A==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index 8676a7bb5ac..172c4cee8e2 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "dbae06252c7f680ad367e1c99ccce18a2dc2edc7", + "pnpmShrinkwrapHash": "cd89d52eeac3e8b3a95f21cb20fdd6422c83084d", "preferredVersionsHash": "1926a5b12ac8f4ab41e76503a0d1d0dccc9c0e06" } diff --git a/rush-plugins/rush-serve-plugin/README.md b/rush-plugins/rush-serve-plugin/README.md index 01bc2e0094d..c8faa8b295d 100644 --- a/rush-plugins/rush-serve-plugin/README.md +++ b/rush-plugins/rush-serve-plugin/README.md @@ -14,3 +14,77 @@ What happens: - Rush uses the configuration in the aforementioned files to configure an Express server to serve project outputs as static (but not cached) content - When a change happens to a source file, Rush's normal watch-mode machinery will rebuild all affected project phases, resulting in new files on disk - The next time one of these files is requested, Rush will serve the new version. Optionally, may support signals for automatic refresh. + +## Live Build Status via Web Socket + +This plugin also provides a web socket server that notifies clients of the build status in real time. To use the server, configure the `buildStatusWebSocketPath` option in `common/config/rush-plugins/rush-serve-plugin.json`. Specifying `/` will make the web socket server available at `wss://localhost:/`. + +The recommended way to connect to the web socket is to serve a static HTML page from the serve plugin using the `globalRouting` configuration. + +To use the socket: +```ts +import type { + IWebSocketEventMessage, + IOperationInfo, + IRushSessionInfo, + ReadableOperationStatus +} from '@rushstack/rush-serve-plugin/api'; + +const socket: WebSocket = new WebSocket(`wss://${self.location.host}${buildStatusWebSocketPath}`); + +const operationsByName: Map = new Map(); +let buildStatus: ReadableOperationStatus = 'Ready'; + +function updateOperations(operations): void { + for (const operation of operations) { + operationsByName.set(operation.name, operation); + } + + for (const [operationName, operation] of operationsByName) { + // Do something with the operation + } +} + +function updateSessionInfo(sessionInfo: IRushSessionInfo): void { + const { actionName, repositoryIdentifier } = sessionInfo; +} + +function updateBuildStatus(newStatus: ReadableOperationStatus): void { + buildStatus = newStatus; + // Render +} + +socket.addEventListener('message', (ev) => { + const message: IWebSocketEventMessage = JSON.parse(ev.data); + + switch (message.event) { + case 'before-execute': { + const { operations } = message; + updateOperations(operations); + updateBuildStatus('Executing'); + break; + } + + case 'status-change': { + const { operations } = message; + updateOperations(operations); + break; + } + + case 'after-execute': { + const { status } = message; + updateBuildStatus(status); + break; + } + + case 'sync': { + operationsByName.clear(); + const { operations, status, sessionInfo } = message; + updateOperations(operations); + updateSessionInfo(sessionInfo); + updateBuildStatus(status); + break; + } + } +}); +``` diff --git a/rush-plugins/rush-serve-plugin/package.json b/rush-plugins/rush-serve-plugin/package.json index 490c74265c3..db054ba1e7d 100644 --- a/rush-plugins/rush-serve-plugin/package.json +++ b/rush-plugins/rush-serve-plugin/package.json @@ -8,7 +8,7 @@ "type": "git", "directory": "rush-plugins/rush-serve-plugin" }, - "main": "lib/index.js", + "main": "lib-commonjs/index.js", "types": "dist/index.d.ts", "scripts": { "build": "heft test --clean", @@ -25,7 +25,8 @@ "compression": "~1.7.4", "cors": "~2.8.5", "express": "4.18.1", - "http2-express-bridge": "~1.0.7" + "http2-express-bridge": "~1.0.7", + "ws": "~8.14.1" }, "devDependencies": { "@rushstack/eslint-config": "workspace:*", @@ -35,6 +36,26 @@ "@types/cors": "~2.8.12", "@types/express": "4.17.13", "@types/heft-jest": "1.0.1", - "@types/node": "14.18.36" + "@types/node": "14.18.36", + "@types/ws": "8.5.5" + }, + "exports": { + ".": { + "require": "./lib/index.js", + "types": "./dist/rush-serve-plugin.d.ts" + }, + "./api": { + "types": "./lib/api.types.d.ts" + } + }, + "typesVersions": { + "*": { + ".": [ + "dist/rush-serve-plugin.d.ts" + ], + "api": [ + "lib/api.types.d.ts" + ] + } } } diff --git a/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts b/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts index 3174161f874..a3d2b7bfbd2 100644 --- a/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts +++ b/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts @@ -25,12 +25,21 @@ export interface IRushServePluginOptions { * The names of phased commands to which the plugin should be applied. */ phasedCommands: ReadonlyArray; + + /** + * The URL path at which to host the web socket connection for monitoring build status. If not specified, the web socket interface will not be enabled. + */ + buildStatusWebSocketPath?: string; + /** * The name of a parameter that Rush is configured to use to pass a port number to underlying operations. * If specified, the plugin will ensure the value is synchronized with the port used for its server. */ portParameterLongName?: string | undefined; + /** + * Routing rules for files that are associated with the entire workspace, rather than a single project (e.g. files output by Rush plugins). + */ globalRouting?: IGlobalRoutingRuleJson[]; } @@ -43,11 +52,13 @@ export class RushServePlugin implements IRushPlugin { private readonly _phasedCommands: Set; private readonly _portParameterLongName: string | undefined; private readonly _globalRoutingRules: IGlobalRoutingRuleJson[]; + private readonly _buildStatusWebSocketPath: string | undefined; public constructor(options: IRushServePluginOptions) { this._phasedCommands = new Set(options.phasedCommands); this._portParameterLongName = options.portParameterLongName; this._globalRoutingRules = options.globalRouting ?? []; + this._buildStatusWebSocketPath = options.buildStatusWebSocketPath; } public apply(rushSession: RushSession, rushConfiguration: RushConfiguration): void { @@ -73,7 +84,8 @@ export class RushServePlugin implements IRushPlugin { rushConfiguration, command, portParameterLongName: this._portParameterLongName, - globalRoutingRules + globalRoutingRules, + buildStatusWebSocketPath: this._buildStatusWebSocketPath }); }; diff --git a/rush-plugins/rush-serve-plugin/src/api.types.ts b/rush-plugins/rush-serve-plugin/src/api.types.ts new file mode 100644 index 00000000000..93882996945 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/api.types.ts @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { OperationStatus } from '@rushstack/rush-sdk'; + +/** + * Human readable status values. These are the PascalCase keys of the `OperationStatus` enumeration. + */ +export type ReadableOperationStatus = keyof typeof OperationStatus; + +/** + * Information about an operation in the graph. + */ +export interface IOperationInfo { + /** + * The display name of the operation. + */ + name: string; + + /** + * The npm package name of the containing Rush Project. + */ + packageName: string; + + /** + * The name of the containing phase. + */ + phaseName: string; + + /** + * If true, this operation is configured to be silent and is included for completeness. + */ + silent: boolean; + /** + * If true, this operation is configured to be a noop and is included for graph completeness. + */ + noop: boolean; + + /** + * The current status of the operation. This value is in PascalCase and is the key of the corresponding `OperationStatus` constant. + */ + status: ReadableOperationStatus; + + /** + * The start time of the operation, if it has started, in milliseconds. Not wall clock time. + */ + startTime: number | undefined; + + /** + * The end time of the operation, if it has finished, in milliseconds. Not wall clock time. + */ + endTime: number | undefined; +} + +/** + * Information about the current Rush session. + */ +export interface IRushSessionInfo { + /** + * The name of the command being run. + */ + actionName: string; + + /** + * A unique identifier for the repository in which this Rush is running. + */ + repositoryIdentifier: string; +} + +/** + * Message sent to a WebSocket client at the start of an execution pass. + */ +export interface IWebSocketBeforeExecuteEventMessage { + event: 'before-execute'; + operations: IOperationInfo[]; +} + +/** + * Message sent to a WebSocket client at the end of an execution pass. + */ +export interface IWebSocketAfterExecuteEventMessage { + event: 'after-execute'; + status: ReadableOperationStatus; +} + +/** + * Message sent to a WebSocket client when one or more operations change status. + * + * Batched to reduce noise and improve throughput. + */ +export interface IWebSocketBatchStatusChangeEventMessage { + event: 'status-change'; + operations: IOperationInfo[]; +} + +/** + * Message sent to a WebSocket client upon initial connection, or when explicitly requested. + * + * @see IWebSocketSyncCommandMessage + */ +export interface IWebSocketSyncEventMessage { + event: 'sync'; + operations: IOperationInfo[]; + sessionInfo: IRushSessionInfo; + status: ReadableOperationStatus; +} + +/** + * The set of possible messages sent to a WebSocket client. + */ +export type IWebSocketEventMessage = + | IWebSocketBeforeExecuteEventMessage + | IWebSocketAfterExecuteEventMessage + | IWebSocketBatchStatusChangeEventMessage + | IWebSocketSyncEventMessage; + +/** + * Message received from a WebSocket client to request a sync. + */ +export interface IWebSocketSyncCommandMessage { + command: 'sync'; +} + +/** + * The set of possible messages received from a WebSocket client. + */ +export type IWebSocketCommandMessage = IWebSocketSyncCommandMessage; diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index 8d09d5fc1ad..27f9de84171 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -2,36 +2,55 @@ // See LICENSE in the project root for license information. import { once } from 'node:events'; -import http2 from 'node:http2'; +import type { Server as HTTPSecureServer } from 'node:https'; +import http2, { Http2SecureServer } from 'node:http2'; import type { AddressInfo } from 'node:net'; +import os from 'node:os'; import express, { type Application } from 'express'; import http2express from 'http2-express-bridge'; import cors from 'cors'; import compression from 'compression'; +import { WebSocketServer, type WebSocket, type MessageEvent } from 'ws'; import { CertificateManager, ICertificate } from '@rushstack/debug-certificate-manager'; -import { AlreadyReportedError } from '@rushstack/node-core-library'; -import type { +import { AlreadyReportedError, Sort } from '@rushstack/node-core-library'; +import { ILogger, RushConfiguration, RushConfigurationProject, RushSession, IPhasedCommand, Operation, - ICreateOperationsContext + ICreateOperationsContext, + IOperationExecutionResult, + OperationStatus, + IExecutionResult } from '@rushstack/rush-sdk'; import type { CommandLineStringParameter } from '@rushstack/ts-command-line'; import { PLUGIN_NAME } from './constants'; import { IRoutingRule, RushServeConfiguration } from './RushProjectServeConfigFile'; +import type { + IOperationInfo, + IWebSocketAfterExecuteEventMessage, + IWebSocketBeforeExecuteEventMessage, + IWebSocketEventMessage, + IWebSocketBatchStatusChangeEventMessage, + IWebSocketSyncEventMessage, + ReadableOperationStatus, + IWebSocketCommandMessage, + IRushSessionInfo +} from './api.types'; + export interface IPhasedCommandHandlerOptions { rushSession: RushSession; rushConfiguration: RushConfiguration; command: IPhasedCommand; portParameterLongName: string | undefined; globalRoutingRules: IRoutingRule[]; + buildStatusWebSocketPath: string | undefined; } export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions): Promise { @@ -54,6 +73,9 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions } } + const webSocketServerUpgrader: WebSocketServerUpgrader | undefined = + tryEnableBuildStatusWebSocketServer(options); + command.hooks.createOperations.tapPromise( { name: PLUGIN_NAME, @@ -194,6 +216,8 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions app ); + webSocketServerUpgrader?.(server); + server.listen(requestedPort); await once(server, 'listening'); @@ -216,3 +240,199 @@ export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions command.hooks.waitingForChanges.tap(PLUGIN_NAME, logHost); } + +type WebSocketServerUpgrader = (server: Http2SecureServer) => void; + +/** + * + */ +function tryEnableBuildStatusWebSocketServer( + options: IPhasedCommandHandlerOptions +): WebSocketServerUpgrader | undefined { + const { buildStatusWebSocketPath } = options; + if (!buildStatusWebSocketPath) { + return; + } + + let operationStates: Map | undefined; + let buildStatus: ReadableOperationStatus = 'Ready'; + + const webSockets: Set = new Set(); + + // Map from OperationStatus enum values back to the names of the constants + const readableStatusFromStatus: { [K in OperationStatus]: ReadableOperationStatus } = { + [OperationStatus.Waiting]: 'Waiting', + [OperationStatus.Ready]: 'Ready', + [OperationStatus.Queued]: 'Queued', + [OperationStatus.Executing]: 'Executing', + [OperationStatus.RemoteExecuting]: 'RemoteExecuting', + [OperationStatus.Success]: 'Success', + [OperationStatus.SuccessWithWarning]: 'SuccessWithWarning', + [OperationStatus.Skipped]: 'Skipped', + [OperationStatus.FromCache]: 'FromCache', + [OperationStatus.Failure]: 'Failure', + [OperationStatus.Blocked]: 'Blocked', + [OperationStatus.NoOp]: 'NoOp' + }; + + /** + * Maps the internal Rush record down to a subset that is JSON-friendly and human readable. + */ + function convertToOperationInfo(record: IOperationExecutionResult): IOperationInfo | undefined { + const { operation } = record; + const { name, associatedPhase, associatedProject, runner } = operation; + + if (!name || !associatedPhase || !associatedProject || !runner) { + return; + } + + return { + name, + packageName: associatedProject.packageName, + phaseName: associatedPhase.name, + + silent: !!runner.silent, + noop: !!runner.isNoOp, + + status: readableStatusFromStatus[record.status], + startTime: record.stopwatch.startTime, + endTime: record.stopwatch.endTime + }; + } + + function convertToOperationInfoArray(records: Iterable): IOperationInfo[] { + const operations: IOperationInfo[] = []; + + for (const record of records) { + const info: IOperationInfo | undefined = convertToOperationInfo(record); + + if (info) { + operations.push(info); + } + } + + Sort.sortBy(operations, (x) => x.name); + return operations; + } + + function sendWebSocketMessage(message: IWebSocketEventMessage): void { + const stringifiedMessage: string = JSON.stringify(message); + for (const socket of webSockets) { + socket.send(stringifiedMessage); + } + } + + const { command } = options; + const sessionInfo: IRushSessionInfo = { + actionName: command.actionName, + repositoryIdentifier: getRepositoryIdentifier(options.rushConfiguration) + }; + + function sendSyncMessage(webSocket: WebSocket): void { + const syncMessage: IWebSocketSyncEventMessage = { + event: 'sync', + operations: convertToOperationInfoArray(operationStates?.values() ?? []), + sessionInfo, + status: buildStatus + }; + + webSocket.send(JSON.stringify(syncMessage)); + } + + const { hooks } = command; + + hooks.beforeExecuteOperations.tap( + PLUGIN_NAME, + (operationsToExecute: Map): void => { + operationStates = operationsToExecute; + + const beforeExecuteMessage: IWebSocketBeforeExecuteEventMessage = { + event: 'before-execute', + operations: convertToOperationInfoArray(operationsToExecute.values()) + }; + buildStatus = 'Executing'; + sendWebSocketMessage(beforeExecuteMessage); + } + ); + + hooks.afterExecuteOperations.tap(PLUGIN_NAME, (result: IExecutionResult): void => { + buildStatus = readableStatusFromStatus[result.status]; + const afterExecuteMessage: IWebSocketAfterExecuteEventMessage = { + event: 'after-execute', + status: buildStatus + }; + sendWebSocketMessage(afterExecuteMessage); + }); + + const pendingStatusChanges: Map = new Map(); + let statusChangeTimeout: NodeJS.Immediate | undefined; + function sendBatchedStatusChange(): void { + statusChangeTimeout = undefined; + const infos: IOperationInfo[] = convertToOperationInfoArray(pendingStatusChanges.values()); + pendingStatusChanges.clear(); + const message: IWebSocketBatchStatusChangeEventMessage = { + event: 'status-change', + operations: infos + }; + sendWebSocketMessage(message); + } + + hooks.onOperationStatusChanged.tap(PLUGIN_NAME, (record: IOperationExecutionResult): void => { + pendingStatusChanges.set(record.operation, record); + if (!statusChangeTimeout) { + statusChangeTimeout = setImmediate(sendBatchedStatusChange); + } + }); + + const connector: WebSocketServerUpgrader = (server: Http2SecureServer) => { + const wss: WebSocketServer = new WebSocketServer({ + server: server as unknown as HTTPSecureServer, + path: buildStatusWebSocketPath + }); + wss.addListener('connection', (webSocket: WebSocket): void => { + webSockets.add(webSocket); + + sendSyncMessage(webSocket); + + webSocket.addEventListener('message', (ev: MessageEvent) => { + const parsedMessage: IWebSocketCommandMessage = JSON.parse(ev.data.toString()); + switch (parsedMessage.command) { + case 'sync': { + sendSyncMessage(webSocket); + break; + } + + default: { + // Unknown message. Ignore. + } + } + }); + + webSocket.addEventListener( + 'close', + () => { + webSockets.delete(webSocket); + }, + { once: true } + ); + }); + }; + + return connector; +} + +function getRepositoryIdentifier(rushConfiguration: RushConfiguration): string { + const { env } = process; + const { CODESPACE_NAME: codespaceName, GITHUB_USER: githubUserName } = env; + + if (codespaceName) { + const usernamePrefix: string | undefined = githubUserName?.replace(/_|$/g, '-'); + const startIndex: number = + usernamePrefix && codespaceName.startsWith(usernamePrefix) ? usernamePrefix.length : 0; + const endIndex: number = codespaceName.lastIndexOf('-'); + const normalizedName: string = codespaceName.slice(startIndex, endIndex).replace(/-/g, ' '); + return `Codespace "${normalizedName}"`; + } + + return `${os.hostname()} - ${rushConfiguration.rushJsonFolder}`; +} diff --git a/rush-plugins/rush-serve-plugin/src/schemas/rush-serve-plugin-options.schema.json b/rush-plugins/rush-serve-plugin/src/schemas/rush-serve-plugin-options.schema.json index f8e440b8e10..f0184060284 100644 --- a/rush-plugins/rush-serve-plugin/src/schemas/rush-serve-plugin-options.schema.json +++ b/rush-plugins/rush-serve-plugin/src/schemas/rush-serve-plugin-options.schema.json @@ -27,6 +27,12 @@ "pattern": "^--(?:[a-z0-9]+)(?:-[a-z0-9]+)*$" }, + "buildStatusWebSocketPath": { + "type": "string", + "description": "The URL path at which to host the web socket connection for monitoring build status. If not specified, the web socket interface will not be enabled.", + "pattern": "^/(?:[a-zA-Z0-9_$-]+(?:/[a-zA-Z0-9_$-]+)*)?$" + }, + "globalRouting": { "type": "array", "description": "Routing rules for files that are associated with the entire workspace, rather than a single project (e.g. files output by Rush plugins).",