Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[rush-serve-plugin] Add build status monitoring via WebSocket #4328

Merged
merged 3 commits into from
Sep 15, 2023
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
[rush-serve-plugin] Add WebSocket support
dmichon-msft committed Sep 14, 2023
commit a3e79eb9fe56366425197129728e000b328065e6
Original file line number Diff line number Diff line change
@@ -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"
}
12 changes: 8 additions & 4 deletions common/config/rush/nonbrowser-approved-packages.json
Original file line number Diff line number Diff line change
@@ -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" ]
74 changes: 74 additions & 0 deletions rush-plugins/rush-serve-plugin/README.md
Original file line number Diff line number Diff line change
@@ -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:<port>/`.

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<string, IOperationInfo> = 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;
}
}
});
```
27 changes: 24 additions & 3 deletions rush-plugins/rush-serve-plugin/package.json
Original file line number Diff line number Diff line change
@@ -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": "18.17.15"
"@types/node": "18.17.15",
"@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"
]
}
}
}
14 changes: 13 additions & 1 deletion rush-plugins/rush-serve-plugin/src/RushServePlugin.ts
Original file line number Diff line number Diff line change
@@ -25,12 +25,21 @@ export interface IRushServePluginOptions {
* The names of phased commands to which the plugin should be applied.
*/
phasedCommands: ReadonlyArray<string>;

/**
* 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<string>;
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
});
};

127 changes: 127 additions & 0 deletions rush-plugins/rush-serve-plugin/src/api.types.ts
Original file line number Diff line number Diff line change
@@ -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;
228 changes: 224 additions & 4 deletions rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
@@ -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<Operation, IOperationExecutionResult> | undefined;
let buildStatus: ReadableOperationStatus = 'Ready';

const webSockets: Set<WebSocket> = 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<IOperationExecutionResult>): 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<Operation, IOperationExecutionResult>): 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<Operation, IOperationExecutionResult> = 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}`;
}
Original file line number Diff line number Diff line change
@@ -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).",