diff --git a/common/changes/@microsoft/rush/rush-serve-dependencies_2025-09-16-23-56.json b/common/changes/@microsoft/rush/rush-serve-dependencies_2025-09-16-23-56.json new file mode 100644 index 00000000000..d4fa959660d --- /dev/null +++ b/common/changes/@microsoft/rush/rush-serve-dependencies_2025-09-16-23-56.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "comment": "[rush-serve-plugin] Support aborting execution via Web Socket. Include information about the dependencies of operations in messages to the client..", + "type": "none" + } + ], + "packageName": "@microsoft/rush" +} \ No newline at end of file diff --git a/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts b/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts index 0126fcd5d30..cc7cb2d8220 100644 --- a/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts +++ b/rush-plugins/rush-serve-plugin/src/RushProjectServeConfigFile.ts @@ -8,35 +8,9 @@ import { Async } from '@rushstack/node-core-library'; import type { ITerminal } from '@rushstack/terminal'; import { RigConfig } from '@rushstack/rig-package'; import type { RushConfigurationProject } from '@rushstack/rush-sdk'; -import rushProjectServeSchema from './schemas/rush-project-serve.schema.json'; - -export interface IRushProjectServeJson { - routing: IRoutingRuleJson[]; -} - -export interface IBaseRoutingRuleJson { - servePath: string; - immutable?: boolean; -} - -export interface IRoutingFolderRuleJson extends IBaseRoutingRuleJson { - projectRelativeFile: undefined; - projectRelativeFolder: string; -} - -export interface IRoutingFileRuleJson extends IBaseRoutingRuleJson { - projectRelativeFile: string; - projectRelativeFolder: undefined; -} -export type IRoutingRuleJson = IRoutingFileRuleJson | IRoutingFolderRuleJson; - -export interface IRoutingRule { - type: 'file' | 'folder'; - diskPath: string; - servePath: string; - immutable: boolean; -} +import rushProjectServeSchema from './schemas/rush-project-serve.schema.json'; +import type { IRushProjectServeJson, IRoutingRule } from './types'; export class RushServeConfiguration { private readonly _loader: ProjectConfigurationFile; diff --git a/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts b/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts index 08be11d31fb..1a4ab89a099 100644 --- a/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts +++ b/rush-plugins/rush-serve-plugin/src/RushServePlugin.ts @@ -6,7 +6,7 @@ import * as path from 'path'; import type { IRushPlugin, RushSession, RushConfiguration, IPhasedCommand } from '@rushstack/rush-sdk'; import { PLUGIN_NAME } from './constants'; -import type { IBaseRoutingRuleJson, IRoutingRule } from './RushProjectServeConfigFile'; +import type { IBaseRoutingRuleJson, IRoutingRule } from './types'; export interface IGlobalRoutingFolderRuleJson extends IBaseRoutingRuleJson { workspaceRelativeFile: undefined; diff --git a/rush-plugins/rush-serve-plugin/src/api.types.ts b/rush-plugins/rush-serve-plugin/src/api.types.ts index 2afcfc7a98d..99b5fcb012f 100644 --- a/rush-plugins/rush-serve-plugin/src/api.types.ts +++ b/rush-plugins/rush-serve-plugin/src/api.types.ts @@ -34,6 +34,11 @@ export interface IOperationInfo { */ name: string; + /** + * The names of the dependencies of the operation. + */ + dependencies: string[]; + /** * The npm package name of the containing Rush Project. */ @@ -151,6 +156,13 @@ export interface IWebSocketSyncCommandMessage { command: 'sync'; } +/** + * Message received from a WebSocket client to request abortion of the current execution pass. + */ +export interface IWebSocketAbortExecutionCommandMessage { + command: 'abort-execution'; +} + /** * Message received from a WebSocket client to request invalidation of one or more operations. */ @@ -162,7 +174,7 @@ export interface IWebSocketInvalidateCommandMessage { /** * The set of possible operation enabled states. */ -export type OperationEnabledState = 'never' | 'changed' | 'affected'; +export type OperationEnabledState = 'never' | 'changed' | 'affected' | 'default'; /** * Message received from a WebSocket client to change the enabled states of operations. @@ -177,5 +189,6 @@ export interface IWebSocketSetEnabledStatesCommandMessage { */ export type IWebSocketCommandMessage = | IWebSocketSyncCommandMessage + | IWebSocketAbortExecutionCommandMessage | IWebSocketInvalidateCommandMessage | IWebSocketSetEnabledStatesCommandMessage; diff --git a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts index e949bf14490..b4805180bf2 100644 --- a/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts +++ b/rush-plugins/rush-serve-plugin/src/phasedCommandHandler.ts @@ -2,62 +2,35 @@ // See LICENSE in the project root for license information. import { once } from 'node:events'; -import type { Server as HTTPSecureServer } from 'node:https'; -import http2, { type Http2SecureServer } from 'node:http2'; +import http2 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, type ICertificate } from '@rushstack/debug-certificate-manager'; -import { AlreadyReportedError, Sort } from '@rushstack/node-core-library'; +import { AlreadyReportedError } from '@rushstack/node-core-library'; import { type ILogger, - type RushConfiguration, type RushConfigurationProject, - type RushSession, - type IPhasedCommand, type Operation, type ICreateOperationsContext, - type IOperationExecutionResult, - OperationStatus, - type IExecutionResult, - type ILogFilePaths, RushConstants } from '@rushstack/rush-sdk'; import { getProjectLogFolders } from '@rushstack/rush-sdk/lib/logic/operations/ProjectLogWritable'; import { type CommandLineIntegerParameter, CommandLineParameterKind } from '@rushstack/ts-command-line'; import { PLUGIN_NAME } from './constants'; -import { type IRoutingRule, RushServeConfiguration } from './RushProjectServeConfigFile'; - -import type { - IOperationInfo, - IWebSocketAfterExecuteEventMessage, - IWebSocketBeforeExecuteEventMessage, - IWebSocketEventMessage, - IWebSocketBatchStatusChangeEventMessage, - IWebSocketSyncEventMessage, - ReadableOperationStatus, - IWebSocketCommandMessage, - IRushSessionInfo, - ILogFileURLs, - OperationEnabledState -} from './api.types'; - -export interface IPhasedCommandHandlerOptions { - rushSession: RushSession; - rushConfiguration: RushConfiguration; - command: IPhasedCommand; - portParameterLongName: string | undefined; - logServePath: string | undefined; - globalRoutingRules: IRoutingRule[]; - buildStatusWebSocketPath: string | undefined; -} +import { RushServeConfiguration } from './RushProjectServeConfigFile'; +import type { IRoutingRule, IPhasedCommandHandlerOptions } from './types'; + +import { + getLogServePathForProject, + tryEnableBuildStatusWebSocketServer, + type WebSocketServerUpgrader +} from './tryEnableBuildStatusWebSocketServer'; export async function phasedCommandHandler(options: IPhasedCommandHandlerOptions): Promise { const { rushSession, command, portParameterLongName, globalRoutingRules } = options; @@ -273,297 +246,3 @@ 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.Success]: 'Success', - [OperationStatus.SuccessWithWarning]: 'SuccessWithWarning', - [OperationStatus.Skipped]: 'Skipped', - [OperationStatus.FromCache]: 'FromCache', - [OperationStatus.Failure]: 'Failure', - [OperationStatus.Blocked]: 'Blocked', - [OperationStatus.NoOp]: 'NoOp', - [OperationStatus.Aborted]: 'Aborted' - }; - - const { logServePath } = options; - - function convertToLogFileUrls( - logFilePaths: ILogFilePaths | undefined, - packageName: string - ): ILogFileURLs | undefined { - if (!logFilePaths || !logServePath) { - return; - } - - const projectLogServePath: string = getLogServePathForProject(logServePath, packageName); - - const logFileUrls: ILogFileURLs = { - text: `${projectLogServePath}${logFilePaths.text.slice(logFilePaths.textFolder.length)}`, - error: `${projectLogServePath}${logFilePaths.error.slice(logFilePaths.textFolder.length)}`, - jsonl: `${projectLogServePath}${logFilePaths.jsonl.slice(logFilePaths.jsonlFolder.length)}` - }; - - return logFileUrls; - } - - /** - * 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, enabled } = operation; - - if (!name || !runner) { - return; - } - - const { packageName } = associatedProject; - - return { - name, - packageName, - phaseName: associatedPhase.name, - - enabled, - silent: record.silent, - noop: !!runner.isNoOp, - - status: readableStatusFromStatus[record.status], - startTime: record.stopwatch.startTime, - endTime: record.stopwatch.endTime, - - logFileURLs: convertToLogFileUrls(record.logFilePaths, packageName) - }; - } - - 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; - - let invalidateOperation: ((operation: Operation, reason: string) => void) | undefined; - - const operationEnabledStates: Map = new Map(); - hooks.createOperations.tap( - { - name: PLUGIN_NAME, - stage: Infinity - }, - (operations: Set, context: ICreateOperationsContext) => { - const potentiallyAffectedOperations: Set = new Set(); - for (const operation of operations) { - const { associatedProject } = operation; - if (context.projectsInUnknownState.has(associatedProject)) { - potentiallyAffectedOperations.add(operation); - } - } - for (const operation of potentiallyAffectedOperations) { - for (const consumer of operation.consumers) { - potentiallyAffectedOperations.add(consumer); - } - - const { name } = operation; - const expectedState: OperationEnabledState | undefined = operationEnabledStates.get(name); - switch (expectedState) { - case 'affected': - operation.enabled = true; - break; - case 'never': - operation.enabled = false; - break; - case 'changed': - operation.enabled = context.projectsInUnknownState.has(operation.associatedProject); - break; - case undefined: - // Use the original value. - break; - } - } - - invalidateOperation = context.invalidateOperation; - - return operations; - } - ); - - 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 infos: IOperationInfo[] = convertToOperationInfoArray(result.operationResults.values() ?? []); - const afterExecuteMessage: IWebSocketAfterExecuteEventMessage = { - event: 'after-execute', - operations: infos, - 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; - } - - case 'set-enabled-states': { - const { enabledStateByOperationName } = parsedMessage; - for (const [name, state] of Object.entries(enabledStateByOperationName)) { - operationEnabledStates.set(name, state); - } - break; - } - - case 'invalidate': { - const { operationNames } = parsedMessage; - const operationNameSet: Set = new Set(operationNames); - if (invalidateOperation && operationStates) { - for (const operation of operationStates.keys()) { - if (operationNameSet.has(operation.name)) { - invalidateOperation(operation, '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}`; -} - -function getLogServePathForProject(logServePath: string, packageName: string): string { - return `${logServePath}/${packageName}`; -} diff --git a/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts b/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts new file mode 100644 index 00000000000..1ecf5f10063 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/tryEnableBuildStatusWebSocketServer.ts @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { Http2SecureServer } from 'node:http2'; +import type { Server as HTTPSecureServer } from 'node:https'; +import os from 'node:os'; + +import { type WebSocket, WebSocketServer, type MessageEvent } from 'ws'; + +import { Sort } from '@rushstack/node-core-library/lib/Sort'; +import { + type Operation, + type IOperationExecutionResult, + OperationStatus, + type ILogFilePaths, + type ICreateOperationsContext, + type IExecutionResult, + type RushConfiguration, + type IExecuteOperationsContext +} from '@rushstack/rush-sdk'; + +import type { + ReadableOperationStatus, + ILogFileURLs, + IOperationInfo, + IWebSocketEventMessage, + IRushSessionInfo, + IWebSocketSyncEventMessage, + OperationEnabledState, + IWebSocketBeforeExecuteEventMessage, + IWebSocketAfterExecuteEventMessage, + IWebSocketBatchStatusChangeEventMessage, + IWebSocketCommandMessage +} from './api.types'; +import { PLUGIN_NAME } from './constants'; +import type { IPhasedCommandHandlerOptions } from './types'; + +export type WebSocketServerUpgrader = (server: Http2SecureServer) => void; + +/** + * Returns a string that identifies the repository, based on the Rush configuration and environment. + * @param rushConfiguration - The Rush configuration object. + * @returns A string identifier for the repository. + */ +export 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}`; +} + +/** + * @param logServePath - The base URL path where logs are being served. + * @param packageName - The npm package name of the project. + * @returns The base URL path for serving logs of the specified project. + */ +export function getLogServePathForProject(logServePath: string, packageName: string): string { + return `${logServePath}/${packageName}`; +} + +/** + * If the `buildStatusWebSocketPath` option is configured, this function returns a `WebSocketServerUpgrader` callback + * that can be used to add a WebSocket server to the HTTPS server. The WebSocket server sends messages + * about operation status changes to connected clients. + * + */ +export function tryEnableBuildStatusWebSocketServer( + options: IPhasedCommandHandlerOptions +): WebSocketServerUpgrader | undefined { + const { buildStatusWebSocketPath } = options; + if (!buildStatusWebSocketPath) { + return; + } + + const operationStates: Map = new Map(); + let buildStatus: ReadableOperationStatus = 'Ready'; + let executionAbortController: AbortController | undefined; + + 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.Success]: 'Success', + [OperationStatus.SuccessWithWarning]: 'SuccessWithWarning', + [OperationStatus.Skipped]: 'Skipped', + [OperationStatus.FromCache]: 'FromCache', + [OperationStatus.Failure]: 'Failure', + [OperationStatus.Blocked]: 'Blocked', + [OperationStatus.NoOp]: 'NoOp', + [OperationStatus.Aborted]: 'Aborted' + }; + + const { logServePath } = options; + + function convertToLogFileUrls( + logFilePaths: ILogFilePaths | undefined, + packageName: string + ): ILogFileURLs | undefined { + if (!logFilePaths || !logServePath) { + return; + } + + const projectLogServePath: string = getLogServePathForProject(logServePath, packageName); + + const logFileUrls: ILogFileURLs = { + text: `${projectLogServePath}${logFilePaths.text.slice(logFilePaths.textFolder.length)}`, + error: `${projectLogServePath}${logFilePaths.error.slice(logFilePaths.textFolder.length)}`, + jsonl: `${projectLogServePath}${logFilePaths.jsonl.slice(logFilePaths.jsonlFolder.length)}` + }; + + return logFileUrls; + } + + /** + * 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, enabled } = operation; + + if (!name || !runner) { + return; + } + + const { packageName } = associatedProject; + + return { + name, + dependencies: Array.from(operation.dependencies, (dep) => dep.name), + packageName, + phaseName: associatedPhase.name, + + enabled, + silent: runner.silent, + noop: !!runner.isNoOp, + + status: readableStatusFromStatus[record.status], + startTime: record.stopwatch.startTime, + endTime: record.stopwatch.endTime, + + logFileURLs: convertToLogFileUrls(record.logFilePaths, packageName) + }; + } + + 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; + + let invalidateOperation: ((operation: Operation, reason: string) => void) | undefined; + + const operationEnabledStates: Map = new Map(); + hooks.createOperations.tap( + { + name: PLUGIN_NAME, + stage: Infinity + }, + (operations: Set, context: ICreateOperationsContext) => { + const potentiallyAffectedOperations: Set = new Set(); + for (const operation of operations) { + const { associatedProject } = operation; + if (context.projectsInUnknownState.has(associatedProject)) { + potentiallyAffectedOperations.add(operation); + } + } + for (const operation of potentiallyAffectedOperations) { + for (const consumer of operation.consumers) { + potentiallyAffectedOperations.add(consumer); + } + + const { name } = operation; + const expectedState: OperationEnabledState | undefined = operationEnabledStates.get(name); + switch (expectedState) { + case 'affected': + operation.enabled = true; + break; + case 'never': + operation.enabled = false; + break; + case 'changed': + operation.enabled = context.projectsInUnknownState.has(operation.associatedProject); + break; + case 'default': + case undefined: + // Use the original value. + break; + } + } + + invalidateOperation = context.invalidateOperation; + + return operations; + } + ); + + hooks.beforeExecuteOperations.tap( + PLUGIN_NAME, + ( + operationsToExecute: Map, + context: IExecuteOperationsContext + ): void => { + for (const [operation, result] of operationsToExecute) { + operationStates.set(operation.name, result); + } + + executionAbortController = context.abortController; + + 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 infos: IOperationInfo[] = convertToOperationInfoArray(result.operationResults.values() ?? []); + const afterExecuteMessage: IWebSocketAfterExecuteEventMessage = { + event: 'after-execute', + operations: infos, + 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; + } + + case 'set-enabled-states': { + const { enabledStateByOperationName } = parsedMessage; + for (const [name, state] of Object.entries(enabledStateByOperationName)) { + operationEnabledStates.set(name, state); + } + break; + } + + case 'invalidate': { + const { operationNames } = parsedMessage; + const operationNameSet: Set = new Set(operationNames); + if (invalidateOperation) { + for (const operationName of operationNameSet) { + const operationState: IOperationExecutionResult | undefined = + operationStates.get(operationName); + if (operationState) { + invalidateOperation(operationState.operation, 'Invalidated via WebSocket'); + operationStates.delete(operationName); + } + } + } + break; + } + + case 'abort-execution': { + executionAbortController?.abort(); + break; + } + + default: { + // Unknown message. Ignore. + } + } + }); + + webSocket.addEventListener( + 'close', + () => { + webSockets.delete(webSocket); + }, + { once: true } + ); + }); + }; + + return connector; +} diff --git a/rush-plugins/rush-serve-plugin/src/types.ts b/rush-plugins/rush-serve-plugin/src/types.ts new file mode 100644 index 00000000000..8e5473c8d94 --- /dev/null +++ b/rush-plugins/rush-serve-plugin/src/types.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { RushConfiguration, RushSession, IPhasedCommand } from '@rushstack/rush-sdk'; + +export interface IPhasedCommandHandlerOptions { + rushSession: RushSession; + rushConfiguration: RushConfiguration; + command: IPhasedCommand; + portParameterLongName: string | undefined; + logServePath: string | undefined; + globalRoutingRules: IRoutingRule[]; + buildStatusWebSocketPath: string | undefined; +} +export interface IRushProjectServeJson { + routing: IRoutingRuleJson[]; +} + +export interface IBaseRoutingRuleJson { + servePath: string; + immutable?: boolean; +} + +export interface IRoutingFolderRuleJson extends IBaseRoutingRuleJson { + projectRelativeFile: undefined; + projectRelativeFolder: string; +} + +export interface IRoutingFileRuleJson extends IBaseRoutingRuleJson { + projectRelativeFile: string; + projectRelativeFolder: undefined; +} + +export type IRoutingRuleJson = IRoutingFileRuleJson | IRoutingFolderRuleJson; + +export interface IRoutingRule { + type: 'file' | 'folder'; + diskPath: string; + servePath: string; + immutable: boolean; +}