diff --git a/README.md b/README.md index e40ff5073d8..42c57f7f2ab 100644 --- a/README.md +++ b/README.md @@ -97,11 +97,14 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/libraries/package-extractor](./libraries/package-extractor/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor.svg)](https://badge.fury.io/js/%40rushstack%2Fpackage-extractor) | [changelog](./libraries/package-extractor/CHANGELOG.md) | [@rushstack/package-extractor](https://www.npmjs.com/package/@rushstack/package-extractor) | | [/libraries/problem-matcher](./libraries/problem-matcher/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher.svg)](https://badge.fury.io/js/%40rushstack%2Fproblem-matcher) | [changelog](./libraries/problem-matcher/CHANGELOG.md) | [@rushstack/problem-matcher](https://www.npmjs.com/package/@rushstack/problem-matcher) | | [/libraries/rig-package](./libraries/rig-package/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frig-package.svg)](https://badge.fury.io/js/%40rushstack%2Frig-package) | [changelog](./libraries/rig-package/CHANGELOG.md) | [@rushstack/rig-package](https://www.npmjs.com/package/@rushstack/rig-package) | +| [/libraries/rush-daemon-protocol](./libraries/rush-daemon-protocol/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon-protocol.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon-protocol) | [changelog](./libraries/rush-daemon-protocol/CHANGELOG.md) | [@rushstack/rush-daemon-protocol](https://www.npmjs.com/package/@rushstack/rush-daemon-protocol) | +| [/libraries/rush-daemon-transport](./libraries/rush-daemon-transport/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-daemon-transport.svg)](https://badge.fury.io/js/%40rushstack%2Frush-daemon-transport) | [changelog](./libraries/rush-daemon-transport/CHANGELOG.md) | [@rushstack/rush-daemon-transport](https://www.npmjs.com/package/@rushstack/rush-daemon-transport) | | [/libraries/rush-lib](./libraries/rush-lib/) | [![npm version](https://badge.fury.io/js/%40microsoft%2Frush-lib.svg)](https://badge.fury.io/js/%40microsoft%2Frush-lib) | | [@microsoft/rush-lib](https://www.npmjs.com/package/@microsoft/rush-lib) | | [/libraries/rush-pnpm-kit-v10](./libraries/rush-pnpm-kit-v10/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v10.svg)](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v10) | [changelog](./libraries/rush-pnpm-kit-v10/CHANGELOG.md) | [@rushstack/rush-pnpm-kit-v10](https://www.npmjs.com/package/@rushstack/rush-pnpm-kit-v10) | | [/libraries/rush-pnpm-kit-v8](./libraries/rush-pnpm-kit-v8/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v8.svg)](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v8) | [changelog](./libraries/rush-pnpm-kit-v8/CHANGELOG.md) | [@rushstack/rush-pnpm-kit-v8](https://www.npmjs.com/package/@rushstack/rush-pnpm-kit-v8) | | [/libraries/rush-pnpm-kit-v9](./libraries/rush-pnpm-kit-v9/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v9.svg)](https://badge.fury.io/js/%40rushstack%2Frush-pnpm-kit-v9) | [changelog](./libraries/rush-pnpm-kit-v9/CHANGELOG.md) | [@rushstack/rush-pnpm-kit-v9](https://www.npmjs.com/package/@rushstack/rush-pnpm-kit-v9) | | [/libraries/rush-sdk](./libraries/rush-sdk/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-sdk.svg)](https://badge.fury.io/js/%40rushstack%2Frush-sdk) | | [@rushstack/rush-sdk](https://www.npmjs.com/package/@rushstack/rush-sdk) | +| [/libraries/rush-terminal-renderer](./libraries/rush-terminal-renderer/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Frush-terminal-renderer.svg)](https://badge.fury.io/js/%40rushstack%2Frush-terminal-renderer) | [changelog](./libraries/rush-terminal-renderer/CHANGELOG.md) | [@rushstack/rush-terminal-renderer](https://www.npmjs.com/package/@rushstack/rush-terminal-renderer) | | [/libraries/stream-collator](./libraries/stream-collator/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fstream-collator.svg)](https://badge.fury.io/js/%40rushstack%2Fstream-collator) | [changelog](./libraries/stream-collator/CHANGELOG.md) | [@rushstack/stream-collator](https://www.npmjs.com/package/@rushstack/stream-collator) | | [/libraries/terminal](./libraries/terminal/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Fterminal.svg)](https://badge.fury.io/js/%40rushstack%2Fterminal) | [changelog](./libraries/terminal/CHANGELOG.md) | [@rushstack/terminal](https://www.npmjs.com/package/@rushstack/terminal) | | [/libraries/tree-pattern](./libraries/tree-pattern/) | [![npm version](https://badge.fury.io/js/%40rushstack%2Ftree-pattern.svg)](https://badge.fury.io/js/%40rushstack%2Ftree-pattern) | [changelog](./libraries/tree-pattern/CHANGELOG.md) | [@rushstack/tree-pattern](https://www.npmjs.com/package/@rushstack/tree-pattern) | @@ -226,6 +229,7 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/build-tests/rush-package-manager-integration-test](./build-tests/rush-package-manager-integration-test/) | Integration tests for non-pnpm package managers in Rush. | | [/build-tests/rush-project-change-analyzer-test](./build-tests/rush-project-change-analyzer-test/) | This is an example project that uses rush-lib's ProjectChangeAnalyzer to | | [/build-tests/rush-redis-cobuild-plugin-integration-test](./build-tests/rush-redis-cobuild-plugin-integration-test/) | Tests connecting to an redis server | +| [/build-tests/rushd-wire-e2e-test](./build-tests/rushd-wire-e2e-test/) | End-to-end conformance tests for the rushd wire layer (protocol + transport + renderer against the rush-lib engine) | | [/build-tests/set-webpack-public-path-plugin-test](./build-tests/set-webpack-public-path-plugin-test/) | Building this project tests the set-webpack-public-path-plugin | | [/build-tests/webpack-local-version-test](./build-tests/webpack-local-version-test/) | Building this project tests the rig loading for the local version of webpack | | [/eslint/local-eslint-config](./eslint/local-eslint-config/) | An ESLint configuration consumed projects inside the rushstack repo. | diff --git a/build-tests/rushd-wire-e2e-test/AGENTS.md b/build-tests/rushd-wire-e2e-test/AGENTS.md new file mode 100644 index 00000000000..558aefd4762 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/AGENTS.md @@ -0,0 +1,51 @@ +# Agent coding contract — `rushd-wire-e2e-test` + +This package is governed by an **ultra-strict lint policy** for generated code. All of the +rules below are enabled to `error` in `eslint.config.js` via the shared +`local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js` mixin. +They apply to **all** TypeScript in this package, **including tests** (`src/**/*.test.ts`). + +## Enforced rules (do not attempt to bypass) + +| Rule | Setting | +| ---- | ------- | +| `complexity` | `['error', 3]` | +| `max-depth` | `['error', 3]` | +| `max-lines-per-function` | `['error', 30]` | +| `max-lines` | `['error', 100]` — every file, including this means: keep files small; split modules | +| `max-params` | `['error', 4]` — use options objects | +| `@typescript-eslint/no-magic-numbers` | `'error'` — every numeric literal must be a named constant | +| `@typescript-eslint/prefer-nullish-coalescing` | `'error'` — use `??`, not `\|\|` or nullish-guard ternaries | +| `import/enforce-node-protocol-usage` | `['error', 'always']` — write `node:crypto`, never `crypto` | +| `import/order` | `['error', { alphabetize: asc, grouped, newlines-between: always }]` | +| `sort-imports` | `['error', { ignoreDeclarationSort: true }]` — sort named members | +| `@typescript-eslint/consistent-type-imports` | `['error', { fixStyle: 'separate-type-imports' }]` — `import type { X }`, never inline `type` specifiers | +| `import/no-relative-parent-imports` | `'error'` for non-test source — no `../` imports outside tests | +| `no-eval`, `@typescript-eslint/no-implied-eval` | `'error'` | + +## Suppression is forbidden — mechanically enforced + +- `linterOptions.noInlineConfig: true` makes **every** `eslint-disable*` comment a lint error. +- `reportUnusedDisableDirectives: 'error'` flags stale suppressions. +- Therefore, as an agent working in this package you MUST NOT: + - add `eslint-disable`, `eslint-disable-next-line`, `eslint-env`, or inline `/* eslint ... */` config comments; + - add entries to any `.eslint-bulk-suppressions.json`; + - add `eslintIgnore` keys to `package.json`; + - add `@ts-nocheck` or `@ts-ignore` comments; + - weaken, reorder, or remove the `strict-codegen` mixin in `eslint.config.js`. +- If a rule fires, **fix the code** (extract a constant, split the function/module, restructure) — never silence it. + +## Deferred rules (do not emulate with hacks) + +The following intended rules have no existing implementation in this repository's ESLint +toolchain and are **not yet enabled** (the user will wire them up later): +`no-magic-strings`, `no-object-mutation`, `no-array-mutation`, +`no-placeholder-implementation`, and the custom zero-tolerance import rules +(`no-re-export`, `require-clean-barrel`, `require-barrel-relative-exports`, +`no-export-alias`, `no-dynamic-import`, `no-hardcoded-secrets`, +`no-parent-internal-access`). Write code that would already satisfy them: prefer immutable +update patterns and named string constants, and never land stubs or `TODO` implementations. + +## Design notes for this package + +- Test-only project: adapts the rush-lib engine's dual-emit into wire frames and proves conformance (golden parity, raw streams, verbosity isolation, backpressure) through the real protocol and transport packages. \ No newline at end of file diff --git a/build-tests/rushd-wire-e2e-test/config/jest.config.json b/build-tests/rushd-wire-e2e-test/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/build-tests/rushd-wire-e2e-test/config/rig.json b/build-tests/rushd-wire-e2e-test/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/build-tests/rushd-wire-e2e-test/eslint.config.js b/build-tests/rushd-wire-e2e-test/eslint.config.js new file mode 100644 index 00000000000..b08a47af297 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/eslint.config.js @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); +const strictCodegenMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen'); + +module.exports = [ + ...nodeProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + // IMPORTANT: The strict-codegen mixin must remain last so its rules win conflicts. + ...strictCodegenMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/build-tests/rushd-wire-e2e-test/package.json b/build-tests/rushd-wire-e2e-test/package.json new file mode 100644 index 00000000000..43e086562f4 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/package.json @@ -0,0 +1,23 @@ +{ + "name": "rushd-wire-e2e-test", + "description": "End-to-end conformance tests for the rushd wire layer (protocol + transport + renderer against the rush-lib engine)", + "version": "1.0.0", + "private": true, + "license": "MIT", + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "devDependencies": { + "@microsoft/rush-lib": "workspace:*", + "@rushstack/heft": "workspace:*", + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-daemon-protocol": "workspace:*", + "@rushstack/rush-daemon-transport": "workspace:*", + "@rushstack/rush-terminal-renderer": "workspace:*", + "@rushstack/terminal": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + } +} diff --git a/build-tests/rushd-wire-e2e-test/src/index.ts b/build-tests/rushd-wire-e2e-test/src/index.ts new file mode 100644 index 00000000000..06b07186077 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/index.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * End-to-end conformance tests for the rushd wire layer. See `src/test/`. + * + * @packageDocumentation + */ + +export {}; diff --git a/build-tests/rushd-wire-e2e-test/src/test/EngineRunners.ts b/build-tests/rushd-wire-e2e-test/src/test/EngineRunners.ts new file mode 100644 index 00000000000..aebb27db98b --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/EngineRunners.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Inline operation runners that use runWithTerminalAsync, matching the +// production runners (ShellOperationRunner, IPCOperationRunner). + +import * as os from 'node:os'; + +import type { RushConfigurationProject } from '@microsoft/rush-lib/lib/api/RushConfigurationProject'; +import type { + IOperationRunner, + IOperationRunnerContext +} from '@microsoft/rush-lib/lib/logic/operations/IOperationRunner'; +import { Operation } from '@microsoft/rush-lib/lib/logic/operations/Operation'; +import type { OperationStatus } from '@microsoft/rush-lib/lib/logic/operations/OperationStatus'; + +function createRunner(name: string, status: OperationStatus): IOperationRunner { + return { + name, + reportTiming: true, + silent: false, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async (context: IOperationRunnerContext) => + await context.runWithTerminalAsync( + async (terminal) => { + terminal.writeLine(`${name}-out ünïcode ✓`); + terminal.writeErrorLine(`${name}-err`); + return status; + }, + { createLogFile: false, logFileSuffix: '' } + ), + getConfigHash: () => 'e2e' + }; +} + +/** Creates a fixture operation writing deterministic unicode output. */ +export function createScenarioOperation(name: string, status: OperationStatus): Operation { + return new Operation({ + runner: createRunner(name, status), + logFilenameIdentifier: name, + phase: { + name: 'phase', + allowWarningsOnSuccess: false, + associatedParameters: new Set(), + dependencies: { self: new Set(), upstream: new Set() }, + isSynthetic: false, + logFilenameIdentifier: 'phase', + missingScriptBehavior: 'silent' + }, + project: { + packageName: name, + projectFolder: os.tmpdir() + } as unknown as RushConfigurationProject + }); +} diff --git a/build-tests/rushd-wire-e2e-test/src/test/EngineScenario.ts b/build-tests/rushd-wire-e2e-test/src/test/EngineScenario.ts new file mode 100644 index 00000000000..8424f267758 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/EngineScenario.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Builds and runs a small rush-lib operation graph with the dual-emit sink +// attached, capturing both the legacy terminal output and the wire frames. + +import { OperationGraph } from '@microsoft/rush-lib/lib/logic/operations/OperationGraph'; +import type { IOperationGraphOptions } from '@microsoft/rush-lib/lib/logic/operations/OperationGraph'; +import { OperationStatus } from '@microsoft/rush-lib/lib/logic/operations/OperationStatus'; + +import { createScenarioOperation } from './EngineRunners'; +import { TestWritable } from './TestWritable'; +import { WireAdapter } from './WireAdapter'; + +const PARALLELISM: number = 1; + +/** Options for {@link runEngineScenarioAsync}. */ +export interface IEngineScenarioOptions { + /** Run the engine in quiet mode (stdout discarded from the collated terminal). */ + readonly quiet: boolean; + /** Make the `beta` operation fail. */ + readonly failing?: boolean; +} + +/** The captured result of one engine run. */ +export interface IEngineScenarioResult { + /** The legacy terminal output (golden reference). */ + readonly writable: TestWritable; + /** The wire frames produced by the dual-emit sink. */ + readonly adapter: WireAdapter; +} + +/** Runs the fixture graph to completion with the wire adapter attached. */ +export async function runEngineScenarioAsync( + options: IEngineScenarioOptions +): Promise { + const writable: TestWritable = new TestWritable(); + const adapter: WireAdapter = new WireAdapter(); + const graphOptions: IOperationGraphOptions = { + quietMode: options.quiet, + debugMode: false, + parallelism: PARALLELISM, + allowOversubscription: true, + destinations: [writable], + abortController: new AbortController() + }; + const graph: OperationGraph = new OperationGraph( + new Set([ + createScenarioOperation('alpha', OperationStatus.Success), + createScenarioOperation( + 'beta', + options.failing ? OperationStatus.Failure : OperationStatus.Success + ) + ]), + graphOptions + ); + graph.eventSink = adapter; + await graph.executeAsync({}); + return { writable, adapter }; +} diff --git a/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts b/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts new file mode 100644 index 00000000000..b8d5b71d1dc --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Shared client-side frame dispatch: decoded frames into a renderer host. + +import { DaemonFrameType, decodeDaemonEventFrame, decodeDaemonLogChunk } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonFrame, IDaemonLogChunk } from '@rushstack/rush-daemon-protocol'; +import type { DaemonRendererHost } from '@rushstack/rush-terminal-renderer'; + +const WIRE_DECODER: InstanceType = new TextDecoder(); + +/** Returns true for `0x02`/`0x03` log frames. */ +export function isLogFrame(frame: IDaemonFrame): boolean { + return frame.kind === DaemonFrameType.logStdout || frame.kind === DaemonFrameType.logStderr; +} + +/** Maps a log frame type to its stream name. */ +export function toStream(frame: IDaemonFrame): 'stdout' | 'stderr' { + return frame.kind === DaemonFrameType.logStderr ? 'stderr' : 'stdout'; +} + +/** Routes one decoded frame into the renderer host. */ +export function dispatchFrame(host: DaemonRendererHost, frame: IDaemonFrame): void { + if (frame.kind === DaemonFrameType.event) { + host.handleEvent(decodeDaemonEventFrame(frame.payload)); + return; + } + if (isLogFrame(frame)) { + const log: IDaemonLogChunk = decodeDaemonLogChunk(frame.payload); + host.handleLogChunk(log.operationId, toStream(frame), log.chunk); + } +} + +/** Collects log frame payloads into per-operation ordered text chunks. */ +export function collectLogChunk(perOperation: Map, frame: IDaemonFrame): void { + if (!isLogFrame(frame)) { + return; + } + const log: IDaemonLogChunk = decodeDaemonLogChunk(frame.payload); + const chunks: string[] = perOperation.get(log.operationId) ?? []; + chunks.push(WIRE_DECODER.decode(log.chunk)); + perOperation.set(log.operationId, chunks); +} diff --git a/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts new file mode 100644 index 00000000000..e3ae58c602f --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonRenderStream, IDaemonRendererTerminal } from '@rushstack/rush-terminal-renderer'; +import { type ITerminalChunk, TerminalChunkKind, TerminalWritable } from '@rushstack/terminal'; + + +const TEST_COLUMNS: number = 80; + +/** A `TerminalWritable` collecting chunk text per stream (engine side). */ +export class TestWritable extends TerminalWritable { + public readonly chunks: ITerminalChunk[] = []; + + public constructor() { + super({ preventAutoclose: true }); + } + + public onWriteChunk(chunk: ITerminalChunk): void { + this.chunks.push(chunk); + } + + public get stdout(): string { + return collectByKind(this.chunks, TerminalChunkKind.Stdout); + } + + public get stderr(): string { + return collectByKind(this.chunks, TerminalChunkKind.Stderr); + } +} + +const WINDOWS_PLATFORM: string = 'win32'; +const CRLF: string = '\r\n'; +const LF: string = '\n'; + +// The engine's pipeline normalizes to OS newlines on the way to the real +// console; on Windows that is CRLF. Normalize the captured golden the same way +// (collapse any existing CRLF first so we never produce CRCRLF). +function toOsNewlines(text: string): string { + if (process.platform !== WINDOWS_PLATFORM) { + return text; + } + return text.split(CRLF).join(LF).split(LF).join(CRLF); +} + +function collectByKind(chunks: readonly ITerminalChunk[], kind: TerminalChunkKind): string { + return toOsNewlines( + chunks + .filter((chunk: ITerminalChunk) => chunk.kind === kind) + .map((chunk: ITerminalChunk) => chunk.text) + .join('') + ); +} + +/** An in-memory renderer terminal (client side). */ +export class CollectingTerminal implements IDaemonRendererTerminal { + public readonly columns: number = TEST_COLUMNS; + public readonly isTTY: boolean = false; + private readonly _writes: [DaemonRenderStream, string][] = []; + + public write(text: string, stream: DaemonRenderStream): void { + this._writes.push([stream, text]); + } + + public get stdout(): string { + return collectWrites(this._writes, 'stdout'); + } + + public get stderr(): string { + return collectWrites(this._writes, 'stderr'); + } +} + +function collectWrites(writes: readonly [DaemonRenderStream, string][], stream: DaemonRenderStream): string { + return writes + .filter(([s]: [DaemonRenderStream, string]) => s === stream) + .map(([, text]: [DaemonRenderStream, string]) => text) + .join(''); +} diff --git a/build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts b/build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts new file mode 100644 index 00000000000..bb6eb46f1cc --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. +// Adapts the rush-lib engine's dual-emit sink into wire frames (the WS2 daemon mapping). + +import type { IOperationExecutionResult } from '@microsoft/rush-lib/lib/logic/operations/IOperationExecutionResult'; +import type { IOperationGraphEventSink } from '@microsoft/rush-lib/lib/logic/operations/OperationEventSink'; +import type { OperationStatus } from '@microsoft/rush-lib/lib/logic/operations/OperationStatus'; +import { + DaemonFrameType, + RUSHD_OPERATION_HEADER, + RUSHD_OPERATION_STREAM_CLOSED, + encodeDaemonEventFrame, + encodeDaemonLogChunk +} from '@rushstack/rush-daemon-protocol'; +import type { DaemonEventType, IDaemonFrame } from '@rushstack/rush-daemon-protocol'; +import { TerminalChunkKind } from '@rushstack/terminal'; +import type { ITerminalChunk } from '@rushstack/terminal'; + + +import { buildWireEnvelope } from './WireEnvelope'; +import type { IWireEnvelopeOptions } from './WireEnvelope'; + +const FIRST_SEQUENCE: number = 1; +const UTF8: BufferEncoding = 'utf8'; + +function toActivityStream(options?: { stderr?: boolean }): 'stdout' | 'stderr' { + return options?.stderr === true ? 'stderr' : 'stdout'; +} + +/** Converts engine dual-emit callbacks into an ordered wire frame stream. */ +export class WireAdapter implements IOperationGraphEventSink { + public readonly frames: IDaemonFrame[] = []; + private _sequence: number = FIRST_SEQUENCE; + + public onOperationRegistered(operationId: string, silent: boolean): void { + this._pushEvent('operationRegistered', { operationId, silent }); + } + + public onOperationStatusChanged( + result: IOperationExecutionResult, + previousStatus: OperationStatus + ): void { + this._pushEvent('operationStatusChanged', { + operationId: result.operation.name, + status: result.status, + previousStatus + }); + } + + public onOperationHeader(operationId: string, completed: number, total: number): void { + this._pushEvent('extension', { + name: RUSHD_OPERATION_HEADER, + data: { operationId, completedOperations: completed, totalOperations: total } + }); + } + + public onOperationChunk(operationId: string, chunk: ITerminalChunk): void { + const kind: DaemonFrameType = + chunk.kind === TerminalChunkKind.Stderr ? DaemonFrameType.logStderr : DaemonFrameType.logStdout; + this.frames.push({ + kind, + payload: encodeDaemonLogChunk({ operationId, chunk: Buffer.from(chunk.text, UTF8) }) + }); + } + + public onActivity(text: string, options?: { operationId?: string; stderr?: boolean }): void { + const stream: 'stdout' | 'stderr' = toActivityStream(options); + const operationId: string | undefined = options?.operationId; + if (operationId === undefined) { + this._pushEvent('activityChanged', { text, stream }); + return; + } + // Operation-scoped status lines are part of the operation's output block: + // scope the event and mark it required so it is never verbosity-filtered. + this._pushEvent( + 'activityChanged', + { text, stream }, + { scope: { operationId }, required: true } + ); + } + + public onOperationStreamClosed(operationId: string): void { + this._pushEvent('extension', { + name: RUSHD_OPERATION_STREAM_CLOSED, + data: { operationId } + }); + } + + private _pushEvent(type: DaemonEventType, payload: unknown, options?: IWireEnvelopeOptions): void { + const envelope: ReturnType = buildWireEnvelope( + type, + payload, + this._sequence, + options + ); + this._sequence += 1; + this.frames.push({ kind: DaemonFrameType.event, payload: encodeDaemonEventFrame(envelope) }); + } +} diff --git a/build-tests/rushd-wire-e2e-test/src/test/WireDriver.ts b/build-tests/rushd-wire-e2e-test/src/test/WireDriver.ts new file mode 100644 index 00000000000..a6b9ba70b73 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/WireDriver.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Drives a recorded frame stream through a real socket/pipe transport pair. + +import * as os from 'node:os'; + +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonFrame } from '@rushstack/rush-daemon-protocol'; +import { DaemonFrameListener, connectDaemonAsync, resolveDaemonPaths } from '@rushstack/rush-daemon-transport'; +import type { DaemonFrameConnection, IDaemonPaths } from '@rushstack/rush-daemon-transport'; + +let pathCounter: number = 0; +const COUNTER_STEP: number = 1; + +function createE2EPaths(): IDaemonPaths { + pathCounter += COUNTER_STEP; + return resolveDaemonPaths( + { platform: process.platform, env: {}, tmpdir: os.tmpdir(), uid: process.getuid?.() }, + `rushd-e2e-${process.pid}-${pathCounter}` + ); +} + +async function writeAllAsync(connection: DaemonFrameConnection, frames: readonly IDaemonFrame[]): Promise { + for (const frame of frames) { + await connection.sendFrameAsync(frame); + } + await connection.closeAsync(); +} + +/** + * Sends `frames` through a real listener/connector pair, invoking + * `handleFrame` for each decoded frame on the client side, in wire order. + * Resolves after the server closes the connection and the client drains. + */ +export async function replayFramesOverSocketAsync( + frames: readonly IDaemonFrame[], + handleFrame: (frame: IDaemonFrame) => void +): Promise { + const paths: IDaemonPaths = createE2EPaths(); + const listener: DaemonFrameListener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: (connection: DaemonFrameConnection) => { + void writeAllAsync(connection, frames); + } + }); + const client: DaemonFrameConnection = await connectDaemonAsync(paths.socketPath); + try { + client.onFrame(handleFrame); + await new Promise((resolve: () => void) => client.onClosed(() => resolve())); + } finally { + await client.closeAsync(); + await listener.closeAsync(); + } +} diff --git a/build-tests/rushd-wire-e2e-test/src/test/WireEndToEnd.test.ts b/build-tests/rushd-wire-e2e-test/src/test/WireEndToEnd.test.ts new file mode 100644 index 00000000000..c6e2d6db115 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/WireEndToEnd.test.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('@microsoft/rush-lib/lib/logic/operations/OperationStateFile'); + +jest.mock('@microsoft/rush-lib/lib/utilities/Utilities', () => { + const actual = jest.requireActual('@microsoft/rush-lib/lib/utilities/Utilities'); + let now: number = 0; + const STEP_MS: number = 100; + return { + ...actual, + Utilities: { ...actual.Utilities, getTimeInMs: () => (now += STEP_MS) } + }; +}); + +jest.mock('@rushstack/terminal', () => { + const actual = jest.requireActual('@rushstack/terminal'); + return { + ...actual, + ConsoleTerminalProvider: { ...actual.ConsoleTerminalProvider, supportsColor: false } + }; +}); + +import type { IDaemonFrame } from '@rushstack/rush-daemon-protocol'; +import type { DaemonVerbosity } from '@rushstack/rush-daemon-protocol'; +import { DaemonRendererHost } from '@rushstack/rush-terminal-renderer'; + +import { runEngineScenarioAsync } from './EngineScenario'; +import type { IEngineScenarioResult } from './EngineScenario'; +import { dispatchFrame } from './FrameDispatch'; +import { CollectingTerminal } from './TestWritable'; +import { replayFramesOverSocketAsync } from './WireDriver'; + +async function renderOverWireAsync( + result: IEngineScenarioResult, + verbosity: DaemonVerbosity +): Promise { + const terminal: CollectingTerminal = new CollectingTerminal(); + const host: DaemonRendererHost = new DaemonRendererHost({ terminal, verbosity }); + await host.initializeAsync(); + await replayFramesOverSocketAsync(result.adapter.frames, (frame: IDaemonFrame) => + dispatchFrame(host, frame) + ); + await host.closeAsync(); + return terminal; +} + +it('renders the wire stream byte-identically to the in-process legacy output', async () => { + const result: IEngineScenarioResult = await runEngineScenarioAsync({ quiet: false }); + const terminal: CollectingTerminal = await renderOverWireAsync(result, 'normal'); + expect(terminal.stdout).toBe(result.writable.stdout); + expect(terminal.stderr).toBe(result.writable.stderr); + expect(terminal.stdout).toContain('Selected 2 operations:'); + expect(terminal.stdout).toContain('==['); +}); + +it('renders quiet-mode clients byte-identically to legacy quiet output', async () => { + const result: IEngineScenarioResult = await runEngineScenarioAsync({ quiet: true }); + const terminal: CollectingTerminal = await renderOverWireAsync(result, 'quiet'); + expect(terminal.stdout).toBe(result.writable.stdout); + expect(terminal.stderr).toBe(result.writable.stderr); +}); + +it('carries the failure status and error text over the wire', async () => { + const result: IEngineScenarioResult = await runEngineScenarioAsync({ quiet: false, failing: true }); + const terminal: CollectingTerminal = await renderOverWireAsync(result, 'normal'); + expect(terminal.stdout).toBe(result.writable.stdout); + expect(terminal.stdout + terminal.stderr).toContain('beta-err'); +}); diff --git a/build-tests/rushd-wire-e2e-test/src/test/WireEnvelope.ts b/build-tests/rushd-wire-e2e-test/src/test/WireEnvelope.ts new file mode 100644 index 00000000000..f23ae900379 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/WireEnvelope.ts @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Envelope construction for the wire adapter. + +import type { DaemonEventType, IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; + +const SCHEMA_VERSION = { major: 0, minor: 1 } as const; +const SOURCE = { packageName: '@microsoft/rush-lib', packageVersion: '0.0.0' } as const; +const SESSION_ID: string = 'e2e-session'; + +/** Optional envelope extras applied by the adapter. */ +export interface IWireEnvelopeOptions { + readonly scope?: { operationId: string }; + readonly required?: boolean; +} + +/** Builds a structurally complete event envelope for the e2e wire stream. */ +export function buildWireEnvelope( + type: DaemonEventType, + payload: unknown, + sequence: number, + options?: IWireEnvelopeOptions +): IDaemonEventEnvelope { + return { + protocolVersion: SCHEMA_VERSION, + eventId: `evt-${sequence}`, + sessionId: SESSION_ID, + sequence, + timestamp: new Date().toISOString(), + source: SOURCE, + scope: options?.scope, + privacy: 'public', + required: options?.required === true, + type, + payload + }; +} diff --git a/build-tests/rushd-wire-e2e-test/src/test/WireRawStreams.test.ts b/build-tests/rushd-wire-e2e-test/src/test/WireRawStreams.test.ts new file mode 100644 index 00000000000..05f66d8092a --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/WireRawStreams.test.ts @@ -0,0 +1,78 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +jest.mock('@microsoft/rush-lib/lib/logic/operations/OperationStateFile'); + +jest.mock('@microsoft/rush-lib/lib/utilities/Utilities', () => { + const actual = jest.requireActual('@microsoft/rush-lib/lib/utilities/Utilities'); + let now: number = 0; + const STEP_MS: number = 100; + return { + ...actual, + Utilities: { ...actual.Utilities, getTimeInMs: () => (now += STEP_MS) } + }; +}); + +jest.mock('@rushstack/terminal', () => { + const actual = jest.requireActual('@rushstack/terminal'); + return { + ...actual, + ConsoleTerminalProvider: { ...actual.ConsoleTerminalProvider, supportsColor: false } + }; +}); + +import { DaemonFrameType, decodeDaemonEventFrame } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonEventEnvelope, IDaemonFrame } from '@rushstack/rush-daemon-protocol'; + +import { runEngineScenarioAsync } from './EngineScenario'; +import type { IEngineScenarioResult } from './EngineScenario'; +import { collectLogChunk } from './FrameDispatch'; +import { replayFramesOverSocketAsync } from './WireDriver'; + +interface ICapturedStream { + readonly events: IDaemonEventEnvelope[]; + readonly perOperation: Map; +} + +function captureFrame(captured: ICapturedStream, frame: IDaemonFrame): void { + collectLogChunk(captured.perOperation, frame); + if (frame.kind === DaemonFrameType.event) { + captured.events.push(decodeDaemonEventFrame(frame.payload)); + } +} + +function isActivityFor(envelope: IDaemonEventEnvelope, operationId: string): boolean { + if (envelope.type !== 'activityChanged') { + return false; + } + const scope: IDaemonEventEnvelope['scope'] = envelope.scope; + return scope !== undefined && scope.operationId === operationId; +} + +function rawStreamText(captured: ICapturedStream, operationId: string): string { + const chunks: string[] | undefined = captured.perOperation.get(operationId); + return chunks === undefined ? '' : chunks.join(''); +} + +function payloadJson(envelope: IDaemonEventEnvelope | undefined): string { + return JSON.stringify(envelope === undefined ? undefined : envelope.payload); +} + +it('delivers each operation\'s raw streams intact over the socket (unicode round-trip)', async () => { + const result: IEngineScenarioResult = await runEngineScenarioAsync({ quiet: false }); + const captured: ICapturedStream = { events: [], perOperation: new Map() }; + await replayFramesOverSocketAsync(result.adapter.frames, (frame: IDaemonFrame) => + captureFrame(captured, frame) + ); + // Per-operation raw bytes match exactly what the runner wrote, in order. + // stderr lines carry their raw ANSI color codes (stripped client-side per caps). + const RED: string = ''; + const RESET: string = ''; + expect(rawStreamText(captured, 'alpha')).toBe(`alpha-out ünïcode ✓\n${RED}alpha-err${RESET}\n`); + expect(rawStreamText(captured, 'beta')).toBe(`beta-out ünïcode ✓\n${RED}beta-err${RESET}\n`); + // The status line rides as a scoped activity event, not a log chunk. + const alphaActivity: IDaemonEventEnvelope | undefined = captured.events.find( + (envelope: IDaemonEventEnvelope) => isActivityFor(envelope, 'alpha') + ); + expect(payloadJson(alphaActivity)).toContain('completed successfully'); +}); diff --git a/build-tests/rushd-wire-e2e-test/tsconfig.json b/build-tests/rushd-wire-e2e-test/tsconfig.json new file mode 100644 index 00000000000..6a778ab9aff --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"] + } +} diff --git a/common/changes/@microsoft/rush/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json b/common/changes/@microsoft/rush/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json new file mode 100644 index 00000000000..9a954f01b9c --- /dev/null +++ b/common/changes/@microsoft/rush/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@microsoft/rush", + "type": "patch", + "comment": "Add an optional internal IOperationGraphEventSink dual-emit hook to OperationGraph/OperationExecutionRecord: structured operation registration/status/header/activity events and an id-tagged per-operation raw output tap, with no change to existing terminal output." + } + ], + "email": "TheLarkInn@users.noreply.github.com", + "packageName": "@microsoft/rush" +} diff --git a/common/changes/@rushstack/rush-daemon-protocol/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json b/common/changes/@rushstack/rush-daemon-protocol/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json new file mode 100644 index 00000000000..cc2fa80fa25 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-protocol/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-protocol", + "type": "minor", + "comment": "Initial release: rushd wire frame taxonomy (0x01-0x05), length-prefixed binary codec, DAEMON_PROTOCOL_VERSION, hello/version negotiation with typed mismatch errors, placeholder event envelope mirroring @rushstack/reporter, and per-subscription verbosity filtering." + } + ], + "email": "TheLarkInn@users.noreply.github.com", + "packageName": "@rushstack/rush-daemon-protocol" +} diff --git a/common/changes/@rushstack/rush-daemon-transport/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json b/common/changes/@rushstack/rush-daemon-transport/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json new file mode 100644 index 00000000000..9884f6c5a28 --- /dev/null +++ b/common/changes/@rushstack/rush-daemon-transport/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon-transport", + "type": "minor", + "comment": "Initial release: workspace-key hashing (sha256 of canonical root + rushVersion + startupOptions), per-user runtime-dir socket/pipe path derivation, net listener/connector with backpressure, and PID/lockfile handling with stale-socket reclaim." + } + ], + "email": "TheLarkInn@users.noreply.github.com", + "packageName": "@rushstack/rush-daemon-transport" +} diff --git a/common/changes/@rushstack/rush-terminal-renderer/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json b/common/changes/@rushstack/rush-terminal-renderer/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json new file mode 100644 index 00000000000..d1874417a31 --- /dev/null +++ b/common/changes/@rushstack/rush-terminal-renderer/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-terminal-renderer", + "type": "minor", + "comment": "Initial release: client-side renderer host for rushd (StreamCollator-backed per-operation collation, legacy-faithful output), per-client verbosity application, and FORCE_COLOR/COLUMNS child-environment threading." + } + ], + "email": "TheLarkInn@users.noreply.github.com", + "packageName": "@rushstack/rush-terminal-renderer" +} diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index a761addba56..772dfea4928 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -46,10 +46,22 @@ "name": "@rushstack/problem-matcher", "allowedCategories": [ "libraries" ] }, + { + "name": "@rushstack/rush-daemon-protocol", + "allowedCategories": [ "libraries", "tests" ] + }, + { + "name": "@rushstack/rush-daemon-transport", + "allowedCategories": [ "tests" ] + }, { "name": "@rushstack/rush-serve-dashboard", "allowedCategories": [ "libraries" ] }, + { + "name": "@rushstack/rush-terminal-renderer", + "allowedCategories": [ "tests" ] + }, { "name": "@rushstack/rush-themed-ui", "allowedCategories": [ "libraries" ] diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml index 4ec1b48ee88..b5bf88b2bb1 100644 --- a/common/config/subspaces/default/pnpm-lock.yaml +++ b/common/config/subspaces/default/pnpm-lock.yaml @@ -2865,6 +2865,36 @@ importers: specifier: workspace:* version: link:../../rigs/local-node-rig + ../../../build-tests/rushd-wire-e2e-test: + devDependencies: + '@microsoft/rush-lib': + specifier: workspace:* + version: link:../../libraries/rush-lib + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../../libraries/node-core-library + '@rushstack/rush-daemon-protocol': + specifier: workspace:* + version: link:../../libraries/rush-daemon-protocol + '@rushstack/rush-daemon-transport': + specifier: workspace:* + version: link:../../libraries/rush-daemon-transport + '@rushstack/rush-terminal-renderer': + specifier: workspace:* + version: link:../../libraries/rush-terminal-renderer + '@rushstack/terminal': + specifier: workspace:* + version: link:../../libraries/terminal + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../build-tests/set-webpack-public-path-plugin-test: devDependencies: '@rushstack/heft': @@ -3213,7 +3243,7 @@ importers: dependencies: '@jest/core': specifier: ~30.3.0 - version: 30.3.0 + version: 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) '@jest/reporters': specifier: ~30.3.0 version: 30.3.0 @@ -3231,7 +3261,7 @@ importers: version: link:../../libraries/terminal jest-config: specifier: ~30.3.0 - version: 30.3.0(@types/node@20.17.19) + version: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) jest-resolve: specifier: ~30.3.0 version: 30.3.0 @@ -3646,7 +3676,7 @@ importers: version: 2.4.0 webpack-dev-server: specifier: ^5.1.0 - version: 5.2.3(webpack@5.105.4) + version: 5.2.3(@types/webpack@4.41.32)(webpack@5.105.4) devDependencies: '@rushstack/heft': specifier: workspace:* @@ -4048,6 +4078,34 @@ importers: specifier: ~9.37.0 version: 9.37.0 + ../../../libraries/rush-daemon-protocol: + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + + ../../../libraries/rush-daemon-transport: + dependencies: + '@rushstack/rush-daemon-protocol': + specifier: workspace:* + version: link:../rush-daemon-protocol + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../libraries/rush-lib: dependencies: '@inquirer/checkbox': @@ -4335,6 +4393,31 @@ importers: specifier: ~5.105.2 version: 5.105.4 + ../../../libraries/rush-terminal-renderer: + dependencies: + '@rushstack/node-core-library': + specifier: workspace:* + version: link:../node-core-library + '@rushstack/rush-daemon-protocol': + specifier: workspace:* + version: link:../rush-daemon-protocol + '@rushstack/stream-collator': + specifier: workspace:* + version: link:../stream-collator + '@rushstack/terminal': + specifier: workspace:* + version: link:../terminal + devDependencies: + '@rushstack/heft': + specifier: workspace:* + version: link:../../apps/heft + eslint: + specifier: ~9.37.0 + version: 9.37.0 + local-node-rig: + specifier: workspace:* + version: link:../../rigs/local-node-rig + ../../../libraries/rush-themed-ui: dependencies: react: @@ -4602,7 +4685,7 @@ importers: version: 1.2.22(@types/node@20.17.19) '@rushstack/heft-node-rig': specifier: 2.11.45 - version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(jest-environment-jsdom@30.3.0) + version: 2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0) '@types/jest': specifier: 30.0.0 version: 30.0.0 @@ -4793,7 +4876,7 @@ importers: version: 5.8.2 url-loader: specifier: ~4.1.1 - version: 4.1.1(webpack@5.105.4) + version: 4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4) webpack: specifier: ~5.105.2 version: 5.105.4 @@ -6840,7 +6923,7 @@ packages: hasBin: true '@colors/colors@1.5.0': - resolution: {integrity: sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==} + resolution: {integrity: sha1-u1BFecHK6SPmV2pPXaQ9Jfl729k=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@colors/colors/-/colors-1.5.0.tgz} engines: {node: '>=0.1.90'} '@csstools/color-helpers@5.1.0': @@ -6880,13 +6963,13 @@ packages: engines: {node: '>=10.0.0'} '@emnapi/core@1.9.2': - resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} + resolution: {integrity: sha1-OHAmXs/8c1LQHq1i2Ng9g1ii0DQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@emnapi/core/-/core-1.9.2.tgz} '@emnapi/runtime@1.9.2': - resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} + resolution: {integrity: sha1-i0aaPbFggXytsd6QUCEanR6oT6I=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@emnapi/runtime/-/runtime-1.9.2.tgz} '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + resolution: {integrity: sha1-KP7SGhuhznl8RKBwq8lNQvOuhUg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz} '@emotion/cache@10.0.29': resolution: {integrity: sha512-fU2VtSVlHiF27empSbxi1O2JFdNWZO+2NFHfwO0pxgTep6Xa3uGb+3pVKfLww2l/IBGLNEZl5Xf/++A4wAYDYQ==} @@ -6961,319 +7044,319 @@ packages: engines: {node: '>=16'} '@esbuild/aix-ppc64@0.25.12': - resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + resolution: {integrity: sha1-gPy+NhMOWLdnBRHoiLjoiiWe12w=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz} engines: {node: '>=18'} cpu: [ppc64] os: [aix] '@esbuild/aix-ppc64@0.28.0': - resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==} + resolution: {integrity: sha1-eiicFY4py/WeoK/IPMgPBtHIlAI=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz} engines: {node: '>=18'} cpu: [ppc64] os: [aix] '@esbuild/android-arm64@0.25.12': - resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + resolution: {integrity: sha1-iqSWX40KeYLcIXNL9mATI6Ztp1I=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [android] '@esbuild/android-arm64@0.28.0': - resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==} + resolution: {integrity: sha1-uIKNnt+jqSZgZE643m5PPCA9exc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [android] '@esbuild/android-arm@0.25.12': - resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + resolution: {integrity: sha1-MAcSEB9/UPHSYnoWLm4JsQm2dno=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm] os: [android] '@esbuild/android-arm@0.28.0': - resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==} + resolution: {integrity: sha1-XsGEdgXgW12+XfkNuf9+PkxY3Kc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-arm/-/android-arm-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm] os: [android] '@esbuild/android-x64@0.25.12': - resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + resolution: {integrity: sha1-h9+ycWEgK9yVjvSLthsJx1j67hY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [android] '@esbuild/android-x64@0.28.0': - resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==} + resolution: {integrity: sha1-OQZCF1uI74K61MzgP4qxP+mxkS4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/android-x64/-/android-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [android] '@esbuild/darwin-arm64@0.25.12': - resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + resolution: {integrity: sha1-eRl4mOwf90XSHAceHHzDyALwwf0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [darwin] '@esbuild/darwin-arm64@0.28.0': - resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==} + resolution: {integrity: sha1-rkUyWWDVlQzWlR5PlzlvTh/32NM=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.25.12': - resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + resolution: {integrity: sha1-FGQAqFYhM/RcTS6tzzfd0JcYB54=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [darwin] '@esbuild/darwin-x64@0.28.0': - resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==} + resolution: {integrity: sha1-wHkkfViba5lEllnZTwaVG4S/8uQ=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [darwin] '@esbuild/freebsd-arm64@0.25.12': - resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + resolution: {integrity: sha1-HF+bpyBuFY/SskxZ+i0si7R8oP4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] '@esbuild/freebsd-arm64@0.28.0': - resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==} + resolution: {integrity: sha1-RcRWIVpIZZPJSQApcgLcEciAo3o=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.25.12': - resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + resolution: {integrity: sha1-6mMfSja+qsS5J5+g/MbKKerusrM=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [freebsd] '@esbuild/freebsd-x64@0.28.0': - resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==} + resolution: {integrity: sha1-A5lJTByF5DiOm3BAvWDUjypbDSw=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [freebsd] '@esbuild/linux-arm64@0.25.12': - resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + resolution: {integrity: sha1-4QZrzlg5TxsRQd7shVel8KIvWXc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [linux] '@esbuild/linux-arm64@0.28.0': - resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==} + resolution: {integrity: sha1-1tnwnvDeVBFr9Fmk1TysfglS/jk=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.25.12': - resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + resolution: {integrity: sha1-RSzWayCTLQi9xTqLYcDjC69DSLk=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm] os: [linux] '@esbuild/linux-arm@0.28.0': - resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==} + resolution: {integrity: sha1-e0L/qEwoiulP3EMcGyionjw7kng=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.25.12': - resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + resolution: {integrity: sha1-sk+KzEW89UGSx/LzvhtT5lUer+A=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz} engines: {node: '>=18'} cpu: [ia32] os: [linux] '@esbuild/linux-ia32@0.28.0': - resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==} + resolution: {integrity: sha1-3rFdES7Y3WBTRra5U9I6If+BJT8=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz} engines: {node: '>=18'} cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.14.54': - resolution: {integrity: sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==} + resolution: {integrity: sha1-3ipL5ni9TQ0f+7hubed5zeWZkCg=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz} engines: {node: '>=12'} cpu: [loong64] os: [linux] '@esbuild/linux-loong64@0.25.12': - resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + resolution: {integrity: sha1-+c//p/yDIlcfvEyLMmjK8VvYGtA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz} engines: {node: '>=18'} cpu: [loong64] os: [linux] '@esbuild/linux-loong64@0.28.0': - resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==} + resolution: {integrity: sha1-gfuJ0H7sx5sVfephAzdXcm/ODKQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz} engines: {node: '>=18'} cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.25.12': - resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + resolution: {integrity: sha1-V1oUvXRkT/q4ka3H1+YNJ1KW8s0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz} engines: {node: '>=18'} cpu: [mips64el] os: [linux] '@esbuild/linux-mips64el@0.28.0': - resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==} + resolution: {integrity: sha1-0OQmkbP/evn7Ihe3D8AfNDvbYrs=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz} engines: {node: '>=18'} cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.25.12': - resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + resolution: {integrity: sha1-dbmccKlfvV93OddpK+/mBgFZGGk=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz} engines: {node: '>=18'} cpu: [ppc64] os: [linux] '@esbuild/linux-ppc64@0.28.0': - resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==} + resolution: {integrity: sha1-OJ8+XpjxfUd8RnzIcTbhoHburYc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz} engines: {node: '>=18'} cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.25.12': - resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + resolution: {integrity: sha1-LjJZRAMhpE553fdTXDJQV9qHXNY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz} engines: {node: '>=18'} cpu: [riscv64] os: [linux] '@esbuild/linux-riscv64@0.28.0': - resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==} + resolution: {integrity: sha1-djvWDVmyQr4S2h5n1XKfMCTGBfo=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz} engines: {node: '>=18'} cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.25.12': - resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + resolution: {integrity: sha1-F2dsq7/lko2lsqDW311YzQjbJmM=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz} engines: {node: '>=18'} cpu: [s390x] os: [linux] '@esbuild/linux-s390x@0.28.0': - resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==} + resolution: {integrity: sha1-qsYGFjSHLkZ33mk7zoAw1zsf0FU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz} engines: {node: '>=18'} cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.25.12': - resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + resolution: {integrity: sha1-BYN3VoXKggZtBMNQfwlSTTzXowY=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [linux] '@esbuild/linux-x64@0.28.0': - resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==} + resolution: {integrity: sha1-TykXdHGI/ndjK87GWy2EtCJBl3k=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [linux] '@esbuild/netbsd-arm64@0.25.12': - resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + resolution: {integrity: sha1-8ExAScsuJS/paxb+2Q9wdGsT9KQ=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] '@esbuild/netbsd-arm64@0.28.0': - resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==} + resolution: {integrity: sha1-gU3wrlegw4aBRJG4OX7rqCCUqUc=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.25.12': - resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + resolution: {integrity: sha1-d9oNCg2CbXySHuo9QCklSLJYoHY=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [netbsd] '@esbuild/netbsd-x64@0.28.0': - resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==} + resolution: {integrity: sha1-4BvffmD6GgjkbUbZYLDZu4rCEK8=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [netbsd] '@esbuild/openbsd-arm64@0.25.12': - resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + resolution: {integrity: sha1-Ypb1hnrt7yioGyKrIAnHhqlS3M0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] '@esbuild/openbsd-arm64@0.28.0': - resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==} + resolution: {integrity: sha1-ShXDaqzKaNLVpMkLcQwGdZ9MH/o=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.25.12': - resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + resolution: {integrity: sha1-+NIzAzYOJ7Fs8GWyO7/0PBQUJnk=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [openbsd] '@esbuild/openbsd-x64@0.28.0': - resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==} + resolution: {integrity: sha1-R15hAUmKjszjAI18OIER16J8F70=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [openbsd] '@esbuild/openharmony-arm64@0.25.12': - resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + resolution: {integrity: sha1-SeC3aHRKOSS+DX/ZfdbOmykj2I0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] '@esbuild/openharmony-arm64@0.28.0': - resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==} + resolution: {integrity: sha1-z9w5V/C3pp8b3hKarRf8wvb6Az4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] '@esbuild/sunos-x64@0.25.12': - resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + resolution: {integrity: sha1-pu19Z3jWflKMgfsWWyP0kRubE9Y=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [sunos] '@esbuild/sunos-x64@0.28.0': - resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==} + resolution: {integrity: sha1-oBPIVv7KzRw67Jhciv4dHLAXSX0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [sunos] '@esbuild/win32-arm64@0.25.12': - resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + resolution: {integrity: sha1-msFMN44bZTrxfQjn0840yu9YcyM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz} engines: {node: '>=18'} cpu: [arm64] os: [win32] '@esbuild/win32-arm64@0.28.0': - resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==} + resolution: {integrity: sha1-6uBeDzUnHK04mLQxaNPpo7uvR+U=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz} engines: {node: '>=18'} cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.25.12': - resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + resolution: {integrity: sha1-kYlC3LuzXMFPyjmvuRteaj0Scmc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz} engines: {node: '>=18'} cpu: [ia32] os: [win32] '@esbuild/win32-ia32@0.28.0': - resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==} + resolution: {integrity: sha1-BhYevFv3XAjWn+s8ayJWBRWROZg=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz} engines: {node: '>=18'} cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.25.12': - resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + resolution: {integrity: sha1-m9rYF2vngRrRSNH4dyNZBB9GxsU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz} engines: {node: '>=18'} cpu: [x64] os: [win32] '@esbuild/win32-x64@0.28.0': - resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==} + resolution: {integrity: sha1-BNkNV1K0zmXStqwl66CP92JP4Hw=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -8530,10 +8613,10 @@ packages: engines: {node: '>=4'} '@napi-rs/wasm-runtime@0.2.12': - resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + resolution: {integrity: sha1-PniouW5sM6bFF+GJTvvVOFp8tvI=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz} '@napi-rs/wasm-runtime@1.0.7': - resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} + resolution: {integrity: sha1-3P6pmnXwYgmiNfPZQeNGClHpsUw=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@napi-rs/wasm-runtime/-/wasm-runtime-1.0.7.tgz} '@noble/hashes@1.4.0': resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==} @@ -8598,7 +8681,7 @@ packages: engines: {node: '>=20.0.0'} '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + resolution: {integrity: sha1-p36nQvqyV3UUVDTrHSMoz1ATrDM=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pkgjs/parseargs/-/parseargs-0.11.0.tgz} engines: {node: '>=14'} '@pkgr/core@0.2.9': @@ -8727,7 +8810,7 @@ packages: '@pnpm/logger': ^5.0.0 '@pnpm/lockfile-types@5.1.5': - resolution: {integrity: sha512-02FP0HynzX+2DcuPtuMy7PH+kLIC0pevAydAOK+zug2bwdlSLErlvSkc+4+3dw60eRWgUXUqyfO2eR/Ansdbng==} + resolution: {integrity: sha1-FLhcl23c90dPWmopNRy1eZWdDsg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pnpm/lockfile-types/-/lockfile-types-5.1.5.tgz} engines: {node: '>=16.14'} '@pnpm/lockfile.fs@1001.1.32': @@ -8741,7 +8824,7 @@ packages: engines: {node: '>=18.12'} '@pnpm/lockfile.types@1001.1.0': - resolution: {integrity: sha512-/rfDUV8M9iMm0QXahHPv6SD6eKNkrMXlhECJVhDkdL4NIifcv6/HZwYtxd0PIndExz04+OE+iV9K8zKG9i/OEA==} + resolution: {integrity: sha1-rhsx7V+7Du0y0CpfVlG8zilTW6E=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pnpm/lockfile.types/-/lockfile.types-1001.1.0.tgz} engines: {node: '>=18.12'} '@pnpm/lockfile.types@1002.0.1': @@ -8793,7 +8876,7 @@ packages: engines: {node: '>=18.12'} '@pnpm/ramda@0.28.1': - resolution: {integrity: sha512-zcAG+lvU0fMziNeGXpPyCyCJYp5ZVrPElEE4t14jAmViaihohocZ+dDkcRIyAomox8pQsuZnv1EyHR+pOhmUWw==} + resolution: {integrity: sha1-DzKrxSddWGoD4Nwd2QoAmsZo/zM=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pnpm/ramda/-/ramda-0.28.1.tgz} '@pnpm/read-modules-dir@2.0.3': resolution: {integrity: sha512-i9OgRvSlxrTS9a2oXokhDxvQzDtfqtsooJ9jaGoHkznue5aFCTSrNZFQ6M18o8hC03QWfnxaKi0BtOvNkKu2+A==} @@ -8820,7 +8903,7 @@ packages: engines: {node: '>=18.12'} '@pnpm/types@1000.7.0': - resolution: {integrity: sha512-1s7FvDqmOEIeFGLUj/VO8sF5lGFxeE/1WALrBpfZhDnMXY/x8FbmuygTTE5joWifebcZ8Ww8Kw2CgBoStsIevQ==} + resolution: {integrity: sha1-g1Hkb73+JfgP7Jdb/fWNDKStYkw=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pnpm/types/-/types-1000.7.0.tgz} engines: {node: '>=18.12'} '@pnpm/types@1000.8.0': @@ -8870,7 +8953,7 @@ packages: resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} '@pothos/core@3.41.2': - resolution: {integrity: sha512-iR1gqd93IyD/snTW47HwKSsRCrvnJaYwjVNcUG8BztZPqMxyJKPAnjPHAgu1XB82KEdysrNqIUnXqnzZIs08QA==} + resolution: {integrity: sha1-ruJt4pccOHJDU0S8NM4Kv5qUVL0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@pothos/core/-/core-3.41.2.tgz} peerDependencies: graphql: '>=15.1.0' @@ -9111,56 +9194,61 @@ packages: engines: {node: '>=14.0.0'} '@rollup/rollup-linux-x64-gnu@4.53.3': - resolution: {integrity: sha512-3EhFi1FU6YL8HTUJZ51imGJWEX//ajQPfqWLI3BQq4TlvHy4X0MOr5q3D2Zof/ka0d5FNdPwZXm3Yyib/UEd+w==} + resolution: {integrity: sha1-/Q3qO7mqB+cINXnyXhwihaRsufo=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.53.3.tgz} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-darwin-arm64@1.6.8': - resolution: {integrity: sha512-e8CTQtzaeGnf+BIzR7wRMUwKfIg0jd/sxMRc1Vd0bCMHBhSN9EsGoMuJJaKeRrSmy2nwMCNWHIG+TvT1CEKg+A==} + resolution: {integrity: sha1-Uph8DLxIeiQL3GsaMYODctrd7is=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-darwin-arm64/-/binding-darwin-arm64-1.6.8.tgz} cpu: [arm64] os: [darwin] '@rspack/binding-darwin-x64@1.6.8': - resolution: {integrity: sha512-ku1XpTEPt6Za11zhpFWhfwrTQogcgi9RJrOUVC4FESiPO9aKyd4hJ+JiPgLY0MZOqsptK6vEAgOip+uDVXrCpg==} + resolution: {integrity: sha1-E8gBzoIQ0Rt7C8Sse/A27DKGKTU=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-darwin-x64/-/binding-darwin-x64-1.6.8.tgz} cpu: [x64] os: [darwin] '@rspack/binding-linux-arm64-gnu@1.6.8': - resolution: {integrity: sha512-fvZX6xZPvBT8qipSpvkKMX5M7yd2BSpZNCZXcefw6gA3uC7LI3gu+er0LrDXY1PtPzVuHTyDx+abwWpagV3PiQ==} + resolution: {integrity: sha1-1wMhrFu9W8EB3potoBxvuYRgFWU=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.6.8.tgz} cpu: [arm64] os: [linux] + libc: [glibc] '@rspack/binding-linux-arm64-musl@1.6.8': - resolution: {integrity: sha512-++XMKcMNrt59HcFBLnRaJcn70k3X0GwkAegZBVpel8xYIAgvoXT5+L8P1ExId/yTFxqedaz8DbcxQnNmMozviw==} + resolution: {integrity: sha1-T5GWtiM2Sc5D5khaXScU7zjfxgM=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.6.8.tgz} cpu: [arm64] os: [linux] + libc: [musl] '@rspack/binding-linux-x64-gnu@1.6.8': - resolution: {integrity: sha512-tv3BWkTE1TndfX+DsE1rSTg8fBevCxujNZ3MlfZ22Wfy9x1FMXTJlWG8VIOXmaaJ1wUHzv8S7cE2YUUJ2LuiCg==} + resolution: {integrity: sha1-t45/YrQVezHhgf6J0xmmAXgqgCs=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.6.8.tgz} cpu: [x64] os: [linux] + libc: [glibc] '@rspack/binding-linux-x64-musl@1.6.8': - resolution: {integrity: sha512-DCGgZ5/in1O3FjHWqXnDsncRy+48cMhfuUAAUyl0yDj1NpsZu9pP+xfGLvGcQTiYrVl7IH9Aojf1eShP/77WGA==} + resolution: {integrity: sha1-xXj3MNip+rhm5KFZIEV++df9X1g=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-linux-x64-musl/-/binding-linux-x64-musl-1.6.8.tgz} cpu: [x64] os: [linux] + libc: [musl] '@rspack/binding-wasm32-wasi@1.6.8': - resolution: {integrity: sha512-VUwdhl/lI4m6o1OGCZ9JwtMjTV/yLY5VZTQdEPKb40JMTlmZ5MBlr5xk7ByaXXYHr6I+qnqEm73iMKQvg6iknw==} + resolution: {integrity: sha1-dtI1iewxrWurZ4TaiffMQsffAnU=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-wasm32-wasi/-/binding-wasm32-wasi-1.6.8.tgz} cpu: [wasm32] '@rspack/binding-win32-arm64-msvc@1.6.8': - resolution: {integrity: sha512-23YX7zlOZlub+nPGDBUzktb4D5D6ETUAluKjXEeHIZ9m7fSlEYBnGL66YE+3t1DHXGd0OqsdwlvrNGcyo6EXDQ==} + resolution: {integrity: sha1-tCy6SrdYjOcvDBPJaNPWLophq0Y=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.6.8.tgz} cpu: [arm64] os: [win32] '@rspack/binding-win32-ia32-msvc@1.6.8': - resolution: {integrity: sha512-cFgRE3APxrY4AEdooVk2LtipwNNT/9mrnjdC5lVbsIsz+SxvGbZR231bxDJEqP15+RJOaD07FO1sIjINFqXMEg==} + resolution: {integrity: sha1-KTojRIxqEfJamru5iWEwWcfaPsQ=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.6.8.tgz} cpu: [ia32] os: [win32] '@rspack/binding-win32-x64-msvc@1.6.8': - resolution: {integrity: sha512-cIuhVsZYd3o3Neo1JSAhJYw6BDvlxaBoqvgwRkG1rs0ExFmEmgYyG7ip9pFKnKNWph/tmW3rDYypmEfjs1is7g==} + resolution: {integrity: sha1-9f0/AbZpTuCMvDgsO9QtZJCrlEY=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@rspack/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.6.8.tgz} cpu: [x64] os: [win32] @@ -10001,61 +10089,65 @@ packages: react-dom: ^16.8.0 || ^17.0.0 '@swc/core-darwin-arm64@1.7.10': - resolution: {integrity: sha512-TYp4x/9w/C/yMU1olK5hTKq/Hi7BjG71UJ4V1U1WxI1JA3uokjQ/GoktDfmH5V5pX4dgGSOJwUe2RjoN8Z/XnA==} + resolution: {integrity: sha1-PuU+21AbI7EERqmNH21tSH2Iodk=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.10.tgz} engines: {node: '>=10'} cpu: [arm64] os: [darwin] '@swc/core-darwin-x64@1.7.10': - resolution: {integrity: sha512-P3LJjAWh5yLc6p5IUwV5LgRfA3R1oDCZDMabYyb2BVQuJTD4MfegW9DhBcUUF5dhBLwq3191KpLVzE+dLTbiXw==} + resolution: {integrity: sha1-cdNUFYDjbr73bTfwCBxJp/Gv6tY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-darwin-x64/-/core-darwin-x64-1.7.10.tgz} engines: {node: '>=10'} cpu: [x64] os: [darwin] '@swc/core-linux-arm-gnueabihf@1.7.10': - resolution: {integrity: sha512-yGOFjE7w/akRTmqGY3FvWYrqbxO7OB2N2FHj2LO5HtzXflfoABb5RyRvdEquX+17J6mEpu4EwjYNraTD/WHIEQ==} + resolution: {integrity: sha1-ez86gp2R+FmlAP5gfmgUiY7oGdk=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.7.10.tgz} engines: {node: '>=10'} cpu: [arm] os: [linux] '@swc/core-linux-arm64-gnu@1.7.10': - resolution: {integrity: sha512-SPWsgWHfdWKKjLrYlvhxcdBJ7Ruy6crJbPoE9NfD95eJEjMnS2yZTqj2ChFsY737WeyhWYlHzgYhYOVCp83YwQ==} + resolution: {integrity: sha1-vdDQ9kwrLAneqXa+ncJI/TWd5VE=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.7.10.tgz} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [glibc] '@swc/core-linux-arm64-musl@1.7.10': - resolution: {integrity: sha512-PUi50bkNqnBL3Z/Zq6jSfwgN9A/taA6u2Zou0tjDJi7oVdpjdr7SxNgCGzMJ/nNg5D/IQn1opM1jktMvpsPAuQ==} + resolution: {integrity: sha1-vJgIuyS6Ek++l3KQGadFvV6IWas=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.7.10.tgz} engines: {node: '>=10'} cpu: [arm64] os: [linux] + libc: [musl] '@swc/core-linux-x64-gnu@1.7.10': - resolution: {integrity: sha512-Sc+pY55gknCAmBQBR6DhlA7jZSxHaLSDb5Sevzi6DOFMXR79NpA6zWTNKwp1GK2AnRIkbAfvYLgOxS5uWTFVpg==} + resolution: {integrity: sha1-EBpx3PkiTrLO7HgaJUqTd0xl/zE=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.7.10.tgz} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [glibc] '@swc/core-linux-x64-musl@1.7.10': - resolution: {integrity: sha512-g5NKx2LXaGd0K26hmEts1Cvb7ptIvq3MHSgr6/D1tRPcDZw1Sp0dYsmyOv0ho4F5GOJyiCooG3oE9FXdb7jIpQ==} + resolution: {integrity: sha1-b8Grv32BgzQ76Ur64L9ZaumNQEg=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.7.10.tgz} engines: {node: '>=10'} cpu: [x64] os: [linux] + libc: [musl] '@swc/core-win32-arm64-msvc@1.7.10': - resolution: {integrity: sha512-plRIsOcfy9t9Q/ivm5DA7I0HaIvfAWPbI+bvVRrr3C/1K2CSqnqZJjEWOAmx2LiyipijNnEaFYuLBp0IkGuJpg==} + resolution: {integrity: sha1-XW7P2kzF6D7IZZEZ8WB7FmP3Cm0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.7.10.tgz} engines: {node: '>=10'} cpu: [arm64] os: [win32] '@swc/core-win32-ia32-msvc@1.7.10': - resolution: {integrity: sha512-GntrVNT23viHtbfzmlK8lfBiKeajH24GzbDT7qXhnoO20suUPcyYZxyvCb4gWM2zu8ZBTPHNlqfrNsriQCZ+lQ==} + resolution: {integrity: sha1-N/MXfaU7KLLcjIOu/vwgIFBk688=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.7.10.tgz} engines: {node: '>=10'} cpu: [ia32] os: [win32] '@swc/core-win32-x64-msvc@1.7.10': - resolution: {integrity: sha512-uXIF8GuSappe1imm6Lf7pHGepfCBjDQlS+qTqvEGE0wZAsL1IVATK9P/cH/OCLfJXeQDTLeSYmrpwjtXNt46tQ==} + resolution: {integrity: sha1-KBQzXAAylcSoCOwbnPGepU+a1uU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.7.10.tgz} engines: {node: '>=10'} cpu: [x64] os: [win32] @@ -10104,7 +10196,7 @@ packages: resolution: {integrity: sha512-yw0omUrxGp8+gEAuieZFeXB4bCqFvmyCDL3GOBv+Q6+cK0m5824ViHZKPgK5DYG1ijN/lbi1hP3UVKywPN7rbQ==} '@tybys/wasm-util@0.10.1': - resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + resolution: {integrity: sha1-7N3TIFzx4tUnRkn/Du3SmR7X9BQ=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@tybys/wasm-util/-/wasm-util-0.10.1.tgz} '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -10620,97 +10712,105 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} '@unrs/resolver-binding-android-arm-eabi@1.11.1': - resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} + resolution: {integrity: sha1-n1sEUDCI5qNUKV6OqP48uZ5Dr4E=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz} cpu: [arm] os: [android] '@unrs/resolver-binding-android-arm64@1.11.1': - resolution: {integrity: sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==} + resolution: {integrity: sha1-dBSIVDG9cXi5ia7cTSXMyzhlvJ8=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz} cpu: [arm64] os: [android] '@unrs/resolver-binding-darwin-arm64@1.11.1': - resolution: {integrity: sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==} + resolution: {integrity: sha1-tKhVb0IXH7nJ97rII1BF6Cqgy98=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz} cpu: [arm64] os: [darwin] '@unrs/resolver-binding-darwin-x64@1.11.1': - resolution: {integrity: sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==} + resolution: {integrity: sha1-/U2BJXsT9NGgg4kKahfADeVx8Nw=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz} cpu: [x64] os: [darwin] '@unrs/resolver-binding-freebsd-x64@1.11.1': - resolution: {integrity: sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==} + resolution: {integrity: sha1-0lEwhNDzfEB3V+IvMr2SSnjP2Zs=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz} cpu: [x64] os: [freebsd] '@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1': - resolution: {integrity: sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==} + resolution: {integrity: sha1-hE0mBdBXSI13+rCXBfKGa4YWTgo=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz} cpu: [arm] os: [linux] '@unrs/resolver-binding-linux-arm-musleabihf@1.11.1': - resolution: {integrity: sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==} + resolution: {integrity: sha1-IEiSmVzvtr0dAX1S0JcZO8Yd2tM=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz} cpu: [arm] os: [linux] '@unrs/resolver-binding-linux-arm64-gnu@1.11.1': - resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} + resolution: {integrity: sha1-Aj6ww6rEYGahC+ej82Lns087350=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': - resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} + resolution: {integrity: sha1-nm+auwZCTjFApgrJlhOXhvXZm+A=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': - resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} + resolution: {integrity: sha1-sRFBfxfJ0bAu++yOCDmPDFUnu0Q=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': - resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} + resolution: {integrity: sha1-kv+/AnSK8+mYc5RcmoperQHVCKk=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': - resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} + resolution: {integrity: sha1-C+xvElj8OQ5rMF6f9EJWyyB94WU=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': - resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} + resolution: {integrity: sha1-V3hDoITFlS9ZBncGM8z7idrJvJQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': - resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} + resolution: {integrity: sha1-NvsxjuvdaQ9toyrF4EmadvqIGTU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': - resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} + resolution: {integrity: sha1-v7mvdfeD+Y9qIsQkQhTv5N8YU9Y=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': - resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} + resolution: {integrity: sha1-dSw1ndh1aEsnQpUA2IIm18xy9x0=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz} engines: {node: '>=14.0.0'} cpu: [wasm32] '@unrs/resolver-binding-win32-arm64-msvc@1.11.1': - resolution: {integrity: sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==} + resolution: {integrity: sha1-zlc15gDkwvu0Cc0FGzt9pKOZrzU=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz} cpu: [arm64] os: [win32] '@unrs/resolver-binding-win32-ia32-msvc@1.11.1': - resolution: {integrity: sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==} + resolution: {integrity: sha1-cvxXvHxk7Fw94NZO4NGBAxe8YKY=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz} cpu: [ia32] os: [win32] '@unrs/resolver-binding-win32-x64-msvc@1.11.1': - resolution: {integrity: sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==} + resolution: {integrity: sha1-U4seEDv42YZOe4XMlvqNb7bEB3c=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz} cpu: [x64] os: [win32] @@ -10742,47 +10842,47 @@ packages: engines: {node: '>=8.9.3'} '@vscode/vsce-sign-alpine-arm64@2.0.6': - resolution: {integrity: sha512-wKkJBsvKF+f0GfsUuGT0tSW0kZL87QggEiqNqK6/8hvqsXvpx8OsTEc3mnE1kejkh5r+qUyQ7PtF8jZYN0mo8Q==} + resolution: {integrity: sha1-LNJEyvXo7FQ/QvuR1N87kzZByPo=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-alpine-arm64/-/vsce-sign-alpine-arm64-2.0.6.tgz} cpu: [arm64] os: [alpine] '@vscode/vsce-sign-alpine-x64@2.0.6': - resolution: {integrity: sha512-YoAGlmdK39vKi9jA18i4ufBbd95OqGJxRvF3n6ZbCyziwy3O+JgOpIUPxv5tjeO6gQfx29qBivQ8ZZTUF2Ba0w==} + resolution: {integrity: sha1-sOgKR5IAHGbif+7iwR6CGtH6FoA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-alpine-x64/-/vsce-sign-alpine-x64-2.0.6.tgz} cpu: [x64] os: [alpine] '@vscode/vsce-sign-darwin-arm64@2.0.6': - resolution: {integrity: sha512-5HMHaJRIQuozm/XQIiJiA0W9uhdblwwl2ZNDSSAeXGO9YhB9MH5C4KIHOmvyjUnKy4UCuiP43VKpIxW1VWP4tQ==} + resolution: {integrity: sha1-S4+hq1XygKmZhb48BvtzDleBDM4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-darwin-arm64/-/vsce-sign-darwin-arm64-2.0.6.tgz} cpu: [arm64] os: [darwin] '@vscode/vsce-sign-darwin-x64@2.0.6': - resolution: {integrity: sha512-25GsUbTAiNfHSuRItoQafXOIpxlYj+IXb4/qarrXu7kmbH94jlm5sdWSCKrrREs8+GsXF1b+l3OB7VJy5jsykw==} + resolution: {integrity: sha1-0skYbZUFSYJyy93YODuwOOvPWCA=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-darwin-x64/-/vsce-sign-darwin-x64-2.0.6.tgz} cpu: [x64] os: [darwin] '@vscode/vsce-sign-linux-arm64@2.0.6': - resolution: {integrity: sha512-cfb1qK7lygtMa4NUl2582nP7aliLYuDEVpAbXJMkDq1qE+olIw/es+C8j1LJwvcRq1I2yWGtSn3EkDp9Dq5FdA==} + resolution: {integrity: sha1-s9hWAUQEC5INjG7dQ3QxS1glVIE=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-linux-arm64/-/vsce-sign-linux-arm64-2.0.6.tgz} cpu: [arm64] os: [linux] '@vscode/vsce-sign-linux-arm@2.0.6': - resolution: {integrity: sha512-UndEc2Xlq4HsuMPnwu7420uqceXjs4yb5W8E2/UkaHBB9OWCwMd3/bRe/1eLe3D8kPpxzcaeTyXiK3RdzS/1CA==} + resolution: {integrity: sha1-CifEKkrbN+lu7HjNe/o4jNTp++8=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-linux-arm/-/vsce-sign-linux-arm-2.0.6.tgz} cpu: [arm] os: [linux] '@vscode/vsce-sign-linux-x64@2.0.6': - resolution: {integrity: sha512-/olerl1A4sOqdP+hjvJ1sbQjKN07Y3DVnxO4gnbn/ahtQvFrdhUi0G1VsZXDNjfqmXw57DmPi5ASnj/8PGZhAA==} + resolution: {integrity: sha1-reEcru7VJPwWvWxDykmuoAKV3ow=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-linux-x64/-/vsce-sign-linux-x64-2.0.6.tgz} cpu: [x64] os: [linux] '@vscode/vsce-sign-win32-arm64@2.0.6': - resolution: {integrity: sha512-ivM/MiGIY0PJNZBoGtlRBM/xDpwbdlCWomUWuLmIxbi1Cxe/1nooYrEQoaHD8ojVRgzdQEUzMsRbyF5cJJgYOg==} + resolution: {integrity: sha1-BoiWgUjgPrOSR5yEkcclBnIb7/w=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-win32-arm64/-/vsce-sign-win32-arm64-2.0.6.tgz} cpu: [arm64] os: [win32] '@vscode/vsce-sign-win32-x64@2.0.6': - resolution: {integrity: sha512-mgth9Kvze+u8CruYMmhHw6Zgy3GRX2S+Ed5oSokDEK5vPEwGGKnmuXua9tmFhomeAnhgJnL4DCna3TiNuGrBTQ==} + resolution: {integrity: sha1-dEMO/0HSaBjCP5gmsEXYx1cy6us=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@vscode/vsce-sign-win32-x64/-/vsce-sign-win32-x64-2.0.6.tgz} cpu: [x64] os: [win32] @@ -10907,11 +11007,11 @@ packages: engines: {node: '>=10.13'} '@zkochan/js-yaml@0.0.11': - resolution: {integrity: sha512-SO+h5Jg079r2JvGle0jbdtk1EY7ppu6TGzmfWTp3Gy61IEb1OVKBocJ6ydTn4++nYFNfRKYenI2MniZQwsM9KQ==} + resolution: {integrity: sha1-T2aH2CGxW1MG1Z391Mu32K5wHI0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@zkochan/js-yaml/-/js-yaml-0.0.11.tgz} hasBin: true '@zkochan/js-yaml@0.0.6': - resolution: {integrity: sha512-nzvgl3VfhcELQ8LyVrYOru+UtAy1nrygk2+AGbTm8a5YcO6o8lSjAT+pfg3vJWxIoZKOUhrK6UU7xW/+00kQrg==} + resolution: {integrity: sha1-l18LMG5wXii4BooHc3+kbT/ASCY=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@zkochan/js-yaml/-/js-yaml-0.0.6.tgz} hasBin: true '@zkochan/rimraf@2.1.3': @@ -10923,7 +11023,7 @@ packages: engines: {node: '>=18.12'} '@zkochan/which@2.0.3': - resolution: {integrity: sha512-C1ReN7vt2/2O0fyTsx5xnbQuxBrmG5NMSbcIkPKCCfCTJgpZBsuRYzFXHj3nVq8vTfK7vxHUmzfCpSHgO7j4rg==} + resolution: {integrity: sha1-okOQNZOQ04wVH6YHgbNiC8WhMtA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@zkochan/which/-/which-2.0.3.tgz} engines: {node: '>= 8'} hasBin: true @@ -11563,7 +11663,7 @@ packages: resolution: {integrity: sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==} bindings@1.5.0: - resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + resolution: {integrity: sha1-EDU8npRTNLwFEabZCzj7x8nFBN8=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/bindings/-/bindings-1.5.0.tgz} bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -12024,7 +12124,7 @@ packages: engines: {node: '>= 12'} commander@9.5.0: - resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + resolution: {integrity: sha1-vAjR61zt98y3l6lhmdQce8PmDTA=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/commander/-/commander-9.5.0.tgz} engines: {node: ^12.20.0 || >=14} comment-parser@1.4.1: @@ -12210,7 +12310,7 @@ packages: hasBin: true cross-spawn@6.0.6: - resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + resolution: {integrity: sha1-MNDvoHEt2361p24ehyG/+vprXVc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/cross-spawn/-/cross-spawn-6.0.6.tgz} engines: {node: '>=4.8'} cross-spawn@7.0.6: @@ -12735,7 +12835,7 @@ packages: engines: {node: '>= 0.8'} encoding@0.1.13: - resolution: {integrity: sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==} + resolution: {integrity: sha1-VldK/deR9UqOmyeFwFgqLSYhD6k=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/encoding/-/encoding-0.1.13.tgz} end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -12843,97 +12943,97 @@ packages: resolution: {integrity: sha512-Twf7I2v4/1tLoIXMT8HlqaBSS5H2wQTs2wx3MNYCI8K1R1/clXyCazrcVCPm/FuO9cyV8+leEaZOWD5C253NDg==} esbuild-android-64@0.14.54: - resolution: {integrity: sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==} + resolution: {integrity: sha1-UF9BgyiEMTu6/7J3BLi8qi2GFr4=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [android] esbuild-android-arm64@0.14.54: - resolution: {integrity: sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==} + resolution: {integrity: sha1-jOadfKuklkbgCZaP5XVKIamHF3E=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz} engines: {node: '>=12'} cpu: [arm64] os: [android] esbuild-darwin-64@0.14.54: - resolution: {integrity: sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==} + resolution: {integrity: sha1-JLpnuajLiQo8CNkBj4h8wiHN2iU=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [darwin] esbuild-darwin-arm64@0.14.54: - resolution: {integrity: sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==} + resolution: {integrity: sha1-P3zbeIiO4F5IjSUKK9qrH6Zxv3M=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz} engines: {node: '>=12'} cpu: [arm64] os: [darwin] esbuild-freebsd-64@0.14.54: - resolution: {integrity: sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==} + resolution: {integrity: sha1-CSUPmXpW7UZQ8+GXnJBf/EC76U0=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [freebsd] esbuild-freebsd-arm64@0.14.54: - resolution: {integrity: sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==} + resolution: {integrity: sha1-uvtG7QT8X5fL2wFthpR6eVefjkg=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz} engines: {node: '>=12'} cpu: [arm64] os: [freebsd] esbuild-linux-32@0.14.54: - resolution: {integrity: sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==} + resolution: {integrity: sha1-4qjEqO/cNVQFMlAz/OvrlB94H+U=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz} engines: {node: '>=12'} cpu: [ia32] os: [linux] esbuild-linux-64@0.14.54: - resolution: {integrity: sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==} + resolution: {integrity: sha1-3l/boclWZs9yNp9StAsDvnEiZlI=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [linux] esbuild-linux-arm64@0.14.54: - resolution: {integrity: sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==} + resolution: {integrity: sha1-2uTNQq6Xh0aLalwVjaTIToOwzos=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz} engines: {node: '>=12'} cpu: [arm64] os: [linux] esbuild-linux-arm@0.14.54: - resolution: {integrity: sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==} + resolution: {integrity: sha1-osHf9tDyHb6PxpmKEiZ1Uz3fzVk=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz} engines: {node: '>=12'} cpu: [arm] os: [linux] esbuild-linux-mips64le@0.14.54: - resolution: {integrity: sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==} + resolution: {integrity: sha1-2ZGOnky5cvjW2ujoZVv57hMe2jQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz} engines: {node: '>=12'} cpu: [mips64el] os: [linux] esbuild-linux-ppc64le@0.14.54: - resolution: {integrity: sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==} + resolution: {integrity: sha1-P5oPbUEHP7GmQGgIRcfeUplfE34=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz} engines: {node: '>=12'} cpu: [ppc64] os: [linux] esbuild-linux-riscv64@0.14.54: - resolution: {integrity: sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==} + resolution: {integrity: sha1-YYhTwCgXimGDe8eZ0gE9RpXkUcg=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz} engines: {node: '>=12'} cpu: [riscv64] os: [linux] esbuild-linux-s390x@0.14.54: - resolution: {integrity: sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==} + resolution: {integrity: sha1-0YhcTFp2u7Wg/hguLIxg654p8qY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz} engines: {node: '>=12'} cpu: [s390x] os: [linux] esbuild-netbsd-64@0.14.54: - resolution: {integrity: sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==} + resolution: {integrity: sha1-aa6Rei/yQbffHb8iuvBL0zA0noE=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [netbsd] esbuild-openbsd-64@0.14.54: - resolution: {integrity: sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==} + resolution: {integrity: sha1-20yElSh6NQpnkN4i7eokelfF1Hs=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [openbsd] @@ -12950,25 +13050,25 @@ packages: esbuild: '*' esbuild-sunos-64@0.14.54: - resolution: {integrity: sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==} + resolution: {integrity: sha1-VCh+49pz04RLchwhvIDB3H4b99o=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [sunos] esbuild-windows-32@0.14.54: - resolution: {integrity: sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==} + resolution: {integrity: sha1-+Kr5pWZ2MLQPD7OqN78Bu9NAzjE=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz} engines: {node: '>=12'} cpu: [ia32] os: [win32] esbuild-windows-64@0.14.54: - resolution: {integrity: sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==} + resolution: {integrity: sha1-v1S1G9PpsPGIb/2yJKQXYDHqCvQ=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz} engines: {node: '>=12'} cpu: [x64] os: [win32] esbuild-windows-arm64@0.14.54: - resolution: {integrity: sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==} + resolution: {integrity: sha1-k30VZ1oV5LDk+v26o6Aad2or6YI=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz} engines: {node: '>=12'} cpu: [arm64] os: [win32] @@ -13263,11 +13363,11 @@ packages: resolution: {integrity: sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==} execa@1.0.0: - resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + resolution: {integrity: sha1-xiNqW7TfbW8V6I5/AXeYIWdJ3dg=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/execa/-/execa-1.0.0.tgz} engines: {node: '>=6'} execa@5.1.1: - resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} + resolution: {integrity: sha1-+ArZy/Qpj3vR1MlVXCHpN0HEEd0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/execa/-/execa-5.1.1.tgz} engines: {node: '>=10'} exit-x@0.2.2: @@ -13449,7 +13549,7 @@ packages: resolution: {integrity: sha512-IzF5MBq+5CR0jXx5RxPe4BICl/oEhBSXKaL9fLhAXrIfIUS77Hr4vzrYyqYMHN6uTt+BOqi3fDCTjjEBCjERKw==} file-uri-to-path@1.0.0: - resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + resolution: {integrity: sha1-VTp7hEb/b2hDWcRF8eN6BdrMM90=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz} fill-range@4.0.0: resolution: {integrity: sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==} @@ -13655,18 +13755,18 @@ packages: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} fsevents@1.2.13: - resolution: {integrity: sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==} + resolution: {integrity: sha1-8yXLBFVZJCi88Rs4M3DvcOO/zDg=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-1.2.13.tgz} engines: {node: '>= 4.0'} os: [darwin] deprecated: Upgrade to fsevents v2 to mitigate potential security issues fsevents@2.3.2: - resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + resolution: {integrity: sha1-ilJveLj99GI7cJ4Ll1xSwkwC/Ro=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.2.tgz} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + resolution: {integrity: sha1-ysZAd4XQNnWipeGlMFxpezR9kNY=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/fsevents/-/fsevents-2.3.3.tgz} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] @@ -13737,15 +13837,15 @@ packages: engines: {node: '>= 0.4'} get-stream@4.1.0: - resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + resolution: {integrity: sha1-wbJVV189wh1Zv8ec09K0axw6VLU=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-stream/-/get-stream-4.1.0.tgz} engines: {node: '>=6'} get-stream@5.2.0: - resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} + resolution: {integrity: sha1-SWaheV7lrOZecGxLe+txJX1uItM=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-stream/-/get-stream-5.2.0.tgz} engines: {node: '>=8'} get-stream@6.0.1: - resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} + resolution: {integrity: sha1-omLY7vZ6ztV8KFKtYWdSakPL97c=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/get-stream/-/get-stream-6.0.1.tgz} engines: {node: '>=10'} get-symbol-description@1.1.0: @@ -13879,7 +13979,7 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} graphql@16.13.2: - resolution: {integrity: sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==} + resolution: {integrity: sha1-TStz31eWsgHxvCdl9dcGf2ictV8=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/graphql/-/graphql-16.13.2.tgz} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} gzip-size@6.0.0: @@ -14115,7 +14215,7 @@ packages: engines: {node: '>= 14'} human-signals@2.1.0: - resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} + resolution: {integrity: sha1-3JH8ukLk0G5Kuu0zs+ejwC9RTqA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/human-signals/-/human-signals-2.1.0.tgz} engines: {node: '>=10.17.0'} humanize-ms@1.2.1: @@ -14512,11 +14612,11 @@ packages: engines: {node: '>= 0.4'} is-stream@1.1.0: - resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==} + resolution: {integrity: sha1-EtSj3U5o4Lec6428hBc66A2RykQ=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-stream/-/is-stream-1.1.0.tgz} engines: {node: '>=0.10.0'} is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} + resolution: {integrity: sha1-+sHj1TuXrVqdCunO8jifWBClwHc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/is-stream/-/is-stream-2.0.1.tgz} engines: {node: '>=8'} is-string@1.1.1: @@ -14589,7 +14689,7 @@ packages: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + resolution: {integrity: sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/isexe/-/isexe-2.0.0.tgz} isobject@2.1.0: resolution: {integrity: sha512-+OUdGJlgjOBZDfxnDjYYG6zp487z0JGNQq3cYQYg5f5hKR+syHMsaztzGeml/4kGG55CSpKSpWTY+jYGgsHLgA==} @@ -15070,7 +15170,7 @@ packages: resolution: {integrity: sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==} keytar@7.9.0: - resolution: {integrity: sha512-VPD8mtVtm5JNtA2AErl6Chp06JBfy7diFQ7TQQhdpWOl6MrCRB+eRbvAZUsbGQS9kiMq0coJsy0W0vHpDCkWsQ==} + resolution: {integrity: sha1-TGIlcI9RtQy/d8Wq6BchlkwpGMs=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/keytar/-/keytar-7.9.0.tgz} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -15485,7 +15585,7 @@ packages: hasBin: true mimic-fn@2.1.0: - resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + resolution: {integrity: sha1-ftLCzMyvhNP/y3pptXcR/CCDQBs=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/mimic-fn/-/mimic-fn-2.1.0.tgz} engines: {node: '>=6'} mimic-fn@3.1.0: @@ -15638,7 +15738,7 @@ packages: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} nan@2.26.2: - resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==} + resolution: {integrity: sha1-Ll4ldkIkxze5iXeQtXwylNTc7pw=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/nan/-/nan-2.26.2.tgz} nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} @@ -15788,11 +15888,11 @@ packages: hasBin: true npm-run-path@2.0.2: - resolution: {integrity: sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==} + resolution: {integrity: sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/npm-run-path/-/npm-run-path-2.0.2.tgz} engines: {node: '>=4'} npm-run-path@4.0.1: - resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} + resolution: {integrity: sha1-t+zR5e1T2o43pV4cImnguX7XSOo=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/npm-run-path/-/npm-run-path-4.0.1.tgz} engines: {node: '>=8'} npmlog@4.1.2: @@ -15903,7 +16003,7 @@ packages: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} onetime@5.1.2: - resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + resolution: {integrity: sha1-0Oluu1awdHbfHdnEgG5SN5hcpF4=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/onetime/-/onetime-5.1.2.tgz} engines: {node: '>=6'} open@10.2.0: @@ -16119,7 +16219,7 @@ packages: engines: {node: '>=0.10.0'} path-key@2.0.1: - resolution: {integrity: sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==} + resolution: {integrity: sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-key/-/path-key-2.0.1.tgz} engines: {node: '>=4'} path-key@3.1.1: @@ -16127,7 +16227,7 @@ packages: engines: {node: '>=8'} path-name@1.0.0: - resolution: {integrity: sha512-/dcAb5vMXH0f51yvMuSUqFpxUcA8JelbRmE5mW/p4CUJxrNgK24IkstnV7ENtg2IDGBOu6izKTG6eilbnbNKWQ==} + resolution: {integrity: sha1-jKBjpj3nmC36lXYO2v/RAhRJTyQ=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/path-name/-/path-name-1.0.0.tgz} path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -16721,7 +16821,7 @@ packages: resolution: {integrity: sha512-SbiLPU40JuJniHexQSAgad32hfwd+DRUdwF2PlVuI5RZD0/vahUco7R8vD86J/tcEKKF9vZrUVwgtmGCqlCKyA==} ramda@0.28.0: - resolution: {integrity: sha512-9QnLuG/kPVgWvMQ4aODhsBUFKOUmnbUnsSXACv+NCQZcHbeb+v8Lodp8OVxtRULN1/xOyYLLaL6npE6dMq5QTA==} + resolution: {integrity: sha1-rNeFaQEAM36LBjyrNHABm+QnzJc=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/ramda/-/ramda-0.28.0.tgz} randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} @@ -17202,7 +17302,7 @@ packages: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} safe-execa@0.1.2: - resolution: {integrity: sha512-vdTshSQ2JsRCgT8eKZWNJIL26C6bVqy1SOmuCMlKHegVeo8KYRobRrefOdUq9OozSPUUiSxrylteeRmLOMFfWg==} + resolution: {integrity: sha1-L7sKbxoAx6RexwM/gmWXV/kb6Mc=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/safe-execa/-/safe-execa-0.1.2.tgz} engines: {node: '>=12'} safe-push-apply@1.0.0: @@ -17229,121 +17329,121 @@ packages: hasBin: true sass-embedded-android-arm64@1.85.1: - resolution: {integrity: sha512-27oRheqNA3SJM2hAxpVbs7mCKUwKPWmEEhyiNFpBINb5ELVLg+Ck5RsGg+SJmo130ul5YX0vinmVB5uPWc8X5w==} + resolution: {integrity: sha1-HKnF4G6hqOz3T/f76mcXBs/lAyA=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-android-arm64/-/sass-embedded-android-arm64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] sass-embedded-android-arm@1.85.1: - resolution: {integrity: sha512-GkcgUGMZtEF9gheuE1dxCU0ZSAifuaFXi/aX7ZXvjtdwmTl9Zc/OHR9oiUJkc8IW9UI7H8TuwlTAA8+SwgwIeQ==} + resolution: {integrity: sha1-87zVn7BcKTGuoSaUlqCYj4C5Nq8=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-android-arm/-/sass-embedded-android-arm-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] sass-embedded-android-ia32@1.85.1: - resolution: {integrity: sha512-f3x16NyRgtXFksIaO/xXKrUhttUBv8V0XsAR2Dhdb/yz4yrDrhzw9Wh8fmw7PlQqECcQvFaoDr3XIIM6lKzasw==} + resolution: {integrity: sha1-C0jPGwoVfAZtjWyPTHz102trIps=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-android-ia32/-/sass-embedded-android-ia32-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [ia32] os: [android] sass-embedded-android-riscv64@1.85.1: - resolution: {integrity: sha512-IP6OijpJ8Mqo7XqCe0LsuZVbAxEFVboa0kXqqR5K55LebEplsTIA2GnmRyMay3Yr/2FVGsZbCb6Wlgkw23eCiA==} + resolution: {integrity: sha1-sgJKrrdUVAEb0qOujuTKwnO15xE=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-android-riscv64/-/sass-embedded-android-riscv64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] sass-embedded-android-x64@1.85.1: - resolution: {integrity: sha512-Mh7CA53wR3ADvXAYipFc/R3vV4PVOzoKwWzPxmq+7i8UZrtsVjKONxGtqWe9JG1mna0C9CRZAx0sv/BzbOJxWg==} + resolution: {integrity: sha1-xTkYMMuzw3jlF3vA4D6Up4LNHME=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-android-x64/-/sass-embedded-android-x64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] sass-embedded-darwin-arm64@1.85.1: - resolution: {integrity: sha512-msWxzhvcP9hqGVegxVePVEfv9mVNTlUgGr6k7O7Ihji702mbtrH/lKwF4aRkkt4g1j7tv10+JtQXmTNi/pi9kA==} + resolution: {integrity: sha1-eay7aGfQFolvhDlxvfoKx24QHfg=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-darwin-arm64/-/sass-embedded-darwin-arm64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] sass-embedded-darwin-x64@1.85.1: - resolution: {integrity: sha512-J4UFHUiyI9Z+mwYMwz11Ky9TYr3hY1fCxeQddjNGL/+ovldtb0yAIHvoVM0BGprQDm5JqhtUk8KyJ3RMJqpaAA==} + resolution: {integrity: sha1-FH7PSb8tGC295s7zN6WKBbepOrE=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-darwin-x64/-/sass-embedded-darwin-x64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] sass-embedded-linux-arm64@1.85.1: - resolution: {integrity: sha512-jGadetB03BMFG2rq3OXub/uvC/lGpbQOiLGEz3NLb2nRZWyauRhzDtvZqkr6BEhxgIWtMtz2020yD8ZJSw/r2w==} + resolution: {integrity: sha1-XHtcJ0lTKZZjClUS0OG/bYRyrNM=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-arm64/-/sass-embedded-linux-arm64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] sass-embedded-linux-arm@1.85.1: - resolution: {integrity: sha512-X0fDh95nNSw1wfRlnkE4oscoEA5Au4nnk785s9jghPFkTBg+A+5uB6trCjf0fM22+Iw6kiP4YYmDdw3BqxAKLQ==} + resolution: {integrity: sha1-iZy6lLc+sRn2Q0aInkZt4xtvXTw=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-arm/-/sass-embedded-linux-arm-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] sass-embedded-linux-ia32@1.85.1: - resolution: {integrity: sha512-7HlYY90d9mitDtNi5s+S+5wYZrTVbkBH2/kf7ixrzh2BFfT0YM81UHLJRnGX93y9aOMBL6DSZAIfkt1RsV9bkQ==} + resolution: {integrity: sha1-8bpT8DQ4ljWv6cEFm8Nqd5NkIbg=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-ia32/-/sass-embedded-linux-ia32-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [ia32] os: [linux] sass-embedded-linux-musl-arm64@1.85.1: - resolution: {integrity: sha512-FLkIT0p18XOkR6wryJ13LqGBDsrYev2dRk9dtiU18NCpNXruKsdBQ1ZnWHVKB3h1dA9lFyEEisC0sooKdNfeOQ==} + resolution: {integrity: sha1-sUziYVx6tGJuiMuiZW+Ro6dI4c4=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-musl-arm64/-/sass-embedded-linux-musl-arm64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] sass-embedded-linux-musl-arm@1.85.1: - resolution: {integrity: sha512-5vcdEqE8QZnu6i6shZo7x2N36V7YUoFotWj2rGekII5ty7Nkaj+VtZhUEOp9tAzEOlaFuDp5CyO1kUCvweT64A==} + resolution: {integrity: sha1-Qo5eKZqf9N/SC1eGHSRAQotfvfY=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-musl-arm/-/sass-embedded-linux-musl-arm-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] sass-embedded-linux-musl-ia32@1.85.1: - resolution: {integrity: sha512-N1093T84zQJor1yyIAdYScB5eAuQarGK1tKgZ4uTnxVlgA7Xi1lXV8Eh7ox9sDqKCaWkVQ3MjqU26vYRBeRWyw==} + resolution: {integrity: sha1-BwYJr9mc0Pnq5yGGyfSi0M32lE0=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-musl-ia32/-/sass-embedded-linux-musl-ia32-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [ia32] os: [linux] sass-embedded-linux-musl-riscv64@1.85.1: - resolution: {integrity: sha512-WRsZS/7qlfYXsa93FBpSruieuURIu7ySfFhzYfF1IbKrNAGwmbduutkHZh2ddm5/vQMvQ0Rdosgv+CslaQHMcw==} + resolution: {integrity: sha1-D0JkvkAnfXzBgUnJ4PA68JVi5vc=, tarball: https://ms-feed-12.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-musl-riscv64/-/sass-embedded-linux-musl-riscv64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] sass-embedded-linux-musl-x64@1.85.1: - resolution: {integrity: sha512-+OlLIilA5TnP0YEqTQ8yZtkW+bJIQYvzoGoNLUEskeyeGuOiIyn2CwL6G4JQB4xZQFaxPHb7JD3EueFkQbH0Pw==} + resolution: {integrity: sha1-tUteH2RtHwwJ2d9+Dm1JYNbtf6E=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-musl-x64/-/sass-embedded-linux-musl-x64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] sass-embedded-linux-riscv64@1.85.1: - resolution: {integrity: sha512-mKKlOwMGLN7yP1p0gB5yG/HX4fYLnpWaqstNuOOXH+fOzTaNg0+1hALg0H0CDIqypPO74M5MS9T6FAJZGdT6dQ==} + resolution: {integrity: sha1-Jxh3Mwf5T+uBXk4vu1qnMiao03U=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-riscv64/-/sass-embedded-linux-riscv64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] sass-embedded-linux-x64@1.85.1: - resolution: {integrity: sha512-uKRTv0z8NgtHV7xSren78+yoWB79sNi7TMqI7Bxd8fcRNIgHQSA8QBdF8led2ETC004hr8h71BrY60RPO+SSvA==} + resolution: {integrity: sha1-BmTMiGuHgYrJ0pv06fKZ/mxj9SQ=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-linux-x64/-/sass-embedded-linux-x64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] sass-embedded-win32-arm64@1.85.1: - resolution: {integrity: sha512-/GMiZXBOc6AEMBC3g25Rp+x8fq9Z6Ql7037l5rajBPhZ+DdFwtdHY0Ou3oIU6XuWUwD06U3ii4XufXVFhsP6PA==} + resolution: {integrity: sha1-0Ht1X4QI193huDCNOlf7/NqdkJw=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-win32-arm64/-/sass-embedded-win32-arm64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] sass-embedded-win32-ia32@1.85.1: - resolution: {integrity: sha512-L+4BWkKKBGFOKVQ2PQ5HwFfkM5FvTf1Xx2VSRvEWt9HxPXp6SPDho6zC8fqNQ3hSjoaoASEIJcSvgfdQYO0gdg==} + resolution: {integrity: sha1-Xl8W4aMPjfMRSKr2WncimNcDfXA=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-win32-ia32/-/sass-embedded-win32-ia32-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [ia32] os: [win32] sass-embedded-win32-x64@1.85.1: - resolution: {integrity: sha512-/FO0AGKWxVfCk4GKsC0yXWBpUZdySe3YAAbQQL0lL6xUd1OiUY8Kow6g4Kc1TB/+z0iuQKKTqI/acJMEYl4iTQ==} + resolution: {integrity: sha1-dY+5bBbncmWkt/JHSkOWLXHvX/0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/sass-embedded-win32-x64/-/sass-embedded-win32-x64-1.85.1.tgz} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] @@ -17585,7 +17685,7 @@ packages: engines: {node: '>= 0.4'} signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + resolution: {integrity: sha1-qaF2f4r4QVURTqq9c/mSc8j1mtk=, tarball: https://ms-feed-17.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/signal-exit/-/signal-exit-3.0.7.tgz} signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} @@ -17912,7 +18012,7 @@ packages: engines: {node: '>=0.10.0'} strip-final-newline@2.0.0: - resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==} + resolution: {integrity: sha1-ibhS+y/L6Tb29LMYevsKEsGrWK0=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/strip-final-newline/-/strip-final-newline-2.0.0.tgz} engines: {node: '>=6'} strip-indent@3.0.0: @@ -18400,7 +18500,7 @@ packages: resolution: {integrity: sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==} uglify-js@3.19.3: - resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + resolution: {integrity: sha1-gjFem7xvKyWIiFis0f/4RBA1t38=, tarball: https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/uglify-js/-/uglify-js-3.19.3.tgz} engines: {node: '>=0.8.0'} hasBin: true @@ -18673,7 +18773,7 @@ packages: resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==} watchpack-chokidar2@2.0.1: - resolution: {integrity: sha512-nCFfBIPKr5Sh61s4LPpy1Wtfi0HE8isJ3d2Yb5/Ppw2P2B/3eVSEBjKfN0fmHJSK14+31KwMKmcrzs2GM4P0Ww==} + resolution: {integrity: sha1-OFAAcu5uzmbzdpk2lQ6hdxvhyVc=, tarball: https://ms-feed-2.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/watchpack-chokidar2/-/watchpack-chokidar2-2.0.1.tgz} watchpack@1.7.5: resolution: {integrity: sha512-9P3MWk6SrKjHsGkLT2KHXdQ/9SNkyoJbabxnKOoJepsvJjJG8uYTR3yTPxPQvNDI3w4Nz1xnE0TLHK4RIVe/MQ==} @@ -22923,7 +23023,7 @@ snapshots: - supports-color - ts-node - '@jest/core@30.3.0': + '@jest/core@30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0))': dependencies: '@jest/console': 30.3.0 '@jest/pattern': 30.0.1 @@ -22938,7 +23038,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.3.0 - jest-config: 30.3.0(@types/node@22.9.3) + jest-config: 30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) jest-haste-map: 30.3.0 jest-message-util: 30.3.0 jest-regex-util: 30.0.1 @@ -24673,16 +24773,16 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': + '@rushstack/heft-jest-plugin@2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0)': dependencies: - '@jest/core': 30.3.0 + '@jest/core': 30.3.0(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) '@jest/reporters': 30.3.0 '@jest/transform': 30.3.0 '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-config-file': 0.20.12(@types/node@20.17.19) '@rushstack/node-core-library': 5.23.3(@types/node@20.17.19) '@rushstack/terminal': 0.24.2(@types/node@20.17.19) - jest-config: 30.3.0(@types/node@20.17.19) + jest-config: 30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)) jest-resolve: 30.3.0 jest-snapshot: 30.3.0 optionalDependencies: @@ -24706,13 +24806,13 @@ snapshots: transitivePeerDependencies: - '@types/node' - '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)': + '@rushstack/heft-node-rig@2.11.45(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)': dependencies: '@microsoft/api-extractor': 7.58.12(@types/node@20.17.19) '@rushstack/eslint-config': 4.6.4(eslint@9.37.0)(typescript@5.8.2) '@rushstack/heft': 1.2.22(@types/node@20.17.19) '@rushstack/heft-api-extractor-plugin': 1.3.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) - '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) + '@rushstack/heft-jest-plugin': 2.0.12(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/jest@30.0.0)(@types/node@20.17.19)(esbuild-register@3.6.0(esbuild@0.28.0))(jest-environment-jsdom@30.3.0)(jest-environment-node@30.3.0) '@rushstack/heft-lint-plugin': 1.2.22(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@rushstack/heft-typescript-plugin': 1.3.17(@rushstack/heft@1.2.22(@types/node@20.17.19))(@types/node@20.17.19) '@types/jest': 30.0.0 @@ -29719,8 +29819,6 @@ snapshots: dedent@0.7.0: {} - dedent@1.7.2: {} - dedent@1.7.2(babel-plugin-macros@3.1.0): optionalDependencies: babel-plugin-macros: 3.1.0 @@ -30265,6 +30363,14 @@ snapshots: transitivePeerDependencies: - supports-color + esbuild-register@3.6.0(esbuild@0.28.0): + dependencies: + debug: 4.4.3(supports-color@8.1.1) + esbuild: 0.28.0 + transitivePeerDependencies: + - supports-color + optional: true + esbuild-runner@2.2.2(esbuild@0.14.54): dependencies: esbuild: 0.14.54 @@ -30836,7 +30942,7 @@ snapshots: eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.9.1(eslint@9.37.0) + '@eslint-community/eslint-utils': 4.9.1(eslint@8.57.1) '@eslint-community/regexpp': 4.12.2 '@eslint/eslintrc': 2.1.4 '@eslint/js': 8.57.1 @@ -31426,6 +31532,13 @@ snapshots: schema-utils: 3.3.0 webpack: 4.47.0 + file-loader@6.2.0(webpack@5.105.4): + dependencies: + loader-utils: 2.0.4 + schema-utils: 3.3.0 + webpack: 5.105.4 + optional: true + file-system-cache@1.1.0: dependencies: fs-extra: 10.1.0 @@ -32813,7 +32926,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-circus@30.3.0: + jest-circus@30.3.0(babel-plugin-macros@3.1.0): dependencies: '@jest/environment': 30.3.0 '@jest/expect': 30.3.0 @@ -32822,7 +32935,7 @@ snapshots: '@types/node': 22.9.3 chalk: 4.1.2 co: 4.6.0 - dedent: 1.7.2 + dedent: 1.7.2(babel-plugin-macros@3.1.0) is-generator-fn: 2.1.0 jest-each: 30.3.0 jest-matcher-utils: 30.3.0 @@ -32918,7 +33031,7 @@ snapshots: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@20.17.19): + jest-config@30.3.0(@types/node@20.17.19)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32931,7 +33044,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0 + jest-circus: 30.3.0(babel-plugin-macros@3.1.0) jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32945,11 +33058,12 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 20.17.19 + esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color - jest-config@30.3.0(@types/node@22.9.3): + jest-config@30.3.0(@types/node@22.9.3)(babel-plugin-macros@3.1.0)(esbuild-register@3.6.0(esbuild@0.28.0)): dependencies: '@babel/core': 7.29.0 '@jest/get-type': 30.1.0 @@ -32962,7 +33076,7 @@ snapshots: deepmerge: 4.3.1 glob: 10.5.0 graceful-fs: 4.2.11 - jest-circus: 30.3.0 + jest-circus: 30.3.0(babel-plugin-macros@3.1.0) jest-docblock: 30.2.0 jest-environment-node: 30.3.0 jest-regex-util: 30.0.1 @@ -32976,6 +33090,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.9.3 + esbuild-register: 3.6.0(esbuild@0.28.0) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -37727,12 +37842,14 @@ snapshots: optionalDependencies: file-loader: 6.2.0(webpack@4.47.0) - url-loader@4.1.1(webpack@5.105.4): + url-loader@4.1.1(file-loader@6.2.0(webpack@5.105.4))(webpack@5.105.4): dependencies: loader-utils: 2.0.4 mime-types: 2.1.35 schema-utils: 3.3.0 webpack: 5.105.4 + optionalDependencies: + file-loader: 6.2.0(webpack@5.105.4) url@0.10.3: dependencies: @@ -37961,17 +38078,6 @@ snapshots: '@types/webpack': 4.41.32 webpack: 5.105.4 - webpack-dev-middleware@7.4.5(webpack@5.105.4): - dependencies: - colorette: 2.0.20 - memfs: 4.57.1 - mime-types: 3.0.2 - on-finished: 2.4.1 - range-parser: 1.2.1 - schema-utils: 4.3.3 - optionalDependencies: - webpack: 5.105.4 - webpack-dev-server@4.9.3(@types/webpack@4.41.32)(webpack@4.47.0): dependencies: '@types/bonjour': 3.5.13 @@ -38055,7 +38161,7 @@ snapshots: - utf-8-validate optional: true - webpack-dev-server@5.2.3(webpack@5.105.4): + webpack-dev-server@5.2.3(@types/webpack@4.41.32)(webpack@5.105.4): dependencies: '@types/bonjour': 3.5.13 '@types/connect-history-api-fallback': 1.5.4 @@ -38084,9 +38190,10 @@ snapshots: serve-index: 1.9.2 sockjs: 0.3.24 spdy: 4.0.2 - webpack-dev-middleware: 7.4.5(webpack@5.105.4) + webpack-dev-middleware: 7.4.5(@types/webpack@4.41.32)(webpack@5.105.4) ws: 8.21.0 optionalDependencies: + '@types/webpack': 4.41.32 webpack: 5.105.4 transitivePeerDependencies: - bufferutil diff --git a/common/config/subspaces/default/repo-state.json b/common/config/subspaces/default/repo-state.json index 61a8682c971..703a802b3fc 100644 --- a/common/config/subspaces/default/repo-state.json +++ b/common/config/subspaces/default/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": "0cdaaac7c5ac76a646450777edcb5277afddf107", + "pnpmShrinkwrapHash": "bbbee402b21414cc1a787a3c06b8a39f5f327eaf", "preferredVersionsHash": "029c99bd6e65c5e1f25e2848340509811ff9753c" } diff --git a/common/reviews/api/rush-daemon-protocol.api.md b/common/reviews/api/rush-daemon-protocol.api.md new file mode 100644 index 00000000000..54d3ad5bfed --- /dev/null +++ b/common/reviews/api/rush-daemon-protocol.api.md @@ -0,0 +1,408 @@ +## API Report File for "@rushstack/rush-daemon-protocol" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +// @beta +export function compareDaemonVerbosity(a: DaemonVerbosity, b: DaemonVerbosity): number; + +// @beta +export function createDaemonHello(protocolVersion: IDaemonProtocolVersion): IDaemonHelloMessage; + +// @beta +export function createDaemonHelloAck(protocolVersion: IDaemonProtocolVersion, sessionId: string): IDaemonHelloAckMessage; + +// @beta +export const DAEMON_CONTROL_MESSAGE_KINDS: readonly [ +'hello', +'helloAck', +'subscribe', +'unsubscribe', +'ping', +'pong', +'error' +]; + +// @beta +export const DAEMON_EVENT_TYPES: readonly [ +'sessionStarted', +'sessionCompleted', +'commandStarted', +'commandCompleted', +'operationRegistered', +'operationStatusChanged', +'activityChanged', +'watchCycleCompleted', +'diagnosticEmitted', +'externalProcessStarted', +'externalOutput', +'externalProcessCompleted', +'artifactAvailable', +'commandResult', +'extension' +]; + +// @beta +export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion; + +// @beta +export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonUnsubscribeMessage | IDaemonPingMessage | IDaemonPongMessage | IDaemonErrorMessage; + +// @beta +export type DaemonControlMessageKind = (typeof DAEMON_CONTROL_MESSAGE_KINDS)[number]; + +// @beta +export type DaemonDiagnosticSeverity = 'debug' | 'info' | 'warning' | 'error'; + +// @beta +export type DaemonEmptyPayload = Record; + +// @beta +export type DaemonEventPrivacy = 'public' | 'local-sensitive' | 'secret'; + +// @beta +export type DaemonEventType = (typeof DAEMON_EVENT_TYPES)[number]; + +// @beta +export type DaemonExtensionEventName = string; + +// @beta +export class DaemonFrameDecoder { + constructor(options?: IDaemonFrameDecoderOptions); + push(chunk: Uint8Array): IDaemonFrame[]; + reset(): void; +} + +// @beta +export enum DaemonFrameType { + controlJson = 1, + event = 5, + logStderr = 3, + logStdout = 2, + stdin = 4 +} + +// @beta +export type DaemonHandshakeOutcome = { + readonly accepted: true; + readonly ack: IDaemonHelloAckMessage; +} | { + readonly accepted: false; + readonly error: ProtocolVersionMismatchError; +}; + +// @beta +export type DaemonJsonNull = null; + +// @beta +export type DaemonJsonValue = string | number | boolean | DaemonJsonNull | readonly DaemonJsonValue[] | { + readonly [key: string]: DaemonJsonValue; +}; + +// @beta +export class DaemonProtocolError extends Error { + constructor(code: DaemonProtocolErrorCode, message: string, options?: IDaemonProtocolErrorOptions); + readonly code: DaemonProtocolErrorCode; +} + +// @beta +export type DaemonProtocolErrorCode = 'frameTooLarge' | 'unknownFrameType' | 'malformedPayload' | 'malformedControlMessage' | 'protocolVersionMismatch'; + +// @beta +export type DaemonVerbosity = 'quiet' | 'normal' | 'verbose' | 'debug'; + +// @beta +export function decodeDaemonControlMessage(payload: Uint8Array): DaemonControlMessage; + +// @beta +export function decodeDaemonEventFrame(payload: Uint8Array): IDaemonEventEnvelope; + +// @beta +export function decodeDaemonLogChunk(payload: Uint8Array): IDaemonLogChunk; + +// @beta +export const DEFAULT_MAX_PAYLOAD_BYTES: number; + +// @beta +export function encodeDaemonControlMessage(message: DaemonControlMessage): Uint8Array; + +// @beta +export function encodeDaemonEventFrame(envelope: IDaemonEventEnvelope): Uint8Array; + +// @beta +export function encodeDaemonFrame(frame: IDaemonFrame): Uint8Array; + +// @beta +export function encodeDaemonFrames(frames: readonly IDaemonFrame[]): Uint8Array[]; + +// @beta +export function encodeDaemonLogChunk(log: IDaemonLogChunk): Uint8Array; + +// @beta +export const FRAME_HEADER_BYTES: number; + +// @beta +export interface IDaemonActivityPayload { + readonly stream?: 'stdout' | 'stderr'; + readonly text: string; +} + +// @beta +export interface IDaemonClientCaps { + readonly colorLevel?: number; + readonly columns?: number; + readonly isTTY: boolean; + readonly verbosity?: DaemonVerbosity; +} + +// @beta +export interface IDaemonDiagnosticPayload { + // (undocumented) + readonly severity: DaemonDiagnosticSeverity; +} + +// @beta +export interface IDaemonErrorMessage { + // (undocumented) + readonly kind: 'error'; + // (undocumented) + readonly payload: { + readonly code: DaemonProtocolErrorCode; + readonly message: string; + }; +} + +// @beta +export interface IDaemonEventEnvelope { + readonly eventId: string; + readonly parentOperationId?: string; + readonly parentSessionId?: string; + readonly payload: TPayload; + readonly privacy: DaemonEventPrivacy; + readonly protocolVersion: { + readonly major: number; + readonly minor: number; + }; + readonly required: boolean; + readonly scope?: IDaemonEventScope; + readonly sequence: number; + readonly sessionId: string; + readonly source: IDaemonEventSource; + readonly sourceSequence?: number; + readonly timestamp: string; + readonly type: DaemonEventType; +} + +// @beta +export interface IDaemonEventScope { + readonly commandName?: string; + readonly operationId?: string; + readonly phaseName?: string; + readonly projectName?: string; +} + +// @beta +export interface IDaemonEventSource { + readonly component?: string; + readonly packageName: string; + readonly packageVersion: string; +} + +// @beta +export interface IDaemonExtensionEventPayload { + readonly data: TData; + readonly name: string; +} + +// @beta +export interface IDaemonFrame { + readonly kind: DaemonFrameType; + readonly payload: Uint8Array; +} + +// @beta +export interface IDaemonFrameDecoderOptions { + readonly maxPayloadBytes?: number; +} + +// @beta +export interface IDaemonHelloAckMessage { + // (undocumented) + readonly kind: 'helloAck'; + // (undocumented) + readonly payload: { + readonly protocolVersion: IDaemonProtocolVersion; + readonly sessionId: string; + }; +} + +// @beta +export interface IDaemonHelloMessage { + // (undocumented) + readonly kind: 'hello'; + // (undocumented) + readonly payload: { + readonly protocolVersion: IDaemonProtocolVersion; + }; +} + +// @beta +export interface IDaemonLogChunk { + readonly chunk: Uint8Array; + readonly operationId: string; +} + +// @beta +export interface IDaemonOperationHeaderPayload { + readonly completedOperations: number; + readonly operationId: string; + readonly totalOperations: number; +} + +// @beta +export interface IDaemonOperationRegisteredPayload { + readonly operationId: string; + readonly silent?: boolean; +} + +// @beta +export interface IDaemonOperationStatusChangedPayload { + readonly operationId: string; + readonly previousStatus?: string; + readonly status: string; +} + +// @beta +export interface IDaemonOperationStreamClosedPayload { + readonly operationId: string; +} + +// @beta +export interface IDaemonPingMessage { + // (undocumented) + readonly kind: 'ping'; + // (undocumented) + readonly payload: DaemonEmptyPayload; +} + +// @beta +export interface IDaemonPongMessage { + // (undocumented) + readonly kind: 'pong'; + // (undocumented) + readonly payload: { + readonly uptimeMs: number; + }; +} + +// @beta +export interface IDaemonProtocolErrorOptions { + readonly cause?: unknown; +} + +// @beta +export interface IDaemonProtocolVersion { + readonly major: number; + readonly minor: number; +} + +// @beta +export interface IDaemonSubscribeMessage { + // (undocumented) + readonly kind: 'subscribe'; + // (undocumented) + readonly payload: IDaemonClientCaps; +} + +// @beta +export interface IDaemonUnsubscribeMessage { + // (undocumented) + readonly kind: 'unsubscribe'; + // (undocumented) + readonly payload: DaemonEmptyPayload; +} + +// @beta +export function isDaemonControlMessageKind(value: unknown): value is DaemonControlMessageKind; + +// @beta +export function isDaemonControlRecord(value: unknown): value is Record; + +// @beta +export function isDaemonEventEnvelope(value: unknown): value is IDaemonEventEnvelope; + +// @beta +export function isDaemonEventType(value: unknown): value is DaemonEventType; + +// @beta +export function isDaemonExtensionEventName(name: string): name is DaemonExtensionEventName; + +// @beta +export function isDaemonFrameType(value: number): value is DaemonFrameType; + +// @beta +export function isDaemonProtocolCompatible(local: IDaemonProtocolVersion, remote: IDaemonProtocolVersion): boolean; + +// @beta +export function isDaemonVerbosity(value: unknown): value is DaemonVerbosity; + +// @beta +export function isRushdExtensionEventName(name: string): boolean; + +// @beta +export const LENGTH_FIELD_BYTES: number; + +// @beta +export const LENGTH_FIELD_OFFSET: number; + +// @beta +export const MAX_OPERATION_ID_BYTES: number; + +// @beta +export function negotiateDaemonHello(hello: IDaemonHelloMessage, localVersion: IDaemonProtocolVersion, sessionId: string): DaemonHandshakeOutcome; + +// @beta +export const OPERATION_ID_LENGTH_BYTES: number; + +// @beta +export const OPERATION_ID_LENGTH_OFFSET: number; + +// @beta +export const PAYLOAD_OFFSET: number; + +// @beta +export class ProtocolVersionMismatchError extends DaemonProtocolError { + constructor(expectedMajor: number, actualMajor: number, options?: IDaemonProtocolErrorOptions); + readonly actualMajor: number; + readonly expectedMajor: number; +} + +// @beta +export const RUSHD_EXTENSION_NAMESPACE: 'rushd'; + +// @beta +export const RUSHD_OPERATION_HEADER: 'rushd.operation-header'; + +// @beta +export const RUSHD_OPERATION_STREAM_CLOSED: 'rushd.operation-stream-closed'; + +// @beta +export function serializeDaemonEventForSubscription(verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope): Uint8Array | undefined; + +// @beta +export function shouldSerializeDaemonEvent(verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope): boolean; + +// @beta +export const TYPE_FIELD_BYTES: number; + +// @beta +export const TYPE_FIELD_OFFSET: number; + +// @beta +export function validateDaemonControlMessage(value: unknown): void; + +// @beta +export function validateDaemonEventEnvelope(value: unknown): IDaemonEventEnvelope; + +``` diff --git a/common/reviews/api/rush-daemon-transport.api.md b/common/reviews/api/rush-daemon-transport.api.md new file mode 100644 index 00000000000..c69a18ee094 --- /dev/null +++ b/common/reviews/api/rush-daemon-transport.api.md @@ -0,0 +1,128 @@ +## API Report File for "@rushstack/rush-daemon-transport" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { IDaemonFrame } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol'; +import type * as net from 'node:net'; + +// @beta +export function computeDaemonWorkspaceKey(input: IWorkspaceKeyInput): string; + +// @beta +export function connectDaemonAsync(socketPath: string, options?: IDaemonConnectorOptions): Promise; + +// @beta +export class DaemonFrameConnection { + constructor(socket: net.Socket); + closeAsync(): Promise; + onClosed(handler: (error: Error | undefined) => void): void; + onFrame(handler: (frame: IDaemonFrame) => void): void; + sendFrameAsync(frame: IDaemonFrame): Promise; + // @internal + get socket(): net.Socket; +} + +// @beta +export class DaemonFrameListener { + closeAsync(): Promise; + static listenAsync(paths: IDaemonPaths, options: IDaemonListenerOptions): Promise; +} + +// @beta +export type DaemonReclaimLockOutcome = { + readonly acquired: true; +} | { + readonly acquired: false; + readonly reason: 'alreadyHeld'; +}; + +// @beta +export class DaemonTransportError extends Error { + constructor(code: DaemonTransportErrorCode, message: string); + readonly code: DaemonTransportErrorCode; +} + +// @beta +export enum DaemonTransportErrorCode { + connectionRefused = "connectionRefused", + connectionTimeout = "connectionTimeout", + daemonAlreadyRunning = "daemonAlreadyRunning", + transportClosed = "transportClosed" +} + +// @beta +export function ensureDaemonRuntimeDir(paths: IDaemonPaths): void; + +// @beta +export interface IDaemonConnectorOptions { + readonly connectTimeoutMs?: number; +} + +// @beta +export interface IDaemonListenerOptions { + readonly onConnection: (connection: DaemonFrameConnection) => void; + readonly protocolVersion: IDaemonProtocolVersion; + readonly startedAt?: string; +} + +// @beta +export interface IDaemonLockfile { + readonly pid: number; + readonly protocolVersion: IDaemonProtocolVersion; + readonly socketPath: string; + readonly startedAt: string; +} + +// @beta +export interface IDaemonPathEnvironment { + readonly env: Readonly>; + readonly platform: NodeJS.Platform; + readonly tmpdir: string; + readonly uid?: number; +} + +// @beta +export interface IDaemonPaths { + readonly lockfilePath: string; + readonly runtimeDir?: string; + readonly socketPath: string; +} + +// @beta +export function isDaemonProcessAlive(pid: number): boolean; + +// @beta +export interface IWorkspaceKeyInput { + readonly canonicalRepoRoot: string; + readonly rushVersion: string; + readonly startupOptions?: Readonly>; +} + +// @beta +export function readDaemonLockfile(lockfilePath: string): IDaemonLockfile | undefined; + +// @beta +export function reclaimStaleDaemonAsync(paths: IDaemonPaths): Promise; + +// @beta +export function removeDaemonArtifacts(lockfilePath: string, socketPath: string): void; + +// @beta +export function resolveDaemonPaths(environment: IDaemonPathEnvironment, workspaceKey: string): IDaemonPaths; + +// @beta +export function resolveDaemonPathsFromProcess(workspaceKey: string): IDaemonPaths; + +// @beta +export function tryAcquireReclaimLock(lockfilePath: string): DaemonReclaimLockOutcome; + +// @beta +export const WORKSPACE_KEY_LENGTH: number; + +// @beta +export function writeDaemonLockfile(lockfilePath: string, lockfile: IDaemonLockfile): void; + +``` diff --git a/common/reviews/api/rush-lib.api.md b/common/reviews/api/rush-lib.api.md index 1583568c3a4..5fbf2a8d07e 100644 --- a/common/reviews/api/rush-lib.api.md +++ b/common/reviews/api/rush-lib.api.md @@ -22,6 +22,7 @@ import { IPackageJson } from '@rushstack/node-core-library'; import { IPrefixMatch } from '@rushstack/lookup-by-path'; import type { IProblemCollector } from '@rushstack/terminal'; import { ITerminal } from '@rushstack/terminal'; +import type { ITerminalChunk } from '@rushstack/terminal'; import { ITerminalProvider } from '@rushstack/terminal'; import { JsonNull } from '@rushstack/node-core-library'; import { JsonObject } from '@rushstack/node-core-library'; @@ -598,6 +599,12 @@ export class IndividualVersionPolicy extends VersionPolicy { export interface _INpmOptionsJson extends IPackageManagerOptionsJsonBase { } +// @internal +export interface _IOperationActivityOptions { + readonly operationId?: string; + readonly stderr?: boolean; +} + // @internal (undocumented) export interface _IOperationBuildCacheOptions { buildCacheConfiguration: BuildCacheConfiguration; @@ -649,6 +656,16 @@ export interface IOperationGraphContext extends ICreateOperationsContext { readonly initialSnapshot?: IInputsSnapshot; } +// @internal +export interface _IOperationGraphEventSink { + onActivity?(text: string, options?: _IOperationActivityOptions): void; + onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; + onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; + onOperationRegistered?(operationId: string, silent: boolean): void; + onOperationStatusChanged?(result: IOperationExecutionResult, previousStatus: OperationStatus): void; + onOperationStreamClosed?(operationId: string): void; +} + // @alpha export interface IOperationGraphIterationOptions { // (undocumented) diff --git a/common/reviews/api/rush-terminal-renderer.api.md b/common/reviews/api/rush-terminal-renderer.api.md new file mode 100644 index 00000000000..e13568ec407 --- /dev/null +++ b/common/reviews/api/rush-terminal-renderer.api.md @@ -0,0 +1,94 @@ +## API Report File for "@rushstack/rush-terminal-renderer" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import type { DaemonVerbosity } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonClientCaps } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; +import { ITerminalChunk } from '@rushstack/terminal'; +import { TerminalWritable } from '@rushstack/terminal'; + +// @beta +export function applyDaemonChildEnvironment(baseEnv: Readonly>, caps: IDaemonClientCaps): Record; + +// @beta +export class DaemonRendererHost { + constructor(options: IDaemonRendererHostOptions); + closeAsync(): Promise; + handleEvent(envelope: IDaemonEventEnvelope): void; + handleLogChunk(operationId: string, stream: 'stdout' | 'stderr', chunk: Uint8Array): void; + initializeAsync(): Promise; +} + +// @beta +export type DaemonRenderStream = 'stdout' | 'stderr'; + +// @beta +export function formatDaemonOperationHeader(operationName: string, completed: number, total: number): string; + +// @beta +export function getDaemonChildEnvironmentOverrides(caps: IDaemonClientCaps): Record; + +// @beta +export interface IDaemonRenderer { + closeAsync(): Promise; + flushAsync(): Promise; + initializeAsync(context: IDaemonRendererContext): Promise; + readonly name: string; + report(event: IDaemonEventEnvelope): void; +} + +// @beta +export interface IDaemonRendererContext { + readonly terminal: IDaemonRendererTerminal; +} + +// @beta +export interface IDaemonRendererHostOptions { + readonly colorLevel?: number; + readonly renderer?: IDaemonRenderer; + readonly terminal: IDaemonRendererTerminal; + readonly verbosity?: DaemonVerbosity; +} + +// @beta +export interface IDaemonRendererTerminal { + readonly columns: number; + readonly isTTY: boolean; + write(text: string, stream: DaemonRenderStream): void; +} + +// @beta +export interface IOperationStreamRegistryOptions { + readonly destination: TerminalWritable; + readonly quiet: boolean; + readonly removeColors: boolean; +} + +// @beta +export class LegacyCollatedRenderer implements IDaemonRenderer { + closeAsync(): Promise; + flushAsync(): Promise; + initializeAsync(context: IDaemonRendererContext): Promise; + // (undocumented) + readonly name: string; + report(event: IDaemonEventEnvelope): void; +} + +// @beta +export class OperationStreamRegistry { + constructor(options: IOperationStreamRegistryOptions); + closeOperation(operationId: string): void; + registerOperation(): void; + writeChunk(operationId: string, chunk: ITerminalChunk): void; +} + +// @beta +export class TerminalSinkWritable extends TerminalWritable { + constructor(terminal: IDaemonRendererTerminal); + onWriteChunk(chunk: ITerminalChunk): void; +} + +``` diff --git a/libraries/rush-daemon-protocol/.npmignore b/libraries/rush-daemon-protocol/.npmignore new file mode 100644 index 00000000000..f7a40e10213 --- /dev/null +++ b/libraries/rush-daemon-protocol/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/libraries/rush-daemon-protocol/AGENTS.md b/libraries/rush-daemon-protocol/AGENTS.md new file mode 100644 index 00000000000..a8383b330ae --- /dev/null +++ b/libraries/rush-daemon-protocol/AGENTS.md @@ -0,0 +1,60 @@ +# Agent coding contract — `@rushstack/rush-daemon-protocol` + +This package is governed by an **ultra-strict lint policy** for generated code. All of the +rules below are enabled to `error` in `eslint.config.js` via the shared +`local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js` mixin. +They apply to **all** TypeScript in this package, **including tests** (`src/**/*.test.ts`). + +## Enforced rules (do not attempt to bypass) + +| Rule | Setting | +| ---- | ------- | +| `complexity` | `['error', 3]` | +| `max-depth` | `['error', 3]` | +| `max-lines-per-function` | `['error', 30]` | +| `max-lines` | `['error', 100]` — every file, including this means: keep files small; split modules | +| `max-params` | `['error', 4]` — use options objects | +| `@typescript-eslint/no-magic-numbers` | `'error'` — every numeric literal must be a named constant | +| `@typescript-eslint/prefer-nullish-coalescing` | `'error'` — use `??`, not `\|\|` or nullish-guard ternaries | +| `import/enforce-node-protocol-usage` | `['error', 'always']` — write `node:crypto`, never `crypto` | +| `import/order` | `['error', { alphabetize: asc, grouped, newlines-between: always }]` | +| `sort-imports` | `['error', { ignoreDeclarationSort: true }]` — sort named members | +| `@typescript-eslint/consistent-type-imports` | `['error', { fixStyle: 'separate-type-imports' }]` — `import type { X }`, never inline `type` specifiers | +| `import/no-relative-parent-imports` | `'error'` for non-test source — no `../` imports outside tests | +| `no-eval`, `@typescript-eslint/no-implied-eval` | `'error'` | + +## Suppression is forbidden — mechanically enforced + +- `linterOptions.noInlineConfig: true` makes **every** `eslint-disable*` comment a lint error. +- `reportUnusedDisableDirectives: 'error'` flags stale suppressions. +- Therefore, as an agent working in this package you MUST NOT: + - add `eslint-disable`, `eslint-disable-next-line`, `eslint-env`, or inline `/* eslint ... */` config comments; + - add entries to any `.eslint-bulk-suppressions.json`; + - add `eslintIgnore` keys to `package.json`; + - add `@ts-nocheck` or `@ts-ignore` comments; + - weaken, reorder, or remove the `strict-codegen` mixin in `eslint.config.js`. +- If a rule fires, **fix the code** (extract a constant, split the function/module, restructure) — never silence it. + +## Deferred rules (do not emulate with hacks) + +The following intended rules have no existing implementation in this repository's ESLint +toolchain and are **not yet enabled** (the user will wire them up later): +`no-magic-strings`, `no-object-mutation`, `no-array-mutation`, +`no-placeholder-implementation`, and the custom zero-tolerance import rules +(`no-re-export`, `require-clean-barrel`, `require-barrel-relative-exports`, +`no-export-alias`, `no-dynamic-import`, `no-hardcoded-secrets`, +`no-parent-internal-access`). Write code that would already satisfy them: prefer immutable +update patterns and named string constants, and never land stubs or `TODO` implementations. + +Note on barrels: `src/index.ts` is this package's public API barrel — it contains ONLY +re-exports of the public surface and is exempt from the deferred barrel notions above. +Do not restructure `index.ts` under the deferred rules; the enabled rules already pass on it. + +## Design notes for this package + +- `src/DaemonEvent*.ts` contains **placeholder** event-contract types that mirror + `@rushstack/reporter`'s `IReporterEventEnvelope` field-for-field. When the reporter + package merges into `main`, these types are replaced by imports from + `@rushstack/reporter` — do not fork the shapes. +- This package must remain dependency-light: platform-agnostic `Uint8Array` payloads + (no `Buffer` on the wire), no runtime dependencies, no `rush-lib`. diff --git a/libraries/rush-daemon-protocol/LICENSE b/libraries/rush-daemon-protocol/LICENSE new file mode 100644 index 00000000000..b96d274e023 --- /dev/null +++ b/libraries/rush-daemon-protocol/LICENSE @@ -0,0 +1,24 @@ +@rushstack/rush-daemon-protocol + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libraries/rush-daemon-protocol/README.md b/libraries/rush-daemon-protocol/README.md new file mode 100644 index 00000000000..935b7f65a2f --- /dev/null +++ b/libraries/rush-daemon-protocol/README.md @@ -0,0 +1,31 @@ +# @rushstack/rush-daemon-protocol + +> **Public beta** — this package is versioned at `0.x`; its API may change between minor versions. + +The engine-agnostic **wire layer** spoken by every client of the Rush daemon (`rushd`): + +- **Frame taxonomy** — five frame types: `0x01` control-json, `0x02` log-stdout, + `0x03` log-stderr, `0x04` stdin, `0x05` event. +- **Length-prefixed binary codec** — a streaming serializer/deserializer that is lossless + for arbitrary (including non-UTF-8) payloads and tolerant of arbitrarily split or + coalesced chunks. +- **`DAEMON_PROTOCOL_VERSION`** — the negotiated protocol version constant. +- **Version negotiation** — a `hello`/`helloAck` handshake with a typed + `ProtocolVersionMismatchError` on major-version mismatch. +- **Event contract** — the `0x05` frame payload envelope (currently a placeholder + mirroring `@rushstack/reporter`'s `IReporterEventEnvelope`; to be replaced by a direct + reference when the reporter package lands) plus namespaced `rushd.*` extension events. +- **Per-subscription verbosity** — a pure filter applied at event serialization so each + client receives its own verbosity subset without mutating shared engine state. + +Part of the Rush 6 / rushd re-architecture: +[microsoft/rushstack#5894](https://github.com/microsoft/rushstack/issues/5894). + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/libraries/rush-daemon-protocol/CHANGELOG.md) - Find + out what's new in the latest version +- [API Reference](https://rushstack.io/pages/api/rush-daemon-protocol/) + +`@rushstack/rush-daemon-protocol` is part of the **Rush Stack** family of projects. diff --git a/libraries/rush-daemon-protocol/config/api-extractor.json b/libraries/rush-daemon-protocol/config/api-extractor.json new file mode 100644 index 00000000000..3dbb76c0e6f --- /dev/null +++ b/libraries/rush-daemon-protocol/config/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "local-node-rig/profiles/default/config/api-extractor-base.json" +} diff --git a/libraries/rush-daemon-protocol/config/jest.config.json b/libraries/rush-daemon-protocol/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/libraries/rush-daemon-protocol/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/libraries/rush-daemon-protocol/config/rig.json b/libraries/rush-daemon-protocol/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/libraries/rush-daemon-protocol/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/libraries/rush-daemon-protocol/eslint.config.js b/libraries/rush-daemon-protocol/eslint.config.js new file mode 100644 index 00000000000..b08a47af297 --- /dev/null +++ b/libraries/rush-daemon-protocol/eslint.config.js @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); +const strictCodegenMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen'); + +module.exports = [ + ...nodeProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + // IMPORTANT: The strict-codegen mixin must remain last so its rules win conflicts. + ...strictCodegenMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/libraries/rush-daemon-protocol/package.json b/libraries/rush-daemon-protocol/package.json new file mode 100644 index 00000000000..d03678ee7d9 --- /dev/null +++ b/libraries/rush-daemon-protocol/package.json @@ -0,0 +1,55 @@ +{ + "name": "@rushstack/rush-daemon-protocol", + "version": "0.1.0", + "description": "Wire protocol for the Rush daemon (rushd): frame taxonomy, length-prefixed binary codec, and version negotiation. (public beta)", + "main": "./lib-commonjs/index.js", + "module": "./lib-esm/index.js", + "types": "./dist/rush-daemon-protocol.d.ts", + "exports": { + ".": { + "types": "./dist/rush-daemon-protocol.d.ts", + "node": "./lib-commonjs/index.js", + "import": "./lib-esm/index.js", + "require": "./lib-commonjs/index.js" + }, + "./lib/*": { + "types": "./lib-dts/*.d.ts", + "node": "./lib-commonjs/*.js", + "import": "./lib-esm/*.js", + "require": "./lib-commonjs/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "keywords": [ + "rush", + "rushd", + "daemon", + "protocol", + "wire", + "frames" + ], + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "libraries/rush-daemon-protocol" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + }, + "sideEffects": false +} diff --git a/libraries/rush-daemon-protocol/src/ControlFrameCodec.ts b/libraries/rush-daemon-protocol/src/ControlFrameCodec.ts new file mode 100644 index 00000000000..f6700eb4843 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/ControlFrameCodec.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { validateDaemonControlMessage } from './ControlMessageValidation'; +import type { DaemonControlMessage } from './DaemonControlMessage'; +import { DaemonProtocolError } from './DaemonProtocolError'; +import { WIRE_TEXT_DECODER, WIRE_TEXT_ENCODER } from './DaemonWireText'; + +/** + * Serializes a control message as UTF-8 JSON for a `0x01` control-json frame. + * + * @beta + */ +export function encodeDaemonControlMessage(message: DaemonControlMessage): Uint8Array { + return WIRE_TEXT_ENCODER.encode(JSON.stringify(message)); +} + +function parseControlJson(payload: Uint8Array): unknown { + try { + return JSON.parse(WIRE_TEXT_DECODER.decode(payload)) as unknown; + } catch (error) { + throw new DaemonProtocolError('malformedControlMessage', 'Control frame payload is not valid JSON.', { + cause: error + }); + } +} + +/** + * Parses and validates the payload of a `0x01` control-json frame. + * + * @throws {@link DaemonProtocolError} when the payload is not valid JSON or + * fails structural validation. + * + * @beta + */ +export function decodeDaemonControlMessage(payload: Uint8Array): DaemonControlMessage { + const parsed: unknown = parseControlJson(payload); + validateDaemonControlMessage(parsed); + return parsed as DaemonControlMessage; +} diff --git a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts new file mode 100644 index 00000000000..8f3f6dcfacc --- /dev/null +++ b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { isDaemonControlMessageKind } from './DaemonControlMessage'; +import { DaemonProtocolError } from './DaemonProtocolError'; +import { isDaemonVerbosity } from './DaemonVerbosity'; + +/** Returns `true` when `value` is a plain object. @beta */ +export function isDaemonControlRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function fail(reason: string): never { + throw new DaemonProtocolError('malformedControlMessage', reason); +} + +function requireRecordField(record: Record, field: string): Record { + const value: unknown = record[field]; + if (!isDaemonControlRecord(value)) { + fail(`Control message field "${field}" must be an object.`); + } + return value; +} + +function requireStringField(record: Record, field: string): void { + if (typeof record[field] !== 'string') { + fail(`Control message field "${field}" must be a string.`); + } +} + +function requireNumberField(record: Record, field: string): void { + if (typeof record[field] !== 'number') { + fail(`Control message field "${field}" must be a number.`); + } +} + +function requireVersion(payload: Record): void { + const version: Record = requireRecordField(payload, 'protocolVersion'); + requireNumberField(version, 'major'); + requireNumberField(version, 'minor'); +} + +function validateHelloAck(payload: Record): void { + requireVersion(payload); + requireStringField(payload, 'sessionId'); +} + +function validateSubscribe(payload: Record): void { + if (typeof payload.isTTY !== 'boolean') { + fail('Subscribe message payload.isTTY must be a boolean.'); + } + requireSubscribeVerbosity(payload); +} + +function requireSubscribeVerbosity(payload: Record): void { + if (payload.verbosity !== undefined && !isDaemonVerbosity(payload.verbosity)) { + fail('Subscribe message payload.verbosity is not a known verbosity level.'); + } +} + +function validateError(payload: Record): void { + requireStringField(payload, 'code'); + requireStringField(payload, 'message'); +} + +type ControlValidator = (payload: Record) => void; + +const noopValidator: ControlValidator = () => undefined; + +const VALIDATORS_BY_KIND: Record = { + hello: requireVersion, + helloAck: validateHelloAck, + subscribe: validateSubscribe, + unsubscribe: noopValidator, + ping: noopValidator, + pong: (payload: Record) => requireNumberField(payload, 'uptimeMs'), + error: validateError +}; + +/** + * Structurally validates a parsed control message. + * + * @throws {@link DaemonProtocolError} when the value is not a well-formed control message. + * + * @beta + */ +export function validateDaemonControlMessage(value: unknown): void { + if (!isDaemonControlRecord(value)) { + fail('Control frame payload is not a JSON object.'); + } + if (!isDaemonControlMessageKind(value.kind)) { + fail('Control message has an unknown kind.'); + } + const payload: Record = requireRecordField(value, 'payload'); + VALIDATORS_BY_KIND[value.kind](payload); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonClientCaps.ts b/libraries/rush-daemon-protocol/src/DaemonClientCaps.ts new file mode 100644 index 00000000000..db0e76b511e --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonClientCaps.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonVerbosity } from './DaemonVerbosity'; + +/** + * Terminal capabilities and verbosity requested by one client subscription. + * + * @remarks + * Carried in the request envelope; the daemon applies `verbosity` as a + * per-subscription serialization filter and threads `columns`/`colorLevel` + * into child process environments (`FORCE_COLOR`/`COLUMNS`) for TTY clients. + * + * @beta + */ +export interface IDaemonClientCaps { + /** Whether the client's output is an interactive TTY. */ + readonly isTTY: boolean; + /** The verbosity subset this client receives. Defaults to `normal`. */ + readonly verbosity?: DaemonVerbosity; + /** The client's terminal width in columns, when known. */ + readonly columns?: number; + /** The client's color support level (0-3), when known. */ + readonly colorLevel?: number; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts new file mode 100644 index 00000000000..36b592b5e3b --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonClientCaps } from './DaemonClientCaps'; +import type { DaemonProtocolErrorCode } from './DaemonProtocolError'; +import type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; + +/** The empty payload of control messages that carry no data. @beta */ +export type DaemonEmptyPayload = Record; + +/** The first frame a client sends on a new connection. @beta */ +export interface IDaemonHelloMessage { + readonly kind: 'hello'; + readonly payload: { readonly protocolVersion: IDaemonProtocolVersion }; +} + +/** The server's accepting reply to a compatible `hello`. @beta */ +export interface IDaemonHelloAckMessage { + readonly kind: 'helloAck'; + readonly payload: { + readonly protocolVersion: IDaemonProtocolVersion; + readonly sessionId: string; + }; +} + +/** Subscribes the connection with the given client capabilities. @beta */ +export interface IDaemonSubscribeMessage { + readonly kind: 'subscribe'; + readonly payload: IDaemonClientCaps; +} + +/** Ends this connection's subscription. @beta */ +export interface IDaemonUnsubscribeMessage { + readonly kind: 'unsubscribe'; + readonly payload: DaemonEmptyPayload; +} + +/** A liveness probe. @beta */ +export interface IDaemonPingMessage { + readonly kind: 'ping'; + readonly payload: DaemonEmptyPayload; +} + +/** The liveness reply. @beta */ +export interface IDaemonPongMessage { + readonly kind: 'pong'; + readonly payload: { readonly uptimeMs: number }; +} + +/** A protocol error sent on the wire. @beta */ +export interface IDaemonErrorMessage { + readonly kind: 'error'; + readonly payload: { + readonly code: DaemonProtocolErrorCode; + readonly message: string; + }; +} + +/** + * The union of every control message carried by a `0x01` control-json frame. + * + * @remarks + * Every variant has the uniform `{ kind, payload }` shape, so reads of `kind` + * stay monomorphic and the payload type is discriminated by `kind`. + * @beta + */ +export type DaemonControlMessage = + | IDaemonHelloMessage + | IDaemonHelloAckMessage + | IDaemonSubscribeMessage + | IDaemonUnsubscribeMessage + | IDaemonPingMessage + | IDaemonPongMessage + | IDaemonErrorMessage; + +/** + * The runtime list of control message `kind` discriminants, from which + * {@link DaemonControlMessageKind} is derived (single source of truth). + * + * @beta + */ +export const DAEMON_CONTROL_MESSAGE_KINDS: readonly [ + 'hello', + 'helloAck', + 'subscribe', + 'unsubscribe', + 'ping', + 'pong', + 'error' +] = ['hello', 'helloAck', 'subscribe', 'unsubscribe', 'ping', 'pong', 'error']; + +/** The union of control message `kind` discriminants, derived from the list. @beta */ +export type DaemonControlMessageKind = (typeof DAEMON_CONTROL_MESSAGE_KINDS)[number]; + +const CONTROL_KIND_SET: ReadonlySet = new Set(DAEMON_CONTROL_MESSAGE_KINDS); + +/** Returns `true` when `value` is a control message `kind`. @beta */ +export function isDaemonControlMessageKind(value: unknown): value is DaemonControlMessageKind { + return typeof value === 'string' && CONTROL_KIND_SET.has(value); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts b/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts new file mode 100644 index 00000000000..37da517480c --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// TODO(reconcile): replace these placeholder types with `IReporterEventEnvelope` +// and friends from `@rushstack/reporter` once that package merges into main +// (#5858). The shapes below mirror the reporter contract field-for-field so the +// swap is mechanical (field order differs: optional fields are declared last). + +import type { DaemonEventType } from './DaemonEventType'; + +/** + * Identifies the code that produced an event. + * + * @beta + */ +export interface IDaemonEventSource { + /** The npm package name of the producer, for example `@microsoft/rush-lib`. */ + readonly packageName: string; + /** The version of the producing package. */ + readonly packageVersion: string; + /** An optional finer-grained component name within the producing package. */ + readonly component?: string; +} + +/** + * Associates an event with the command, operation, project, and phase it belongs to. + * + * @beta + */ +export interface IDaemonEventScope { + /** The name of the Rush command the event belongs to. */ + readonly commandName?: string; + /** The identifier of the operation the event belongs to. */ + readonly operationId?: string; + /** The name of the project the event belongs to. */ + readonly projectName?: string; + /** The name of the phase the event belongs to. */ + readonly phaseName?: string; +} + +/** + * Classifies how sensitive a value is, and therefore which destinations may receive it. + * + * @beta + */ +export type DaemonEventPrivacy = 'public' | 'local-sensitive' | 'secret'; + +/** + * The canonical, immutable envelope wrapping every event carried by a `0x05` frame. + * + * @remarks + * Envelopes are immutable and JSON-serializable. `sequence` is authoritative for + * ordering; `timestamp` is informational only. Required fields are declared + * before optional fields to keep the object layout monomorphic. + * + * @beta + */ +export interface IDaemonEventEnvelope { + /** The event-schema protocol version that produced this event. */ + readonly protocolVersion: { readonly major: number; readonly minor: number }; + /** A unique identifier for this event, assigned by the sink on emission. */ + readonly eventId: string; + /** The identifier of the session that produced this event. */ + readonly sessionId: string; + /** The authoritative monotonic ordering value assigned by the producer's manager. */ + readonly sequence: number; + /** The informational ISO 8601 time at which the event was created. */ + readonly timestamp: string; + /** The code that produced this event. */ + readonly source: IDaemonEventSource; + /** The minimum privacy classification floor for every field in this event. */ + readonly privacy: DaemonEventPrivacy; + /** Whether this event is correctness-critical and must never be dropped. */ + readonly required: boolean; + /** The core event type, or `extension` for a namespaced extension event. */ + readonly type: DaemonEventType; + /** The JSON-serializable payload for this event type. */ + readonly payload: TPayload; + /** The identifier of the parent session, when from a child session. */ + readonly parentSessionId?: string; + /** The identifier of the parent operation that spawned the child session. */ + readonly parentOperationId?: string; + /** For child sessions, the producer's original local sequence value. */ + readonly sourceSequence?: number; + /** The command, operation, project, and phase this event belongs to. */ + readonly scope?: IDaemonEventScope; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts b/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts new file mode 100644 index 00000000000..d566550e0f7 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonEventEnvelope } from './DaemonEventEnvelope'; +import { validateDaemonEventEnvelope } from './DaemonEventValidation'; +import { DaemonProtocolError } from './DaemonProtocolError'; +import type { DaemonVerbosity } from './DaemonVerbosity'; +import { shouldSerializeDaemonEvent } from './DaemonVerbosityFilter'; +import { WIRE_TEXT_DECODER, WIRE_TEXT_ENCODER } from './DaemonWireText'; + +/** + * Serializes an event envelope as UTF-8 JSON for a `0x05` event frame. + * + * @beta + */ +export function encodeDaemonEventFrame(envelope: IDaemonEventEnvelope): Uint8Array { + return WIRE_TEXT_ENCODER.encode(JSON.stringify(envelope)); +} + +/** + * Parses and structurally validates the payload of a `0x05` event frame. + * + * @remarks + * Unknown optional fields introduced by newer minor protocol versions are + * preserved; malformed input is rejected with a typed error rather than + * reaching event routing. + * + * @throws {@link DaemonProtocolError} when the payload is not valid JSON or + * fails envelope shape validation. + * + * @beta + */ +export function decodeDaemonEventFrame(payload: Uint8Array): IDaemonEventEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse(WIRE_TEXT_DECODER.decode(payload)) as unknown; + } catch (error) { + throw new DaemonProtocolError('malformedPayload', 'Event frame payload is not valid JSON.', { + cause: error + }); + } + return validateDaemonEventEnvelope(parsed); +} + +/** + * Serializes `envelope` for a subscription at `verbosity`, or returns + * `undefined` when the filter suppresses it for that subscription. + * + * @remarks + * This is the serialization-time hook implementing per-client verbosity: the + * shared engine event stream is never mutated; each subscription decides + * independently. + * + * @beta + */ +export function serializeDaemonEventForSubscription( + verbosity: DaemonVerbosity, + envelope: IDaemonEventEnvelope +): Uint8Array | undefined { + if (!shouldSerializeDaemonEvent(verbosity, envelope)) { + return undefined; + } + return encodeDaemonEventFrame(envelope); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonEventType.ts b/libraries/rush-daemon-protocol/src/DaemonEventType.ts new file mode 100644 index 00000000000..78362e406d0 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonEventType.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// TODO(reconcile): replace with `ReporterEventType`/`REPORTER_EVENT_TYPES` from +// `@rushstack/reporter` once that package merges into main (#5858). + +/** + * The runtime list of every core event type, in canonical order. + * + * @remarks + * The `as const` declaration is the single source of truth: the + * {@link DaemonEventType} union is derived from it, so the list and the type + * can never drift apart. + * + * @beta + */ +/** + * The runtime list of every core event type, in canonical order. + * + * @remarks + * Annotated as a readonly literal-string tuple, and the {@link DaemonEventType} + * union is derived from it, so the list and the type can never drift apart. + * + * @beta + */ +export const DAEMON_EVENT_TYPES: readonly [ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'activityChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult', + 'extension' +] = [ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'activityChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult', + 'extension' +]; + +/** + * The closed set of core event type identifiers carried by `0x05` event frames. + * + * @remarks + * Derived from {@link DAEMON_EVENT_TYPES}. The set is intentionally closed and + * mirrors the reporter event contract; producers that need a custom event use + * the `extension` type with a namespaced identifier instead. + * + * @beta + */ +export type DaemonEventType = (typeof DAEMON_EVENT_TYPES)[number]; + +const DAEMON_EVENT_TYPE_SET: ReadonlySet = new Set(DAEMON_EVENT_TYPES); + +/** + * Returns `true` when `value` is a core event type identifier. + * + * @beta + */ +export function isDaemonEventType(value: unknown): value is DaemonEventType { + return typeof value === 'string' && DAEMON_EVENT_TYPE_SET.has(value); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonEventValidation.ts b/libraries/rush-daemon-protocol/src/DaemonEventValidation.ts new file mode 100644 index 00000000000..ecb28b9ccd1 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonEventValidation.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { isDaemonControlRecord } from './ControlMessageValidation'; +import type { IDaemonEventEnvelope, IDaemonEventSource } from './DaemonEventEnvelope'; +import { isDaemonEventType } from './DaemonEventType'; +import { DaemonProtocolError } from './DaemonProtocolError'; + +const ENVELOPE_STRING_FIELDS: readonly string[] = ['eventId', 'sessionId', 'timestamp']; + +function hasStringFields(record: Record, fields: readonly string[]): boolean { + return fields.every((field: string) => typeof record[field] === 'string'); +} + +function isProtocolVersionLike(value: unknown): boolean { + if (!isDaemonControlRecord(value)) { + return false; + } + return typeof value.major === 'number' && typeof value.minor === 'number'; +} + +function isEventSource(value: unknown): value is IDaemonEventSource { + if (!isDaemonControlRecord(value)) { + return false; + } + return typeof value.packageName === 'string' && typeof value.packageVersion === 'string'; +} + +function hasValidScalars(record: Record): boolean { + return typeof record.sequence === 'number' && typeof record.required === 'boolean'; +} + +function hasValidEnvelopeShape(record: Record): boolean { + return [ + isDaemonEventType(record.type), + hasStringFields(record, ENVELOPE_STRING_FIELDS), + hasValidScalars(record), + isEventSource(record.source), + isProtocolVersionLike(record.protocolVersion) + ].every(Boolean); +} + +/** + * Returns `true` when `value` is structurally a valid event envelope. + * + * @remarks + * Unknown optional fields introduced by newer minor protocol versions are + * tolerated (they are preserved through JSON round-trips); only the core + * shape is validated. + * + * @beta + */ +export function isDaemonEventEnvelope(value: unknown): value is IDaemonEventEnvelope { + return isDaemonControlRecord(value) && hasValidEnvelopeShape(value); +} + +/** + * Validates `value` as an event envelope, throwing a typed + * {@link DaemonProtocolError} (`malformedPayload`) otherwise. + * + * @beta + */ +export function validateDaemonEventEnvelope(value: unknown): IDaemonEventEnvelope { + if (!isDaemonEventEnvelope(value)) { + throw new DaemonProtocolError( + 'malformedPayload', + 'Event frame payload is not a valid event envelope.' + ); + } + return value; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonExtensionEventName.ts b/libraries/rush-daemon-protocol/src/DaemonExtensionEventName.ts new file mode 100644 index 00000000000..656b7c8d4c0 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonExtensionEventName.ts @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// TODO(reconcile): replace with `isReporterExtensionEventName` from +// `@rushstack/reporter` once that package merges into main (#5858). + +/** + * The name of an extension event. + * + * @remarks + * Extension events carry namespaced identifiers of the form `.`, + * for example `rushd.client-subscribed`. Each dot-separated segment is lowercase + * and begins with a letter. Namespacing keeps daemon-specific events from + * colliding with the closed core event set. + * + * @beta + */ +export type DaemonExtensionEventName = string; + +const EXTENSION_EVENT_NAME_REGEXP: RegExp = + /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*(?:\.[a-z][a-z0-9]*(?:-[a-z0-9]+)*)+$/; + +/** + * The namespace reserved for rushd-specific extension events. + * + * @beta + */ +export const RUSHD_EXTENSION_NAMESPACE: 'rushd' = 'rushd'; + +const RUSHD_NAMESPACE_PREFIX: string = `${RUSHD_EXTENSION_NAMESPACE}.`; + +/** + * Returns `true` if `name` is a valid namespaced extension event identifier. + * + * @remarks + * A valid name has at least two dot-separated segments (a namespace and a name). + * Each segment is lowercase, begins with a letter, and may contain digits and + * internal single hyphens. + * + * @beta + */ +export function isDaemonExtensionEventName(name: string): name is DaemonExtensionEventName { + return EXTENSION_EVENT_NAME_REGEXP.test(name); +} + +/** + * Returns `true` if `name` is a valid extension event identifier in the + * reserved `rushd.*` namespace. + * + * @beta + */ +export function isRushdExtensionEventName(name: string): boolean { + return name.startsWith(RUSHD_NAMESPACE_PREFIX) && isDaemonExtensionEventName(name); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonFrame.ts b/libraries/rush-daemon-protocol/src/DaemonFrame.ts new file mode 100644 index 00000000000..42719f575f2 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonFrame.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonFrameType } from './DaemonFrameType'; + +/** + * A single decoded wire frame: a kind byte plus its opaque payload bytes. + * + * @remarks + * The frame layer never interprets payloads. Interpretation (JSON control + * messages, id-tagged log chunks, event envelopes) belongs to the message + * layer, so that raw log and stdin bytes round-trip losslessly, including + * non-UTF-8 content. + * + * The payload is a `Uint8Array` (not a Node.js `Buffer`) so the protocol is + * platform-agnostic and can later generalize to WebSocket transports. + * + * @beta + */ +export interface IDaemonFrame { + /** + * The frame kind byte. + */ + readonly kind: DaemonFrameType; + + /** + * The payload bytes. Never a view onto a larger shared buffer. + */ + readonly payload: Uint8Array; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonFrameType.ts b/libraries/rush-daemon-protocol/src/DaemonFrameType.ts new file mode 100644 index 00000000000..95a4dd1cd8c --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonFrameType.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The wire frame kind byte of the rushd protocol. + * + * @remarks + * The taxonomy is fixed by the protocol specification: + * `0x01` control-json, `0x02` log-stdout, `0x03` log-stderr, `0x04` stdin, `0x05` event. + * + * @beta + */ +export enum DaemonFrameType { + /** A UTF-8 JSON control message, for example `hello` or `subscribe`. */ + controlJson = 0x01, + /** Raw stdout bytes belonging to one operation's stream. */ + logStdout = 0x02, + /** Raw stderr bytes belonging to one operation's stream. */ + logStderr = 0x03, + /** Raw stdin bytes forwarded from a client. */ + stdin = 0x04, + /** A UTF-8 JSON event envelope. */ + event = 0x05 +} + +const LOWEST_FRAME_TYPE: DaemonFrameType = DaemonFrameType.controlJson; +const HIGHEST_FRAME_TYPE: DaemonFrameType = DaemonFrameType.event; + +/** + * Returns `true` when `value` is a byte assigned to a known frame kind. + * + * @remarks + * The taxonomy is a contiguous range, so the containment test is a numeric + * comparison rather than a collection scan. + * + * @beta + */ +export function isDaemonFrameType(value: number): value is DaemonFrameType { + return ( + Number.isInteger(value) && value >= LOWEST_FRAME_TYPE && value <= HIGHEST_FRAME_TYPE + ); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonHandshake.ts b/libraries/rush-daemon-protocol/src/DaemonHandshake.ts new file mode 100644 index 00000000000..236b1a48bf0 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonHandshake.ts @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonHelloAckMessage, IDaemonHelloMessage } from './DaemonControlMessage'; +import { ProtocolVersionMismatchError } from './DaemonProtocolError'; +import type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; +import { isDaemonProtocolCompatible } from './DaemonProtocolVersion'; + +/** + * Creates the `hello` message a client sends as its first frame. + * + * @beta + */ +export function createDaemonHello(protocolVersion: IDaemonProtocolVersion): IDaemonHelloMessage { + return { kind: 'hello', payload: { protocolVersion } }; +} + +/** + * Creates the `helloAck` message a server replies with when versions match. + * + * @beta + */ +export function createDaemonHelloAck( + protocolVersion: IDaemonProtocolVersion, + sessionId: string +): IDaemonHelloAckMessage { + return { kind: 'helloAck', payload: { protocolVersion, sessionId } }; +} + +/** + * The outcome of evaluating a peer's `hello` against the local protocol version. + * + * @beta + */ +export type DaemonHandshakeOutcome = + | { readonly accepted: true; readonly ack: IDaemonHelloAckMessage } + | { readonly accepted: false; readonly error: ProtocolVersionMismatchError }; + +/** + * Evaluates a peer's `hello`; on major-version match returns the `helloAck` to + * send, otherwise the typed mismatch error to send (or throw). + * + * @beta + */ +export function negotiateDaemonHello( + hello: IDaemonHelloMessage, + localVersion: IDaemonProtocolVersion, + sessionId: string +): DaemonHandshakeOutcome { + if (!isDaemonProtocolCompatible(localVersion, hello.payload.protocolVersion)) { + return { + accepted: false, + error: new ProtocolVersionMismatchError( + localVersion.major, + hello.payload.protocolVersion.major + ) + }; + } + return { accepted: true, ack: createDaemonHelloAck(localVersion, sessionId) }; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonJsonValue.ts b/libraries/rush-daemon-protocol/src/DaemonJsonValue.ts new file mode 100644 index 00000000000..a1006a321c3 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonJsonValue.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// TODO(reconcile): replace with `ReporterJsonNull`/`ReporterJsonValue` from +// `@rushstack/reporter` once that package merges into main (#5858). + +/** + * Represents JSON's `null`, which daemon event payloads may contain. + * + * @remarks + * JSON parsers always return JavaScript's `null`. Event payloads are transported + * as JSON, so this alias describes that value without triggering the repo's + * no-new-null lint rule. Do not use it for any other purpose. + * + * @beta + */ +export type DaemonJsonNull = null; + +/** + * A JSON-serializable value. + * + * @remarks + * Event payloads are immutable and JSON-serializable, and JavaScript `Error` + * instances are never serialized directly. Typing a payload as + * `DaemonJsonValue` ensures it round-trips through `JSON.stringify`/`JSON.parse` + * without loss. + * + * @beta + */ +export type DaemonJsonValue = + | string + | number + | boolean + | DaemonJsonNull + | readonly DaemonJsonValue[] + | { readonly [key: string]: DaemonJsonValue }; diff --git a/libraries/rush-daemon-protocol/src/DaemonOperationPayloads.ts b/libraries/rush-daemon-protocol/src/DaemonOperationPayloads.ts new file mode 100644 index 00000000000..926e20e4494 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonOperationPayloads.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// TODO(reconcile): align with `@rushstack/reporter`'s payload vocabulary once +// that package merges into main (#5858). + +/** + * Payload of an `operationRegistered` event: one operation known to the engine. + * + * @beta + */ +export interface IDaemonOperationRegisteredPayload { + /** The operation identifier (also the display name today). */ + readonly operationId: string; + /** Whether the operation is silent (excluded from progress totals). */ + readonly silent?: boolean; +} + +/** + * Payload of an `operationStatusChanged` event. + * + * @remarks + * `status` carries the engine's raw status string (for example `SUCCESS` or + * `FAILURE`) so no information is lost versus the legacy colorized text. + * + * @beta + */ +export interface IDaemonOperationStatusChangedPayload { + /** The operation whose status changed. */ + readonly operationId: string; + /** The new raw engine status string. */ + readonly status: string; + /** The previous raw engine status string, when known. */ + readonly previousStatus?: string; +} + +/** + * Payload of an `activityChanged` event: a human-oriented status line. + * + * @beta + */ +export interface IDaemonActivityPayload { + /** The activity text (for example the summary lines). */ + readonly text: string; + /** The stream the line was written to. Defaults to `stdout`. */ + readonly stream?: 'stdout' | 'stderr'; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonProtocolError.ts b/libraries/rush-daemon-protocol/src/DaemonProtocolError.ts new file mode 100644 index 00000000000..ad3b91a078e --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonProtocolError.ts @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The closed set of machine-readable rushd protocol error codes. + * + * @beta + */ +export type DaemonProtocolErrorCode = + | 'frameTooLarge' + | 'unknownFrameType' + | 'malformedPayload' + | 'malformedControlMessage' + | 'protocolVersionMismatch'; + +/** + * Options for {@link DaemonProtocolError} construction. + * + * @beta + */ +export interface IDaemonProtocolErrorOptions { + /** + * The underlying cause, attached per the standard `Error` `cause` convention. + */ + readonly cause?: unknown; +} + +/** + * A typed error raised by the rushd wire protocol. + * + * @remarks + * Every protocol failure carries a machine-readable + * {@link DaemonProtocolError.code | code} so peers can react programmatically + * (for example by restarting the daemon on a version mismatch). + * + * @beta + */ +export class DaemonProtocolError extends Error { + /** + * The machine-readable error code. + */ + public readonly code: DaemonProtocolErrorCode; + + public constructor( + code: DaemonProtocolErrorCode, + message: string, + options?: IDaemonProtocolErrorOptions + ) { + super(message, options); + this.name = 'DaemonProtocolError'; + this.code = code; + } +} + +/** + * A protocol error raised (or sent) when the peer's protocol major version + * differs from the local one. + * + * @beta + */ +export class ProtocolVersionMismatchError extends DaemonProtocolError { + /** + * The protocol major version required by the rejecting peer. + */ + public readonly expectedMajor: number; + + /** + * The protocol major version offered by the rejected peer. + */ + public readonly actualMajor: number; + + public constructor( + expectedMajor: number, + actualMajor: number, + options?: IDaemonProtocolErrorOptions + ) { + super( + 'protocolVersionMismatch', + `Unsupported rushd protocol major version ${actualMajor}; this peer requires major version ${expectedMajor}.`, + options + ); + this.name = 'ProtocolVersionMismatchError'; + this.expectedMajor = expectedMajor; + this.actualMajor = actualMajor; + } +} diff --git a/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts new file mode 100644 index 00000000000..1bc51b155fb --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * A rushd wire protocol version. + * + * @remarks + * The `major` version gates compatibility: peers whose major versions differ + * reject one another during the handshake. `minor` versions are additive, so a + * peer ignores unknown optional fields introduced by a newer minor. + * + * @beta + */ +export interface IDaemonProtocolVersion { + /** + * The major protocol version. Incremented only for breaking changes. + */ + readonly major: number; + + /** + * The minor protocol version. Incremented for additive, backward-compatible changes. + */ + readonly minor: number; +} + +/** + * The wire protocol version implemented by this package. + * + * @remarks + * Exchanged during the connection handshake; a major-version mismatch is a + * typed, terminal error. Starts at `0.x` while rushd is in public beta. + * + * @beta + */ +export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion = { + major: 0, + minor: 1 +}; + +/** + * Returns `true` when two versions are wire-compatible (same major version). + * + * @beta + */ +export function isDaemonProtocolCompatible( + local: IDaemonProtocolVersion, + remote: IDaemonProtocolVersion +): boolean { + return local.major === remote.major; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts b/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts new file mode 100644 index 00000000000..9b0f1ede45c --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The payload shape of `extension` events: a namespaced name plus data. + * + * @beta + */ +export interface IDaemonExtensionEventPayload { + /** The namespaced extension event name, for example `rushd.operation-stream-closed`. */ + readonly name: string; + /** The extension's data. */ + readonly data: TData; +} + +/** + * Extension event name: an operation's output stream was closed. + * + * @remarks + * The trigger is the engine closing the operation's collated writer at the end + * of its execution — after all of its output and status lines were written. + * This is the authoritative "no more output for this operation" signal; clients + * should flush the operation's buffered collation state on receipt. The display + * side of the lifecycle (when buffered output first becomes visible) is carried + * by {@link RUSHD_OPERATION_HEADER}. + * + * @beta + */ +export const RUSHD_OPERATION_STREAM_CLOSED: 'rushd.operation-stream-closed' = + 'rushd.operation-stream-closed'; + +/** + * Extension event name: an operation's collated output first became visible. + * + * @remarks + * The trigger is the collator activating the operation's stream (its header is + * displayed). The payload carries the engine-authoritative progress counters + * (completed/total) rendered into the legacy `==[ name ]===[ x of y ]==` + * header; clients may recompute these from the preceding event stream, but the + * carried values remain correct for a client that attaches mid-iteration and + * never saw the preceding stream. + * + * @beta + */ +export const RUSHD_OPERATION_HEADER: 'rushd.operation-header' = 'rushd.operation-header'; + +/** Data payload of a {@link RUSHD_OPERATION_STREAM_CLOSED} event. @beta */ +export interface IDaemonOperationStreamClosedPayload { + /** The operation whose stream closed. */ + readonly operationId: string; +} + +/** Data payload of a {@link RUSHD_OPERATION_HEADER} event. @beta */ +export interface IDaemonOperationHeaderPayload { + /** The operation whose output was displayed. */ + readonly operationId: string; + /** The 1-based count of operations displayed so far. */ + readonly completedOperations: number; + /** The total operations in the iteration. */ + readonly totalOperations: number; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonVerbosity.ts b/libraries/rush-daemon-protocol/src/DaemonVerbosity.ts new file mode 100644 index 00000000000..83fdd0f0508 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonVerbosity.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The per-subscription verbosity levels a client may request. + * + * @remarks + * Verbosity is applied as a pure filter at event serialization time; it never + * mutates shared engine state, so concurrent clients may each use a different + * verbosity. Levels are ordered `quiet` \< `normal` \< `verbose` \< `debug`. + * + * @beta + */ +export type DaemonVerbosity = 'quiet' | 'normal' | 'verbose' | 'debug'; + +const VERBOSITY_ORDER: readonly DaemonVerbosity[] = ['quiet', 'normal', 'verbose', 'debug']; + +/** + * Returns `true` when `value` is a valid verbosity level name. + * + * @beta + */ +export function isDaemonVerbosity(value: unknown): value is DaemonVerbosity { + return typeof value === 'string' && (VERBOSITY_ORDER as readonly string[]).includes(value); +} + +/** + * Compares two verbosity levels; returns a negative number when `a` is quieter than `b`. + * + * @beta + */ +export function compareDaemonVerbosity(a: DaemonVerbosity, b: DaemonVerbosity): number { + return VERBOSITY_ORDER.indexOf(a) - VERBOSITY_ORDER.indexOf(b); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts b/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts new file mode 100644 index 00000000000..b147cec2e15 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { isDaemonControlRecord } from './ControlMessageValidation'; +import type { IDaemonEventEnvelope } from './DaemonEventEnvelope'; +import type { DaemonEventType } from './DaemonEventType'; +import type { DaemonVerbosity } from './DaemonVerbosity'; + +/** The severity of a diagnostic event payload, used by verbosity filtering. @beta */ +export type DaemonDiagnosticSeverity = 'debug' | 'info' | 'warning' | 'error'; + +/** The minimal structural contract the verbosity filter needs for `diagnosticEmitted` payloads. @beta */ +export interface IDaemonDiagnosticPayload { + readonly severity: DaemonDiagnosticSeverity; +} + +const DIAGNOSTIC_SEVERITIES: readonly DaemonDiagnosticSeverity[] = ['debug', 'info', 'warning', 'error']; +const DIAGNOSTIC_SEVERITY_SET: ReadonlySet = new Set(DIAGNOSTIC_SEVERITIES); + +// The engine only emits activityChanged events for lines it actually printed, +// so quiet mode still receives the few lines legacy quiet mode shows (for +// example the parallelism line and hook errors). +const QUIET_TYPES: ReadonlySet = new Set([ + 'commandResult', + 'diagnosticEmitted', + 'activityChanged' +]); + +const NORMAL_TYPES: ReadonlySet = new Set([ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'activityChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'externalProcessStarted', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult' +]); + +function isDiagnosticSeverity(value: unknown): value is DaemonDiagnosticSeverity { + return typeof value === 'string' && DIAGNOSTIC_SEVERITY_SET.has(value); +} + +function readDiagnosticSeverity(payload: unknown): DaemonDiagnosticSeverity | undefined { + const severity: unknown = isDaemonControlRecord(payload) ? payload.severity : undefined; + return isDiagnosticSeverity(severity) ? severity : undefined; +} + +function isQuietDiagnostic(severity: DaemonDiagnosticSeverity | undefined): boolean { + return severity === 'error' || severity === 'warning'; +} + +function allowsDiagnostic(verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope): boolean { + if (verbosity === 'quiet') { + return isQuietDiagnostic(readDiagnosticSeverity(envelope.payload)); + } + return verbosity === 'normal' ? readDiagnosticSeverity(envelope.payload) !== 'debug' : true; +} + +function isTypeAllowed(verbosity: DaemonVerbosity, type: DaemonEventType): boolean { + if (verbosity === 'verbose') { + return true; + } + return verbosity === 'quiet' ? QUIET_TYPES.has(type) : NORMAL_TYPES.has(type); +} + +function isAllowedAtVerbosity(verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope): boolean { + if (envelope.type === 'extension') { + return false; + } + return envelope.type === 'diagnosticEmitted' + ? allowsDiagnostic(verbosity, envelope) + : isTypeAllowed(verbosity, envelope.type); +} + +/** + * Returns `true` when `envelope` should be serialized for a subscription at `verbosity`. + * + * @remarks + * This is the per-client filter applied at event serialization time. It is a pure + * function of the envelope — shared engine state is never consulted or mutated — so + * two clients at different verbosities each receive the correct subset of the same + * event stream. `debug` passes everything, including `rushd.*` extension events. + * `required` events are never filtered out. + * @beta + */ +export function shouldSerializeDaemonEvent( + verbosity: DaemonVerbosity, + envelope: IDaemonEventEnvelope +): boolean { + if (verbosity === 'debug' || envelope.required) { + return true; + } + return isAllowedAtVerbosity(verbosity, envelope); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonWireText.ts b/libraries/rush-daemon-protocol/src/DaemonWireText.ts new file mode 100644 index 00000000000..d28e04d4549 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonWireText.ts @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Shared, stateless UTF-8 codec instances for the wire layer. Both are safe + * for concurrent reuse. + * + * @internal + */ +export const WIRE_TEXT_ENCODER: InstanceType = new TextEncoder(); + +/** @internal */ +export const WIRE_TEXT_DECODER: InstanceType = new TextDecoder(); diff --git a/libraries/rush-daemon-protocol/src/FrameConstants.ts b/libraries/rush-daemon-protocol/src/FrameConstants.ts new file mode 100644 index 00000000000..9f52f21f1cd --- /dev/null +++ b/libraries/rush-daemon-protocol/src/FrameConstants.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** The byte length of the frame payload-length field (`u32` little-endian). @beta */ +export const LENGTH_FIELD_BYTES: number = 4; + +/** The byte length of the frame type field (`u8`). @beta */ +export const TYPE_FIELD_BYTES: number = 1; + +/** The total byte length of a frame header: length field plus type field. @beta */ +export const FRAME_HEADER_BYTES: number = LENGTH_FIELD_BYTES + TYPE_FIELD_BYTES; + +/** The offset of the payload-length field within the header. @beta */ +export const LENGTH_FIELD_OFFSET: number = 0; + +/** The offset of the frame type field within the header. @beta */ +export const TYPE_FIELD_OFFSET: number = LENGTH_FIELD_BYTES; + +const KIBIBYTE: number = 1024; +const MEBIBYTE: number = KIBIBYTE * KIBIBYTE; +const DEFAULT_MAX_PAYLOAD_MEBIBYTES: number = 16; + +/** + * The default maximum accepted payload size of a single frame. + * + * @remarks + * Guards the streaming decoder against corrupt or hostile length prefixes. + * Callers may override it per decoder. + * @beta + */ +export const DEFAULT_MAX_PAYLOAD_BYTES: number = DEFAULT_MAX_PAYLOAD_MEBIBYTES * MEBIBYTE; + +/** The byte length of the operation-id length prefix used by log frames (`u16` little-endian). @beta */ +export const OPERATION_ID_LENGTH_BYTES: number = 2; + +/** The maximum byte length of an operation id in a log frame (`u16` range). @beta */ +export const MAX_OPERATION_ID_BYTES: number = 65535; + +/** The offset of the operation-id length prefix within a log frame payload. @beta */ +export const OPERATION_ID_LENGTH_OFFSET: number = 0; + +/** The offset of the frame payload within a serialized frame. @beta */ +export const PAYLOAD_OFFSET: number = FRAME_HEADER_BYTES; diff --git a/libraries/rush-daemon-protocol/src/FrameDecoder.ts b/libraries/rush-daemon-protocol/src/FrameDecoder.ts new file mode 100644 index 00000000000..84910a6fab5 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/FrameDecoder.ts @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonFrame } from './DaemonFrame'; +import { isDaemonFrameType } from './DaemonFrameType'; +import { DaemonProtocolError } from './DaemonProtocolError'; +import { + DEFAULT_MAX_PAYLOAD_BYTES, + FRAME_HEADER_BYTES, + LENGTH_FIELD_OFFSET, + TYPE_FIELD_OFFSET +} from './FrameConstants'; +import { SegmentBuffer } from './SegmentBuffer'; + +/** Options for {@link DaemonFrameDecoder}. @beta */ +export interface IDaemonFrameDecoderOptions { + /** The maximum accepted payload size of a single frame, in bytes. */ + readonly maxPayloadBytes?: number; +} +const HEX_RADIX: number = 16; +const SINGLE_BYTE: number = 1; +const LITTLE_ENDIAN: boolean = true; + +/** + * An incremental, streaming decoder for length-prefixed rushd frames. + * + * @remarks + * Feed arbitrarily split or coalesced chunks to {@link DaemonFrameDecoder.push | push}; + * complete frames are returned in wire order. Received bytes accumulate in a segment + * list (never concatenated per push); payloads copy out once per completed frame. + * @beta + */ +export class DaemonFrameDecoder { + #pending: SegmentBuffer; + readonly #maxPayloadBytes: number; + + public constructor(options?: IDaemonFrameDecoderOptions) { + this.#pending = new SegmentBuffer(); + this.#maxPayloadBytes = options?.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES; + } + + /** Feeds received bytes and returns every frame completed by them. + * @throws {@link DaemonProtocolError} when a header is invalid; tear down the connection. */ + public push(chunk: Uint8Array): IDaemonFrame[] { + this.#pending.push(chunk); + const frames: IDaemonFrame[] = []; + let frame: IDaemonFrame | undefined = this.#tryExtractFrame(); + while (frame !== undefined) { + frames.push(frame); + frame = this.#tryExtractFrame(); + } + return frames; + } + + /** Discards any buffered partial frame. */ + public reset(): void { + this.#pending.clear(); + } + + #tryExtractFrame(): IDaemonFrame | undefined { + if (this.#pending.byteLength < FRAME_HEADER_BYTES) { + return undefined; + } + const payloadLength: number = this.#readPayloadLength(); + this.#assertPayloadLength(payloadLength); + const frameBytes: number = FRAME_HEADER_BYTES + payloadLength; + return this.#pending.byteLength < frameBytes ? undefined : this.#takeFrame(frameBytes, payloadLength); + } + #readPayloadLength(): number { + const headerBytes: Uint8Array = this.#pending.readBytes(LENGTH_FIELD_OFFSET, FRAME_HEADER_BYTES); + return new DataView(headerBytes.buffer).getUint32(LENGTH_FIELD_OFFSET, LITTLE_ENDIAN); + } + + #assertPayloadLength(payloadLength: number): void { + if (payloadLength > this.#maxPayloadBytes) { + const message: string = `Frame payload of ${payloadLength} bytes exceeds the maximum of ${this.#maxPayloadBytes}.`; + throw new DaemonProtocolError('frameTooLarge', message); + } + } + + #takeFrame(frameBytes: number, payloadLength: number): IDaemonFrame { + const kindByte: number = this.#pending.readBytes(TYPE_FIELD_OFFSET, SINGLE_BYTE)[LENGTH_FIELD_OFFSET]; + this.#assertKnownKind(kindByte); + const payload: Uint8Array = this.#pending.readBytes(FRAME_HEADER_BYTES, payloadLength); + this.#pending.consume(frameBytes); + return { kind: kindByte, payload }; + } + + #assertKnownKind(kindByte: number): void { + if (!isDaemonFrameType(kindByte)) { + throw new DaemonProtocolError( + 'unknownFrameType', + `Frame declared an unknown frame kind byte 0x${kindByte.toString(HEX_RADIX)}.` + ); + } + } +} diff --git a/libraries/rush-daemon-protocol/src/FrameEncoder.ts b/libraries/rush-daemon-protocol/src/FrameEncoder.ts new file mode 100644 index 00000000000..472bc2212ec --- /dev/null +++ b/libraries/rush-daemon-protocol/src/FrameEncoder.ts @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonFrame } from './DaemonFrame'; +import { + FRAME_HEADER_BYTES, + LENGTH_FIELD_OFFSET, + TYPE_FIELD_OFFSET +} from './FrameConstants'; + +const LITTLE_ENDIAN: boolean = true; + +/** + * Serializes a single frame as `[u32 LE payloadLength][u8 frameKind][payload]`. + * + * @remarks + * The length field counts only the payload bytes, never the header. The result + * is a freshly allocated array, safe to retain or mutate by the caller. + * + * @beta + */ +export function encodeDaemonFrame(frame: IDaemonFrame): Uint8Array { + const serialized: Uint8Array = new Uint8Array(FRAME_HEADER_BYTES + frame.payload.length); + const view: DataView = new DataView(serialized.buffer); + view.setUint32(LENGTH_FIELD_OFFSET, frame.payload.length, LITTLE_ENDIAN); + view.setUint8(TYPE_FIELD_OFFSET, frame.kind); + serialized.set(frame.payload, FRAME_HEADER_BYTES); + return serialized; +} + +/** + * Serializes a sequence of frames into an array of per-frame byte arrays, in + * wire order. + * + * @remarks + * The frames are deliberately NOT concatenated: the transport writes each part + * sequentially (for example with `socket.cork()`/`uncork()`), avoiding a full + * copy of the batch. + * + * @beta + */ +export function encodeDaemonFrames(frames: readonly IDaemonFrame[]): Uint8Array[] { + const parts: Uint8Array[] = []; + for (const frame of frames) { + parts.push(encodeDaemonFrame(frame)); + } + return parts; +} diff --git a/libraries/rush-daemon-protocol/src/LogFrameCodec.ts b/libraries/rush-daemon-protocol/src/LogFrameCodec.ts new file mode 100644 index 00000000000..cacc6b67b59 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/LogFrameCodec.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonProtocolError } from './DaemonProtocolError'; +import { WIRE_TEXT_DECODER, WIRE_TEXT_ENCODER } from './DaemonWireText'; +import { + MAX_OPERATION_ID_BYTES, + OPERATION_ID_LENGTH_BYTES, + OPERATION_ID_LENGTH_OFFSET +} from './FrameConstants'; + +const LITTLE_ENDIAN: boolean = true; + +/** + * An id-tagged chunk of one operation's raw output stream. + * + * @beta + */ +export interface IDaemonLogChunk { + /** + * The operation this chunk belongs to. + */ + readonly operationId: string; + + /** + * The raw stream bytes. May contain arbitrary (including non-UTF-8) content. + */ + readonly chunk: Uint8Array; +} + +/** + * Serializes a log chunk as `[u16 LE operationIdBytes][operationId utf8][raw chunk]`. + * + * @remarks + * The operation id is UTF-8 encoded exactly once; the resulting byte count is + * measured from that encoding and the payload is allocated exactly once. + * + * @throws {@link DaemonProtocolError} when the operation id exceeds + * {@link MAX_OPERATION_ID_BYTES} bytes when UTF-8 encoded. + * + * @beta + */ +export function encodeDaemonLogChunk(log: IDaemonLogChunk): Uint8Array { + const idBytes: Uint8Array = WIRE_TEXT_ENCODER.encode(log.operationId); + if (idBytes.length > MAX_OPERATION_ID_BYTES) { + throw new DaemonProtocolError( + 'malformedPayload', + `Operation id is ${idBytes.length} bytes, exceeding the maximum of ${MAX_OPERATION_ID_BYTES}.` + ); + } + const payload: Uint8Array = new Uint8Array(OPERATION_ID_LENGTH_BYTES + idBytes.length + log.chunk.length); + new DataView(payload.buffer).setUint16(OPERATION_ID_LENGTH_OFFSET, idBytes.length, LITTLE_ENDIAN); + payload.set(idBytes, OPERATION_ID_LENGTH_BYTES); + payload.set(log.chunk, OPERATION_ID_LENGTH_BYTES + idBytes.length); + return payload; +} + +/** + * Parses a log frame payload back into its operation id and raw chunk. + * + * @throws {@link DaemonProtocolError} when the payload is truncated or its + * length prefix overruns the payload. + * + * @beta + */ +export function decodeDaemonLogChunk(payload: Uint8Array): IDaemonLogChunk { + if (payload.length < OPERATION_ID_LENGTH_BYTES) { + throw new DaemonProtocolError( + 'malformedPayload', + 'Log frame payload is too short to contain an operation id length.' + ); + } + const idLength: number = new DataView(payload.buffer, payload.byteOffset).getUint16( + OPERATION_ID_LENGTH_OFFSET, + LITTLE_ENDIAN + ); + const chunkOffset: number = OPERATION_ID_LENGTH_BYTES + idLength; + if (payload.length < chunkOffset) { + throw new DaemonProtocolError( + 'malformedPayload', + `Log frame declared an operation id of ${idLength} bytes but the payload is ${payload.length} bytes.` + ); + } + const operationId: string = WIRE_TEXT_DECODER.decode( + payload.subarray(OPERATION_ID_LENGTH_BYTES, chunkOffset) + ); + const chunk: Uint8Array = payload.slice(chunkOffset); + return { operationId, chunk }; +} diff --git a/libraries/rush-daemon-protocol/src/SegmentBuffer.ts b/libraries/rush-daemon-protocol/src/SegmentBuffer.ts new file mode 100644 index 00000000000..5c7c2a7e28f --- /dev/null +++ b/libraries/rush-daemon-protocol/src/SegmentBuffer.ts @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const EMPTY_LENGTH: number = 0; +const FIRST_SEGMENT: number = 0; + +/** + * An append-only byte accumulator that avoids repeated concatenation. + * + * @remarks + * Incoming chunks are stored as a list of segments; bytes are copied only when + * a complete region is consumed, never on append. Used by the frame decoder so + * that receiving a chunk costs O(1) rather than O(pending). + * + * @internal + */ +export class SegmentBuffer { + #segments: Uint8Array[] = []; + #bytes: number = EMPTY_LENGTH; + + /** The total buffered byte count. */ + public get byteLength(): number { + return this.#bytes; + } + + /** Appends a chunk (retained by reference; do not mutate after pushing). */ + public push(chunk: Uint8Array): void { + if (chunk.length === EMPTY_LENGTH) { + return; + } + this.#segments.push(chunk); + this.#bytes += chunk.length; + } + + /** Discards all buffered bytes. */ + public clear(): void { + this.#segments = []; + this.#bytes = EMPTY_LENGTH; + } + + /** Copies `length` bytes starting at `offset` into a fresh array. */ + public readBytes(offset: number, length: number): Uint8Array { + const result: Uint8Array = new Uint8Array(length); + let written: number = EMPTY_LENGTH; + let skipped: number = EMPTY_LENGTH; + for (const segment of this.#segments) { + written += this.#readFromSegment(segment, offset - skipped, result, written); + skipped += segment.length; + if (written === length) { + break; + } + } + return result; + } + + /** Drops `count` bytes from the front of the buffer. */ + public consume(count: number): void { + let remaining: number = count; + while (remaining > EMPTY_LENGTH && this.#segments.length > EMPTY_LENGTH) { + remaining -= this.#consumeFromFirstSegment(remaining); + } + this.#bytes -= count; + } + + /** Consumes up to `count` bytes from the first segment, returning how many. */ + #consumeFromFirstSegment(count: number): number { + const first: Uint8Array = this.#segments[FIRST_SEGMENT]; + if (first.length > count) { + this.#segments[FIRST_SEGMENT] = first.subarray(count); + return count; + } + this.#segments.shift(); + return first.length; + } + + /** Copies what this segment can contribute, returning the byte count copied. */ + #readFromSegment( + segment: Uint8Array, + startBefore: number, + target: Uint8Array, + targetOffset: number + ): number { + const start: number = Math.max(EMPTY_LENGTH, startBefore); + const available: number = segment.length - start; + const wanted: number = target.length - targetOffset; + if (available <= EMPTY_LENGTH || wanted <= EMPTY_LENGTH) { + return EMPTY_LENGTH; + } + const amount: number = Math.min(available, wanted); + target.set(segment.subarray(start, start + amount), targetOffset); + return amount; + } +} diff --git a/libraries/rush-daemon-protocol/src/index.ts b/libraries/rush-daemon-protocol/src/index.ts new file mode 100644 index 00000000000..4de98d0bb1b --- /dev/null +++ b/libraries/rush-daemon-protocol/src/index.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The wire layer every rushd client speaks: the frame taxonomy and + * length-prefixed binary codec, the event envelope contract, and the + * connection handshake with version negotiation. + * + * @remarks + * Engine-agnostic, platform-agnostic (`Uint8Array` payloads, never `Buffer`), + * zero runtime dependencies, and no `rush-lib` dependency. The event contract + * mirrors `@rushstack/reporter`'s envelope as a placeholder until it merges. + * @packageDocumentation + */ + +export type { IDaemonFrame } from './DaemonFrame'; +export { DaemonFrameType, isDaemonFrameType } from './DaemonFrameType'; +export { + DEFAULT_MAX_PAYLOAD_BYTES, FRAME_HEADER_BYTES, LENGTH_FIELD_BYTES, LENGTH_FIELD_OFFSET, + MAX_OPERATION_ID_BYTES, OPERATION_ID_LENGTH_BYTES, OPERATION_ID_LENGTH_OFFSET, PAYLOAD_OFFSET, + TYPE_FIELD_BYTES, TYPE_FIELD_OFFSET +} from './FrameConstants'; +export { encodeDaemonFrame, encodeDaemonFrames } from './FrameEncoder'; +export { DaemonFrameDecoder, type IDaemonFrameDecoderOptions } from './FrameDecoder'; +export { DaemonProtocolError, ProtocolVersionMismatchError } from './DaemonProtocolError'; +export type { DaemonProtocolErrorCode, IDaemonProtocolErrorOptions } from './DaemonProtocolError'; +export { DAEMON_PROTOCOL_VERSION, isDaemonProtocolCompatible } from './DaemonProtocolVersion'; +export type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; +export type { IDaemonClientCaps } from './DaemonClientCaps'; +export { DAEMON_CONTROL_MESSAGE_KINDS, isDaemonControlMessageKind } from './DaemonControlMessage'; +export type { DaemonControlMessage, DaemonControlMessageKind, DaemonEmptyPayload } from './DaemonControlMessage'; +export type { IDaemonErrorMessage, IDaemonHelloAckMessage, IDaemonHelloMessage } from './DaemonControlMessage'; +export type { IDaemonPingMessage, IDaemonPongMessage, IDaemonSubscribeMessage, IDaemonUnsubscribeMessage } from './DaemonControlMessage'; +export { isDaemonControlRecord, validateDaemonControlMessage } from './ControlMessageValidation'; +export { decodeDaemonControlMessage, encodeDaemonControlMessage } from './ControlFrameCodec'; +export { decodeDaemonLogChunk, encodeDaemonLogChunk, type IDaemonLogChunk } from './LogFrameCodec'; +export { createDaemonHello, createDaemonHelloAck, negotiateDaemonHello } from './DaemonHandshake'; +export type { DaemonHandshakeOutcome } from './DaemonHandshake'; +export type { DaemonJsonNull, DaemonJsonValue } from './DaemonJsonValue'; +export { DAEMON_EVENT_TYPES, isDaemonEventType, type DaemonEventType } from './DaemonEventType'; +export type { DaemonEventPrivacy, IDaemonEventEnvelope, IDaemonEventScope, IDaemonEventSource } from './DaemonEventEnvelope'; +export { isDaemonEventEnvelope, validateDaemonEventEnvelope } from './DaemonEventValidation'; +export { isDaemonExtensionEventName, isRushdExtensionEventName, RUSHD_EXTENSION_NAMESPACE } from './DaemonExtensionEventName'; +export type { DaemonExtensionEventName } from './DaemonExtensionEventName'; +export { compareDaemonVerbosity, isDaemonVerbosity, type DaemonVerbosity } from './DaemonVerbosity'; +export { shouldSerializeDaemonEvent } from './DaemonVerbosityFilter'; +export type { DaemonDiagnosticSeverity, IDaemonDiagnosticPayload } from './DaemonVerbosityFilter'; +export { decodeDaemonEventFrame, encodeDaemonEventFrame, serializeDaemonEventForSubscription } from './DaemonEventFrameCodec'; +export type { IDaemonActivityPayload, IDaemonOperationRegisteredPayload, IDaemonOperationStatusChangedPayload } from './DaemonOperationPayloads'; +export { RUSHD_OPERATION_HEADER, RUSHD_OPERATION_STREAM_CLOSED } from './DaemonRushdExtensions'; +export type { IDaemonExtensionEventPayload, IDaemonOperationHeaderPayload, IDaemonOperationStreamClosedPayload } from './DaemonRushdExtensions'; diff --git a/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts new file mode 100644 index 00000000000..7a73101e744 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; +import type { DaemonControlMessage } from '../DaemonControlMessage'; +import { DAEMON_PROTOCOL_VERSION } from '../DaemonProtocolVersion'; + +import { captureProtocolError } from './TestVectors'; + +const UPTIME_MS: number = 42; +const COLUMNS: number = 120; + +const MESSAGES: readonly DaemonControlMessage[] = [ + { kind: 'hello', payload: { protocolVersion: DAEMON_PROTOCOL_VERSION } }, + { kind: 'helloAck', payload: { protocolVersion: DAEMON_PROTOCOL_VERSION, sessionId: 's-1' } }, + { kind: 'subscribe', payload: { isTTY: true, verbosity: 'verbose', columns: COLUMNS } }, + { kind: 'unsubscribe', payload: {} }, + { kind: 'ping', payload: {} }, + { kind: 'pong', payload: { uptimeMs: UPTIME_MS } }, + { kind: 'error', payload: { code: 'malformedPayload', message: 'bad' } } +]; + +it('round-trips every control message kind', () => { + for (const message of MESSAGES) { + expect(decodeDaemonControlMessage(encodeDaemonControlMessage(message))).toEqual(message); + } +}); + +it('rejects a non-JSON control payload', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from('not-json')) + ); + expect(error.code).toBe('malformedControlMessage'); + expect(error.cause).toBeDefined(); +}); + +it('rejects a control message with an unknown kind', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from('{"kind":"teleport","payload":{}}')) + ); + expect(error.code).toBe('malformedControlMessage'); +}); + +it('rejects a control message without a payload object', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from('{"kind":"hello"}')) + ); + expect(error.code).toBe('malformedControlMessage'); +}); + +it('rejects a hello without a version', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from('{"kind":"hello","payload":{}}')) + ); + expect(error.code).toBe('malformedControlMessage'); +}); + +it('rejects a subscribe with an unknown verbosity', () => { + const json: string = '{"kind":"subscribe","payload":{"isTTY":true,"verbosity":"loud"}}'; + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from(json)) + ); + expect(error.code).toBe('malformedControlMessage'); +}); diff --git a/libraries/rush-daemon-protocol/src/test/EventContract.test.ts b/libraries/rush-daemon-protocol/src/test/EventContract.test.ts new file mode 100644 index 00000000000..4953b838a47 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/EventContract.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonEventEnvelope } from '../DaemonEventEnvelope'; +import { DAEMON_EVENT_TYPES } from '../DaemonEventType'; +import type { + RUSHD_EXTENSION_NAMESPACE +} from '../DaemonExtensionEventName'; +import { + isDaemonExtensionEventName, + isRushdExtensionEventName +} from '../DaemonExtensionEventName'; + +// Compile-time assertion helpers: a mismatch is a TypeScript error, so these +// declarations pin the placeholder contract to the reporter envelope shape. +type AssertExact = [T] extends [U] ? ([U] extends [T] ? true : false) : false; + +type ExpectedEnvelopeKeys = + | 'protocolVersion' + | 'eventId' + | 'sessionId' + | 'parentSessionId' + | 'parentOperationId' + | 'sequence' + | 'sourceSequence' + | 'timestamp' + | 'source' + | 'scope' + | 'privacy' + | 'required' + | 'type' + | 'payload'; + +const envelopeKeysMatch: AssertExact = true; +const namespaceIsRushd: AssertExact = true; + +it('pins the placeholder envelope keys to the reporter contract', () => { + expect(envelopeKeysMatch).toBe(true); + expect(namespaceIsRushd).toBe(true); +}); + +it('declares the closed core event union in canonical order', () => { + expect(DAEMON_EVENT_TYPES).toEqual([ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'activityChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult', + 'extension' + ]); +}); + +it('accepts namespaced extension names and rejects malformed ones', () => { + expect(isDaemonExtensionEventName('rushd.client-subscribed')).toBe(true); + expect(isDaemonExtensionEventName('acme.cache-warmed')).toBe(true); + expect(isDaemonExtensionEventName('NoNamespace')).toBe(false); + expect(isDaemonExtensionEventName('rushd.')).toBe(false); + expect(isDaemonExtensionEventName('rushd.HasCaps')).toBe(false); +}); + +it('scopes rushd-only extension events to the rushd namespace', () => { + expect(isRushdExtensionEventName('rushd.client-subscribed')).toBe(true); + expect(isRushdExtensionEventName('acme.cache-warmed')).toBe(false); +}); diff --git a/libraries/rush-daemon-protocol/src/test/EventValidation.test.ts b/libraries/rush-daemon-protocol/src/test/EventValidation.test.ts new file mode 100644 index 00000000000..d6950151ba5 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/EventValidation.test.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 { IDaemonEventEnvelope } from '../DaemonEventEnvelope'; +import { decodeDaemonEventFrame, encodeDaemonEventFrame } from '../DaemonEventFrameCodec'; +import { isDaemonEventEnvelope } from '../DaemonEventValidation'; + +import { captureProtocolError, createTestEnvelope } from './TestVectors'; + +it('accepts a well-formed envelope and round-trips it', () => { + const envelope: IDaemonEventEnvelope = createTestEnvelope({ type: 'operationStatusChanged' }); + const decoded: IDaemonEventEnvelope = decodeDaemonEventFrame(encodeDaemonEventFrame(envelope)); + expect(decoded).toEqual(envelope); +}); + +it('rejects an envelope with an unknown event type', () => { + const invalid: unknown = { ...createTestEnvelope({ type: 'commandResult' }), type: 'warpDrive' }; + expect(isDaemonEventEnvelope(invalid)).toBe(false); + const error: ReturnType = captureProtocolError(() => + decodeDaemonEventFrame(Buffer.from(JSON.stringify(invalid))) + ); + expect(error.code).toBe('malformedPayload'); +}); + +it('rejects an envelope missing required string fields', () => { + const partial: unknown = { ...createTestEnvelope({ type: 'commandResult' }), eventId: undefined }; + expect(isDaemonEventEnvelope(partial)).toBe(false); +}); + +it('rejects an envelope with a malformed source', () => { + const badSource: unknown = { ...createTestEnvelope({ type: 'commandResult' }), source: {} }; + expect(isDaemonEventEnvelope(badSource)).toBe(false); +}); + +it('rejects non-JSON event payloads with a typed error carrying the cause', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonEventFrame(Buffer.from('{nope')) + ); + expect(error.code).toBe('malformedPayload'); + expect(error.cause).toBeDefined(); +}); diff --git a/libraries/rush-daemon-protocol/src/test/FrameCodec.test.ts b/libraries/rush-daemon-protocol/src/test/FrameCodec.test.ts new file mode 100644 index 00000000000..0a07e3cd330 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/FrameCodec.test.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonFrame } from '../DaemonFrame'; +import { DaemonFrameType } from '../DaemonFrameType'; +import { FRAME_HEADER_BYTES, LENGTH_FIELD_OFFSET, TYPE_FIELD_OFFSET } from '../FrameConstants'; +import { DaemonFrameDecoder } from '../FrameDecoder'; +import { encodeDaemonFrame, encodeDaemonFrames } from '../FrameEncoder'; + +import { + EMPTY_COUNT, + FIRST_INDEX, + NON_UTF8_BYTES, + PAIR_COUNT, + SINGLE_COUNT, + captureProtocolError +} from './TestVectors'; + +const ALL_KINDS: readonly DaemonFrameType[] = [ + DaemonFrameType.controlJson, + DaemonFrameType.logStdout, + DaemonFrameType.logStderr, + DaemonFrameType.stdin, + DaemonFrameType.event +]; +const TINY_LIMIT: number = 8; +const UNKNOWN_KIND_BYTE: number = 0x7e; +const FIRST_SPLIT: number = 1; + +function roundTrip(kind: DaemonFrameType, payload: Uint8Array): IDaemonFrame { + const frames: IDaemonFrame[] = new DaemonFrameDecoder().push(encodeDaemonFrame({ kind, payload })); + expect(frames).toHaveLength(SINGLE_COUNT); + return frames[FIRST_INDEX]; +} + +function expectBytesEqual(actual: Uint8Array, expected: Uint8Array): void { + expect(Buffer.from(actual).equals(Buffer.from(expected))).toBe(true); +} + +it('round-trips every frame kind with non-UTF-8 payloads', () => { + for (const kind of ALL_KINDS) { + const frame: IDaemonFrame = roundTrip(kind, NON_UTF8_BYTES); + expect(frame.kind).toBe(kind); + expectBytesEqual(frame.payload, NON_UTF8_BYTES); + } +}); + +it('decodes coalesced frames in wire order', () => { + const first: IDaemonFrame = { kind: DaemonFrameType.logStdout, payload: Buffer.from('a') }; + const second: IDaemonFrame = { kind: DaemonFrameType.logStderr, payload: NON_UTF8_BYTES }; + const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); + for (const part of encodeDaemonFrames([first, second])) { + const frames: IDaemonFrame[] = decoder.push(part); + expect(frames).toHaveLength(SINGLE_COUNT); + } +}); + +it('returns per-frame byte arrays without concatenating the batch', () => { + const frames: IDaemonFrame[] = [ + { kind: DaemonFrameType.logStdout, payload: Buffer.from('a') }, + { kind: DaemonFrameType.logStderr, payload: NON_UTF8_BYTES } + ]; + const parts: Uint8Array[] = encodeDaemonFrames(frames); + expect(parts).toHaveLength(PAIR_COUNT); + const merged: IDaemonFrame[] = new DaemonFrameDecoder().push(Buffer.concat(parts)); + expect(merged).toHaveLength(PAIR_COUNT); + expectBytesEqual(merged[SINGLE_COUNT].payload, NON_UTF8_BYTES); +}); + +it('decodes a frame split at every possible byte boundary', () => { + const encoded: Uint8Array = encodeDaemonFrame({ kind: DaemonFrameType.stdin, payload: NON_UTF8_BYTES }); + for (let splitAt: number = FIRST_SPLIT; splitAt < encoded.length; splitAt++) { + const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); + expect(decoder.push(encoded.subarray(FIRST_INDEX, splitAt))).toHaveLength(EMPTY_COUNT); + const frames: IDaemonFrame[] = decoder.push(encoded.subarray(splitAt)); + expect(frames).toHaveLength(SINGLE_COUNT); + expectBytesEqual(frames[FIRST_INDEX].payload, NON_UTF8_BYTES); + } +}); + +it('rejects an oversized payload declaration', () => { + const header: Buffer = Buffer.alloc(FRAME_HEADER_BYTES); + header.writeUInt32LE(TINY_LIMIT + SINGLE_COUNT, LENGTH_FIELD_OFFSET); + header.writeUInt8(DaemonFrameType.logStdout, TYPE_FIELD_OFFSET); + const decoder: DaemonFrameDecoder = new DaemonFrameDecoder({ maxPayloadBytes: TINY_LIMIT }); + const error: ReturnType = captureProtocolError(() => decoder.push(header)); + expect(error.code).toBe('frameTooLarge'); +}); + +it('rejects an unknown frame kind byte', () => { + const header: Buffer = Buffer.alloc(FRAME_HEADER_BYTES); + header.writeUInt8(UNKNOWN_KIND_BYTE, TYPE_FIELD_OFFSET); + const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); + const error: ReturnType = captureProtocolError(() => decoder.push(header)); + expect(error.code).toBe('unknownFrameType'); +}); diff --git a/libraries/rush-daemon-protocol/src/test/Handshake.test.ts b/libraries/rush-daemon-protocol/src/test/Handshake.test.ts new file mode 100644 index 00000000000..da22780c1cf --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/Handshake.test.ts @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createDaemonHello, negotiateDaemonHello } from '../DaemonHandshake'; +import type { DaemonHandshakeOutcome } from '../DaemonHandshake'; +import { ProtocolVersionMismatchError } from '../DaemonProtocolError'; +import { DAEMON_PROTOCOL_VERSION } from '../DaemonProtocolVersion'; + +const NEWER_MAJOR: number = 1; +const SESSION_ID: string = 'session-abc'; + +it('exports a well-formed DAEMON_PROTOCOL_VERSION', () => { + expect(typeof DAEMON_PROTOCOL_VERSION.major).toBe('number'); + expect(typeof DAEMON_PROTOCOL_VERSION.minor).toBe('number'); +}); + +it('accepts a matching major version', () => { + const hello: ReturnType = createDaemonHello(DAEMON_PROTOCOL_VERSION); + const outcome: DaemonHandshakeOutcome = negotiateDaemonHello(hello, DAEMON_PROTOCOL_VERSION, SESSION_ID); + expect(outcome.accepted).toBe(true); + if (outcome.accepted) { + expect(outcome.ack.payload.sessionId).toBe(SESSION_ID); + expect(outcome.ack.payload.protocolVersion).toEqual(DAEMON_PROTOCOL_VERSION); + } +}); + +it('rejects a mismatched major version with a typed error', () => { + const hello: ReturnType = createDaemonHello({ + major: DAEMON_PROTOCOL_VERSION.major + NEWER_MAJOR, + minor: DAEMON_PROTOCOL_VERSION.minor + }); + const outcome: DaemonHandshakeOutcome = negotiateDaemonHello(hello, DAEMON_PROTOCOL_VERSION, SESSION_ID); + expect(outcome.accepted).toBe(false); + if (!outcome.accepted) { + expect(outcome.error).toBeInstanceOf(ProtocolVersionMismatchError); + expect(outcome.error.code).toBe('protocolVersionMismatch'); + expect(outcome.error.expectedMajor).toBe(DAEMON_PROTOCOL_VERSION.major); + expect(outcome.error.actualMajor).toBe(DAEMON_PROTOCOL_VERSION.major + NEWER_MAJOR); + } +}); + +it('supports the Error cause convention', () => { + const cause: Error = new Error('root cause'); + const error: ProtocolVersionMismatchError = new ProtocolVersionMismatchError( + DAEMON_PROTOCOL_VERSION.major, + DAEMON_PROTOCOL_VERSION.major + NEWER_MAJOR, + { cause } + ); + expect(error.cause).toBe(cause); +}); diff --git a/libraries/rush-daemon-protocol/src/test/LogFrameCodec.test.ts b/libraries/rush-daemon-protocol/src/test/LogFrameCodec.test.ts new file mode 100644 index 00000000000..bab5512c8c7 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/LogFrameCodec.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonFrame } from '../DaemonFrame'; +import { DaemonFrameType } from '../DaemonFrameType'; +import { MAX_OPERATION_ID_BYTES } from '../FrameConstants'; +import { DaemonFrameDecoder } from '../FrameDecoder'; +import { encodeDaemonFrame } from '../FrameEncoder'; +import { decodeDaemonLogChunk, encodeDaemonLogChunk } from '../LogFrameCodec'; + +import { FIRST_INDEX, NON_UTF8_BYTES, SINGLE_COUNT, captureProtocolError } from './TestVectors'; + +const TOO_LONG_ID_BYTES: number = MAX_OPERATION_ID_BYTES + SINGLE_COUNT; +const DECLARED_ID_BYTES: number = 100; +const SHORT_PAYLOAD_BYTES: number = 1; +const TRUNCATED_PREFIX_BYTES: number = 2; +const EMPTY_BYTES: number = 0; +const WIRE_DECODER: InstanceType = new TextDecoder(); + +/** One step of the interleaving plan: [operationId, text, kind]. */ +const INTERLEAVE_PLAN: readonly [string, string, DaemonFrameType][] = [ + ['op-a', 'a1', DaemonFrameType.logStdout], + ['op-b', 'b1', DaemonFrameType.logStderr], + ['op-a', 'a2', DaemonFrameType.logStdout] +]; + +function expectBytesEqual(actual: Uint8Array, expected: Uint8Array): void { + expect(Buffer.from(actual).equals(Buffer.from(expected))).toBe(true); +} + +it('round-trips an id-tagged log chunk with non-UTF-8 bytes', () => { + const decoded: ReturnType = decodeDaemonLogChunk( + encodeDaemonLogChunk({ operationId: 'build#my-app', chunk: NON_UTF8_BYTES }) + ); + expect(decoded.operationId).toBe('build#my-app'); + expectBytesEqual(decoded.chunk, NON_UTF8_BYTES); +}); + +function decodeStep( + decoder: DaemonFrameDecoder, + step: readonly [string, string, DaemonFrameType] +): [string, string][] { + const [operationId, text, kind] = step; + const frames: IDaemonFrame[] = decoder.push( + encodeDaemonFrame({ kind, payload: encodeDaemonLogChunk({ operationId, chunk: Buffer.from(text) }) }) + ); + return frames.map( + (frame: IDaemonFrame) => + [operationId, WIRE_DECODER.decode(decodeDaemonLogChunk(frame.payload).chunk)] as [string, string] + ); +} + +function routeStep( + decoder: DaemonFrameDecoder, + step: readonly [string, string, DaemonFrameType], + sinks: Map +): void { + for (const [operationId, text] of decodeStep(decoder, step)) { + sinks.get(operationId)?.push(text); + } +} + +it('reassembles interleaved per-operation streams without reordering', () => { + const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); + const sinks: Map = new Map([ + ['op-a', []], + ['op-b', []] + ]); + for (const step of INTERLEAVE_PLAN) { + routeStep(decoder, step, sinks); + } + expect(sinks.get('op-a')).toEqual(['a1', 'a2']); + expect(sinks.get('op-b')).toEqual(['b1']); +}); + +it('rejects an operation id longer than the u16 range', () => { + const operationId: string = 'x'.repeat(TOO_LONG_ID_BYTES); + const error: ReturnType = captureProtocolError(() => + encodeDaemonLogChunk({ operationId, chunk: new Uint8Array(EMPTY_BYTES) }) + ); + expect(error.code).toBe('malformedPayload'); +}); + +it('rejects a truncated log payload', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonLogChunk(new Uint8Array(SHORT_PAYLOAD_BYTES)) + ); + expect(error.code).toBe('malformedPayload'); +}); + +it('rejects a log payload whose id prefix overruns it', () => { + const payload: Buffer = Buffer.alloc(TRUNCATED_PREFIX_BYTES + SHORT_PAYLOAD_BYTES); + payload.writeUInt16LE(DECLARED_ID_BYTES, FIRST_INDEX); + const error: ReturnType = captureProtocolError(() => + decodeDaemonLogChunk(payload) + ); + expect(error.code).toBe('malformedPayload'); +}); diff --git a/libraries/rush-daemon-protocol/src/test/TestVectors.ts b/libraries/rush-daemon-protocol/src/test/TestVectors.ts new file mode 100644 index 00000000000..9c67d3fa722 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/TestVectors.ts @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonEventEnvelope } from '../DaemonEventEnvelope'; +import type { DaemonEventType } from '../DaemonEventType'; +import type { DaemonProtocolError } from '../DaemonProtocolError'; + +const BYTE_FF: number = 0xff; +const BYTE_FE: number = 0xfe; +const BYTE_80: number = 0x80; +const BYTE_NUL: number = 0x00; + +/** A byte sequence that is invalid UTF-8, for lossless round-trip tests. */ +export const NON_UTF8_BYTES: Buffer = Buffer.from([BYTE_FF, BYTE_FE, BYTE_80, BYTE_NUL]); + +const FIRST_SEQUENCE: number = 1; +const SCHEMA_MAJOR: number = 0; +const SCHEMA_MINOR: number = 1; + +/** The count of an empty collection. */ +export const EMPTY_COUNT: number = 0; + +/** The count of a single-element collection. */ +export const SINGLE_COUNT: number = 1; + +/** The count of a two-element collection. */ +export const PAIR_COUNT: number = 2; + +/** The index of the first element of a collection or buffer. */ +export const FIRST_INDEX: number = 0; + +/** + * Captures the {@link DaemonProtocolError} thrown by `run`. + */ +export function captureProtocolError(run: () => unknown): DaemonProtocolError { + try { + run(); + } catch (error) { + return error as DaemonProtocolError; + } + throw new Error('Expected the call to throw a DaemonProtocolError.'); +} + +/** Options for {@link createTestEnvelope}. */ +export interface ITestEnvelopeOptions { + readonly type: DaemonEventType; + readonly payload?: unknown; + readonly required?: boolean; +} + +/** Creates a minimal but structurally complete event envelope for tests. */ +export function createTestEnvelope(options: ITestEnvelopeOptions): IDaemonEventEnvelope { + return { + protocolVersion: { major: SCHEMA_MAJOR, minor: SCHEMA_MINOR }, + eventId: 'evt-test', + sessionId: 'session-test', + sequence: FIRST_SEQUENCE, + timestamp: '2026-08-13T00:00:00.000Z', + source: { packageName: '@rushstack/rush-daemon-protocol', packageVersion: '0.1.0' }, + privacy: 'public', + required: options.required ?? false, + type: options.type, + payload: options.payload + }; +} diff --git a/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts b/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts new file mode 100644 index 00000000000..c4ce477708c --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonEventEnvelope } from '../DaemonEventEnvelope'; +import { serializeDaemonEventForSubscription } from '../DaemonEventFrameCodec'; +import { shouldSerializeDaemonEvent } from '../DaemonVerbosityFilter'; + +import { createTestEnvelope } from './TestVectors'; + +function diagnostic(severity: string): IDaemonEventEnvelope { + return createTestEnvelope({ type: 'diagnosticEmitted', payload: { severity } }); +} + +it('quiet passes commandResult, activity lines, and error/warning diagnostics only', () => { + expect(shouldSerializeDaemonEvent('quiet', createTestEnvelope({ type: 'commandResult' }))).toBe(true); + expect(shouldSerializeDaemonEvent('quiet', createTestEnvelope({ type: 'activityChanged' }))).toBe(true); + expect(shouldSerializeDaemonEvent('quiet', diagnostic('error'))).toBe(true); + expect(shouldSerializeDaemonEvent('quiet', diagnostic('warning'))).toBe(true); + expect(shouldSerializeDaemonEvent('quiet', diagnostic('info'))).toBe(false); + expect(shouldSerializeDaemonEvent('quiet', createTestEnvelope({ type: 'operationStatusChanged' }))).toBe(false); +}); + +it('normal passes lifecycle, activity lines, and non-debug diagnostics', () => { + expect(shouldSerializeDaemonEvent('normal', createTestEnvelope({ type: 'operationStatusChanged' }))).toBe(true); + expect(shouldSerializeDaemonEvent('normal', createTestEnvelope({ type: 'watchCycleCompleted' }))).toBe(true); + // Legacy prints status/summary text in normal (non-quiet) mode, so activityChanged passes. + expect(shouldSerializeDaemonEvent('normal', createTestEnvelope({ type: 'activityChanged' }))).toBe(true); + expect(shouldSerializeDaemonEvent('normal', diagnostic('info'))).toBe(true); + expect(shouldSerializeDaemonEvent('normal', diagnostic('debug'))).toBe(false); + expect(shouldSerializeDaemonEvent('normal', createTestEnvelope({ type: 'externalOutput' }))).toBe(false); +}); + +it('verbose adds activity and external output but not extensions', () => { + expect(shouldSerializeDaemonEvent('verbose', createTestEnvelope({ type: 'activityChanged' }))).toBe(true); + expect(shouldSerializeDaemonEvent('verbose', createTestEnvelope({ type: 'externalOutput' }))).toBe(true); + expect(shouldSerializeDaemonEvent('verbose', createTestEnvelope({ type: 'extension' }))).toBe(false); +}); + +it('debug passes everything including extensions', () => { + expect(shouldSerializeDaemonEvent('debug', createTestEnvelope({ type: 'extension' }))).toBe(true); + expect(shouldSerializeDaemonEvent('debug', diagnostic('debug'))).toBe(true); +}); + +it('never filters required events regardless of verbosity', () => { + const envelope: IDaemonEventEnvelope = createTestEnvelope({ type: 'activityChanged', required: true }); + expect(shouldSerializeDaemonEvent('quiet', envelope)).toBe(true); +}); + +it('serialization returns undefined for filtered subscriptions and bytes otherwise', () => { + const envelope: IDaemonEventEnvelope = createTestEnvelope({ type: 'operationStatusChanged' }); + expect(serializeDaemonEventForSubscription('quiet', envelope)).toBeUndefined(); + const serialized: Uint8Array | undefined = serializeDaemonEventForSubscription('verbose', envelope); + expect(serialized).toBeDefined(); + const text: string = serialized === undefined ? '{}' : new TextDecoder().decode(serialized); + expect(JSON.parse(text)).toMatchObject({ type: 'operationStatusChanged' }); +}); + +it('gives two clients at different verbosities different subsets of one stream', () => { + const events: readonly IDaemonEventEnvelope[] = [ + createTestEnvelope({ type: 'operationStatusChanged' }), + diagnostic('error'), + createTestEnvelope({ type: 'activityChanged' }) + ]; + const quietCount: number = events.filter((e: IDaemonEventEnvelope) => + shouldSerializeDaemonEvent('quiet', e) + ).length; + const verboseCount: number = events.filter((e: IDaemonEventEnvelope) => + shouldSerializeDaemonEvent('verbose', e) + ).length; + expect(quietCount).toBeLessThan(verboseCount); +}); diff --git a/libraries/rush-daemon-protocol/tsconfig.json b/libraries/rush-daemon-protocol/tsconfig.json new file mode 100644 index 00000000000..6a778ab9aff --- /dev/null +++ b/libraries/rush-daemon-protocol/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"] + } +} diff --git a/libraries/rush-daemon-transport/.npmignore b/libraries/rush-daemon-transport/.npmignore new file mode 100644 index 00000000000..f7a40e10213 --- /dev/null +++ b/libraries/rush-daemon-transport/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/libraries/rush-daemon-transport/AGENTS.md b/libraries/rush-daemon-transport/AGENTS.md new file mode 100644 index 00000000000..59ba7e4a74a --- /dev/null +++ b/libraries/rush-daemon-transport/AGENTS.md @@ -0,0 +1,51 @@ +# Agent coding contract — @rushstack/rush-daemon-transport + +This package is governed by an **ultra-strict lint policy** for generated code. All of the +rules below are enabled to `error` in `eslint.config.js` via the shared +`local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js` mixin. +They apply to **all** TypeScript in this package, **including tests** (`src/**/*.test.ts`). + +## Enforced rules (do not attempt to bypass) + +| Rule | Setting | +| ---- | ------- | +| `complexity` | `['error', 3]` | +| `max-depth` | `['error', 3]` | +| `max-lines-per-function` | `['error', 30]` | +| `max-lines` | `['error', 100]` — every file, including this means: keep files small; split modules | +| `max-params` | `['error', 4]` — use options objects | +| `@typescript-eslint/no-magic-numbers` | `'error'` — every numeric literal must be a named constant | +| `@typescript-eslint/prefer-nullish-coalescing` | `'error'` — use `??`, not `\|\|` or nullish-guard ternaries | +| `import/enforce-node-protocol-usage` | `['error', 'always']` — write `node:crypto`, never `crypto` | +| `import/order` | `['error', { alphabetize: asc, grouped, newlines-between: always }]` | +| `sort-imports` | `['error', { ignoreDeclarationSort: true }]` — sort named members | +| `@typescript-eslint/consistent-type-imports` | `['error', { fixStyle: 'separate-type-imports' }]` — `import type { X }`, never inline `type` specifiers | +| `import/no-relative-parent-imports` | `'error'` for non-test source — no `../` imports outside tests | +| `no-eval`, `@typescript-eslint/no-implied-eval` | `'error'` | + +## Suppression is forbidden — mechanically enforced + +- `linterOptions.noInlineConfig: true` makes **every** `eslint-disable*` comment a lint error. +- `reportUnusedDisableDirectives: 'error'` flags stale suppressions. +- Therefore, as an agent working in this package you MUST NOT: + - add `eslint-disable`, `eslint-disable-next-line`, `eslint-env`, or inline `/* eslint ... */` config comments; + - add entries to any `.eslint-bulk-suppressions.json`; + - add `eslintIgnore` keys to `package.json`; + - add `@ts-nocheck` or `@ts-ignore` comments; + - weaken, reorder, or remove the `strict-codegen` mixin in `eslint.config.js`. +- If a rule fires, **fix the code** (extract a constant, split the function/module, restructure) — never silence it. + +## Deferred rules (do not emulate with hacks) + +The following intended rules have no existing implementation in this repository's ESLint +toolchain and are **not yet enabled** (the user will wire them up later): +`no-magic-strings`, `no-object-mutation`, `no-array-mutation`, +`no-placeholder-implementation`, and the custom zero-tolerance import rules +(`no-re-export`, `require-clean-barrel`, `require-barrel-relative-exports`, +`no-export-alias`, `no-dynamic-import`, `no-hardcoded-secrets`, +`no-parent-internal-access`). Write code that would already satisfy them: prefer immutable +update patterns and named string constants, and never land stubs or `TODO` implementations. + +## Design notes for this package + +- This package owns transport mechanics only: path derivation, sockets/pipes, and lockfiles. Protocol schemas live in `@rushstack/rush-daemon-protocol`; presentation lives in `@rushstack/rush-terminal-renderer`. No `rush-lib` dependency. \ No newline at end of file diff --git a/libraries/rush-daemon-transport/LICENSE b/libraries/rush-daemon-transport/LICENSE new file mode 100644 index 00000000000..56ad0fec060 --- /dev/null +++ b/libraries/rush-daemon-transport/LICENSE @@ -0,0 +1,24 @@ +@rushstack/rush-daemon-transport + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libraries/rush-daemon-transport/README.md b/libraries/rush-daemon-transport/README.md new file mode 100644 index 00000000000..52e6b64d794 --- /dev/null +++ b/libraries/rush-daemon-transport/README.md @@ -0,0 +1,28 @@ +# @rushstack/rush-daemon-transport + +> **Public beta** — this package is versioned at `0.x`; its API may change between minor versions. + +The workspace-keyed socket/pipe **transport** for the Rush daemon (`rushd`): + +- **Workspace keys** — `sha256(canonicalRepoRoot + rushVersion + startupOptions)`, so distinct + workspaces, Rush versions, or startup options resolve to distinct daemon endpoints while the + same workspace stays stable across runs. +- **Per-user path derivation** — `$XDG_RUNTIME_DIR`-aware Unix domain sockets on POSIX and + `\\.\pipe\rushd-` named pipes on Windows. +- **`net` listener and connector** — framed with + [`@rushstack/rush-daemon-protocol`](https://www.npmjs.com/package/@rushstack/rush-daemon-protocol), + with backpressure-aware writes. +- **PID/lockfile handling** — stale sockets and dead PIDs are detected (two-factor: PID liveness + plus a connect probe) and reclaimed without manual cleanup. + +Part of the Rush 6 / rushd re-architecture: +[microsoft/rushstack#5894](https://github.com/microsoft/rushstack/issues/5894). + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/libraries/rush-daemon-transport/CHANGELOG.md) - + Find out what's new in the latest version +- [API Reference](https://rushstack.io/pages/api/rush-daemon-transport/) + +`@rushstack/rush-daemon-transport` is part of the **Rush Stack** family of projects. diff --git a/libraries/rush-daemon-transport/config/api-extractor.json b/libraries/rush-daemon-transport/config/api-extractor.json new file mode 100644 index 00000000000..3dbb76c0e6f --- /dev/null +++ b/libraries/rush-daemon-transport/config/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "local-node-rig/profiles/default/config/api-extractor-base.json" +} diff --git a/libraries/rush-daemon-transport/config/jest.config.json b/libraries/rush-daemon-transport/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/libraries/rush-daemon-transport/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/libraries/rush-daemon-transport/config/rig.json b/libraries/rush-daemon-transport/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/libraries/rush-daemon-transport/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/libraries/rush-daemon-transport/eslint.config.js b/libraries/rush-daemon-transport/eslint.config.js new file mode 100644 index 00000000000..b08a47af297 --- /dev/null +++ b/libraries/rush-daemon-transport/eslint.config.js @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); +const strictCodegenMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen'); + +module.exports = [ + ...nodeProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + // IMPORTANT: The strict-codegen mixin must remain last so its rules win conflicts. + ...strictCodegenMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/libraries/rush-daemon-transport/package.json b/libraries/rush-daemon-transport/package.json new file mode 100644 index 00000000000..29f74025baf --- /dev/null +++ b/libraries/rush-daemon-transport/package.json @@ -0,0 +1,59 @@ +{ + "name": "@rushstack/rush-daemon-transport", + "version": "0.1.0", + "description": "Workspace-keyed socket/pipe transport for the Rush daemon (rushd): path derivation, net listener/connector, and PID/lockfile handling. (public beta)", + "main": "./lib-commonjs/index.js", + "module": "./lib-esm/index.js", + "types": "./dist/rush-daemon-transport.d.ts", + "exports": { + ".": { + "types": "./dist/rush-daemon-transport.d.ts", + "node": "./lib-commonjs/index.js", + "import": "./lib-esm/index.js", + "require": "./lib-commonjs/index.js" + }, + "./lib/*": { + "types": "./lib-dts/*.d.ts", + "node": "./lib-commonjs/*.js", + "import": "./lib-esm/*.js", + "require": "./lib-commonjs/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "keywords": ["rush", "rushd", "daemon", "transport", "socket", "named-pipe"], + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "libraries/rush-daemon-transport" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": { + "@rushstack/rush-daemon-protocol": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "sideEffects": false +} diff --git a/libraries/rush-daemon-transport/src/DaemonConnector.ts b/libraries/rush-daemon-transport/src/DaemonConnector.ts new file mode 100644 index 00000000000..c766cedaeff --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonConnector.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as net from 'node:net'; + +import { DaemonFrameConnection } from './DaemonFrameConnection'; +import { DaemonTransportError, DaemonTransportErrorCode } from './DaemonTransportError'; + +const DEFAULT_CONNECT_TIMEOUT_MS: number = 5000; +const NO_TIMEOUT: number = 0; + +/** Options for {@link connectDaemonAsync}. @beta */ +export interface IDaemonConnectorOptions { + /** The connect timeout in milliseconds. Defaults to 5000. */ + readonly connectTimeoutMs?: number; +} + +/** + * Connects to the daemon's socket (POSIX) or named pipe (Windows). + * + * @throws {@link DaemonTransportError} `connectionRefused` when nothing listens + * at the path, or `connectionTimeout` when the attempt stalls. + * + * @beta + */ +export async function connectDaemonAsync( + socketPath: string, + options?: IDaemonConnectorOptions +): Promise { + const timeoutMs: number = options?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + return new Promise( + (resolve: (connection: DaemonFrameConnection) => void, reject: (error: Error) => void) => { + const socket: net.Socket = net.createConnection(socketPath); + socket.setTimeout(timeoutMs); + socket.once('connect', () => { + socket.setTimeout(NO_TIMEOUT); + resolve(new DaemonFrameConnection(socket)); + }); + socket.once('timeout', () => fail(socket, reject, DaemonTransportErrorCode.connectionTimeout, + `Timed out connecting to daemon at ${socketPath}.`)); + socket.once('error', () => fail(socket, reject, DaemonTransportErrorCode.connectionRefused, + `Could not connect to daemon at ${socketPath}.`)); + } + ); +} + +function fail( + socket: net.Socket, + reject: (error: Error) => void, + code: DaemonTransportErrorCode, + message: string +): void { + socket.destroy(); + reject(new DaemonTransportError(code, message)); +} diff --git a/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts b/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts new file mode 100644 index 00000000000..dce6c5a545d --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { once } from 'node:events'; +import type * as net from 'node:net'; + +import { DaemonFrameDecoder, encodeDaemonFrame } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonFrame } from '@rushstack/rush-daemon-protocol'; + +import { DaemonTransportError, DaemonTransportErrorCode } from './DaemonTransportError'; + +/** One end of a framed rushd connection over a `net` socket (Unix socket or named pipe). + * @remarks + * Incoming bytes decode to frames in wire order; a malformed frame or a throwing handler fails the + * connection closed instead of escaping the socket callback. Outgoing frames are backpressured so a + * slow consumer loses nothing. + * @beta */ +export class DaemonFrameConnection { + private readonly _socket: net.Socket; + private readonly _decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); + private _frameHandler: ((frame: IDaemonFrame) => void) | undefined; + private _closedHandler: ((error: Error | undefined) => void) | undefined; + private _closedError: Error | undefined; + + public constructor(socket: net.Socket) { + this._socket = socket; + socket.on('data', (chunk: Buffer) => this._onData(chunk)); + socket.on('error', (error: Error) => this._onError(error)); + socket.on('close', () => this._onClose()); + } + + /** Registers the frame handler invoked for each decoded frame. */ + public onFrame(handler: (frame: IDaemonFrame) => void): void { + this._frameHandler = handler; + } + /** Registers the close handler, invoked at most once with the cause. */ + public onClosed(handler: (error: Error | undefined) => void): void { + this._closedHandler = handler; + } + + /** Encodes and writes a frame, resolving when the socket has drained it. @throws {@link DaemonTransportError} when closed. */ + public async sendFrameAsync(frame: IDaemonFrame): Promise { + this._assertOpen(); + if (!this._socket.write(encodeDaemonFrame(frame))) { + await once(this._socket, 'drain'); + } + } + + /** Half-closes the writable side and releases the socket. */ + public async closeAsync(): Promise { + this._socket.end(); + this._socket.destroySoon(); + } + + /** The wrapped socket, for the internal raw-write test hook. @internal */ + public get socket(): net.Socket { + return this._socket; + } + private _assertOpen(): void { + if (this._closedError !== undefined || this._socket.closed) { + throw new DaemonTransportError( + DaemonTransportErrorCode.transportClosed, + 'Cannot send a frame on a closed connection.' + ); + } + } + + private _onData(chunk: Buffer): void { + let frames: IDaemonFrame[]; + try { + frames = this._decoder.push(chunk); + } catch (error) { + this._fail(error); + return; + } + for (const frame of frames) { + this._dispatchFrame(frame); + } + } + private _dispatchFrame(frame: IDaemonFrame): void { + try { + this._frameHandler?.(frame); + } catch (error) { + this._fail(error); + } + } + + private _fail(error: unknown): void { + const cause: Error = error instanceof Error ? error : new Error(String(error)); + this._closedError = this._closedError ?? cause; + this._socket.destroy(cause); + } + private _onError(error: Error): void { + this._closedError = this._closedError ?? error; + } + + private _onClose(): void { + this._closedHandler?.(this._closedError); + } +} diff --git a/libraries/rush-daemon-transport/src/DaemonListener.ts b/libraries/rush-daemon-transport/src/DaemonListener.ts new file mode 100644 index 00000000000..f4b17aefb43 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonListener.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as net from 'node:net'; + +import type { IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol'; + +import { DaemonFrameConnection } from './DaemonFrameConnection'; +import { ADDRESS_IN_USE, listenOrErrorAsync, toListenTransportError } from './DaemonListenerNet'; +import type { INetError } from './DaemonListenerNet'; +import { ensureDaemonRuntimeDir, removeDaemonArtifacts, writeDaemonLockfile } from './DaemonLockfile'; +import type { IDaemonPaths } from './DaemonPaths'; +import { reclaimStaleDaemonAsync } from './DaemonReclaim'; + +const FIRST_ATTEMPT: number = 0; +const RECLAIM_ATTEMPT: number = 1; + + +/** Options for {@link DaemonFrameListener.listenAsync}. @beta */ +export interface IDaemonListenerOptions { + /** The wire protocol version this daemon speaks (recorded in the lockfile). */ + readonly protocolVersion: IDaemonProtocolVersion; + /** The ISO 8601 start time recorded in the lockfile. Defaults to now. */ + readonly startedAt?: string; + /** Invoked for each newly connected client. */ + readonly onConnection: (connection: DaemonFrameConnection) => void; +} + +/** The daemon-side framed listener bound to a workspace's socket/pipe path. + * @remarks + * Binding reclaims the path from a dead daemon automatically (see + * {@link reclaimStaleDaemonAsync}); when a live daemon owns the path, a typed + * `daemonAlreadyRunning` transport error is thrown. + * @beta */ +export class DaemonFrameListener { + private readonly _server: net.Server; + private readonly _paths: IDaemonPaths; + private constructor(server: net.Server, paths: IDaemonPaths) { + this._server = server; + this._paths = paths; + } + /** Binds the socket/pipe path and writes the PID lockfile. */ + public static async listenAsync( + paths: IDaemonPaths, + options: IDaemonListenerOptions + ): Promise { + const server: net.Server = net.createServer((socket: net.Socket) => { + options.onConnection(new DaemonFrameConnection(socket)); + }); + ensureDaemonRuntimeDir(paths); + await listenWithReclaimAsync(server, paths); + // Lockfile after bind: a pre-existing stale record must read as dead, not + // as a live owner that would make reclaim refuse. + writeDaemonLockfile(paths.lockfilePath, { + pid: process.pid, + protocolVersion: options.protocolVersion, + startedAt: options.startedAt ?? new Date().toISOString(), + socketPath: paths.socketPath + }); + return new DaemonFrameListener(server, paths); + } + + /** Stops accepting connections and releases the socket/pipe and lockfile. */ + public async closeAsync(): Promise { + await new Promise((resolve: () => void) => this._server.close(() => resolve())); + removeDaemonArtifacts(this._paths.lockfilePath, this._paths.socketPath); + } +} + + +async function listenWithReclaimAsync(server: net.Server, paths: IDaemonPaths): Promise { + for (let attempt: number = FIRST_ATTEMPT; attempt <= RECLAIM_ATTEMPT; attempt++) { + const bound: boolean = await tryListenOnceAsync(server, paths, attempt); + if (bound) { + return; + } + } +} + +async function tryListenOnceAsync( + server: net.Server, + paths: IDaemonPaths, + attempt: number +): Promise { + const error: INetError | undefined = await listenOrErrorAsync(server, paths.socketPath); + return error ? recoverFromListenErrorAsync(error, paths, attempt) : true; +} + +async function recoverFromListenErrorAsync( + error: INetError, + paths: IDaemonPaths, + attempt: number +): Promise { + const canReclaim: boolean = error.code === ADDRESS_IN_USE && attempt === FIRST_ATTEMPT; + if (!canReclaim) { + throw toListenTransportError(error, paths.socketPath); + } + await reclaimStaleDaemonAsync(paths); + return false; +} diff --git a/libraries/rush-daemon-transport/src/DaemonListenerNet.ts b/libraries/rush-daemon-transport/src/DaemonListenerNet.ts new file mode 100644 index 00000000000..4d8a616ba63 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonListenerNet.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as net from 'node:net'; + +import { DaemonTransportError, DaemonTransportErrorCode } from './DaemonTransportError'; + +/** A `net` error with its Node.js `code` string. @internal */ +export interface INetError extends Error { + code?: string; +} + +/** The Node.js error code for a bound socket/pipe path. */ +export const ADDRESS_IN_USE: string = 'EADDRINUSE'; + +/** + * Attempts one `listen` on `socketPath`, resolving with the error (if any) + * instead of rejecting so callers can implement retry/reclaim loops. + * + * @internal + */ +export async function listenOrErrorAsync( + server: net.Server, + socketPath: string +): Promise { + return new Promise((resolve: (error: INetError | undefined) => void) => { + const onError: (error: INetError) => void = (error: INetError) => resolve(error); + server.once('error', onError); + server.listen(socketPath, () => { + server.removeListener('error', onError); + resolve(undefined); + }); + }); +} + +/** + * Maps a `net` listen failure to a typed transport error. + * + * @internal + */ +export function toListenTransportError(error: INetError, socketPath: string): DaemonTransportError { + if (error.code === ADDRESS_IN_USE) { + return new DaemonTransportError( + DaemonTransportErrorCode.daemonAlreadyRunning, + `The daemon path ${socketPath} is still in use after reclaim.` + ); + } + return new DaemonTransportError( + DaemonTransportErrorCode.transportClosed, + `Failed to listen at ${socketPath}: ${error.message}` + ); +} diff --git a/libraries/rush-daemon-transport/src/DaemonLockfile.ts b/libraries/rush-daemon-transport/src/DaemonLockfile.ts new file mode 100644 index 00000000000..f81fbd1ef76 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonLockfile.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import type { IDaemonProtocolVersion } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonPaths } from './DaemonPaths'; + +const UTF8: BufferEncoding = 'utf8'; +const NO_SIGNAL: number = 0; +const DIR_MODE: number = 0o700; +const FILE_MODE: number = 0o600; + +/** The on-disk contents of a daemon PID/lock file. @beta */ +export interface IDaemonLockfile { + /** The process id of the daemon. */ + readonly pid: number; + /** The wire protocol version the daemon speaks. */ + readonly protocolVersion: IDaemonProtocolVersion; + /** The ISO 8601 time the daemon started. */ + readonly startedAt: string; + /** The socket/pipe path the daemon listens on. */ + readonly socketPath: string; +} + +/** Creates the per-user runtime directory (mode `0700`) when the platform has one. + * Must be called before binding a POSIX socket inside it. @beta */ +export function ensureDaemonRuntimeDir(paths: IDaemonPaths): void { + if (paths.runtimeDir !== undefined) { + fs.mkdirSync(paths.runtimeDir, { recursive: true, mode: DIR_MODE }); + } +} + +/** Returns `true` when a process with `pid` exists and is signalable. @beta */ +export function isDaemonProcessAlive(pid: number): boolean { + try { + process.kill(pid, NO_SIGNAL); + return true; + } catch { + return false; + } +} + +function isDaemonLockfileRecord(value: unknown): value is IDaemonLockfile { + if (typeof value !== 'object' || value === null) { + return false; + } + return typeof (value as IDaemonLockfile).pid === 'number'; +} + +/** Reads and parses a daemon lockfile, or returns `undefined` when absent, unreadable, + * or malformed (a bare reclaim-mutex record carries no daemon `pid`). @beta */ +export function readDaemonLockfile(lockfilePath: string): IDaemonLockfile | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(lockfilePath, UTF8)); + } catch { + return undefined; + } + return isDaemonLockfileRecord(parsed) ? parsed : undefined; +} + +/** Writes the daemon lockfile, creating the runtime directory when needed. @beta */ +export function writeDaemonLockfile(lockfilePath: string, lockfile: IDaemonLockfile): void { + fs.mkdirSync(path.dirname(lockfilePath), { recursive: true, mode: DIR_MODE }); + fs.writeFileSync(lockfilePath, JSON.stringify(lockfile), { encoding: UTF8, mode: FILE_MODE }); +} + +/** Removes the daemon lockfile and (on POSIX) the stale socket file; idempotent. @beta */ +export function removeDaemonArtifacts(lockfilePath: string, socketPath: string): void { + for (const filePath of [lockfilePath, socketPath]) { + try { + fs.unlinkSync(filePath); + } catch { + // Already gone; reclaim is idempotent. + } + } +} diff --git a/libraries/rush-daemon-transport/src/DaemonPaths.ts b/libraries/rush-daemon-transport/src/DaemonPaths.ts new file mode 100644 index 00000000000..fb1f4e558ac --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonPaths.ts @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as path from 'node:path'; + +const WINDOWS_PLATFORM: NodeJS.Platform = 'win32'; +const PIPE_PREFIX: string = '\\\\.\\pipe\\'; +const SOCKET_SUFFIX: string = '.sock'; +const LOCKFILE_SUFFIX: string = '.pid.json'; +const RUNTIME_DIR_NAME: string = 'rushd'; +const XDG_RUNTIME_DIR_ENV: string = 'XDG_RUNTIME_DIR'; + +/** + * The platform facts {@link resolveDaemonPaths} needs, injectable for tests. + * + * @beta + */ +export interface IDaemonPathEnvironment { + /** The operating system platform (`process.platform`). */ + readonly platform: NodeJS.Platform; + /** Environment variables (`process.env`). */ + readonly env: Readonly>; + /** The per-user temporary directory (`os.tmpdir()`). */ + readonly tmpdir: string; + /** The numeric user id on POSIX platforms (`process.getuid()`), when available. */ + readonly uid?: number; +} + +/** + * The resolved transport paths for one workspace key. + * + * @beta + */ +export interface IDaemonPaths { + /** The per-user runtime directory (POSIX only; `undefined` on Windows). */ + readonly runtimeDir?: string; + /** The socket path (POSIX) or named pipe path (Windows). */ + readonly socketPath: string; + /** The PID/lock file path. */ + readonly lockfilePath: string; +} + +/** + * Resolves the per-user socket/pipe and lockfile paths for a workspace key. + * + * @remarks + * POSIX: `$XDG_RUNTIME_DIR/rushd-/` (falling back to `/rushd-/`), + * with the socket at `rushd-.sock` inside it. Windows: the named pipe + * `\\.\pipe\rushd-`; the lockfile lives in `/rushd/` (the + * temporary directory is already per-user on Windows). + * + * @beta + */ +export function resolveDaemonPaths(environment: IDaemonPathEnvironment, workspaceKey: string): IDaemonPaths { + if (environment.platform === WINDOWS_PLATFORM) { + return { + runtimeDir: undefined, + socketPath: `${PIPE_PREFIX}${workspaceKey}`, + lockfilePath: path.win32.join(environment.tmpdir, RUNTIME_DIR_NAME, `${workspaceKey}${LOCKFILE_SUFFIX}`) + }; + } + const base: string = environment.env[XDG_RUNTIME_DIR_ENV] ?? environment.tmpdir; + const runtimeDir: string = path.posix.join(base, `${RUNTIME_DIR_NAME}-${environment.uid}`); + return { + runtimeDir, + socketPath: path.posix.join(runtimeDir, `${workspaceKey}${SOCKET_SUFFIX}`), + lockfilePath: path.posix.join(runtimeDir, `${workspaceKey}${LOCKFILE_SUFFIX}`) + }; +} diff --git a/libraries/rush-daemon-transport/src/DaemonPathsFromProcess.ts b/libraries/rush-daemon-transport/src/DaemonPathsFromProcess.ts new file mode 100644 index 00000000000..611b08af7ea --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonPathsFromProcess.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import type { IDaemonPaths } from './DaemonPaths'; +import { resolveDaemonPaths } from './DaemonPaths'; + +/** + * Resolves the daemon paths for a workspace key using the current process's + * platform, environment, temporary directory, and user id. + * + * @remarks + * This is the production entry point; {@link resolveDaemonPaths} remains the + * pure, injectable form for tests. + * + * @beta + */ +export function resolveDaemonPathsFromProcess(workspaceKey: string): IDaemonPaths { + return resolveDaemonPaths( + { + platform: process.platform, + env: process.env, + tmpdir: os.tmpdir(), + uid: process.getuid?.() + }, + workspaceKey + ); +} diff --git a/libraries/rush-daemon-transport/src/DaemonRawWrite.ts b/libraries/rush-daemon-transport/src/DaemonRawWrite.ts new file mode 100644 index 00000000000..a0d85d8acd8 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonRawWrite.ts @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type * as net from 'node:net'; + +/** + * Writes already-encoded bytes verbatim to a socket. Test hook used by + * robustness tests to inject a malformed frame past the frame encoder. + * + * @internal + */ +export function writeRawSocketBytes(socket: net.Socket, bytes: Uint8Array): void { + socket.write(bytes); +} diff --git a/libraries/rush-daemon-transport/src/DaemonReclaim.ts b/libraries/rush-daemon-transport/src/DaemonReclaim.ts new file mode 100644 index 00000000000..e70d12f0f22 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonReclaim.ts @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; + +import { connectDaemonAsync } from './DaemonConnector'; +import type { DaemonFrameConnection } from './DaemonFrameConnection'; +import { + isDaemonProcessAlive, + readDaemonLockfile, + removeDaemonArtifacts +} from './DaemonLockfile'; +import type { IDaemonPaths } from './DaemonPaths'; +import { tryAcquireReclaimLock } from './DaemonReclaimLock'; +import type { DaemonReclaimLockOutcome } from './DaemonReclaimLock'; +import { DaemonTransportError, DaemonTransportErrorCode } from './DaemonTransportError'; + +/** + * Reclaims the socket/pipe path when it is held by a dead daemon. + * + * @remarks + * Two-factor stale detection — the lockfile PID must be dead *and* a connect + * probe must fail — so a daemon that is alive but momentarily unresponsive is + * never reclaimed underneath itself. Reclaims are serialized through the + * lockfile mutex ({@link tryAcquireReclaimLock}): only the mutex holder may + * unlink the socket path, so a concurrent starter cannot delete a socket that + * another process just bound. + * + * @throws {@link DaemonTransportError} with code `daemonAlreadyRunning` when a + * live (or plausibly live) daemon owns the path, or when another starter holds + * the reclaim lock. + * + * @beta + */ +export async function reclaimStaleDaemonAsync(paths: IDaemonPaths): Promise { + // The mutex lives beside the lockfile (never the same file): the lockfile + // records the *running* daemon's live PID, while the mutex only ever records + // a reclaimer's pid. So a live daemon is "locked" (its PID alive), while a + // dead daemon's stale record is safe to steal. + const lock: DaemonReclaimLockOutcome = tryAcquireReclaimLock(reclaimLockPath(paths)); + if (!lock.acquired) { + throwAlreadyRunning(paths, 'another starter holds the reclaim lock'); + } + try { + await reclaimUnderLockAsync(paths); + } finally { + try { + fs.unlinkSync(reclaimLockPath(paths)); + } catch { + // Another starter may have already cleared it; release is best-effort. + } + } +} + +function reclaimLockPath(paths: IDaemonPaths): string { + return `${paths.lockfilePath}.reclaim`; +} + +async function reclaimUnderLockAsync(paths: IDaemonPaths): Promise { + if (isLockfilePidAlive(readDaemonLockfile(paths.lockfilePath))) { + throwAlreadyRunning(paths, 'its lockfile PID is alive'); + } + const probeFailed: boolean = await probeConnectionFailsAsync(paths.socketPath); + if (!probeFailed) { + throwAlreadyRunning(paths, 'it answers a connect probe'); + } + removeDaemonArtifacts(paths.lockfilePath, paths.socketPath); +} + +function isLockfilePidAlive(lockfile: ReturnType): boolean { + return lockfile !== undefined && isDaemonProcessAlive(lockfile.pid); +} + +function throwAlreadyRunning(paths: IDaemonPaths, reason: string): never { + throw new DaemonTransportError( + DaemonTransportErrorCode.daemonAlreadyRunning, + `A live daemon already listens at ${paths.socketPath} (${reason}).` + ); +} + +async function probeConnectionFailsAsync(socketPath: string): Promise { + try { + const probe: DaemonFrameConnection = await connectDaemonAsync(socketPath); + await probe.closeAsync(); + return false; + } catch { + return true; + } +} diff --git a/libraries/rush-daemon-transport/src/DaemonReclaimLock.ts b/libraries/rush-daemon-transport/src/DaemonReclaimLock.ts new file mode 100644 index 00000000000..d90ddb3b29b --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonReclaimLock.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; + +import { isDaemonProcessAlive } from './DaemonLockfile'; + +const UTF8: BufferEncoding = 'utf8'; +const FILE_MODE: number = 0o600; + +/** + * The outcome of attempting to take the reclaim mutex on a lockfile. + * + * @beta + */ +export type DaemonReclaimLockOutcome = + | { readonly acquired: true } + | { readonly acquired: false; readonly reason: 'alreadyHeld' }; + +function readMutexPid(lockfilePath: string): number | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(lockfilePath, UTF8)); + } catch { + return undefined; + } + return extractMutexPid(parsed); +} + +function extractMutexPid(parsed: unknown): number | undefined { + const pid: unknown = isPlainRecord(parsed) ? parsed.mutexPid : undefined; + return typeof pid === 'number' ? pid : undefined; +} + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function stealFromDeadHolder(lockfilePath: string): DaemonReclaimLockOutcome { + const holderPid: number | undefined = readMutexPid(lockfilePath); + const holderAlive: boolean = holderPid !== undefined && isDaemonProcessAlive(holderPid); + if (holderAlive) { + return { acquired: false, reason: 'alreadyHeld' }; + } + return rewriteMutexEntry(lockfilePath); +} + +function rewriteMutexEntry(lockfilePath: string): DaemonReclaimLockOutcome { + // The holder is dead (or the record is a corrupt/bare mutex record): steal by + // unlinking the stale record and re-creating the mutex entry atomically. + try { + fs.unlinkSync(lockfilePath); + fs.writeFileSync(lockfilePath, JSON.stringify({ mutexPid: process.pid }), { + encoding: UTF8, + flag: 'wx', + mode: FILE_MODE + }); + return { acquired: true }; + } catch { + // Lost the steal race to another starter; they now hold the mutex. + return { acquired: false, reason: 'alreadyHeld' }; + } +} + +/** + * Attempts to take an exclusive reclaim mutex on `lockfilePath` by creating it + * with the `wx` flag. A lockfile left by a dead process is stale-safe to steal + * (the holder can no longer be binding the socket); one held by a live process + * means another starter is reclaiming or running. + * + * @beta + */ +export function tryAcquireReclaimLock(lockfilePath: string): DaemonReclaimLockOutcome { + try { + fs.writeFileSync(lockfilePath, JSON.stringify({ mutexPid: process.pid }), { + encoding: UTF8, + flag: 'wx', + mode: FILE_MODE + }); + return { acquired: true }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } + return stealFromDeadHolder(lockfilePath); + } +} diff --git a/libraries/rush-daemon-transport/src/DaemonTransportError.ts b/libraries/rush-daemon-transport/src/DaemonTransportError.ts new file mode 100644 index 00000000000..7a7efb9310d --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonTransportError.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The closed set of machine-readable rushd transport error codes. + * + * @beta + */ +export enum DaemonTransportErrorCode { + /** Another live daemon already owns the socket/pipe for this workspace key. */ + daemonAlreadyRunning = 'daemonAlreadyRunning', + /** No daemon is listening at the socket/pipe path. */ + connectionRefused = 'connectionRefused', + /** The connection attempt exceeded the configured timeout. */ + connectionTimeout = 'connectionTimeout', + /** The transport was closed while an operation was in flight. */ + transportClosed = 'transportClosed' +} + +/** + * A typed error raised by the rushd socket/pipe transport. + * + * @beta + */ +export class DaemonTransportError extends Error { + /** + * The machine-readable error code. + */ + public readonly code: DaemonTransportErrorCode; + + public constructor(code: DaemonTransportErrorCode, message: string) { + super(message); + this.name = 'DaemonTransportError'; + this.code = code; + } +} diff --git a/libraries/rush-daemon-transport/src/WorkspaceKey.ts b/libraries/rush-daemon-transport/src/WorkspaceKey.ts new file mode 100644 index 00000000000..e63d53956be --- /dev/null +++ b/libraries/rush-daemon-transport/src/WorkspaceKey.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { createHash } from 'node:crypto'; + +/** The number of hex characters of the sha256 digest used as the workspace key. @beta */ +export const WORKSPACE_KEY_LENGTH: number = 32; + +const HASH_ALGORITHM: string = 'sha256'; +const HASH_ENCODING: 'hex' = 'hex'; +const FIELD_SEPARATOR: string = '\u0000'; +const KEY_PREFIX: string = 'rushd-'; +const DIGEST_START: number = 0; + +function isPlainRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * The inputs that identity-hash a daemon-ownable workspace. + * + * @beta + */ +export interface IWorkspaceKeyInput { + /** The canonical (realpath-resolved, normalized) absolute repository root. */ + readonly canonicalRepoRoot: string; + /** The Rush version string, for example `5.178.0`. */ + readonly rushVersion: string; + /** Daemon startup options; serialized deterministically (keys sorted). */ + readonly startupOptions?: Readonly>; +} + +function stableSerializePrimitive(value: unknown): string { + const serialized: string | undefined = JSON.stringify(value); + return serialized ?? ''; +} + +function stableSerialize(value: unknown): string { + if (Array.isArray(value)) { + return `[${value.map(stableSerialize).join(',')}]`; + } + if (isPlainRecord(value)) { + const entries: string = Object.keys(value) + .sort() + .map((key: string) => `${JSON.stringify(key)}:${stableSerialize(value[key])}`) + .join(','); + return `{${entries}}`; + } + return stableSerializePrimitive(value); +} + +/** + * Computes the workspace key: a truncated `sha256` of the canonical repository + * root, the Rush version, and the startup options. + * + * @remarks + * Distinct workspaces, Rush versions, or startup options produce distinct keys + * (and therefore distinct socket/pipe paths); the same workspace resolves to the + * same key across runs. + * + * @beta + */ +export function computeDaemonWorkspaceKey(input: IWorkspaceKeyInput): string { + const material: string = [ + input.canonicalRepoRoot, + input.rushVersion, + stableSerialize(input.startupOptions ?? {}) + ].join(FIELD_SEPARATOR); + const digest: string = createHash(HASH_ALGORITHM).update(material, 'utf8').digest(HASH_ENCODING); + return `${KEY_PREFIX}${digest.slice(DIGEST_START, WORKSPACE_KEY_LENGTH)}`; +} diff --git a/libraries/rush-daemon-transport/src/index.ts b/libraries/rush-daemon-transport/src/index.ts new file mode 100644 index 00000000000..4674d34b024 --- /dev/null +++ b/libraries/rush-daemon-transport/src/index.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * Workspace-keyed socket/pipe transport for the Rush daemon (`rushd`): + * workspace-key hashing, per-user runtime-dir path derivation, a `net` + * listener/connector, and PID/lockfile handling with stale-socket reclaim. + * + * @remarks + * Frames are encoded with `@rushstack/rush-daemon-protocol`; this package owns + * where the bytes live and how endpoints find each other. It has no `rush-lib` + * dependency. + * + * @packageDocumentation + */ + +export { connectDaemonAsync, type IDaemonConnectorOptions } from './DaemonConnector'; +export { DaemonFrameConnection } from './DaemonFrameConnection'; +export { DaemonFrameListener, type IDaemonListenerOptions } from './DaemonListener'; +export { + ensureDaemonRuntimeDir, + isDaemonProcessAlive, + readDaemonLockfile, + removeDaemonArtifacts, + writeDaemonLockfile, + type IDaemonLockfile +} from './DaemonLockfile'; +export { tryAcquireReclaimLock, type DaemonReclaimLockOutcome } from './DaemonReclaimLock'; +export { resolveDaemonPaths, type IDaemonPathEnvironment, type IDaemonPaths } from './DaemonPaths'; +export { resolveDaemonPathsFromProcess } from './DaemonPathsFromProcess'; +export { reclaimStaleDaemonAsync } from './DaemonReclaim'; +export { DaemonTransportError, DaemonTransportErrorCode } from './DaemonTransportError'; +export { + computeDaemonWorkspaceKey, + WORKSPACE_KEY_LENGTH, + type IWorkspaceKeyInput +} from './WorkspaceKey'; diff --git a/libraries/rush-daemon-transport/src/test/Backpressure.test.ts b/libraries/rush-daemon-transport/src/test/Backpressure.test.ts new file mode 100644 index 00000000000..0538b2f6869 --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/Backpressure.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonFrameType } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonFrame } from '@rushstack/rush-daemon-protocol'; + +import type { DaemonFrameConnection } from '../DaemonFrameConnection'; +import type { IDaemonPaths } from '../DaemonPaths'; + +import { createDeferred, createTestDaemonPaths, startTestDaemonPair } from './TestDaemonFixture'; +import type { ITestDaemonPair } from './TestDaemonFixture'; +import type { IDeferred } from './TestDaemonFixture'; + +const KIBIBYTE: number = 1024; +const MEBIBYTE: number = KIBIBYTE * KIBIBYTE; +const FRAME_COUNT: number = 16; +const FILL_BYTE: number = 0x61; +const EMPTY_TOTAL: number = 0; +const FIRST_INDEX: number = 0; + +async function sendLargeFramesAsync(serverSide: Promise): Promise { + const server: DaemonFrameConnection = await serverSide; + for (let index: number = FIRST_INDEX; index < FRAME_COUNT; index++) { + await server.sendFrameAsync({ + kind: DaemonFrameType.logStdout, + payload: Buffer.alloc(MEBIBYTE, FILL_BYTE) + }); + } +} + +it('delivers every frame intact when the writer outpaces the reader', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const received: Buffer[] = []; + const allReceived: IDeferred = createDeferred(); + const pair: ITestDaemonPair = await startTestDaemonPair(paths); + try { + pair.client.onFrame((frame: IDaemonFrame) => { + received.push(Buffer.from(frame.payload)); + if (received.length === FRAME_COUNT) { + allReceived.resolve(); + } + }); + await sendLargeFramesAsync(pair.serverSide); + await allReceived.promise; + const totalBytes: number = received.reduce( + (sum: number, chunk: Buffer) => sum + chunk.length, + EMPTY_TOTAL + ); + expect(received.length).toBe(FRAME_COUNT); + expect(totalBytes).toBe(FRAME_COUNT * MEBIBYTE); + } finally { + await pair.client.closeAsync(); + await pair.listener.closeAsync(); + } +}); diff --git a/libraries/rush-daemon-transport/src/test/ConnectionRobustness.test.ts b/libraries/rush-daemon-transport/src/test/ConnectionRobustness.test.ts new file mode 100644 index 00000000000..4734bf31a6f --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/ConnectionRobustness.test.ts @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonFrameConnection } from '../DaemonFrameConnection'; +import type { IDaemonPaths } from '../DaemonPaths'; +import { writeRawSocketBytes } from '../DaemonRawWrite'; + +import { createDeferred, createTestDaemonPaths, startTestDaemonPair } from './TestDaemonFixture'; +import type { IDeferred, ITestDaemonPair } from './TestDaemonFixture'; + +const FRAME_BYTES: number = 5; +const UNKNOWN_KIND_BYTE: number = 0x7e; +const HEADER_TYPE_OFFSET: number = 4; +const EMPTY_LENGTH: number = 0; +const LENGTH_OFFSET: number = 0; + +it('fails the connection closed (never crashing the process) on a malformed frame', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const clientClosed: IDeferred = createDeferred(); + const pair: ITestDaemonPair = await startTestDaemonPair(paths); + try { + const server: DaemonFrameConnection = await pair.serverSide; + pair.client.onFrame(() => { + throw new Error('no frame should decode from malformed bytes'); + }); + pair.client.onClosed((error: Error | undefined) => clientClosed.resolve(error)); + // Inject a raw malformed frame (unknown kind byte, empty payload). + const malformed: Buffer = Buffer.alloc(FRAME_BYTES); + malformed.writeUInt32LE(EMPTY_LENGTH, LENGTH_OFFSET); + malformed.writeUInt8(UNKNOWN_KIND_BYTE, HEADER_TYPE_OFFSET); + writeRawSocketBytes(server.socket, malformed); + const closeError: Error | undefined = await clientClosed.promise; + expect(closeError).toBeDefined(); + expect(String(closeError)).toContain('unknown frame kind'); + } finally { + await pair.client.closeAsync(); + await pair.listener.closeAsync(); + } +}); diff --git a/libraries/rush-daemon-transport/src/test/DaemonPaths.test.ts b/libraries/rush-daemon-transport/src/test/DaemonPaths.test.ts new file mode 100644 index 00000000000..aee4c54bce7 --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/DaemonPaths.test.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonPathEnvironment } from '../DaemonPaths'; +import { resolveDaemonPaths } from '../DaemonPaths'; + +const TEST_UID: number = 1000; +const KEY: string = 'rushd-deadbeef'; + +function posixEnv(env: Readonly>): IDaemonPathEnvironment { + return { platform: 'linux', env, tmpdir: '/tmp', uid: TEST_UID }; +} + +it('uses XDG_RUNTIME_DIR on POSIX when set', () => { + const paths: ReturnType = resolveDaemonPaths( + posixEnv({ XDG_RUNTIME_DIR: '/run/user/1000' }), + KEY + ); + expect(paths.socketPath).toBe('/run/user/1000/rushd-1000/rushd-deadbeef.sock'); + expect(paths.lockfilePath).toBe('/run/user/1000/rushd-1000/rushd-deadbeef.pid.json'); +}); + +it('falls back to the temp dir on POSIX without XDG_RUNTIME_DIR', () => { + const paths: ReturnType = resolveDaemonPaths(posixEnv({}), KEY); + expect(paths.socketPath).toBe('/tmp/rushd-1000/rushd-deadbeef.sock'); +}); + +it('uses a named pipe on Windows', () => { + const paths: ReturnType = resolveDaemonPaths( + { platform: 'win32', env: {}, tmpdir: 'C:\\Users\\u\\AppData\\Local\\Temp', uid: undefined }, + KEY + ); + expect(paths.socketPath).toBe('\\\\.\\pipe\\rushd-deadbeef'); + expect(paths.runtimeDir).toBeUndefined(); + expect(paths.lockfilePath).toContain('rushd-deadbeef.pid.json'); +}); + +it('derives distinct paths for distinct keys', () => { + const first: ReturnType = resolveDaemonPaths(posixEnv({}), KEY); + const second: ReturnType = resolveDaemonPaths(posixEnv({}), 'rushd-00000000'); + expect(first.socketPath).not.toBe(second.socketPath); +}); diff --git a/libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.ts b/libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.ts new file mode 100644 index 00000000000..aab9a836c57 --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + createDaemonHello, + decodeDaemonControlMessage, + encodeDaemonControlMessage, + negotiateDaemonHello +} from '@rushstack/rush-daemon-protocol'; +import type { IDaemonFrame , + createDaemonHelloAck} from '@rushstack/rush-daemon-protocol'; + +import type { DaemonFrameConnection } from '../DaemonFrameConnection'; +import type { IDaemonPaths } from '../DaemonPaths'; + +import { createDeferred, createTestDaemonPaths, startTestDaemonPair } from './TestDaemonFixture'; +import type { IDeferred, ITestDaemonPair } from './TestDaemonFixture'; + +const NEWER_MAJOR: number = 1; + +function helloFrame(major: number): IDaemonFrame { + return { + kind: DaemonFrameType.controlJson, + payload: encodeDaemonControlMessage( + createDaemonHello({ major, minor: DAEMON_PROTOCOL_VERSION.minor }) + ) + }; +} + +function replyToHello(server: DaemonFrameConnection, frame: IDaemonFrame): void { + const hello: ReturnType = decodeDaemonControlMessage( + frame.payload + ); + if (hello.kind !== 'hello') { + return; + } + const outcome: ReturnType = negotiateDaemonHello( + hello, + DAEMON_PROTOCOL_VERSION, + 'session-e2e' + ); + const reply: ReturnType | ReturnType = + outcome.accepted + ? outcome.ack + : { + kind: 'error' as const, + payload: { code: outcome.error.code, message: outcome.error.message } + }; + void server + .sendFrameAsync({ kind: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage(reply) }) + .then(() => server.closeAsync()); +} + +async function runHandshakeAsync(major: number): Promise> { + const paths: IDaemonPaths = createTestDaemonPaths(); + const answered: IDeferred> = createDeferred>(); + const pair: ITestDaemonPair = await startTestDaemonPair(paths); + try { + const server: DaemonFrameConnection = await pair.serverSide; + server.onFrame((frame: IDaemonFrame) => replyToHello(server, frame)); + pair.client.onFrame((frame: IDaemonFrame) => { + answered.resolve( + decodeDaemonControlMessage(frame.payload) as unknown as Record + ); + }); + await pair.client.sendFrameAsync(helloFrame(major)); + return await answered.promise; + } finally { + await pair.client.closeAsync(); + await pair.listener.closeAsync(); + } +} + +it('negotiates a matching version over a real socket', async () => { + const reply: Record = await runHandshakeAsync(DAEMON_PROTOCOL_VERSION.major); + const payload: Record = reply.payload as Record; + expect(reply.kind).toBe('helloAck'); + expect(payload.sessionId).toBe('session-e2e'); +}); + +it('returns a typed error frame for a mismatched major version', async () => { + const reply: Record = await runHandshakeAsync( + DAEMON_PROTOCOL_VERSION.major + NEWER_MAJOR + ); + const payload: Record = reply.payload as Record; + expect(reply.kind).toBe('error'); + expect(payload.code).toBe('protocolVersionMismatch'); +}); diff --git a/libraries/rush-daemon-transport/src/test/Reclaim.test.ts b/libraries/rush-daemon-transport/src/test/Reclaim.test.ts new file mode 100644 index 00000000000..e43ac1e74a5 --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/Reclaim.test.ts @@ -0,0 +1,71 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; + +import { DaemonFrameListener } from '../DaemonListener'; +import { readDaemonLockfile, writeDaemonLockfile } from '../DaemonLockfile'; +import type { IDaemonPaths } from '../DaemonPaths'; +import { DaemonTransportErrorCode } from '../DaemonTransportError'; + +import { createTestDaemonPaths } from './TestDaemonFixture'; + +const NO_ARGS: readonly string[] = []; +const DIR_MODE: number = 0o700; +const MISSING_PID: number = 0; + +function listen(paths: IDaemonPaths): Promise { + return DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: () => undefined + }); +} + +async function getDeadPidAsync(): Promise { + const child: ReturnType = spawn(process.execPath, NO_ARGS, { stdio: 'ignore' }); + await once(child, 'exit'); + return child.pid ?? MISSING_PID; +} + +function plantStaleArtifacts(paths: IDaemonPaths, deadPid: number): void { + const staleDir: string = paths.runtimeDir ?? path.dirname(paths.lockfilePath); + fs.mkdirSync(staleDir, { recursive: true, mode: DIR_MODE }); + if (paths.runtimeDir !== undefined) { + fs.writeFileSync(paths.socketPath, 'stale'); + } + writeDaemonLockfile(paths.lockfilePath, { + pid: deadPid, + protocolVersion: DAEMON_PROTOCOL_VERSION, + startedAt: new Date().toISOString(), + socketPath: paths.socketPath + }); +} + +it('reclaims a stale socket and dead-PID lockfile without manual cleanup', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + plantStaleArtifacts(paths, await getDeadPidAsync()); + const listener: DaemonFrameListener = await listen(paths); + try { + expect(readDaemonLockfile(paths.lockfilePath)?.pid).toBe(process.pid); + } finally { + await listener.closeAsync(); + } +}); + +it('refuses to reclaim a path owned by a live daemon', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const listener: DaemonFrameListener = await listen(paths); + try { + await expect(listen(paths)).rejects.toMatchObject({ + name: 'DaemonTransportError', + code: DaemonTransportErrorCode.daemonAlreadyRunning + }); + } finally { + await listener.closeAsync(); + } +}); diff --git a/libraries/rush-daemon-transport/src/test/SocketExchange.test.ts b/libraries/rush-daemon-transport/src/test/SocketExchange.test.ts new file mode 100644 index 00000000000..df726ec163a --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/SocketExchange.test.ts @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DAEMON_PROTOCOL_VERSION, DaemonFrameType } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonFrame } from '@rushstack/rush-daemon-protocol'; + +import { connectDaemonAsync } from '../DaemonConnector'; +import type { DaemonFrameConnection } from '../DaemonFrameConnection'; +import { DaemonFrameListener } from '../DaemonListener'; +import { readDaemonLockfile } from '../DaemonLockfile'; +import type { IDaemonPaths } from '../DaemonPaths'; + +import { createDeferred, createTestDaemonPaths } from './TestDaemonFixture'; + +const BYTE_FF: number = 0xff; +const BYTE_00: number = 0x00; +const BINARY_PAYLOAD: Buffer = Buffer.from([BYTE_FF, BYTE_00, BYTE_FF]); + +it('exchanges a frame over the workspace socket with a written lockfile', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const serverReady: ReturnType> = + createDeferred(); + const listener: DaemonFrameListener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: (connection: DaemonFrameConnection) => serverReady.resolve(connection) + }); + const client: DaemonFrameConnection = await connectDaemonAsync(paths.socketPath); + try { + const serverSide: DaemonFrameConnection = await serverReady.promise; + const echoed: ReturnType> = createDeferred(); + client.onFrame((frame: IDaemonFrame) => echoed.resolve(frame)); + serverSide.onFrame((frame: IDaemonFrame) => { + void serverSide.sendFrameAsync(frame); + }); + await client.sendFrameAsync({ kind: DaemonFrameType.logStdout, payload: BINARY_PAYLOAD }); + const reply: IDaemonFrame = await echoed.promise; + expect(reply.kind).toBe(DaemonFrameType.logStdout); + expect(Buffer.from(reply.payload).equals(BINARY_PAYLOAD)).toBe(true); + expect(readDaemonLockfile(paths.lockfilePath)?.pid).toBe(process.pid); + } finally { + await client.closeAsync(); + await listener.closeAsync(); + } +}); + +it('cleans up the lockfile and socket on close', async () => { + const paths: IDaemonPaths = createTestDaemonPaths(); + const listener: DaemonFrameListener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: () => undefined + }); + expect(readDaemonLockfile(paths.lockfilePath)).toBeDefined(); + await listener.closeAsync(); + expect(readDaemonLockfile(paths.lockfilePath)).toBeUndefined(); +}); diff --git a/libraries/rush-daemon-transport/src/test/TestDaemonFixture.ts b/libraries/rush-daemon-transport/src/test/TestDaemonFixture.ts new file mode 100644 index 00000000000..b04a4f0f859 --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/TestDaemonFixture.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as os from 'node:os'; + +import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol'; + +import { connectDaemonAsync } from '../DaemonConnector'; +import type { DaemonFrameConnection } from '../DaemonFrameConnection'; +import { DaemonFrameListener } from '../DaemonListener'; +import type { IDaemonPaths } from '../DaemonPaths'; +import { resolveDaemonPaths } from '../DaemonPaths'; + +let testKeyCounter: number = 0; +const COUNTER_START: number = 1; + +/** Creates unique daemon paths for the current platform in the temp dir. */ +export function createTestDaemonPaths(): IDaemonPaths { + testKeyCounter += COUNTER_START; + const workspaceKey: string = `rushd-test-${process.pid}-${testKeyCounter}`; + return resolveDaemonPaths( + { platform: process.platform, env: {}, tmpdir: os.tmpdir(), uid: process.getuid?.() }, + workspaceKey + ); +} + +/** A minimal deferred promise for crossing the callback/async boundary. */ +export interface IDeferred { + readonly promise: Promise; + readonly resolve: (value: T) => void; +} + +/** Creates a {@link IDeferred}. */ +export function createDeferred(): IDeferred { + let resolveFn: ((value: T) => void) | undefined; + const promise: Promise = new Promise((resolve: (value: T) => void) => { + resolveFn = resolve; + }); + return { + promise, + resolve: (value: T) => resolveFn?.(value) + }; +} + +/** A connected client/server pair over a test listener. */ +export interface ITestDaemonPair { + readonly listener: DaemonFrameListener; + readonly client: DaemonFrameConnection; + readonly serverSide: Promise; +} + +/** Starts a test listener and connects one client to it. */ +export async function startTestDaemonPair(paths: IDaemonPaths): Promise { + const serverReady: IDeferred = createDeferred(); + const listener: DaemonFrameListener = await DaemonFrameListener.listenAsync(paths, { + protocolVersion: DAEMON_PROTOCOL_VERSION, + onConnection: (connection: DaemonFrameConnection) => serverReady.resolve(connection) + }); + const client: DaemonFrameConnection = await connectDaemonAsync(paths.socketPath); + return { listener, client, serverSide: serverReady.promise }; +} diff --git a/libraries/rush-daemon-transport/src/test/WorkspaceKey.test.ts b/libraries/rush-daemon-transport/src/test/WorkspaceKey.test.ts new file mode 100644 index 00000000000..00207e6bc0e --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/WorkspaceKey.test.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { WORKSPACE_KEY_LENGTH, computeDaemonWorkspaceKey } from '../WorkspaceKey'; + +const KEY_PREFIX_LENGTH: number = 'rushd-'.length; +const FULL_KEY_LENGTH: number = KEY_PREFIX_LENGTH + WORKSPACE_KEY_LENGTH; + +const BASE_INPUT = { + canonicalRepoRoot: '/repos/example', + rushVersion: '5.178.0', + startupOptions: { watch: true, parallelism: 4 } +} as const; + +it('is stable across runs for the same workspace', () => { + expect(computeDaemonWorkspaceKey(BASE_INPUT)).toBe(computeDaemonWorkspaceKey(BASE_INPUT)); +}); + +it('differs for a different repository root', () => { + const other: string = computeDaemonWorkspaceKey({ ...BASE_INPUT, canonicalRepoRoot: '/repos/other' }); + expect(other).not.toBe(computeDaemonWorkspaceKey(BASE_INPUT)); +}); + +it('differs for a different Rush version', () => { + const other: string = computeDaemonWorkspaceKey({ ...BASE_INPUT, rushVersion: '6.0.0' }); + expect(other).not.toBe(computeDaemonWorkspaceKey(BASE_INPUT)); +}); + +it('differs for different startup options', () => { + const other: string = computeDaemonWorkspaceKey({ ...BASE_INPUT, startupOptions: { watch: false } }); + expect(other).not.toBe(computeDaemonWorkspaceKey(BASE_INPUT)); +}); + +it('is insensitive to startup option key order', () => { + const reordered: string = computeDaemonWorkspaceKey({ + ...BASE_INPUT, + startupOptions: { parallelism: 4, watch: true } + }); + expect(reordered).toBe(computeDaemonWorkspaceKey(BASE_INPUT)); +}); + +it('produces a rushd-prefixed truncated hex key', () => { + const key: string = computeDaemonWorkspaceKey(BASE_INPUT); + expect(key).toHaveLength(FULL_KEY_LENGTH); + expect(key.startsWith('rushd-')).toBe(true); + expect(/^rushd-[0-9a-f]+$/.test(key)).toBe(true); +}); diff --git a/libraries/rush-daemon-transport/tsconfig.json b/libraries/rush-daemon-transport/tsconfig.json new file mode 100644 index 00000000000..6a778ab9aff --- /dev/null +++ b/libraries/rush-daemon-transport/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"] + } +} diff --git a/libraries/rush-lib/src/index.ts b/libraries/rush-lib/src/index.ts index 5ab636b906d..f2df5c851a1 100644 --- a/libraries/rush-lib/src/index.ts +++ b/libraries/rush-lib/src/index.ts @@ -182,6 +182,10 @@ export { PhasedCommandHooks } from './pluginFramework/PhasedCommandHooks'; export type { IOperationGraph, IOperationGraphIterationOptions } from './logic/operations/IOperationGraph'; +export type { + IOperationGraphEventSink as _IOperationGraphEventSink, + IOperationActivityOptions as _IOperationActivityOptions +} from './logic/operations/OperationEventSink'; export { OperationGraphHooks } from './pluginFramework/OperationGraphHooks'; export type { IRushPlugin } from './pluginFramework/IRushPlugin'; diff --git a/libraries/rush-lib/src/logic/operations/OperationChunkTap.ts b/libraries/rush-lib/src/logic/operations/OperationChunkTap.ts new file mode 100644 index 00000000000..89d434df150 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/OperationChunkTap.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TerminalWritable, type ITerminalChunk } from '@rushstack/terminal'; + +/** + * A passive tap in an operation's terminal pipeline that forwards each chunk, + * tagged with the operation id, to the graph's event sink. Installed upstream + * of the quiet-mode discard so every byte is observable regardless of the + * CLI's verbosity flags. + * + * @internal + */ +export class OperationChunkTap extends TerminalWritable { + private readonly _operationId: string; + private readonly _onChunk: (operationId: string, chunk: ITerminalChunk) => void; + + public constructor( + operationId: string, + onChunk: (operationId: string, chunk: ITerminalChunk) => void + ) { + super({ preventAutoclose: true }); + this._operationId = operationId; + this._onChunk = onChunk; + } + + /** {@inheritDoc @rushstack/terminal#TerminalWritable.onWriteChunk} */ + public onWriteChunk(chunk: ITerminalChunk): void { + this._onChunk(this._operationId, chunk); + } +} diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts new file mode 100644 index 00000000000..cda9311ebb4 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -0,0 +1,80 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { ITerminalChunk } from '@rushstack/terminal'; + +import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { OperationStatus } from './OperationStatus'; + +/** + * Provenance of a status line emitted via the sink's activity callback. + * + * @internal + */ +export interface IOperationActivityOptions { + /** + * Set when the line was written to an operation's own collated stream. + */ + readonly operationId?: string; + /** + * True when the line was written to stderr. + */ + readonly stderr?: boolean; +} + +/** + * A structured, presentation-free event sink for the operation graph. + * + * @remarks + * When a host (for example the Rush daemon) assigns a sink, the engine + * "dual-emits": every operation state transition and every status line that + * would be written as colorized terminal text is also emitted here as + * structured data, with no change to the existing terminal output. + * + * All events are emitted synchronously in engine order. Implementations must + * not call back into the graph. + * + * @internal + */ +export interface IOperationGraphEventSink { + /** + * Invoked when an operation is prepared for an iteration. + */ + onOperationRegistered?(operationId: string, silent: boolean): void; + + /** + * Invoked synchronously on every operation status transition. The result's + * `status`, `error`, and `stopwatch` reflect the new state. + */ + onOperationStatusChanged?( + result: IOperationExecutionResult, + previousStatus: OperationStatus + ): void; + + /** + * Invoked when an operation's collated output is about to be displayed, + * with the progress counters rendered in the legacy + * `==[ name ]===[ x of y ]==` header. + */ + onOperationHeader?(operationId: string, completedOperations: number, totalOperations: number): void; + + /** + * Invoked for each chunk of an operation's raw output, upstream of any + * quiet-mode filtering. Concatenated chunks for one operation exactly match + * what the collated sink receives for that operation. + */ + onOperationChunk?(operationId: string, chunk: ITerminalChunk): void; + + /** + * Invoked when an operation's collated output stream is closed at the end of + * its execution, after all status lines and output have been written. This + * is the authoritative "no more output for this operation" signal. + */ + onOperationStreamClosed?(operationId: string): void; + + /** + * Invoked for each human-oriented status line written to the terminal, + * carrying the plain (pre-colorization) text. + */ + onActivity?(text: string, options?: IOperationActivityOptions): void; +} diff --git a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts index 2de7655d1d3..70c898920bf 100644 --- a/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts +++ b/libraries/rush-lib/src/logic/operations/OperationExecutionRecord.ts @@ -20,6 +20,8 @@ import { CollatedTerminal, type CollatedWriter, type StreamCollator } from '@rus import { coerceParallelism } from './ParseParallelism'; import { OperationStatus, TERMINAL_STATUSES } from './OperationStatus'; +import type { IOperationGraphEventSink } from './OperationEventSink'; +import { OperationChunkTap } from './OperationChunkTap'; import type { IOperationRunner, IOperationRunnerContext } from './IOperationRunner'; import type { Operation } from './Operation'; import { Stopwatch } from '../../utilities/Stopwatch'; @@ -49,6 +51,12 @@ export interface IOperationExecutionRecordContext { inputsSnapshot: IInputsSnapshot | undefined; maxParallelism: number; + /** + * Optional structured event sink for dual-emit. When present, every status + * transition and raw output chunk is also emitted as structured events. + */ + eventSink?: IOperationGraphEventSink; + debugMode: boolean; quietMode: boolean; } @@ -252,10 +260,20 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera if (newStatus === this._status) { return; } + const previousStatus: OperationStatus = this._status; this._status = newStatus; + this._context.eventSink?.onOperationStatusChanged?.(this, previousStatus); this._context.onOperationStateChanged?.(this); } + /** + * The iteration's structured event sink, when dual-emit is enabled. + * @internal + */ + public get eventSink(): IOperationGraphEventSink | undefined { + return this._context.eventSink; + } + public get silent(): boolean { return !this.enabled || this.runner.silent; } @@ -373,12 +391,25 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera newlineKind: NewlineKind.Lf // for StdioSummarizer }); + const chunkTapDestinations: TerminalWritable[] = []; + const eventSink: IOperationGraphEventSink | undefined = this._context.eventSink; + if (eventSink?.onOperationChunk) { + // Tap the stream upstream of the quiet-mode discard so the sink observes + // the exact bytes the collated writer would receive, regardless of verbosity. + chunkTapDestinations.push( + new OperationChunkTap(this.name, (operationId, chunk) => + eventSink.onOperationChunk?.(operationId, chunk) + ) + ); + } + const splitterTransform1: SplitterTransform = new SplitterTransform({ destinations: [ this.quietMode ? new DiscardStdoutTransform({ destination: this.collatedWriter }) : this.collatedWriter, - stderrLineTransform + stderrLineTransform, + ...chunkTapDestinations ] }); @@ -450,6 +481,9 @@ export class OperationExecutionRecord implements IOperationRunnerContext, IOpera } finally { if (this.isTerminal) { this._collatedWriter?.close(); + if (this._collatedWriter) { + this._context.eventSink?.onOperationStreamClosed?.(this.name); + } this.stdioSummarizer.close(); this.problemCollector.close(); } diff --git a/libraries/rush-lib/src/logic/operations/OperationGraph.ts b/libraries/rush-lib/src/logic/operations/OperationGraph.ts index 8d01d746186..9df45bff4ba 100644 --- a/libraries/rush-lib/src/logic/operations/OperationGraph.ts +++ b/libraries/rush-lib/src/logic/operations/OperationGraph.ts @@ -15,6 +15,7 @@ import { NewlineKind, Async, InternalError, AlreadyReportedError } from '@rushst import { AsyncOperationQueue, type IOperationSortFunction } from './AsyncOperationQueue'; import type { Operation } from './Operation'; import { OperationStatus, SUCCESS_STATUSES, TERMINAL_STATUSES } from './OperationStatus'; +import type { IOperationGraphEventSink } from './OperationEventSink'; import { type IOperationExecutionContext, type IOperationExecutionRecordContext, @@ -171,6 +172,18 @@ export class OperationGraph implements IOperationGraph { private _scheduledIteration: IExecutionIterationContext | undefined = undefined; private _terminalSplitter: SplitterTransform; + + /** + * Optional structured event sink enabling "dual-emit": every operation state + * transition and every colorized status line is also emitted as structured + * events. Terminal output is unchanged whether or not a sink is assigned. + * The sink is captured when an iteration is scheduled; assign it before + * calling {@link IOperationGraph.scheduleIterationAsync}. + * + * @internal + */ + public eventSink: IOperationGraphEventSink | undefined = undefined; + private _idleTimeout: NodeJS.Timeout | undefined = undefined; /** Tracks if a graph state change notification has been scheduled for next tick. */ private _graphStateChangeScheduled: boolean = false; @@ -625,10 +638,12 @@ export class OperationGraph implements IOperationGraph { records: new Map(), promise: undefined, completedOperations: 0, - totalOperations: 0 + totalOperations: 0, + eventSink: this.eventSink }; const executionRecords: Map = iterationContext.records; + const { eventSink } = this; for (const operation of sortedOperations) { const executionRecord: OperationExecutionRecord = new OperationExecutionRecord( operation, @@ -636,6 +651,7 @@ export class OperationGraph implements IOperationGraph { ); executionRecords.set(operation, executionRecord); + eventSink?.onOperationRegistered?.(executionRecord.name, executionRecord.silent); } for (const [operation, record] of executionRecords) { @@ -680,9 +696,9 @@ export class OperationGraph implements IOperationGraph { this.hooks.onIterationScheduled.call(iterationContext.records); } catch (e) { // Surface configuration-time issues clearly - terminal.writeStderrLine( - Colorize.red(`An error occurred in onIterationScheduled hook: ${(e as Error).message}`) - ); + const errorMessage: string = `An error occurred in onIterationScheduled hook: ${(e as Error).message}`; + eventSink?.onActivity?.(errorMessage, { stderr: true }); + terminal.writeStderrLine(Colorize.red(errorMessage)); throw e; } if (!this._currentIteration) { @@ -695,6 +711,11 @@ export class OperationGraph implements IOperationGraph { function onWriterActive(writer: CollatedWriter | undefined): void { if (writer) { iterationContext.completedOperations++; + eventSink?.onOperationHeader?.( + writer.taskName, + iterationContext.completedOperations, + iterationContext.totalOperations + ); // Format a header like this // // ==[ @rushstack/the-long-thing ]=================[ 1 of 1000 ]== @@ -800,9 +821,12 @@ export class OperationGraph implements IOperationGraph { onResultAsync: onOperationCompleteAsync }; + const { eventSink } = this; if (!this.quietMode) { const plural: string = totalOperations === 1 ? '' : 's'; - terminal.writeStdoutLine(`Selected ${totalOperations} operation${plural}:`); + const selectedLine: string = `Selected ${totalOperations} operation${plural}:`; + terminal.writeStdoutLine(selectedLine); + eventSink?.onActivity?.(selectedLine); const nonSilentOperations: string[] = []; for (const record of executionRecords.values()) { if (!record.silent) { @@ -812,13 +836,17 @@ export class OperationGraph implements IOperationGraph { nonSilentOperations.sort(); for (const name of nonSilentOperations) { terminal.writeStdoutLine(` ${name}`); + eventSink?.onActivity?.(` ${name}`); } terminal.writeStdoutLine(''); + eventSink?.onActivity?.(''); } const maxSimultaneousProcesses: number = Math.min(totalOperations, this.parallelism); // For logging purposes, don't confuse the user by suggesting we might run more operations in parallel than are scheduled. - terminal.writeStdoutLine(`Executing a maximum of ${maxSimultaneousProcesses} simultaneous processes...`); + const parallelismLine: string = `Executing a maximum of ${maxSimultaneousProcesses} simultaneous processes...`; + terminal.writeStdoutLine(parallelismLine); + eventSink?.onActivity?.(parallelismLine); const bailStatus: OperationStatus | undefined | void = abortSignal.aborted ? OperationStatus.Aborted @@ -1123,13 +1151,16 @@ function _handleOperationFailure(record: OperationExecutionRecord, context: ISta const { name } = record; const { terminal } = record.collatedWriter; // Creates the writer if needed + record.eventSink?.onActivity?.(`"${name}" failed to build.`, { operationId: name, stderr: true }); terminal.writeStderrLine(Colorize.red(`"${name}" failed to build.`)); const blockedQueue: Set = new Set(record.consumers); for (const blockedRecord of blockedQueue) { if (blockedRecord.status === OperationStatus.Waiting) { if (!blockedRecord.silent) { - terminal.writeStdoutLine(`"${blockedRecord.name}" is blocked by "${name}".`); + const blockedLine: string = `"${blockedRecord.name}" is blocked by "${name}".`; + record.eventSink?.onActivity?.(blockedLine, { operationId: blockedRecord.name }); + terminal.writeStdoutLine(blockedLine); } blockedRecord.status = OperationStatus.Blocked; context.executionQueue.complete(blockedRecord); @@ -1157,6 +1188,9 @@ function _handleOperationFromCache( context: IStatefulExecutionContext ): void { if (!record.silent) { + record.eventSink?.onActivity?.(`"${record.name}" was restored from the build cache.`, { + operationId: record.name + }); record.collatedWriter.terminal.writeStdoutLine( Colorize.green(`"${record.name}" was restored from the build cache.`) ); @@ -1171,6 +1205,7 @@ function _handleOperationSkipped(record: OperationExecutionRecord, context: ISta // Do not set resultByOperation here. "Skipped" means the operation was not executed, // so it should not be considered the last *execution* result. if (!record.silent) { + record.eventSink?.onActivity?.(`"${record.name}" was skipped.`, { operationId: record.name }); record.collatedWriter.terminal.writeStdoutLine(Colorize.green(`"${record.name}" was skipped.`)); } } @@ -1180,6 +1215,9 @@ function _handleOperationSkipped(record: OperationExecutionRecord, context: ISta */ function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { if (!record.silent) { + record.eventSink?.onActivity?.(`"${record.name}" did not define any work.`, { + operationId: record.name + }); record.collatedWriter.terminal.writeStdoutLine( Colorize.gray(`"${record.name}" did not define any work.`) ); @@ -1193,6 +1231,10 @@ function _handleOperationNoOp(record: OperationExecutionRecord, context: IStatef function _handleOperationSuccess(record: OperationExecutionRecord, context: IStatefulExecutionContext): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { + record.eventSink?.onActivity?.( + `"${record.name}" completed successfully in ${stopwatch.toString()}.`, + { operationId: record.name } + ); record.collatedWriter.terminal.writeStdoutLine( Colorize.green(`"${record.name}" completed successfully in ${stopwatch.toString()}.`) ); @@ -1209,6 +1251,10 @@ function _handleOperationSuccessWithWarning( ): void { const stopwatch: IStopwatchResult = _getOperationStopwatch(record); if (!record.silent) { + record.eventSink?.onActivity?.( + `"${record.name}" completed with warnings in ${stopwatch.toString()}.`, + { operationId: record.name, stderr: true } + ); record.collatedWriter.terminal.writeStderrLine( Colorize.yellow(`"${record.name}" completed with warnings in ${stopwatch.toString()}.`) ); diff --git a/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts new file mode 100644 index 00000000000..c16d6c91f32 --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// Deterministic Stopwatch timing, matching OperationGraph.test.ts +jest.mock('@rushstack/terminal', () => { + const originalModule = jest.requireActual('@rushstack/terminal'); + return { + ...originalModule, + ConsoleTerminalProvider: { + ...originalModule.ConsoleTerminalProvider, + supportsColor: true + } + }; +}); + +jest.mock('../../../utilities/Utilities'); +jest.mock('../OperationStateFile'); +jest.mock('../ProjectLogWritable', () => { + const actual = jest.requireActual('../ProjectLogWritable'); + const terminalModule = jest.requireActual('@rushstack/terminal'); + const { TerminalWritable } = terminalModule; + class MockTerminalWritable extends TerminalWritable { + public readonly chunks: string[] = []; + protected onWriteChunk(chunk: { text: string }): void { + this.chunks.push(chunk.text); + } + protected onClose(): void { + /* noop */ + } + } + return { + ...actual, + initializeProjectLogFilesAsync: jest.fn(async () => new MockTerminalWritable()) + }; +}); + +import { MockWritable, type ITerminalChunk } from '@rushstack/terminal'; +import type { CollatedTerminal } from '@rushstack/stream-collator'; + +import type { IPhase } from '../../../api/CommandLineConfiguration'; +import type { RushConfigurationProject } from '../../../api/RushConfigurationProject'; +import type { IOperationGraphEventSink } from '../OperationEventSink'; +import type { IOperationExecutionResult } from '../IOperationExecutionResult'; +import { OperationGraph, type IOperationGraphOptions } from '../OperationGraph'; +import { OperationStatus } from '../OperationStatus'; +import { Operation } from '../Operation'; +import type { IOperationRunner, IOperationRunnerContext } from '../IOperationRunner'; +import { MockOperationRunner } from './MockOperationRunner'; + +const mockPhase: IPhase = { + name: 'phase', + allowWarningsOnSuccess: false, + associatedParameters: new Set(), + dependencies: { self: new Set(), upstream: new Set() }, + isSynthetic: false, + logFilenameIdentifier: 'phase', + missingScriptBehavior: 'silent' +}; + +function createOperation(name: string, runner: IOperationRunner): Operation { + return new Operation({ + runner, + logFilenameIdentifier: name, + phase: mockPhase, + project: { packageName: name } as unknown as RushConfigurationProject + }); +} + +class RecordingSink implements IOperationGraphEventSink { + public readonly registered: [string, boolean][] = []; + public readonly transitions: [string, string][] = []; + public readonly headers: [string, number, number][] = []; + public readonly activities: string[] = []; + public readonly chunks: Map = new Map(); + + public onOperationRegistered(operationId: string, silent: boolean): void { + this.registered.push([operationId, silent]); + } + public onOperationStatusChanged(result: IOperationExecutionResult): void { + this.transitions.push([result.operation.name, result.status]); + } + public onOperationHeader(operationId: string, completed: number, total: number): void { + this.headers.push([operationId, completed, total]); + } + public onActivity(text: string): void { + this.activities.push(text); + } + public onOperationChunk(operationId: string, chunk: ITerminalChunk): void { + let chunks: string[] | undefined = this.chunks.get(operationId); + if (!chunks) { + chunks = []; + this.chunks.set(operationId, chunks); + } + chunks.push(chunk.text); + } +} + +function createGraphOptions(mockWritable: MockWritable, quietMode: boolean): IOperationGraphOptions { + return { + quietMode, + debugMode: false, + parallelism: 1, + allowOversubscription: true, + destinations: [mockWritable], + abortController: new AbortController() + }; +} + +describe('OperationGraph event sink (dual-emit)', () => { + let mockWritable: MockWritable; + beforeEach(() => { + mockWritable = new MockWritable(); + }); + + it('emits registration, transitions, headers, and activity lines for every operation', async () => { + const sink: RecordingSink = new RecordingSink(); + const graph: OperationGraph = new OperationGraph( + new Set([ + createOperation('alpha', new MockOperationRunner('alpha', async () => OperationStatus.Success)), + createOperation('beta', new MockOperationRunner('beta', async () => OperationStatus.Success)) + ]), + createGraphOptions(mockWritable, false) + ); + graph.eventSink = sink; + + await graph.executeAsync({}); + + expect(sink.registered.map(([name]) => name).sort()).toEqual(['alpha', 'beta']); + for (const name of ['alpha', 'beta']) { + const statuses: string[] = sink.transitions + .filter(([opName]) => opName === name) + .map(([, status]) => status); + expect(statuses).toEqual([OperationStatus.Queued, OperationStatus.Executing, OperationStatus.Success]); + } + const sortedHeaders: [string, number, number][] = [...sink.headers].sort((a, b) => + a[0].localeCompare(b[0]) + ); + expect(sortedHeaders).toEqual([ + ['alpha', expect.any(Number), 2], + ['beta', expect.any(Number), 2] + ]); + expect(sink.headers.map(([, completed]) => completed).sort()).toEqual([1, 2]); + expect(sink.activities).toContain('Selected 2 operations:'); + expect(sink.activities.some((line: string) => line.includes('simultaneous processes'))).toBe(true); + expect(sink.activities.some((line: string) => line.includes('"alpha" completed successfully'))).toBe( + true + ); + }); + + it('emits raw per-operation chunks even in quiet mode, matching the collated stream', async () => { + const sink: RecordingSink = new RecordingSink(); + // Use runWithTerminalAsync like the production runners (ShellOperationRunner, + // IPCOperationRunner) do, so output flows through the tapped pipeline. + const runner: IOperationRunner = { + name: 'logger', + reportTiming: true, + silent: false, + cacheable: false, + warningsAreAllowed: false, + isNoOp: false, + executeAsync: async (context: IOperationRunnerContext) => + await context.runWithTerminalAsync( + async (terminal) => { + terminal.writeLine('quiet-hidden-stdout'); + terminal.writeErrorLine('quiet-visible-stderr'); + return OperationStatus.Success; + }, + { createLogFile: false, logFileSuffix: '' } + ), + getConfigHash: () => 'mock' + }; + const graph: OperationGraph = new OperationGraph( + new Set([createOperation('logger', runner)]), + createGraphOptions(mockWritable, true) + ); + graph.eventSink = sink; + + await graph.executeAsync({}); + + const tappedText: string = (sink.chunks.get('logger') ?? []).join(''); + expect(tappedText).toContain('quiet-hidden-stdout'); + expect(tappedText).toContain('quiet-visible-stderr'); + // Quiet mode discards stdout from the collated terminal, but the tap still saw it. + expect(mockWritable.getAllOutput()).not.toContain('quiet-hidden-stdout'); + }); + + it('leaves terminal output byte-identical whether or not a sink is attached', async () => { + const makeRunner: () => MockOperationRunner = () => + new MockOperationRunner('echo', async (terminal: CollatedTerminal) => { + terminal.writeStdoutLine('hello from echo'); + return OperationStatus.Success; + }); + + const plainWritable: MockWritable = new MockWritable(); + await new OperationGraph( + new Set([createOperation('echo', makeRunner())]), + createGraphOptions(plainWritable, false) + ).executeAsync({}); + + const tappedWritable: MockWritable = new MockWritable(); + const tappedGraph: OperationGraph = new OperationGraph( + new Set([createOperation('echo', makeRunner())]), + createGraphOptions(tappedWritable, false) + ); + tappedGraph.eventSink = new RecordingSink(); + await tappedGraph.executeAsync({}); + + expect(tappedWritable.getAllOutput()).toEqual(plainWritable.getAllOutput()); + }); +}); diff --git a/libraries/rush-terminal-renderer/.npmignore b/libraries/rush-terminal-renderer/.npmignore new file mode 100644 index 00000000000..f7a40e10213 --- /dev/null +++ b/libraries/rush-terminal-renderer/.npmignore @@ -0,0 +1,36 @@ +# THIS IS A STANDARD TEMPLATE FOR .npmignore FILES IN THIS REPO. + +# Ignore all files by default, to avoid accidentally publishing unintended files. +* + +# Use negative patterns to bring back the specific things we want to publish. +!/bin/** +!/lib/** +!/lib-*/** +!/dist/** +!/includes/** + +!CHANGELOG.md +!CHANGELOG.json +!heft-plugin.json +!rush-plugin-manifest.json +!ThirdPartyNotice.txt + +# Ignore certain patterns that should not get published. +/dist/*.stats.* +/lib/**/test/ +/lib-*/**/test/ +*.test.js +*.test.[cm]js +*.test.d.ts +*.test.d.[cm]ts + +# NOTE: These don't need to be specified, because NPM includes them automatically. +# +# package.json +# README.md +# LICENSE + +# --------------------------------------------------------------------------- +# DO NOT MODIFY ABOVE THIS LINE! Add any project-specific overrides below. +# --------------------------------------------------------------------------- diff --git a/libraries/rush-terminal-renderer/AGENTS.md b/libraries/rush-terminal-renderer/AGENTS.md new file mode 100644 index 00000000000..a5081fd9780 --- /dev/null +++ b/libraries/rush-terminal-renderer/AGENTS.md @@ -0,0 +1,51 @@ +# Agent coding contract — @rushstack/rush-terminal-renderer + +This package is governed by an **ultra-strict lint policy** for generated code. All of the +rules below are enabled to `error` in `eslint.config.js` via the shared +`local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js` mixin. +They apply to **all** TypeScript in this package, **including tests** (`src/**/*.test.ts`). + +## Enforced rules (do not attempt to bypass) + +| Rule | Setting | +| ---- | ------- | +| `complexity` | `['error', 3]` | +| `max-depth` | `['error', 3]` | +| `max-lines-per-function` | `['error', 30]` | +| `max-lines` | `['error', 100]` — every file, including this means: keep files small; split modules | +| `max-params` | `['error', 4]` — use options objects | +| `@typescript-eslint/no-magic-numbers` | `'error'` — every numeric literal must be a named constant | +| `@typescript-eslint/prefer-nullish-coalescing` | `'error'` — use `??`, not `\|\|` or nullish-guard ternaries | +| `import/enforce-node-protocol-usage` | `['error', 'always']` — write `node:crypto`, never `crypto` | +| `import/order` | `['error', { alphabetize: asc, grouped, newlines-between: always }]` | +| `sort-imports` | `['error', { ignoreDeclarationSort: true }]` — sort named members | +| `@typescript-eslint/consistent-type-imports` | `['error', { fixStyle: 'separate-type-imports' }]` — `import type { X }`, never inline `type` specifiers | +| `import/no-relative-parent-imports` | `'error'` for non-test source — no `../` imports outside tests | +| `no-eval`, `@typescript-eslint/no-implied-eval` | `'error'` | + +## Suppression is forbidden — mechanically enforced + +- `linterOptions.noInlineConfig: true` makes **every** `eslint-disable*` comment a lint error. +- `reportUnusedDisableDirectives: 'error'` flags stale suppressions. +- Therefore, as an agent working in this package you MUST NOT: + - add `eslint-disable`, `eslint-disable-next-line`, `eslint-env`, or inline `/* eslint ... */` config comments; + - add entries to any `.eslint-bulk-suppressions.json`; + - add `eslintIgnore` keys to `package.json`; + - add `@ts-nocheck` or `@ts-ignore` comments; + - weaken, reorder, or remove the `strict-codegen` mixin in `eslint.config.js`. +- If a rule fires, **fix the code** (extract a constant, split the function/module, restructure) — never silence it. + +## Deferred rules (do not emulate with hacks) + +The following intended rules have no existing implementation in this repository's ESLint +toolchain and are **not yet enabled** (the user will wire them up later): +`no-magic-strings`, `no-object-mutation`, `no-array-mutation`, +`no-placeholder-implementation`, and the custom zero-tolerance import rules +(`no-re-export`, `require-clean-barrel`, `require-barrel-relative-exports`, +`no-export-alias`, `no-dynamic-import`, `no-hardcoded-secrets`, +`no-parent-internal-access`). Write code that would already satisfy them: prefer immutable +update patterns and named string constants, and never land stubs or `TODO` implementations. + +## Design notes for this package + +- The renderer interface mirrors `@rushstack/reporter`'s `IReporter` so the real `default`/`ai`/`plaintext` reporters drop in unchanged at the reporter reconciliation. No `rush-lib` dependency. \ No newline at end of file diff --git a/libraries/rush-terminal-renderer/LICENSE b/libraries/rush-terminal-renderer/LICENSE new file mode 100644 index 00000000000..42ac55381f9 --- /dev/null +++ b/libraries/rush-terminal-renderer/LICENSE @@ -0,0 +1,24 @@ +@rushstack/rush-terminal-renderer + +Copyright (c) Microsoft Corporation. All rights reserved. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/libraries/rush-terminal-renderer/README.md b/libraries/rush-terminal-renderer/README.md new file mode 100644 index 00000000000..79013483c7a --- /dev/null +++ b/libraries/rush-terminal-renderer/README.md @@ -0,0 +1,28 @@ +# @rushstack/rush-terminal-renderer + +> **Public beta** — this package is versioned at `0.x`; its API may change between minor versions. + +The CLI client's **presentation layer** for the Rush daemon (`rushd`): + +- **Reporter host** — drives event renderers from the daemon's `0x05` event stream; the host + interface mirrors `@rushstack/reporter`'s `IReporter` so its `default`/`ai`/`plaintext` + reporters drop in unchanged. +- **Faithful collation** — hosts `@rushstack/stream-collator` client-side, reproducing the + legacy in-process terminal output (per-operation blocks and `==[ name ]===[ x of y ]==` + headers) byte-for-byte from id-tagged raw streams. +- **Per-client verbosity** — quiet/verbose/debug filtering applied at event serialization and + display, never mutating shared engine state; concurrent clients each get their own subset. +- **Terminal capability threading** — computes `FORCE_COLOR`/`COLUMNS` for child processes from + each client's request envelope; non-TTY children receive neither. + +Part of the Rush 6 / rushd re-architecture: +[microsoft/rushstack#5894](https://github.com/microsoft/rushstack/issues/5894). + +## Links + +- [CHANGELOG.md]( + https://github.com/microsoft/rushstack/blob/main/libraries/rush-terminal-renderer/CHANGELOG.md) - + Find out what's new in the latest version +- [API Reference](https://rushstack.io/pages/api/rush-terminal-renderer/) + +`@rushstack/rush-terminal-renderer` is part of the **Rush Stack** family of projects. diff --git a/libraries/rush-terminal-renderer/config/api-extractor.json b/libraries/rush-terminal-renderer/config/api-extractor.json new file mode 100644 index 00000000000..3dbb76c0e6f --- /dev/null +++ b/libraries/rush-terminal-renderer/config/api-extractor.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "local-node-rig/profiles/default/config/api-extractor-base.json" +} diff --git a/libraries/rush-terminal-renderer/config/jest.config.json b/libraries/rush-terminal-renderer/config/jest.config.json new file mode 100644 index 00000000000..d1749681d90 --- /dev/null +++ b/libraries/rush-terminal-renderer/config/jest.config.json @@ -0,0 +1,3 @@ +{ + "extends": "local-node-rig/profiles/default/config/jest.config.json" +} diff --git a/libraries/rush-terminal-renderer/config/rig.json b/libraries/rush-terminal-renderer/config/rig.json new file mode 100644 index 00000000000..165ffb001f5 --- /dev/null +++ b/libraries/rush-terminal-renderer/config/rig.json @@ -0,0 +1,7 @@ +{ + // The "rig.json" file directs tools to look for their config files in an external package. + // Documentation for this system: https://www.npmjs.com/package/@rushstack/rig-package + "$schema": "https://developer.microsoft.com/json-schemas/rig-package/rig.schema.json", + + "rigPackageName": "local-node-rig" +} diff --git a/libraries/rush-terminal-renderer/eslint.config.js b/libraries/rush-terminal-renderer/eslint.config.js new file mode 100644 index 00000000000..b08a47af297 --- /dev/null +++ b/libraries/rush-terminal-renderer/eslint.config.js @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const nodeProfile = require('local-node-rig/profiles/default/includes/eslint/flat/profile/node'); +const friendlyLocalsMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/friendly-locals'); +const tsdocMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/tsdoc'); +const strictCodegenMixin = require('local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen'); + +module.exports = [ + ...nodeProfile, + ...friendlyLocalsMixin, + ...tsdocMixin, + // IMPORTANT: The strict-codegen mixin must remain last so its rules win conflicts. + ...strictCodegenMixin, + { + files: ['**/*.ts', '**/*.tsx'], + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname + } + } + } +]; diff --git a/libraries/rush-terminal-renderer/package.json b/libraries/rush-terminal-renderer/package.json new file mode 100644 index 00000000000..77805edc379 --- /dev/null +++ b/libraries/rush-terminal-renderer/package.json @@ -0,0 +1,69 @@ +{ + "name": "@rushstack/rush-terminal-renderer", + "version": "0.1.0", + "description": "Client-side renderer for the Rush daemon (rushd): hosts reporters and per-operation stream collation, applies per-client verbosity, and threads terminal capabilities to child processes. (public beta)", + "main": "./lib-commonjs/index.js", + "module": "./lib-esm/index.js", + "types": "./dist/rush-terminal-renderer.d.ts", + "exports": { + ".": { + "types": "./dist/rush-terminal-renderer.d.ts", + "node": "./lib-commonjs/index.js", + "import": "./lib-esm/index.js", + "require": "./lib-commonjs/index.js" + }, + "./lib/*": { + "types": "./lib-dts/*.d.ts", + "node": "./lib-commonjs/*.js", + "import": "./lib-esm/*.js", + "require": "./lib-commonjs/*.js" + }, + "./package.json": "./package.json" + }, + "typesVersions": { + "*": { + "lib/*": [ + "lib-dts/*" + ] + } + }, + "keywords": [ + "rush", + "rushd", + "daemon", + "renderer", + "terminal", + "reporter" + ], + "license": "MIT", + "repository": { + "url": "https://github.com/microsoft/rushstack.git", + "type": "git", + "directory": "libraries/rush-terminal-renderer" + }, + "scripts": { + "build": "heft build --clean", + "_phase:build": "heft run --only build -- --clean", + "_phase:test": "heft run --only test -- --clean" + }, + "dependencies": { + "@rushstack/node-core-library": "workspace:*", + "@rushstack/rush-daemon-protocol": "workspace:*", + "@rushstack/stream-collator": "workspace:*", + "@rushstack/terminal": "workspace:*" + }, + "devDependencies": { + "@rushstack/heft": "workspace:*", + "eslint": "~9.37.0", + "local-node-rig": "workspace:*" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "sideEffects": false +} diff --git a/libraries/rush-terminal-renderer/src/ChildEnvironment.ts b/libraries/rush-terminal-renderer/src/ChildEnvironment.ts new file mode 100644 index 00000000000..4cba7429de6 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/ChildEnvironment.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonClientCaps } from '@rushstack/rush-daemon-protocol'; + +const FORCE_COLOR_VAR: string = 'FORCE_COLOR'; +const COLUMNS_VAR: string = 'COLUMNS'; +const DEFAULT_TTY_COLOR_LEVEL: number = 1; + +function getColumnsOverride(columns: number | undefined): Record { + return columns === undefined ? {} : { [COLUMNS_VAR]: String(columns) }; +} + +/** + * Computes the environment overrides a child process inherits from one + * client's request envelope. + * + * @remarks + * A TTY client's child receives `FORCE_COLOR` (its `colorLevel`, defaulting to + * `1`) and `COLUMNS` (when known); a non-TTY client's child receives neither. + * The result is computed fresh per request and never cached, so concurrent + * clients cannot contaminate each other. + * + * @beta + */ +export function getDaemonChildEnvironmentOverrides(caps: IDaemonClientCaps): Record { + if (!caps.isTTY) { + return {}; + } + return { + [FORCE_COLOR_VAR]: String(caps.colorLevel ?? DEFAULT_TTY_COLOR_LEVEL), + ...getColumnsOverride(caps.columns) + }; +} + +/** + * Merges per-client overrides into a base environment, removing any ambient + * `FORCE_COLOR`/`COLUMNS` first so a non-TTY client's child provably receives + * neither variable. + * + * @beta + */ +export function applyDaemonChildEnvironment( + baseEnv: Readonly>, + caps: IDaemonClientCaps +): Record { + const filtered: [string, string | undefined][] = Object.entries(baseEnv).filter( + ([key]: [string, string | undefined]) => key !== FORCE_COLOR_VAR && key !== COLUMNS_VAR + ); + return { ...Object.fromEntries(filtered), ...getDaemonChildEnvironmentOverrides(caps) }; +} diff --git a/libraries/rush-terminal-renderer/src/DaemonRenderer.ts b/libraries/rush-terminal-renderer/src/DaemonRenderer.ts new file mode 100644 index 00000000000..a4fc7f543ff --- /dev/null +++ b/libraries/rush-terminal-renderer/src/DaemonRenderer.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// TODO(reconcile): this interface intentionally mirrors `@rushstack/reporter`'s +// `IReporter` (name/initializeAsync/report/flushAsync/closeAsync) so the real +// `default`/`ai`/`plaintext` reporters drop in unchanged once that package merges. + +import type { IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonRendererTerminal } from './DaemonRendererTerminal'; + +/** + * The context supplied to a renderer when it is initialized. + * + * @beta + */ +export interface IDaemonRendererContext { + /** The terminal the renderer renders to. */ + readonly terminal: IDaemonRendererTerminal; +} + +/** + * A subscriber that renders daemon events to the client's terminal. + * + * @remarks + * The host owns ordering and fan-out; `report` is called once per event in + * wire order and is never called concurrently with itself. + * + * @beta + */ +export interface IDaemonRenderer { + /** A stable, unique name for this renderer. */ + readonly name: string; + + /** Prepares the renderer for use. */ + initializeAsync(context: IDaemonRendererContext): Promise; + + /** Renders a single event. Called in wire order. */ + report(event: IDaemonEventEnvelope): void; + + /** Flushes any buffered output. */ + flushAsync(): Promise; + + /** Flushes and releases the renderer's destination. */ + closeAsync(): Promise; +} diff --git a/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts b/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts new file mode 100644 index 00000000000..cf024c7c033 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { + DaemonVerbosity, + IDaemonEventEnvelope +} from '@rushstack/rush-daemon-protocol'; +import { TerminalChunkKind } from '@rushstack/terminal'; + +import type { IDaemonRenderer } from './DaemonRenderer'; +import type { IDaemonRendererHostOptions } from './DaemonRendererHostOptions'; +import type { IDaemonRendererTerminal } from './DaemonRendererTerminal'; +import { HostEventRouter } from './HostEventRouter'; +import { LegacyCollatedRenderer } from './LegacyCollatedRenderer'; +import { OperationStreamRegistry } from './OperationStreamRegistry'; +import { TerminalSinkWritable } from './TerminalSinkWritable'; +import { shouldRemoveColors } from './TerminalStatuses'; + +const DEFAULT_VERBOSITY: DaemonVerbosity = 'normal'; +const QUIET_VERBOSITY: DaemonVerbosity = 'quiet'; + +function toChunkKind(stream: 'stdout' | 'stderr'): TerminalChunkKind { + return stream === 'stderr' ? TerminalChunkKind.Stderr : TerminalChunkKind.Stdout; +} + +const CHUNK_DECODER: InstanceType = new TextDecoder('utf8', { fatal: false }); + +/** + * The CLI client's presentation host: routes decoded daemon frames to the + * per-operation collator and to the event renderer. + * @beta + */ +export class DaemonRendererHost { + private readonly _renderer: IDaemonRenderer; + private readonly _verbosity: DaemonVerbosity; + private readonly _streams: OperationStreamRegistry; + private readonly _router: HostEventRouter; + private readonly _terminal: IDaemonRendererTerminal; + + public constructor(options: IDaemonRendererHostOptions) { + this._terminal = options.terminal; + this._verbosity = options.verbosity ?? DEFAULT_VERBOSITY; + this._renderer = options.renderer ?? new LegacyCollatedRenderer(); + this._streams = new OperationStreamRegistry({ + destination: new TerminalSinkWritable(options.terminal), + removeColors: shouldRemoveColors(options.colorLevel), + quiet: this._verbosity === QUIET_VERBOSITY + }); + this._router = new HostEventRouter(this._streams, this._renderer, this._verbosity); + } + + /** + * Initializes the renderer. Must be awaited before the first + * {@link DaemonRendererHost.handleEvent} call. + */ + public async initializeAsync(): Promise { + await this._renderer.initializeAsync({ terminal: this._terminal }); + } + + /** Feeds one decoded `0x05` event envelope into the host. */ + public handleEvent(envelope: IDaemonEventEnvelope): void { + this._router.routeEvent(envelope); + } + + /** Feeds one decoded `0x02`/`0x03` log chunk into the collator. */ + public handleLogChunk(operationId: string, stream: 'stdout' | 'stderr', chunk: Uint8Array): void { + if (stream === 'stdout' && this._verbosity === QUIET_VERBOSITY) { + // Match the legacy quiet-mode DiscardStdoutTransform: per-client display + // filtering, without mutating the shared stream. + return; + } + this._streams.writeChunk(operationId, { + kind: toChunkKind(stream), + text: CHUNK_DECODER.decode(chunk) + }); + } + + /** Flushes and closes the renderer. */ + public async closeAsync(): Promise { + await this._renderer.flushAsync(); + await this._renderer.closeAsync(); + } +} diff --git a/libraries/rush-terminal-renderer/src/DaemonRendererHostOptions.ts b/libraries/rush-terminal-renderer/src/DaemonRendererHostOptions.ts new file mode 100644 index 00000000000..af03a9fc36b --- /dev/null +++ b/libraries/rush-terminal-renderer/src/DaemonRendererHostOptions.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonVerbosity } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonRenderer } from './DaemonRenderer'; +import type { IDaemonRendererTerminal } from './DaemonRendererTerminal'; + +/** + * Options for {@link DaemonRendererHost}. + * + * @beta + */ +export interface IDaemonRendererHostOptions { + /** The client terminal to render to. */ + readonly terminal: IDaemonRendererTerminal; + /** This client's verbosity; filters events at this subscription only. */ + readonly verbosity?: DaemonVerbosity; + /** The event renderer. Defaults to the legacy-compatible renderer. */ + readonly renderer?: IDaemonRenderer; + /** The client's color level; `0`/undefined on a non-TTY strips ANSI colors. */ + readonly colorLevel?: number; +} diff --git a/libraries/rush-terminal-renderer/src/DaemonRendererTerminal.ts b/libraries/rush-terminal-renderer/src/DaemonRendererTerminal.ts new file mode 100644 index 00000000000..ad2a425eec3 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/DaemonRendererTerminal.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The stream a piece of rendered output belongs to. + * + * @beta + */ +export type DaemonRenderStream = 'stdout' | 'stderr'; + +/** + * The terminal a renderer writes to. + * + * @remarks + * Implemented by the CLI client with its real terminal; implemented by tests + * with an in-memory sink. Mirrors the shape `@rushstack/reporter` reporters + * expect so they can be hosted here unchanged after the reporter reconciliation. + * + * @beta + */ +export interface IDaemonRendererTerminal { + /** + * The terminal width in columns. + */ + readonly columns: number; + + /** + * Whether the terminal is an interactive TTY. + */ + readonly isTTY: boolean; + + /** + * Writes text to the given stream. + */ + write(text: string, stream: DaemonRenderStream): void; +} diff --git a/libraries/rush-terminal-renderer/src/HostEventRouter.ts b/libraries/rush-terminal-renderer/src/HostEventRouter.ts new file mode 100644 index 00000000000..a5a81ba1676 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/HostEventRouter.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { + type DaemonVerbosity, + type IDaemonEventEnvelope, + type IDaemonExtensionEventPayload, + type IDaemonOperationRegisteredPayload, + type IDaemonOperationStreamClosedPayload, + RUSHD_OPERATION_STREAM_CLOSED, + shouldSerializeDaemonEvent +} from '@rushstack/rush-daemon-protocol'; +import { TerminalChunkKind } from '@rushstack/terminal'; + +import type { IDaemonRenderer } from './DaemonRenderer'; +import type { OperationStreamRegistry } from './OperationStreamRegistry'; + +function readScopeOperationId(envelope: IDaemonEventEnvelope): string | undefined { + const scope: IDaemonEventEnvelope['scope'] = envelope.scope; + return scope === undefined ? undefined : scope.operationId; +} + +/** + * Routes decoded event envelopes between the collator (stream-affecting and + * operation-scoped events) and the verbosity-filtered renderer. + * @internal + */ +export class HostEventRouter { + private readonly _streams: OperationStreamRegistry; + private readonly _renderer: IDaemonRenderer; + private readonly _verbosity: DaemonVerbosity; + + public constructor( + streams: OperationStreamRegistry, + renderer: IDaemonRenderer, + verbosity: DaemonVerbosity + ) { + this._streams = streams; + this._renderer = renderer; + this._verbosity = verbosity; + } + + /** Routes one decoded `0x05` event envelope. */ + public routeEvent(envelope: IDaemonEventEnvelope): void { + this._trackOperationLifecycle(envelope); + if (this._routeScopedActivity(envelope)) { + return; + } + if (shouldSerializeDaemonEvent(this._verbosity, envelope)) { + this._renderer.report(envelope); + } + } + + private _trackOperationLifecycle(envelope: IDaemonEventEnvelope): void { + if (envelope.type === 'operationRegistered') { + this._trackRegistered(envelope.payload as IDaemonOperationRegisteredPayload); + } + if (envelope.type === 'extension') { + this._trackExtension(envelope.payload as IDaemonExtensionEventPayload); + } + } + + private _trackRegistered(payload: IDaemonOperationRegisteredPayload): void { + if (!payload.silent) { + this._streams.registerOperation(); + } + } + + private _trackExtension(payload: IDaemonExtensionEventPayload): void { + if (payload.name === RUSHD_OPERATION_STREAM_CLOSED) { + const data: IDaemonOperationStreamClosedPayload = + payload.data as IDaemonOperationStreamClosedPayload; + this._streams.closeOperation(data.operationId); + } + } + + // Operation-scoped activity lines are part of the operation's output block + // (legacy writes them to the operation's collated stream, bypassing the + // quiet-mode stdout discard), so they route to the collator, not the renderer. + private _routeScopedActivity(envelope: IDaemonEventEnvelope): boolean { + const operationId: string | undefined = readScopeOperationId(envelope); + if (envelope.type !== 'activityChanged' || operationId === undefined) { + return false; + } + this._writeActivityLine(operationId, envelope.payload); + return true; + } + + private _writeActivityLine(operationId: string, payload: unknown): void { + const activity: unknown = payload; + const text: unknown = (activity as { text?: unknown }).text; + const stream: unknown = (activity as { stream?: unknown }).stream; + if (typeof text === 'string') { + this._streams.writeChunk(operationId, { + kind: stream === 'stderr' ? TerminalChunkKind.Stderr : TerminalChunkKind.Stdout, + text: `${text}\n` + }); + } + } +} diff --git a/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts b/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts new file mode 100644 index 00000000000..85b86607524 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { EOL } from 'node:os'; + +import type { IDaemonActivityPayload, IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonRenderer, IDaemonRendererContext } from './DaemonRenderer'; +import type { IDaemonRendererTerminal } from './DaemonRendererTerminal'; + +const RENDERER_NAME: string = 'legacy-collated'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isActivityPayload(payload: unknown): payload is IDaemonActivityPayload { + return isRecord(payload) && typeof payload.text === 'string'; +} + +/** + * The default renderer: byte-faithful with the legacy in-process terminal + * output for the same event stream. Per-operation collation and headers are + * handled by the host's `StreamCollator`; this renderer prints activity lines + * (the structured form of the legacy summary/status text). + * + * @remarks + * Daemon-specific chrome must be additive and isolated: new presentation + * belongs in additional renderers, not in edits here. + * + * @beta + */ +export class LegacyCollatedRenderer implements IDaemonRenderer { + public readonly name: string = RENDERER_NAME; + private _terminal: IDaemonRendererTerminal | undefined; + + /** {@inheritDoc IDaemonRenderer.initializeAsync} */ + public async initializeAsync(context: IDaemonRendererContext): Promise { + this._terminal = context.terminal; + } + + /** {@inheritDoc IDaemonRenderer.report} */ + public report(event: IDaemonEventEnvelope): void { + if (event.type !== 'activityChanged' || !isActivityPayload(event.payload)) { + return; + } + this._writeLine(event.payload.text); + } + + private _writeLine(text: string): void { + // Emit the client's OS newline, matching the newline normalization the + // collated pipeline applies (TextRewriterTransform OsDefault) so global + // status lines and collated blocks are consistent on every platform. + this._terminal?.write(`${text}${EOL}`, 'stdout'); + } + + /** {@inheritDoc IDaemonRenderer.flushAsync} */ + public async flushAsync(): Promise { + // Append-only writes need no flush. + } + + /** {@inheritDoc IDaemonRenderer.closeAsync} */ + public async closeAsync(): Promise { + this._terminal = undefined; + } +} diff --git a/libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts b/libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts new file mode 100644 index 00000000000..d761d07a556 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { NewlineKind } from '@rushstack/node-core-library'; +import { CollatedTerminal, StreamCollator } from '@rushstack/stream-collator'; +import type { CollatedWriter } from '@rushstack/stream-collator'; +import { TextRewriterTransform } from '@rushstack/terminal'; +import type { ITerminalChunk, TerminalWritable } from '@rushstack/terminal'; + +import { formatDaemonOperationHeader } from './RendererHeader'; + +/** Options for {@link OperationStreamRegistry}. @beta */ +export interface IOperationStreamRegistryOptions { + /** The sink the collated output flows to. */ + readonly destination: TerminalWritable; + /** Whether to strip ANSI colors from the collated output. */ + readonly removeColors: boolean; + /** Whether to suppress the blank line after each operation header. */ + readonly quiet: boolean; +} + +/** + * Hosts the `StreamCollator` for faithful per-operation collation on the + * client, reproducing the legacy in-process pipeline (including the + * `==[ name ]===[ x of y ]==` headers) from id-tagged raw streams. + * + * @beta + */ +export class OperationStreamRegistry { + private readonly _collator: StreamCollator; + private readonly _collatedTerminal: CollatedTerminal; + private readonly _writers: Map; + private readonly _quiet: boolean; + private _completedOperations: number; + private _totalOperations: number; + + public constructor(options: IOperationStreamRegistryOptions) { + this._writers = new Map(); + this._quiet = options.quiet; + this._completedOperations = 0; + this._totalOperations = 0; + const transform: TextRewriterTransform = new TextRewriterTransform({ + destination: options.destination, + normalizeNewlines: NewlineKind.OsDefault, + removeColors: options.removeColors + }); + this._collatedTerminal = new CollatedTerminal(transform); + this._collator = new StreamCollator({ + destination: transform, + onWriterActive: (writer: CollatedWriter | undefined) => this._onWriterActive(writer) + }); + } + + /** Increments the total-operation count shown in headers. */ + public registerOperation(): void { + this._totalOperations += 1; + } + + /** Writes one raw chunk to the operation's collated stream. */ + public writeChunk(operationId: string, chunk: ITerminalChunk): void { + let writer: CollatedWriter | undefined = this._writers.get(operationId); + if (writer === undefined) { + writer = this._collator.registerTask(operationId); + this._writers.set(operationId, writer); + } + writer.writeChunk(chunk); + } + + /** Closes the operation's stream, flushing its collated output. */ + public closeOperation(operationId: string): void { + const writer: CollatedWriter | undefined = this._writers.get(operationId); + if (writer !== undefined && writer.isOpen) { + writer.close(); + } + } + + private _onWriterActive(writer: CollatedWriter | undefined): void { + if (writer === undefined) { + return; + } + this._completedOperations += 1; + const header: string = formatDaemonOperationHeader( + writer.taskName, + this._completedOperations, + this._totalOperations + ); + this._collatedTerminal.writeStdoutLine(`\n${header}`); + if (!this._quiet) { + this._collatedTerminal.writeStdoutLine(''); + } + } +} diff --git a/libraries/rush-terminal-renderer/src/RendererHeader.ts b/libraries/rush-terminal-renderer/src/RendererHeader.ts new file mode 100644 index 00000000000..01162f58cb0 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/RendererHeader.ts @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { Colorize } from '@rushstack/terminal'; + +const ASCII_HEADER_WIDTH: number = 79; +const LEFT_BRACKETS: number = 4; +const RIGHT_BRACKETS: number = 4; +const NAME_PADDING: number = 1; +const COUNT_PADDING: number = 1; +const TWO_BRACKETS: number = 2; +const MIN_MIDDLE: number = 0; + +/** + * Formats the legacy per-operation collated header line, byte-identical to + * rush-lib's `OperationGraph` `onWriterActive` output: + * `==[ name ]=================[ 1 of 1000 ]==` + * + * @beta + */ +export function formatDaemonOperationHeader( + operationName: string, + completed: number, + total: number +): string { + const leftPart: string = `${Colorize.gray('==[')} ${Colorize.cyan(operationName)} `; + const leftPartLength: number = LEFT_BRACKETS + operationName.length + NAME_PADDING; + const completedOfTotal: string = `${completed} of ${total}`; + const rightPart: string = ` ${Colorize.white(completedOfTotal)} ${Colorize.gray(']==')}`; + const rightPartLength: number = COUNT_PADDING + completedOfTotal.length + RIGHT_BRACKETS; + const middleLength: number = Math.max( + ASCII_HEADER_WIDTH - (leftPartLength + rightPartLength + TWO_BRACKETS), + MIN_MIDDLE + ); + const middlePart: string = Colorize.gray(`]${'='.repeat(middleLength)}[`); + return `${leftPart}${middlePart}${rightPart}`; +} diff --git a/libraries/rush-terminal-renderer/src/TerminalSinkWritable.ts b/libraries/rush-terminal-renderer/src/TerminalSinkWritable.ts new file mode 100644 index 00000000000..6ff84cdd5d4 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/TerminalSinkWritable.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { type ITerminalChunk, TerminalChunkKind, TerminalWritable } from '@rushstack/terminal'; + +import type { IDaemonRendererTerminal } from './DaemonRendererTerminal'; + +/** + * A terminal writable that forwards chunk text to a renderer terminal, + * preserving the stdout/stderr distinction. + * + * @beta + */ +export class TerminalSinkWritable extends TerminalWritable { + private readonly _terminal: IDaemonRendererTerminal; + + public constructor(terminal: IDaemonRendererTerminal) { + super({ preventAutoclose: true }); + this._terminal = terminal; + } + + /** {@inheritDoc @rushstack/terminal#TerminalWritable.onWriteChunk} */ + public onWriteChunk(chunk: ITerminalChunk): void { + this._terminal.write(chunk.text, chunk.kind === TerminalChunkKind.Stderr ? 'stderr' : 'stdout'); + } +} diff --git a/libraries/rush-terminal-renderer/src/TerminalStatuses.ts b/libraries/rush-terminal-renderer/src/TerminalStatuses.ts new file mode 100644 index 00000000000..a581237b893 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/TerminalStatuses.ts @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The raw engine operation status strings that end an operation's stream. + * + * @beta + */ +export const TERMINAL_OPERATION_STATUSES: ReadonlySet = new Set([ + 'SUCCESS', + 'SUCCESS WITH WARNINGS', + 'SKIPPED', + 'FROM CACHE', + 'FAILURE', + 'BLOCKED', + 'NO OP', + 'ABORTED' +]); + +const NO_COLOR_LEVEL: number = 0; + +/** + * Whether the collated pipeline should strip ANSI colors for a client with + * the given color level (absent or `0` means strip). + * + * @beta + */ +export function shouldRemoveColors(colorLevel: number | undefined): boolean { + return colorLevel === undefined || colorLevel === NO_COLOR_LEVEL; +} diff --git a/libraries/rush-terminal-renderer/src/index.ts b/libraries/rush-terminal-renderer/src/index.ts new file mode 100644 index 00000000000..903227f49f5 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/index.ts @@ -0,0 +1,33 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +/** + * The CLI client's presentation layer for the Rush daemon (`rushd`): hosts the + * per-operation `StreamCollator` for faithful collation, applies per-client + * verbosity at event delivery, and threads terminal capabilities + * (`FORCE_COLOR`/`COLUMNS`) into child process environments. + * + * @remarks + * The renderer interface mirrors `@rushstack/reporter`'s `IReporter` so the + * reporter package's `default`/`ai`/`plaintext` reporters can be hosted here + * unchanged once that package merges into main. This package has no + * `rush-lib` dependency. + * + * @packageDocumentation + */ + +export { + applyDaemonChildEnvironment, + getDaemonChildEnvironmentOverrides +} from './ChildEnvironment'; +export type { IDaemonRenderer, IDaemonRendererContext } from './DaemonRenderer'; +export { DaemonRendererHost } from './DaemonRendererHost'; +export type { IDaemonRendererHostOptions } from './DaemonRendererHostOptions'; +export type { DaemonRenderStream, IDaemonRendererTerminal } from './DaemonRendererTerminal'; +export { LegacyCollatedRenderer } from './LegacyCollatedRenderer'; +export { + OperationStreamRegistry, + type IOperationStreamRegistryOptions +} from './OperationStreamRegistry'; +export { formatDaemonOperationHeader } from './RendererHeader'; +export { TerminalSinkWritable } from './TerminalSinkWritable'; diff --git a/libraries/rush-terminal-renderer/src/test/ChildEnvironment.test.ts b/libraries/rush-terminal-renderer/src/test/ChildEnvironment.test.ts new file mode 100644 index 00000000000..7839d00f6f3 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/test/ChildEnvironment.test.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { IDaemonClientCaps } from '@rushstack/rush-daemon-protocol'; + +import { + applyDaemonChildEnvironment, + getDaemonChildEnvironmentOverrides +} from '../ChildEnvironment'; + +const COLUMNS: number = 132; +const COLOR_LEVEL: number = 3; +const EMPTY_COUNT: number = 0; + +const TTY_CAPS: IDaemonClientCaps = { isTTY: true, columns: COLUMNS, colorLevel: COLOR_LEVEL }; +const NON_TTY_CAPS: IDaemonClientCaps = { isTTY: false }; + +it('gives a TTY client FORCE_COLOR and COLUMNS', () => { + expect(getDaemonChildEnvironmentOverrides(TTY_CAPS)).toEqual({ + FORCE_COLOR: String(COLOR_LEVEL), + COLUMNS: String(COLUMNS) + }); +}); + +it('defaults FORCE_COLOR to 1 for a TTY client without a color level', () => { + expect(getDaemonChildEnvironmentOverrides({ isTTY: true })).toEqual({ FORCE_COLOR: '1' }); +}); + +it('gives a non-TTY client neither variable', () => { + expect(getDaemonChildEnvironmentOverrides(NON_TTY_CAPS)).toEqual({}); +}); + +it('strips ambient FORCE_COLOR/COLUMNS for a non-TTY client', () => { + const base: Record = { FORCE_COLOR: '1', COLUMNS: '200', PATH: '/bin' }; + const result: Record = applyDaemonChildEnvironment(base, NON_TTY_CAPS); + expect(result).toEqual({ PATH: '/bin' }); +}); + +it('overrides ambient values for a TTY client', () => { + const base: Record = { FORCE_COLOR: '0', PATH: '/bin' }; + const result: Record = applyDaemonChildEnvironment(base, TTY_CAPS); + expect(result).toEqual({ PATH: '/bin', FORCE_COLOR: String(COLOR_LEVEL), COLUMNS: String(COLUMNS) }); +}); + +it('computes independent results for concurrent clients', () => { + const ttyResult: Record = getDaemonChildEnvironmentOverrides(TTY_CAPS); + const nonTtyResult: Record = getDaemonChildEnvironmentOverrides(NON_TTY_CAPS); + expect(Object.keys(ttyResult)).not.toHaveLength(EMPTY_COUNT); + expect(Object.keys(nonTtyResult)).toHaveLength(EMPTY_COUNT); + expect(getDaemonChildEnvironmentOverrides(TTY_CAPS)).toEqual(ttyResult); +}); diff --git a/libraries/rush-terminal-renderer/src/test/LegacyPipelineReplica.ts b/libraries/rush-terminal-renderer/src/test/LegacyPipelineReplica.ts new file mode 100644 index 00000000000..38318c06c56 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/test/LegacyPipelineReplica.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// An INDEPENDENT replica of rush-lib OperationGraph's terminal pipeline, used +// as the golden reference. Do not import renderer implementation code here. + +import { NewlineKind } from '@rushstack/node-core-library'; +import { CollatedTerminal, type CollatedWriter, StreamCollator } from '@rushstack/stream-collator'; +import type { + TerminalWritable} from '@rushstack/terminal'; +import { + Colorize, + type ITerminalChunk, + TerminalChunkKind, + TextRewriterTransform +} from '@rushstack/terminal'; + +const ASCII_HEADER_WIDTH: number = 79; +const INITIAL_COUNT: number = 0; +// These mirror rush-lib's literal arithmetic in OperationGraph.onWriterActive; +// they are named here to satisfy no-magic-numbers without changing the math. +const LEGACY_LEFT_BRACKET_CHARS: number = 4; +const LEGACY_NAME_PADDING: number = 1; +const LEGACY_COUNT_PADDING: number = 1; +const LEGACY_RIGHT_BRACKET_CHARS: number = 4; +const LEGACY_TWO_BRACKETS: number = 2; +const LEGACY_MIN_MIDDLE: number = 0; + +/** Replicates the legacy in-process collated output pipeline. */ +export class LegacyPipelineReplica { + private readonly _collator: StreamCollator; + private readonly _terminal: CollatedTerminal; + private readonly _writers: Map; + private readonly _quiet: boolean; + private readonly _total: number; + private _completed: number; + + public constructor(destination: TerminalWritable, totalOperations: number, quiet: boolean) { + this._writers = new Map(); + this._quiet = quiet; + this._total = totalOperations; + this._completed = INITIAL_COUNT; + const transform: TextRewriterTransform = new TextRewriterTransform({ + destination, + normalizeNewlines: NewlineKind.OsDefault, + removeColors: true + }); + this._terminal = new CollatedTerminal(transform); + this._collator = new StreamCollator({ + destination: transform, + onWriterActive: (writer: CollatedWriter | undefined) => this._legacyOnWriterActive(writer) + }); + } + + public writeChunk(operationId: string, chunk: ITerminalChunk): void { + // Legacy quiet mode installs a DiscardStdoutTransform upstream of the collator. + if (this._isDiscarded(chunk)) { + return; + } + let writer: CollatedWriter | undefined = this._writers.get(operationId); + if (writer === undefined) { + writer = this._collator.registerTask(operationId); + this._writers.set(operationId, writer); + } + writer.writeChunk(chunk); + } + + private _isDiscarded(chunk: ITerminalChunk): boolean { + return this._quiet && chunk.kind === TerminalChunkKind.Stdout; + } + + public closeOperation(operationId: string): void { + const writer: CollatedWriter | undefined = this._writers.get(operationId); + if (writer !== undefined && writer.isOpen) { + writer.close(); + } + } + + private _legacyOnWriterActive(writer: CollatedWriter | undefined): void { + if (!writer) { + return; + } + this._completed += 1; + const leftPart: string = Colorize.gray('==[') + ' ' + Colorize.cyan(writer.taskName) + ' '; + const leftPartLength: number = + LEGACY_LEFT_BRACKET_CHARS + writer.taskName.length + LEGACY_NAME_PADDING; + const completedOfTotal: string = `${this._completed} of ${this._total}`; + const rightPart: string = ' ' + Colorize.white(completedOfTotal) + ' ' + Colorize.gray(']=='); + const rightPartLength: number = LEGACY_COUNT_PADDING + completedOfTotal.length + LEGACY_RIGHT_BRACKET_CHARS; + const middleLength: number = Math.max( + ASCII_HEADER_WIDTH - (leftPartLength + rightPartLength + LEGACY_TWO_BRACKETS), + LEGACY_MIN_MIDDLE + ); + const middlePart: string = Colorize.gray(']' + '='.repeat(middleLength) + '['); + this._terminal.writeStdoutLine('\n' + leftPart + middlePart + rightPart); + if (!this._quiet) { + this._terminal.writeStdoutLine(''); + } + } +} diff --git a/libraries/rush-terminal-renderer/src/test/RendererHostParity.test.ts b/libraries/rush-terminal-renderer/src/test/RendererHostParity.test.ts new file mode 100644 index 00000000000..6b61fa6d2ef --- /dev/null +++ b/libraries/rush-terminal-renderer/src/test/RendererHostParity.test.ts @@ -0,0 +1,87 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { TerminalChunkKind } from '@rushstack/terminal'; + +import { DaemonRendererHost } from '../DaemonRendererHost'; + +import { LegacyPipelineReplica } from './LegacyPipelineReplica'; +import { CollectingWritable, TestTerminal } from './TestTerminal'; + +const TOTAL_OPERATIONS: number = 2; + +interface IFixture { + readonly host: DaemonRendererHost; + readonly replica: LegacyPipelineReplica; + readonly hostTerminal: TestTerminal; + readonly legacySink: CollectingWritable; +} + +function createFixture(quiet: boolean): IFixture { + const hostTerminal: TestTerminal = new TestTerminal(); + const legacySink: CollectingWritable = new CollectingWritable(); + return { + host: new DaemonRendererHost({ + terminal: hostTerminal, + verbosity: quiet ? 'quiet' : 'normal' + }), + replica: new LegacyPipelineReplica(legacySink, TOTAL_OPERATIONS, quiet), + hostTerminal, + legacySink + }; +} + +const minimalEnvelope = { + protocolVersion: { major: 0, minor: 1 }, + eventId: 'e', + sessionId: 's', + sequence: 1, + timestamp: '2026-08-13T00:00:00.000Z', + source: { packageName: 'test', packageVersion: '0' }, + privacy: 'public', + required: false +} as const; + +function driveOperations(fixture: IFixture): void { + for (const operationId of ['op-a', 'op-b']) { + fixture.host.handleEvent({ + ...minimalEnvelope, + type: 'operationRegistered', + payload: { operationId } + }); + } + fixture.host.handleLogChunk('op-a', 'stdout', Buffer.from('a1\n')); + fixture.replica.writeChunk('op-a', { kind: TerminalChunkKind.Stdout, text: 'a1\n' }); + fixture.host.handleLogChunk('op-b', 'stderr', Buffer.from('b1\n')); + fixture.replica.writeChunk('op-b', { kind: TerminalChunkKind.Stderr, text: 'b1\n' }); + for (const operationId of ['op-a', 'op-b']) { + fixture.host.handleEvent({ + ...minimalEnvelope, + type: 'operationStatusChanged', + payload: { operationId, status: 'SUCCESS' } + }); + fixture.host.handleEvent({ + ...minimalEnvelope, + type: 'extension', + payload: { name: 'rushd.operation-stream-closed', data: { operationId } } + }); + fixture.replica.closeOperation(operationId); + } +} + +it('matches the legacy in-process output byte-for-byte for the same run', async () => { + const fixture: IFixture = createFixture(false); + await fixture.host.initializeAsync(); + driveOperations(fixture); + expect(fixture.hostTerminal.stdout).toBe(fixture.legacySink.stdout); + expect(fixture.hostTerminal.stderr).toBe(fixture.legacySink.stderr); + expect(fixture.hostTerminal.stdout).toContain('==['); + expect(fixture.hostTerminal.stdout).toContain('op-a'); +}); + +it('suppresses the header blank line in quiet mode, exactly like legacy', async () => { + const fixture: IFixture = createFixture(true); + await fixture.host.initializeAsync(); + driveOperations(fixture); + expect(fixture.hostTerminal.stdout).toBe(fixture.legacySink.stdout); +}); diff --git a/libraries/rush-terminal-renderer/src/test/RendererHostVerbosity.test.ts b/libraries/rush-terminal-renderer/src/test/RendererHostVerbosity.test.ts new file mode 100644 index 00000000000..012cbfb582f --- /dev/null +++ b/libraries/rush-terminal-renderer/src/test/RendererHostVerbosity.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonEventType, IDaemonEventEnvelope } from '@rushstack/rush-daemon-protocol'; + +import type { IDaemonRenderer, IDaemonRendererContext } from '../DaemonRenderer'; +import { DaemonRendererHost } from '../DaemonRendererHost'; + +import { TestTerminal } from './TestTerminal'; + +class RecordingRenderer implements IDaemonRenderer { + public readonly name: string = 'recording'; + public readonly events: IDaemonEventEnvelope[] = []; + public async initializeAsync(context: IDaemonRendererContext): Promise { + return Promise.resolve(); + } + public report(event: IDaemonEventEnvelope): void { + this.events.push(event); + } + public async flushAsync(): Promise { + return Promise.resolve(); + } + public async closeAsync(): Promise { + return Promise.resolve(); + } +} + +function makeEnvelope( + type: DaemonEventType, + operationId: string, + payload?: unknown +): IDaemonEventEnvelope { + const effectivePayload: unknown = payload ?? { operationId }; + return { + protocolVersion: { major: 0, minor: 1 }, + eventId: `e-${type}`, + sessionId: 's', + sequence: 1, + timestamp: '2026-08-13T00:00:00.000Z', + source: { packageName: 'test', packageVersion: '0' }, + privacy: 'public', + required: false, + type, + payload: effectivePayload + }; +} + +function hostAt(verbosity: 'quiet' | 'verbose'): { host: DaemonRendererHost; renderer: RecordingRenderer } { + const renderer: RecordingRenderer = new RecordingRenderer(); + return { + host: new DaemonRendererHost({ terminal: new TestTerminal(), verbosity, renderer }), + renderer + }; +} + +it('gives two clients at different verbosities the correct subsets of one stream', () => { + const quiet: ReturnType = hostAt('quiet'); + const verbose: ReturnType = hostAt('verbose'); + const stream: IDaemonEventEnvelope[] = [ + makeEnvelope('operationRegistered', 'op-a'), + makeEnvelope('operationStatusChanged', 'op-a', { operationId: 'op-a', status: 'SUCCESS' }), + makeEnvelope('activityChanged', 'op-a', { text: 'detail' }), + makeEnvelope('commandResult', '', { status: 'SUCCESS' }) + ]; + for (const envelope of stream) { + quiet.host.handleEvent(envelope); + verbose.host.handleEvent(envelope); + } + const quietTypes: string[] = quiet.renderer.events.map((e: IDaemonEventEnvelope) => e.type); + const verboseTypes: string[] = verbose.renderer.events.map((e: IDaemonEventEnvelope) => e.type); + // Quiet shows the global activity lines legacy quiet mode prints, plus the result. + expect(quietTypes).toEqual(['activityChanged', 'commandResult']); + expect(verboseTypes).toHaveLength(stream.length); + expect(quietTypes.length).toBeLessThan(verboseTypes.length); +}); + +it('filters stdout display per client without mutating the shared stream', () => { + const quietTerminal: TestTerminal = new TestTerminal(); + const verboseTerminal: TestTerminal = new TestTerminal(); + const quietHost: DaemonRendererHost = new DaemonRendererHost({ + terminal: quietTerminal, + verbosity: 'quiet' + }); + const verboseHost: DaemonRendererHost = new DaemonRendererHost({ + terminal: verboseTerminal, + verbosity: 'verbose' + }); + // Both clients receive the same raw stream; display filtering is per-client. + quietHost.handleLogChunk('op-a', 'stdout', Buffer.from('hello\n')); + verboseHost.handleLogChunk('op-a', 'stdout', Buffer.from('hello\n')); + quietHost.handleLogChunk('op-a', 'stderr', Buffer.from('oops\n')); + verboseHost.handleLogChunk('op-a', 'stderr', Buffer.from('oops\n')); + // Quiet matches legacy DiscardStdoutTransform: stdout hidden, stderr shown. + expect(quietTerminal.stdout).not.toContain('hello'); + expect(quietTerminal.stderr).toContain('oops'); + expect(verboseTerminal.stdout).toContain('hello'); + expect(verboseTerminal.stderr).toContain('oops'); +}); diff --git a/libraries/rush-terminal-renderer/src/test/TestTerminal.ts b/libraries/rush-terminal-renderer/src/test/TestTerminal.ts new file mode 100644 index 00000000000..ebf6cadf4b8 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/test/TestTerminal.ts @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { type ITerminalChunk, TerminalChunkKind, TerminalWritable } from '@rushstack/terminal'; + +import type { DaemonRenderStream, IDaemonRendererTerminal } from '../DaemonRendererTerminal'; + +const DEFAULT_TEST_COLUMNS: number = 80; + +/** A terminal writable capturing chunk text per stream kind. */ +export class CollectingWritable extends TerminalWritable { + public readonly chunks: ITerminalChunk[] = []; + + public constructor() { + super({ preventAutoclose: true }); + } + + public onWriteChunk(chunk: ITerminalChunk): void { + this.chunks.push(chunk); + } + + /** The concatenated text of all stdout chunks. */ + public get stdout(): string { + return this._collect(TerminalChunkKind.Stdout); + } + + /** The concatenated text of all stderr chunks. */ + public get stderr(): string { + return this._collect(TerminalChunkKind.Stderr); + } + + private _collect(kind: TerminalChunkKind): string { + return this.chunks + .filter((chunk: ITerminalChunk) => chunk.kind === kind) + .map((chunk: ITerminalChunk) => chunk.text) + .join(''); + } +} + +/** An in-memory renderer terminal capturing stdout and stderr separately. */ +export class TestTerminal implements IDaemonRendererTerminal { + public readonly columns: number = DEFAULT_TEST_COLUMNS; + public readonly isTTY: boolean = false; + private readonly _writes: [DaemonRenderStream, string][] = []; + + public write(text: string, stream: DaemonRenderStream): void { + this._writes.push([stream, text]); + } + + /** All stdout text written so far, concatenated. */ + public get stdout(): string { + return this._collect('stdout'); + } + + /** All stderr text written so far, concatenated. */ + public get stderr(): string { + return this._collect('stderr'); + } + + private _collect(stream: DaemonRenderStream): string { + return this._writes + .filter(([s]: [DaemonRenderStream, string]) => s === stream) + .map(([, text]: [DaemonRenderStream, string]) => text) + .join(''); + } +} diff --git a/libraries/rush-terminal-renderer/tsconfig.json b/libraries/rush-terminal-renderer/tsconfig.json new file mode 100644 index 00000000000..6a778ab9aff --- /dev/null +++ b/libraries/rush-terminal-renderer/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"] + } +} diff --git a/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js new file mode 100644 index 00000000000..3b1f4bd10e1 --- /dev/null +++ b/rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +// "strict-codegen" mixin +// +// An ultra-strict rule set for newly generated packages (for example the rushd wire-layer +// packages). It intentionally only uses rules that already ship with this repository's +// ESLint toolchain (@typescript-eslint, eslint-plugin-import, and ESLint core); no +// additional plugins are installed. +// +// IMPORTANT: Mixins must be included in your ESLint configuration AFTER the profile. +// +// Suppression is mechanically forbidden: `noInlineConfig` makes every `eslint-disable` +// comment an error, and unused disable directives are reported as errors. + +const typescriptEslintPlugin = require('@typescript-eslint/eslint-plugin'); +const importPlugin = require('eslint-plugin-import'); + +const strictCodegenMixin = [ + { + linterOptions: { + noInlineConfig: true, + reportUnusedDisableDirectives: 'error' + } + }, + { + files: ['**/*.ts', '**/*.tsx'], + plugins: { + '@typescript-eslint': typescriptEslintPlugin, + import: importPlugin + }, + rules: { + // Complexity budget: tiny functions, tiny files, shallow nesting, few parameters. + complexity: ['error', 3], + 'max-depth': ['error', 3], + 'max-lines-per-function': ['error', 30], + 'max-lines': ['error', 100], + 'max-params': ['error', 4], + + // Every numeric literal earns a name. (TS-aware successor of core no-magic-numbers.) + // Enum members and readonly class property initializers are already named + // declarations, so they satisfy the rule's intent. + '@typescript-eslint/no-magic-numbers': [ + 'error', + { ignoreEnums: true, ignoreReadonlyClassProperties: true } + ], + + // `??` instead of `||`/ternary nullish guards. + '@typescript-eslint/prefer-nullish-coalescing': 'error', + + // Import hygiene (existing-rule equivalents of the zero-tolerance "imports" family). + 'import/enforce-node-protocol-usage': ['error', 'always'], + 'import/order': [ + 'error', + { + alphabetize: { order: 'asc', caseInsensitive: true }, + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'always' + } + ], + 'sort-imports': ['error', { ignoreDeclarationSort: true, ignoreMemberSort: false }], + // Repo house style: inline type specifiers (`import { type X, Y }`), which also + // keeps import/no-duplicates satisfied. (The zero-tolerance no-inline-type-import + // rule is deferred; see AGENTS.md.) + '@typescript-eslint/consistent-type-imports': ['error', { prefer: 'type-imports' }], + + // No unsafe code generation. + 'no-eval': 'error', + '@typescript-eslint/no-implied-eval': 'error', + + // Wire codecs must be able to express JSON's `null` in payload types + // (e.g. the recursive JSON-value union), which this warn-level repo rule forbids. + // Disabled here so wire-fidelity types do not require inline suppressions + // (which this mixin forbids via noInlineConfig). + '@rushstack/no-new-null': 'off' + } + }, + { + files: ['**/*.ts', '**/*.tsx'], + ignores: ['**/*.test.ts', '**/*.spec.ts', '**/test/**'], + rules: { + // Unit tests import implementation modules relatively (repo convention); + // production source must never reach outside its own directory via "..". + 'import/no-relative-parent-imports': 'error' + } + } +]; + +module.exports = [...strictCodegenMixin]; diff --git a/rigs/local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js b/rigs/local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js new file mode 100644 index 00000000000..4e5ea5d302b --- /dev/null +++ b/rigs/local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +const strictCodegenMixin = require('local-eslint-config/flat/mixins/strict-codegen'); + +module.exports = [...strictCodegenMixin]; diff --git a/rush.json b/rush.json index 723bcdc8ba3..07fae13962f 100644 --- a/rush.json +++ b/rush.json @@ -1320,6 +1320,30 @@ "reviewCategory": "libraries", "shouldPublish": false }, + { + "packageName": "@rushstack/rush-daemon-protocol", + "projectFolder": "libraries/rush-daemon-protocol", + "reviewCategory": "libraries", + "shouldPublish": true + }, + { + "packageName": "rushd-wire-e2e-test", + "projectFolder": "build-tests/rushd-wire-e2e-test", + "reviewCategory": "tests", + "shouldPublish": false + }, + { + "packageName": "@rushstack/rush-daemon-transport", + "projectFolder": "libraries/rush-daemon-transport", + "reviewCategory": "libraries", + "shouldPublish": true + }, + { + "packageName": "@rushstack/rush-terminal-renderer", + "projectFolder": "libraries/rush-terminal-renderer", + "reviewCategory": "libraries", + "shouldPublish": true + }, { "packageName": "@rushstack/stream-collator", "projectFolder": "libraries/stream-collator",