From 9057d8ad1ca7ce46ee554cb5d9522085272c68c1 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 06:31:31 +0000 Subject: [PATCH 1/8] [rushd] Wire layer: protocol, transport & presentation (WS1, #5896) Add the engine-agnostic rushd wire layer as three new 0.x packages plus additive rush-lib engine instrumentation: - @rushstack/rush-daemon-protocol: frame taxonomy (0x01 control-json, 0x02/0x03 log-stdout/stderr, 0x04 stdin, 0x05 event), length-prefixed binary codec, DAEMON_PROTOCOL_VERSION, hello/version negotiation with typed mismatch errors, and per-subscription verbosity filtering at serialization. Event envelope mirrors the @rushstack/reporter contract as a placeholder pending its merge (#5858). - @rushstack/rush-daemon-transport: workspace-key hashing, per-user runtime-dir socket/pipe paths, net listener/connector with backpressure, and PID/lockfile stale-socket reclaim. - @rushstack/rush-terminal-renderer: client reporter host with StreamCollator-backed per-op collation (byte-parity with legacy), per-client verbosity, and FORCE_COLOR/COLUMNS child-env threading. - rush-lib: optional internal IOperationGraphEventSink dual-emit (structured status/activity/header events + per-op raw output tap) with byte-identical legacy output. - build-tests/rushd-wire-e2e-test: cross-layer conformance suite (golden parity over a real socket, raw-stream integrity, verbosity isolation, failure propagation). - rigs: shared strict-codegen ESLint mixin for the new packages, with inline suppression disabled (noInlineConfig). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- README.md | 3 + build-tests/rushd-wire-e2e-test/AGENTS.md | 51 ++ .../config/jest.config.json | 3 + .../rushd-wire-e2e-test/config/rig.json | 7 + .../rushd-wire-e2e-test/eslint.config.js | 23 + build-tests/rushd-wire-e2e-test/package.json | 23 + build-tests/rushd-wire-e2e-test/src/index.ts | 10 + .../src/test/EngineRunners.ts | 57 ++ .../src/test/EngineScenario.ts | 60 ++ .../src/test/FrameDispatch.ts | 41 ++ .../src/test/TestWritable.ts | 62 ++ .../src/test/WireAdapter.ts | 99 ++++ .../src/test/WireDriver.ts | 55 ++ .../src/test/WireEndToEnd.test.ts | 69 +++ .../src/test/WireEnvelope.ts | 38 ++ .../src/test/WireRawStreams.test.ts | 78 +++ build-tests/rushd-wire-e2e-test/tsconfig.json | 7 + ...hd-wire-layer-ws1_2026-08-14-05-30-00.json | 11 + ...hd-wire-layer-ws1_2026-08-14-05-30-00.json | 11 + ...hd-wire-layer-ws1_2026-08-14-05-30-00.json | 11 + ...hd-wire-layer-ws1_2026-08-14-05-30-00.json | 11 + .../rush/browser-approved-packages.json | 12 + .../config/subspaces/default/pnpm-lock.yaml | 557 +++++++++++------- .../config/subspaces/default/repo-state.json | 2 +- .../reviews/api/rush-daemon-protocol.api.md | 336 +++++++++++ .../reviews/api/rush-daemon-transport.api.md | 115 ++++ common/reviews/api/rush-lib.api.md | 17 + .../reviews/api/rush-terminal-renderer.api.md | 94 +++ libraries/rush-daemon-protocol/.npmignore | 36 ++ libraries/rush-daemon-protocol/AGENTS.md | 55 ++ libraries/rush-daemon-protocol/LICENSE | 24 + libraries/rush-daemon-protocol/README.md | 31 + .../config/api-extractor.json | 4 + .../config/jest.config.json | 3 + .../rush-daemon-protocol/config/rig.json | 7 + .../rush-daemon-protocol/eslint.config.js | 23 + libraries/rush-daemon-protocol/package.json | 63 ++ .../src/ControlFrameCodec.ts | 42 ++ .../src/ControlMessageValidation.ts | 100 ++++ .../src/DaemonControlMessage.ts | 82 +++ .../src/DaemonEventEnvelope.ts | 91 +++ .../src/DaemonEventFrameCodec.ts | 61 ++ .../src/DaemonEventType.ts | 65 ++ .../src/DaemonExtensionEventName.ts | 54 ++ .../rush-daemon-protocol/src/DaemonFrame.ts | 27 + .../src/DaemonFrameType.ts | 41 ++ .../src/DaemonHandshake.ts | 60 ++ .../src/DaemonJsonValue.ts | 36 ++ .../src/DaemonOperationPayloads.ts | 47 ++ .../src/DaemonProtocolError.ts | 71 +++ .../src/DaemonProtocolVersion.ts | 50 ++ .../src/DaemonRushdExtensions.ts | 37 ++ .../src/DaemonVerbosity.ts | 34 ++ .../src/DaemonVerbosityFilter.ts | 99 ++++ .../src/FrameConstants.ts | 43 ++ .../rush-daemon-protocol/src/FrameDecoder.ts | 99 ++++ .../rush-daemon-protocol/src/FrameEncoder.ts | 42 ++ .../rush-daemon-protocol/src/LogFrameCodec.ts | 79 +++ libraries/rush-daemon-protocol/src/index.ts | 100 ++++ .../src/test/ControlFrame.test.ts | 57 ++ .../src/test/EventContract.test.ts | 73 +++ .../src/test/FrameCodec.test.ts | 80 +++ .../src/test/Handshake.test.ts | 40 ++ .../src/test/LogFrameCodec.test.ts | 75 +++ .../src/test/TestVectors.ts | 65 ++ .../src/test/VerbosityFilter.test.ts | 70 +++ libraries/rush-daemon-protocol/tsconfig.json | 7 + libraries/rush-daemon-transport/.npmignore | 36 ++ libraries/rush-daemon-transport/AGENTS.md | 51 ++ libraries/rush-daemon-transport/LICENSE | 24 + libraries/rush-daemon-transport/README.md | 28 + .../config/api-extractor.json | 4 + .../config/jest.config.json | 3 + .../rush-daemon-transport/config/rig.json | 7 + .../rush-daemon-transport/eslint.config.js | 23 + libraries/rush-daemon-transport/package.json | 59 ++ .../src/DaemonConnector.ts | 55 ++ .../src/DaemonFrameConnection.ts | 91 +++ .../src/DaemonListener.ts | 100 ++++ .../src/DaemonListenerNet.ts | 52 ++ .../src/DaemonLockfile.ts | 96 +++ .../rush-daemon-transport/src/DaemonPaths.ts | 69 +++ .../src/DaemonPathsFromProcess.ts | 29 + .../src/DaemonReclaim.ts | 60 ++ .../src/DaemonTransportError.ts | 36 ++ .../rush-daemon-transport/src/WorkspaceKey.ts | 71 +++ libraries/rush-daemon-transport/src/index.ts | 36 ++ .../src/test/Backpressure.test.ts | 55 ++ .../src/test/DaemonPaths.test.ts | 42 ++ .../src/test/HandshakeOverWire.test.ts | 83 +++ .../src/test/Reclaim.test.ts | 71 +++ .../src/test/SocketExchange.test.ts | 55 ++ .../src/test/TestDaemonFixture.ts | 61 ++ .../src/test/WorkspaceKey.test.ts | 47 ++ libraries/rush-daemon-transport/tsconfig.json | 7 + libraries/rush-lib/src/index.ts | 4 + .../src/logic/operations/OperationChunkTap.ts | 31 + .../logic/operations/OperationEventSink.ts | 81 +++ .../operations/OperationExecutionRecord.ts | 36 +- .../src/logic/operations/OperationGraph.ts | 60 +- .../test/OperationGraphEventSink.test.ts | 210 +++++++ libraries/rush-terminal-renderer/.npmignore | 36 ++ libraries/rush-terminal-renderer/AGENTS.md | 51 ++ libraries/rush-terminal-renderer/LICENSE | 24 + libraries/rush-terminal-renderer/README.md | 28 + .../config/api-extractor.json | 4 + .../config/jest.config.json | 3 + .../rush-terminal-renderer/config/rig.json | 7 + .../rush-terminal-renderer/eslint.config.js | 23 + libraries/rush-terminal-renderer/package.json | 69 +++ .../src/ChildEnvironment.ts | 51 ++ .../src/DaemonRenderer.ts | 46 ++ .../src/DaemonRendererHost.ts | 78 +++ .../src/DaemonRendererHostOptions.ts | 23 + .../src/DaemonRendererTerminal.ts | 36 ++ .../src/HostEventRouter.ts | 100 ++++ .../src/LegacyCollatedRenderer.ts | 61 ++ .../src/OperationStreamRegistry.ts | 92 +++ .../src/RendererHeader.ts | 37 ++ .../src/TerminalSinkWritable.ts | 26 + .../src/TerminalStatuses.ts | 30 + libraries/rush-terminal-renderer/src/index.ts | 33 ++ .../src/test/ChildEnvironment.test.ts | 51 ++ .../src/test/LegacyPipelineReplica.ts | 100 ++++ .../src/test/RendererHostParity.test.ts | 87 +++ .../src/test/RendererHostVerbosity.test.ts | 98 +++ .../src/test/TestTerminal.ts | 66 +++ .../rush-terminal-renderer/tsconfig.json | 7 + .../eslint/flat/mixins/strict-codegen.js | 89 +++ .../eslint/flat/mixins/strict-codegen.js | 6 + rush.json | 24 + 131 files changed, 6861 insertions(+), 234 deletions(-) create mode 100644 build-tests/rushd-wire-e2e-test/AGENTS.md create mode 100644 build-tests/rushd-wire-e2e-test/config/jest.config.json create mode 100644 build-tests/rushd-wire-e2e-test/config/rig.json create mode 100644 build-tests/rushd-wire-e2e-test/eslint.config.js create mode 100644 build-tests/rushd-wire-e2e-test/package.json create mode 100644 build-tests/rushd-wire-e2e-test/src/index.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/EngineRunners.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/EngineScenario.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/WireDriver.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/WireEndToEnd.test.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/WireEnvelope.ts create mode 100644 build-tests/rushd-wire-e2e-test/src/test/WireRawStreams.test.ts create mode 100644 build-tests/rushd-wire-e2e-test/tsconfig.json create mode 100644 common/changes/@microsoft/rush/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json create mode 100644 common/changes/@rushstack/rush-daemon-protocol/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json create mode 100644 common/changes/@rushstack/rush-daemon-transport/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json create mode 100644 common/changes/@rushstack/rush-terminal-renderer/thelarkinn-rushd-wire-layer-ws1_2026-08-14-05-30-00.json create mode 100644 common/reviews/api/rush-daemon-protocol.api.md create mode 100644 common/reviews/api/rush-daemon-transport.api.md create mode 100644 common/reviews/api/rush-terminal-renderer.api.md create mode 100644 libraries/rush-daemon-protocol/.npmignore create mode 100644 libraries/rush-daemon-protocol/AGENTS.md create mode 100644 libraries/rush-daemon-protocol/LICENSE create mode 100644 libraries/rush-daemon-protocol/README.md create mode 100644 libraries/rush-daemon-protocol/config/api-extractor.json create mode 100644 libraries/rush-daemon-protocol/config/jest.config.json create mode 100644 libraries/rush-daemon-protocol/config/rig.json create mode 100644 libraries/rush-daemon-protocol/eslint.config.js create mode 100644 libraries/rush-daemon-protocol/package.json create mode 100644 libraries/rush-daemon-protocol/src/ControlFrameCodec.ts create mode 100644 libraries/rush-daemon-protocol/src/ControlMessageValidation.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonControlMessage.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonEventType.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonExtensionEventName.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonFrame.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonFrameType.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonHandshake.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonJsonValue.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonOperationPayloads.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonProtocolError.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonVerbosity.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts create mode 100644 libraries/rush-daemon-protocol/src/FrameConstants.ts create mode 100644 libraries/rush-daemon-protocol/src/FrameDecoder.ts create mode 100644 libraries/rush-daemon-protocol/src/FrameEncoder.ts create mode 100644 libraries/rush-daemon-protocol/src/LogFrameCodec.ts create mode 100644 libraries/rush-daemon-protocol/src/index.ts create mode 100644 libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts create mode 100644 libraries/rush-daemon-protocol/src/test/EventContract.test.ts create mode 100644 libraries/rush-daemon-protocol/src/test/FrameCodec.test.ts create mode 100644 libraries/rush-daemon-protocol/src/test/Handshake.test.ts create mode 100644 libraries/rush-daemon-protocol/src/test/LogFrameCodec.test.ts create mode 100644 libraries/rush-daemon-protocol/src/test/TestVectors.ts create mode 100644 libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts create mode 100644 libraries/rush-daemon-protocol/tsconfig.json create mode 100644 libraries/rush-daemon-transport/.npmignore create mode 100644 libraries/rush-daemon-transport/AGENTS.md create mode 100644 libraries/rush-daemon-transport/LICENSE create mode 100644 libraries/rush-daemon-transport/README.md create mode 100644 libraries/rush-daemon-transport/config/api-extractor.json create mode 100644 libraries/rush-daemon-transport/config/jest.config.json create mode 100644 libraries/rush-daemon-transport/config/rig.json create mode 100644 libraries/rush-daemon-transport/eslint.config.js create mode 100644 libraries/rush-daemon-transport/package.json create mode 100644 libraries/rush-daemon-transport/src/DaemonConnector.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonFrameConnection.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonListener.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonListenerNet.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonLockfile.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonPaths.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonPathsFromProcess.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonReclaim.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonTransportError.ts create mode 100644 libraries/rush-daemon-transport/src/WorkspaceKey.ts create mode 100644 libraries/rush-daemon-transport/src/index.ts create mode 100644 libraries/rush-daemon-transport/src/test/Backpressure.test.ts create mode 100644 libraries/rush-daemon-transport/src/test/DaemonPaths.test.ts create mode 100644 libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.ts create mode 100644 libraries/rush-daemon-transport/src/test/Reclaim.test.ts create mode 100644 libraries/rush-daemon-transport/src/test/SocketExchange.test.ts create mode 100644 libraries/rush-daemon-transport/src/test/TestDaemonFixture.ts create mode 100644 libraries/rush-daemon-transport/src/test/WorkspaceKey.test.ts create mode 100644 libraries/rush-daemon-transport/tsconfig.json create mode 100644 libraries/rush-lib/src/logic/operations/OperationChunkTap.ts create mode 100644 libraries/rush-lib/src/logic/operations/OperationEventSink.ts create mode 100644 libraries/rush-lib/src/logic/operations/test/OperationGraphEventSink.test.ts create mode 100644 libraries/rush-terminal-renderer/.npmignore create mode 100644 libraries/rush-terminal-renderer/AGENTS.md create mode 100644 libraries/rush-terminal-renderer/LICENSE create mode 100644 libraries/rush-terminal-renderer/README.md create mode 100644 libraries/rush-terminal-renderer/config/api-extractor.json create mode 100644 libraries/rush-terminal-renderer/config/jest.config.json create mode 100644 libraries/rush-terminal-renderer/config/rig.json create mode 100644 libraries/rush-terminal-renderer/eslint.config.js create mode 100644 libraries/rush-terminal-renderer/package.json create mode 100644 libraries/rush-terminal-renderer/src/ChildEnvironment.ts create mode 100644 libraries/rush-terminal-renderer/src/DaemonRenderer.ts create mode 100644 libraries/rush-terminal-renderer/src/DaemonRendererHost.ts create mode 100644 libraries/rush-terminal-renderer/src/DaemonRendererHostOptions.ts create mode 100644 libraries/rush-terminal-renderer/src/DaemonRendererTerminal.ts create mode 100644 libraries/rush-terminal-renderer/src/HostEventRouter.ts create mode 100644 libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts create mode 100644 libraries/rush-terminal-renderer/src/OperationStreamRegistry.ts create mode 100644 libraries/rush-terminal-renderer/src/RendererHeader.ts create mode 100644 libraries/rush-terminal-renderer/src/TerminalSinkWritable.ts create mode 100644 libraries/rush-terminal-renderer/src/TerminalStatuses.ts create mode 100644 libraries/rush-terminal-renderer/src/index.ts create mode 100644 libraries/rush-terminal-renderer/src/test/ChildEnvironment.test.ts create mode 100644 libraries/rush-terminal-renderer/src/test/LegacyPipelineReplica.ts create mode 100644 libraries/rush-terminal-renderer/src/test/RendererHostParity.test.ts create mode 100644 libraries/rush-terminal-renderer/src/test/RendererHostVerbosity.test.ts create mode 100644 libraries/rush-terminal-renderer/src/test/TestTerminal.ts create mode 100644 libraries/rush-terminal-renderer/tsconfig.json create mode 100644 rigs/decoupled-local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js create mode 100644 rigs/local-node-rig/profiles/default/includes/eslint/flat/mixins/strict-codegen.js diff --git a/README.md b/README.md index e40ff5073d8..9de0b29238b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,10 @@ 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-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/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) | 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..63c328366b0 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.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. + +// 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'; + +/** Returns true for `0x02`/`0x03` log frames. */ +export function isLogFrame(frame: IDaemonFrame): boolean { + return frame.type === DaemonFrameType.logStdout || frame.type === DaemonFrameType.logStderr; +} + +/** Maps a log frame type to its stream name. */ +export function toStream(frame: IDaemonFrame): 'stdout' | 'stderr' { + return frame.type === DaemonFrameType.logStderr ? 'stderr' : 'stdout'; +} + +/** Routes one decoded frame into the renderer host. */ +export function dispatchFrame(host: DaemonRendererHost, frame: IDaemonFrame): void { + if (frame.type === 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(log.chunk.toString('utf8')); + 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..82e43ff7e2c --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts @@ -0,0 +1,62 @@ +// 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); + } +} + +function collectByKind(chunks: readonly ITerminalChunk[], kind: TerminalChunkKind): string { + return 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..3af5b522611 --- /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 type: DaemonFrameType = + chunk.kind === TerminalChunkKind.Stderr ? DaemonFrameType.logStderr : DaemonFrameType.logStdout; + this.frames.push({ + type, + 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({ type: 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..64979160d41 --- /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.type === 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..9a79fa4af11 --- /dev/null +++ b/build-tests/rushd-wire-e2e-test/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2019" + } +} 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..6e096ccfedf --- /dev/null +++ b/common/reviews/api/rush-daemon-protocol.api.md @@ -0,0 +1,336 @@ +## 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 string[]; + +// @beta +export const DAEMON_EVENT_TYPES: readonly DaemonEventType[]; + +// @beta +export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion; + +// Warning: (ae-forgotten-export) The symbol "IDaemonErrorMessage" needs to be exported by the entry point index.d.ts +// +// @beta +export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonErrorMessage | { + readonly kind: 'unsubscribe'; +} | { + readonly kind: 'ping'; +} | { + readonly kind: 'pong'; + readonly uptimeMs: number; +}; + +// @beta +export type DaemonDiagnosticSeverity = 'debug' | 'info' | 'warning' | 'error'; + +// @beta +export type DaemonEventPrivacy = 'public' | 'local-sensitive' | 'secret'; + +// @beta +export type DaemonEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension'; + +// @beta +export type DaemonExtensionEventName = string; + +// @beta +export class DaemonFrameDecoder { + constructor(options?: IDaemonFrameDecoderOptions); + push(chunk: Buffer): 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); + readonly code: DaemonProtocolErrorCode; +} + +// @beta +export enum DaemonProtocolErrorCode { + frameTooLarge = "frameTooLarge", + malformedControlMessage = "malformedControlMessage", + malformedPayload = "malformedPayload", + protocolVersionMismatch = "protocolVersionMismatch", + unknownFrameType = "unknownFrameType" +} + +// @beta +export type DaemonVerbosity = 'quiet' | 'normal' | 'verbose' | 'debug'; + +// @beta +export function decodeDaemonControlMessage(payload: Buffer): DaemonControlMessage; + +// @beta +export function decodeDaemonEventFrame(payload: Buffer): IDaemonEventEnvelope; + +// @beta +export function decodeDaemonLogChunk(payload: Buffer): IDaemonLogChunk; + +// @beta +export const DEFAULT_MAX_PAYLOAD_BYTES: number; + +// @beta +export function encodeDaemonControlMessage(message: DaemonControlMessage): Buffer; + +// @beta +export function encodeDaemonEventFrame(envelope: IDaemonEventEnvelope): Buffer; + +// @beta +export function encodeDaemonFrame(frame: IDaemonFrame): Buffer; + +// @beta +export function encodeDaemonFrames(frames: readonly IDaemonFrame[]): Buffer; + +// @beta +export function encodeDaemonLogChunk(log: IDaemonLogChunk): Buffer; + +// @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 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 payload: Buffer; + readonly type: DaemonFrameType; +} + +// @beta +export interface IDaemonFrameDecoderOptions { + readonly maxPayloadBytes?: number; +} + +// @beta +export interface IDaemonHelloAckMessage { + // (undocumented) + readonly kind: 'helloAck'; + // (undocumented) + readonly protocolVersion: IDaemonProtocolVersion; + // (undocumented) + readonly sessionId: string; +} + +// @beta +export interface IDaemonHelloMessage { + // (undocumented) + readonly kind: 'hello'; + // (undocumented) + readonly protocolVersion: IDaemonProtocolVersion; +} + +// @beta +export interface IDaemonLogChunk { + readonly chunk: Buffer; + 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 IDaemonProtocolVersion { + readonly major: number; + readonly minor: number; +} + +// @beta +export interface IDaemonSubscribeMessage { + // (undocumented) + readonly caps: IDaemonClientCaps; + // (undocumented) + readonly kind: 'subscribe'; +} + +// @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); + 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): Buffer | 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; + +``` 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..3ed60b8b15a --- /dev/null +++ b/common/reviews/api/rush-daemon-transport.api.md @@ -0,0 +1,115 @@ +## 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; +} + +// @beta +export class DaemonFrameListener { + closeAsync(): Promise; + static listenAsync(paths: IDaemonPaths, options: IDaemonListenerOptions): Promise; +} + +// @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 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..236254ffd48 --- /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: Buffer): 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..89bbe2cb437 --- /dev/null +++ b/libraries/rush-daemon-protocol/AGENTS.md @@ -0,0 +1,55 @@ +# 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. + +## Design notes for this package + +- `src/events/` 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: Node.js builtins only; no `rush-lib`. diff --git a/libraries/rush-daemon-protocol/LICENSE b/libraries/rush-daemon-protocol/LICENSE new file mode 100644 index 00000000000..bd4533ad992 --- /dev/null +++ b/libraries/rush-daemon-protocol/LICENSE @@ -0,0 +1,24 @@ +@rushstack/operation-graph + +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..297c70c105a --- /dev/null +++ b/libraries/rush-daemon-protocol/package.json @@ -0,0 +1,63 @@ +{ + "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:*" + }, + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + }, + "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..efdb66e9bed --- /dev/null +++ b/libraries/rush-daemon-protocol/src/ControlFrameCodec.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 { validateDaemonControlMessage } from './ControlMessageValidation'; +import type { DaemonControlMessage } from './DaemonControlMessage'; +import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; + +const UTF8: BufferEncoding = 'utf8'; + +/** + * Serializes a control message as UTF-8 JSON for a `0x01` control-json frame. + * + * @beta + */ +export function encodeDaemonControlMessage(message: DaemonControlMessage): Buffer { + return Buffer.from(JSON.stringify(message), UTF8); +} + +function parseControlJson(payload: Buffer): unknown { + try { + return JSON.parse(payload.toString(UTF8)) as unknown; + } catch (error) { + throw new DaemonProtocolError( + DaemonProtocolErrorCode.malformedControlMessage, + `Control frame payload is not valid JSON: ${(error as Error).message}` + ); + } +} + +/** + * 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: Buffer): 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..926e3c6cf1c --- /dev/null +++ b/libraries/rush-daemon-protocol/src/ControlMessageValidation.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 { DAEMON_CONTROL_MESSAGE_KINDS } from './DaemonControlMessage'; +import { DaemonProtocolError, DaemonProtocolErrorCode } 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(DaemonProtocolErrorCode.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(record: Record): void { + const version: Record = requireRecordField(record, 'protocolVersion'); + requireNumberField(version, 'major'); + requireNumberField(version, 'minor'); +} + +function requireCapsVerbosity(caps: Record): void { + if (caps.verbosity !== undefined && !isDaemonVerbosity(caps.verbosity)) { + fail('Subscribe message caps.verbosity is not a known verbosity level.'); + } +} + +function validateCaps(record: Record): void { + const caps: Record = requireRecordField(record, 'caps'); + if (typeof caps.isTTY !== 'boolean') { + fail('Subscribe message caps.isTTY must be a boolean.'); + } + requireCapsVerbosity(caps); +} + +function validateHelloAck(record: Record): void { + requireVersion(record); + requireStringField(record, 'sessionId'); +} + +function validateError(record: Record): void { + requireStringField(record, 'code'); + requireStringField(record, 'message'); +} + +type ControlValidator = (record: Record) => void; + +const noopValidator: ControlValidator = () => undefined; + +const VALIDATORS_BY_KIND: Record = { + hello: requireVersion, + helloAck: validateHelloAck, + subscribe: validateCaps, + unsubscribe: noopValidator, + ping: noopValidator, + pong: (record: Record) => requireNumberField(record, 'uptimeMs'), + error: validateError +}; + +function requireKnownKind(record: Record): string { + const kind: unknown = record.kind; + if (typeof kind !== 'string' || !DAEMON_CONTROL_MESSAGE_KINDS.includes(kind)) { + fail('Control message has an unknown kind.'); + } + return kind; +} + +/** + * 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.'); + } + const kind: string = requireKnownKind(value); + VALIDATORS_BY_KIND[kind](value); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts new file mode 100644 index 00000000000..773e35d5b36 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts @@ -0,0 +1,82 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import type { DaemonProtocolErrorCode } from './DaemonProtocolError'; +import type { IDaemonProtocolVersion } from './DaemonProtocolVersion'; +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 { + /** The verbosity subset this client receives. Defaults to `normal`. */ + readonly verbosity?: DaemonVerbosity; + /** Whether the client's output is an interactive TTY. */ + readonly isTTY: boolean; + /** 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; +} + +/** The first frame a client sends on a new connection. @beta */ +export interface IDaemonHelloMessage { + readonly kind: 'hello'; + readonly protocolVersion: IDaemonProtocolVersion; +} + +/** The server's accepting reply to a compatible `hello`. @beta */ +export interface IDaemonHelloAckMessage { + readonly kind: 'helloAck'; + readonly protocolVersion: IDaemonProtocolVersion; + readonly sessionId: string; +} + +/** Subscribes the connection to event and log streams with the given capabilities. @beta */ +export interface IDaemonSubscribeMessage { + readonly kind: 'subscribe'; + readonly caps: IDaemonClientCaps; +} + +/** A protocol error sent on the wire. @beta */ +export interface IDaemonErrorMessage { + readonly kind: 'error'; + readonly code: DaemonProtocolErrorCode; + readonly message: string; +} + +/** + * The union of every control message carried by a `0x01` control-json frame. + * + * @beta + */ +export type DaemonControlMessage = + | IDaemonHelloMessage + | IDaemonHelloAckMessage + | IDaemonSubscribeMessage + | IDaemonErrorMessage + | { readonly kind: 'unsubscribe' } + | { readonly kind: 'ping' } + | { readonly kind: 'pong'; readonly uptimeMs: number }; + +/** + * The runtime list of control message `kind` discriminants. + * + * @beta + */ +export const DAEMON_CONTROL_MESSAGE_KINDS: readonly string[] = [ + 'hello', + 'helloAck', + 'subscribe', + 'unsubscribe', + 'ping', + 'pong', + 'error' +]; diff --git a/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts b/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts new file mode 100644 index 00000000000..ff037e729ad --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts @@ -0,0 +1,91 @@ +// 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. + +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. + * + * @remarks + * - `public` values may be written to any destination, including telemetry. + * - `local-sensitive` values may appear in local reporter output but never in telemetry. + * - `secret` values must never reach any local log or telemetry. + * + * @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. + * + * @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 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; + /** The authoritative monotonic ordering value assigned by the producer's manager. */ + readonly sequence: number; + /** For child sessions, the producer's original local sequence value. */ + readonly sourceSequence?: 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 command, operation, project, and phase this event belongs to. */ + readonly scope?: IDaemonEventScope; + /** 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; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts b/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts new file mode 100644 index 00000000000..83e105484af --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.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 type { IDaemonEventEnvelope } from './DaemonEventEnvelope'; +import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; +import type { DaemonVerbosity } from './DaemonVerbosity'; +import { shouldSerializeDaemonEvent } from './DaemonVerbosityFilter'; + +const UTF8: BufferEncoding = 'utf8'; + +/** + * Serializes an event envelope as UTF-8 JSON for a `0x05` event frame. + * + * @beta + */ +export function encodeDaemonEventFrame(envelope: IDaemonEventEnvelope): Buffer { + return Buffer.from(JSON.stringify(envelope), UTF8); +} + +/** + * Parses the payload of a `0x05` event frame. + * + * @remarks + * Performs JSON parsing plus minimal envelope shape validation; unknown + * optional fields introduced by newer minor protocol versions are preserved. + * + * @beta + */ +export function decodeDaemonEventFrame(payload: Buffer): IDaemonEventEnvelope { + let parsed: unknown; + try { + parsed = JSON.parse(payload.toString(UTF8)) as unknown; + } catch (error) { + throw new DaemonProtocolError( + DaemonProtocolErrorCode.malformedPayload, + `Event frame payload is not valid JSON: ${(error as Error).message}` + ); + } + return parsed as IDaemonEventEnvelope; +} + +/** + * 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 +): Buffer | 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..8d00a8cd84d --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonEventType.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. + +// TODO(reconcile): replace with `ReporterEventType`/`REPORTER_EVENT_TYPES` from +// `@rushstack/reporter` once that package merges into main (#5858). + +/** + * The closed set of core event type identifiers carried by `0x05` event frames. + * + * @remarks + * 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 (see {@link isDaemonExtensionEventName}) rather than adding a new + * core type. + * + * @beta + */ +export type DaemonEventType = + | 'sessionStarted' + | 'sessionCompleted' + | 'commandStarted' + | 'commandCompleted' + | 'operationRegistered' + | 'operationStatusChanged' + | 'activityChanged' + | 'watchCycleCompleted' + | 'diagnosticEmitted' + | 'externalProcessStarted' + | 'externalOutput' + | 'externalProcessCompleted' + | 'artifactAvailable' + | 'commandResult' + | 'extension'; + +/** + * The runtime list of every core event type, in canonical order. + * + * @beta + */ +export const DAEMON_EVENT_TYPES: readonly DaemonEventType[] = [ + 'sessionStarted', + 'sessionCompleted', + 'commandStarted', + 'commandCompleted', + 'operationRegistered', + 'operationStatusChanged', + 'activityChanged', + 'watchCycleCompleted', + 'diagnosticEmitted', + 'externalProcessStarted', + 'externalOutput', + 'externalProcessCompleted', + 'artifactAvailable', + 'commandResult', + 'extension' +]; + +/** + * 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_TYPES as readonly string[]).includes(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..66de6675861 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonFrame.ts @@ -0,0 +1,27 @@ +// 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 type 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. + * + * @beta + */ +export interface IDaemonFrame { + /** + * The frame type byte. + */ + readonly type: DaemonFrameType; + + /** + * The payload bytes. Never a view onto a larger shared buffer. + */ + readonly payload: Buffer; +} diff --git a/libraries/rush-daemon-protocol/src/DaemonFrameType.ts b/libraries/rush-daemon-protocol/src/DaemonFrameType.ts new file mode 100644 index 00000000000..715ce65b56f --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonFrameType.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. + +/** + * The wire frame type 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 ALL_FRAME_TYPES: readonly DaemonFrameType[] = [ + DaemonFrameType.controlJson, + DaemonFrameType.logStdout, + DaemonFrameType.logStderr, + DaemonFrameType.stdin, + DaemonFrameType.event +]; + +/** + * Returns `true` when `value` is a byte assigned to a known frame type. + * + * @beta + */ +export function isDaemonFrameType(value: number): value is DaemonFrameType { + return (ALL_FRAME_TYPES as readonly number[]).includes(value); +} diff --git a/libraries/rush-daemon-protocol/src/DaemonHandshake.ts b/libraries/rush-daemon-protocol/src/DaemonHandshake.ts new file mode 100644 index 00000000000..e2788d65565 --- /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', protocolVersion }; +} + +/** + * Creates the `helloAck` message a server replies with when versions match. + * + * @param protocolVersion - the server's own protocol version + * @param sessionId - the session identifier assigned to the connection + * + * @beta + */ +export function createDaemonHelloAck( + protocolVersion: IDaemonProtocolVersion, + sessionId: string +): IDaemonHelloAckMessage { + return { kind: 'helloAck', 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.protocolVersion)) { + return { + accepted: false, + error: new ProtocolVersionMismatchError(localVersion.major, hello.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..9d7d03c14b0 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonProtocolError.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. + +/** + * The closed set of machine-readable rushd protocol error codes. + * + * @beta + */ +export enum DaemonProtocolErrorCode { + /** A frame header declared a payload larger than the configured maximum. */ + frameTooLarge = 'frameTooLarge', + /** A frame header carried a type byte outside the protocol taxonomy. */ + unknownFrameType = 'unknownFrameType', + /** A frame payload was malformed for its frame type. */ + malformedPayload = 'malformedPayload', + /** A control frame did not contain a well-formed control message. */ + malformedControlMessage = 'malformedControlMessage', + /** The peer's protocol major version differs from the local one. */ + protocolVersionMismatch = 'protocolVersionMismatch' +} + +/** + * 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) { + super(message); + 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) { + super( + DaemonProtocolErrorCode.protocolVersionMismatch, + `Unsupported rushd protocol major version ${actualMajor}; this peer requires major version ${expectedMajor}.` + ); + 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..44434d6b5cc --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.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. + +/** + * 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. @beta */ +export const RUSHD_OPERATION_STREAM_CLOSED: 'rushd.operation-stream-closed' = + 'rushd.operation-stream-closed'; + +/** Extension event name: an operation's collated header was displayed. @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..54bf8f93875 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.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. + +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']; + +// 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_SEVERITIES as readonly string[]).includes(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/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..5e393bd58ea --- /dev/null +++ b/libraries/rush-daemon-protocol/src/FrameDecoder.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. + +import type { IDaemonFrame } from './DaemonFrame'; +import { isDaemonFrameType } from './DaemonFrameType'; +import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; +import { + DEFAULT_MAX_PAYLOAD_BYTES, + FRAME_HEADER_BYTES, + LENGTH_FIELD_OFFSET, + TYPE_FIELD_OFFSET +} from './FrameConstants'; + +/** Options for {@link DaemonFrameDecoder}. @beta */ +export interface IDaemonFrameDecoderOptions { + /** The maximum accepted payload size of a single frame, in bytes. */ + readonly maxPayloadBytes?: number; +} + +const EMPTY_LENGTH: number = 0; +const EMPTY_BUFFER: Buffer = Buffer.alloc(EMPTY_LENGTH); +const HEX_RADIX: number = 16; + +/** + * 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. Payloads are copied out of the + * receive buffer, so retained frames never pin a larger slab. + * @beta + */ +export class DaemonFrameDecoder { + private _pending: Buffer; + private readonly _maxPayloadBytes: number; + + public constructor(options?: IDaemonFrameDecoderOptions) { + this._pending = EMPTY_BUFFER; + 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: Buffer): IDaemonFrame[] { + const pending: Buffer = this._pending; + this._pending = pending.length === EMPTY_LENGTH ? chunk : Buffer.concat([pending, 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 = EMPTY_BUFFER; + } + + private _tryExtractFrame(): IDaemonFrame | undefined { + if (this._pending.length < FRAME_HEADER_BYTES) { + return undefined; + } + const payloadLength: number = this._pending.readUInt32LE(LENGTH_FIELD_OFFSET); + this._assertPayloadLength(payloadLength); + const frameBytes: number = FRAME_HEADER_BYTES + payloadLength; + if (this._pending.length < frameBytes) { + return undefined; + } + return this._takeFrame(frameBytes); + } + + private _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(DaemonProtocolErrorCode.frameTooLarge, message); + } + } + + private _takeFrame(frameBytes: number): IDaemonFrame { + const typeByte: number = this._pending.readUInt8(TYPE_FIELD_OFFSET); + this._assertKnownType(typeByte); + const payload: Buffer = Buffer.from(this._pending.subarray(FRAME_HEADER_BYTES, frameBytes)); + this._pending = this._pending.subarray(frameBytes); + return { type: typeByte, payload }; + } + + private _assertKnownType(typeByte: number): void { + if (!isDaemonFrameType(typeByte)) { + throw new DaemonProtocolError( + DaemonProtocolErrorCode.unknownFrameType, + `Frame declared an unknown frame type byte 0x${typeByte.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..1049196b5a7 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/FrameEncoder.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 { IDaemonFrame } from './DaemonFrame'; +import { + FRAME_HEADER_BYTES, + LENGTH_FIELD_OFFSET, + PAYLOAD_OFFSET, + TYPE_FIELD_OFFSET +} from './FrameConstants'; + +/** + * Serializes a single frame as `[u32 LE payloadLength][u8 frameType][payload]`. + * + * @remarks + * The length field counts only the payload bytes, never the header. The result + * is a freshly allocated buffer, safe to retain or mutate by the caller. + * + * @beta + */ +export function encodeDaemonFrame(frame: IDaemonFrame): Buffer { + const serialized: Buffer = Buffer.alloc(FRAME_HEADER_BYTES + frame.payload.length); + serialized.writeUInt32LE(frame.payload.length, LENGTH_FIELD_OFFSET); + serialized.writeUInt8(frame.type, TYPE_FIELD_OFFSET); + frame.payload.copy(serialized, PAYLOAD_OFFSET); + return serialized; +} + +/** + * Serializes a sequence of frames into one contiguous buffer. + * + * @param frames - the frames to serialize, in wire order + * + * @beta + */ +export function encodeDaemonFrames(frames: readonly IDaemonFrame[]): Buffer { + const parts: Buffer[] = []; + for (const frame of frames) { + parts.push(encodeDaemonFrame(frame)); + } + return Buffer.concat(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..91753244553 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/LogFrameCodec.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; +import { + MAX_OPERATION_ID_BYTES, + OPERATION_ID_LENGTH_BYTES, + OPERATION_ID_LENGTH_OFFSET +} from './FrameConstants'; + +const UTF8: BufferEncoding = 'utf8'; + +/** + * 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: Buffer; +} + +/** + * Serializes a log chunk as `[u16 LE operationIdBytes][operationId utf8][raw chunk]`. + * + * @throws {@link DaemonProtocolError} when the operation id exceeds + * {@link MAX_OPERATION_ID_BYTES} bytes when UTF-8 encoded. + * + * @beta + */ +export function encodeDaemonLogChunk(log: IDaemonLogChunk): Buffer { + const idBytes: Buffer = Buffer.from(log.operationId, UTF8); + if (idBytes.length > MAX_OPERATION_ID_BYTES) { + throw new DaemonProtocolError( + DaemonProtocolErrorCode.malformedPayload, + `Operation id is ${idBytes.length} bytes, exceeding the maximum of ${MAX_OPERATION_ID_BYTES}.` + ); + } + const payload: Buffer = Buffer.alloc(OPERATION_ID_LENGTH_BYTES + idBytes.length + log.chunk.length); + payload.writeUInt16LE(idBytes.length, OPERATION_ID_LENGTH_OFFSET); + idBytes.copy(payload, OPERATION_ID_LENGTH_BYTES); + log.chunk.copy(payload, 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: Buffer): IDaemonLogChunk { + if (payload.length < OPERATION_ID_LENGTH_BYTES) { + throw new DaemonProtocolError( + DaemonProtocolErrorCode.malformedPayload, + 'Log frame payload is too short to contain an operation id length.' + ); + } + const idLength: number = payload.readUInt16LE(OPERATION_ID_LENGTH_OFFSET); + const chunkOffset: number = OPERATION_ID_LENGTH_BYTES + idLength; + if (payload.length < chunkOffset) { + throw new DaemonProtocolError( + DaemonProtocolErrorCode.malformedPayload, + `Log frame declared an operation id of ${idLength} bytes but the payload is ${payload.length} bytes.` + ); + } + const operationId: string = payload.toString(UTF8, OPERATION_ID_LENGTH_BYTES, chunkOffset); + const chunk: Buffer = Buffer.from(payload.subarray(chunkOffset)); + return { operationId, chunk }; +} diff --git a/libraries/rush-daemon-protocol/src/index.ts b/libraries/rush-daemon-protocol/src/index.ts new file mode 100644 index 00000000000..25391f36179 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/index.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. + +/** + * 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 + * This package is engine-agnostic and has no `rush-lib` dependency. The event + * contract currently mirrors `@rushstack/reporter`'s envelope as a placeholder + * and will reference it directly once the reporter package merges into main. + * + * @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, + DaemonProtocolErrorCode, + ProtocolVersionMismatchError +} from './DaemonProtocolError'; +export { + DAEMON_PROTOCOL_VERSION, + isDaemonProtocolCompatible, + type IDaemonProtocolVersion +} from './DaemonProtocolVersion'; +export { + DAEMON_CONTROL_MESSAGE_KINDS, + type DaemonControlMessage, + type IDaemonClientCaps, + type IDaemonHelloAckMessage, + type IDaemonHelloMessage, + type IDaemonSubscribeMessage +} from './DaemonControlMessage'; +export { validateDaemonControlMessage } from './ControlMessageValidation'; +export { decodeDaemonControlMessage, encodeDaemonControlMessage } from './ControlFrameCodec'; +export { decodeDaemonLogChunk, encodeDaemonLogChunk, type IDaemonLogChunk } from './LogFrameCodec'; +export { + createDaemonHello, + createDaemonHelloAck, + negotiateDaemonHello, + 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 { + isDaemonExtensionEventName, + isRushdExtensionEventName, + RUSHD_EXTENSION_NAMESPACE, + type DaemonExtensionEventName +} from './DaemonExtensionEventName'; +export { compareDaemonVerbosity, isDaemonVerbosity, type DaemonVerbosity } from './DaemonVerbosity'; +export { + shouldSerializeDaemonEvent, + type DaemonDiagnosticSeverity, + type IDaemonDiagnosticPayload +} from './DaemonVerbosityFilter'; +export { + decodeDaemonEventFrame, + encodeDaemonEventFrame, + serializeDaemonEventForSubscription +} from './DaemonEventFrameCodec'; +export type { + IDaemonActivityPayload, + IDaemonOperationRegisteredPayload, + IDaemonOperationStatusChangedPayload +} from './DaemonOperationPayloads'; +export { + RUSHD_OPERATION_HEADER, + RUSHD_OPERATION_STREAM_CLOSED, + type IDaemonExtensionEventPayload, + type IDaemonOperationHeaderPayload, + type 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..3cc92757c4a --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.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. + +import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; +import type { DaemonControlMessage } from '../DaemonControlMessage'; +import { DaemonProtocolErrorCode } from '../DaemonProtocolError'; +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', protocolVersion: DAEMON_PROTOCOL_VERSION }, + { kind: 'helloAck', protocolVersion: DAEMON_PROTOCOL_VERSION, sessionId: 's-1' }, + { kind: 'subscribe', caps: { isTTY: true, verbosity: 'verbose', columns: COLUMNS } }, + { kind: 'unsubscribe' }, + { kind: 'ping' }, + { kind: 'pong', uptimeMs: UPTIME_MS }, + { kind: 'error', code: DaemonProtocolErrorCode.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(DaemonProtocolErrorCode.malformedControlMessage); +}); + +it('rejects a control message with an unknown kind', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from('{"kind":"teleport"}')) + ); + expect(error.code).toBe(DaemonProtocolErrorCode.malformedControlMessage); +}); + +it('rejects a hello without a version', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from('{"kind":"hello"}')) + ); + expect(error.code).toBe(DaemonProtocolErrorCode.malformedControlMessage); +}); + +it('rejects a subscribe with an unknown verbosity', () => { + const json: string = '{"kind":"subscribe","caps":{"isTTY":true,"verbosity":"loud"}}'; + const error: ReturnType = captureProtocolError(() => + decodeDaemonControlMessage(Buffer.from(json)) + ); + expect(error.code).toBe(DaemonProtocolErrorCode.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/FrameCodec.test.ts b/libraries/rush-daemon-protocol/src/test/FrameCodec.test.ts new file mode 100644 index 00000000000..e9b3d067b9c --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/FrameCodec.test.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 { IDaemonFrame } from '../DaemonFrame'; +import { DaemonFrameType } from '../DaemonFrameType'; +import { DaemonProtocolErrorCode } from '../DaemonProtocolError'; +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_TYPES: readonly DaemonFrameType[] = [ + DaemonFrameType.controlJson, + DaemonFrameType.logStdout, + DaemonFrameType.logStderr, + DaemonFrameType.stdin, + DaemonFrameType.event +]; +const TINY_LIMIT: number = 8; +const UNKNOWN_TYPE_BYTE: number = 0x7e; +const FIRST_SPLIT: number = 1; + +function roundTrip(type: DaemonFrameType, payload: Buffer): IDaemonFrame { + const frames: IDaemonFrame[] = new DaemonFrameDecoder().push(encodeDaemonFrame({ type, payload })); + expect(frames).toHaveLength(SINGLE_COUNT); + return frames[FIRST_INDEX]; +} + +it('round-trips every frame type with non-UTF-8 payloads', () => { + for (const type of ALL_TYPES) { + const frame: IDaemonFrame = roundTrip(type, NON_UTF8_BYTES); + expect(frame.type).toBe(type); + expect(frame.payload.equals(NON_UTF8_BYTES)).toBe(true); + } +}); + +it('decodes coalesced frames in wire order', () => { + const first: IDaemonFrame = { type: DaemonFrameType.logStdout, payload: Buffer.from('a') }; + const second: IDaemonFrame = { type: DaemonFrameType.logStderr, payload: NON_UTF8_BYTES }; + const frames: IDaemonFrame[] = new DaemonFrameDecoder().push(encodeDaemonFrames([first, second])); + expect(frames).toHaveLength(PAIR_COUNT); + expect(frames[FIRST_INDEX].type).toBe(DaemonFrameType.logStdout); + expect(frames[SINGLE_COUNT].payload.equals(NON_UTF8_BYTES)).toBe(true); +}); + +it('decodes a frame split at every possible byte boundary', () => { + const encoded: Buffer = encodeDaemonFrame({ type: 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); + expect(frames[FIRST_INDEX].payload.equals(NON_UTF8_BYTES)).toBe(true); + } +}); + +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(DaemonProtocolErrorCode.frameTooLarge); +}); + +it('rejects an unknown frame type byte', () => { + const header: Buffer = Buffer.alloc(FRAME_HEADER_BYTES); + header.writeUInt8(UNKNOWN_TYPE_BYTE, TYPE_FIELD_OFFSET); + const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); + const error: ReturnType = captureProtocolError(() => decoder.push(header)); + expect(error.code).toBe(DaemonProtocolErrorCode.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..ce1dce8c925 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/Handshake.test.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 { createDaemonHello, negotiateDaemonHello } from '../DaemonHandshake'; +import type { DaemonHandshakeOutcome } from '../DaemonHandshake'; +import { DaemonProtocolErrorCode, 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.sessionId).toBe(SESSION_ID); + expect(outcome.ack.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(DaemonProtocolErrorCode.protocolVersionMismatch); + expect(outcome.error.expectedMajor).toBe(DAEMON_PROTOCOL_VERSION.major); + expect(outcome.error.actualMajor).toBe(DAEMON_PROTOCOL_VERSION.major + NEWER_MAJOR); + } +}); 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..c389af7af2f --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/LogFrameCodec.test.ts @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import { DaemonFrameType } from '../DaemonFrameType'; +import { DaemonProtocolErrorCode } from '../DaemonProtocolError'; +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_ID_BYTES: number = 2; +const STREAM_PARITY: number = 2; + +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'); + expect(decoded.chunk.equals(NON_UTF8_BYTES)).toBe(true); +}); + +it('reassembles interleaved per-operation streams without reordering', () => { + const firstA: Buffer = Buffer.from('a1'); + const secondA: Buffer = Buffer.from('a2'); + const firstB: Buffer = Buffer.from('b1'); + const wire: Buffer = Buffer.concat( + [firstA, firstB, secondA].map((chunk: Buffer, index: number) => + encodeDaemonFrame({ + type: index % STREAM_PARITY === FIRST_INDEX ? DaemonFrameType.logStdout : DaemonFrameType.logStderr, + payload: encodeDaemonLogChunk({ + operationId: index % STREAM_PARITY === FIRST_INDEX ? 'op-a' : 'op-b', + chunk + }) + }) + )); + const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); + const stdout: string[] = []; + const stderr: string[] = []; + for (const frame of decoder.push(wire)) { + const log: ReturnType = decodeDaemonLogChunk(frame.payload); + const sink: string[] = log.operationId === 'op-a' ? stdout : stderr; + sink.push(log.chunk.toString()); + } + expect(stdout).toEqual(['a1', 'a2']); + expect(stderr).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: Buffer.alloc(FIRST_INDEX) }) + ); + expect(error.code).toBe(DaemonProtocolErrorCode.malformedPayload); +}); + +it('rejects a truncated log payload', () => { + const error: ReturnType = captureProtocolError(() => + decodeDaemonLogChunk(Buffer.alloc(SHORT_PAYLOAD_BYTES)) + ); + expect(error.code).toBe(DaemonProtocolErrorCode.malformedPayload); +}); + +it('rejects a log payload whose id prefix overruns it', () => { + const payload: Buffer = Buffer.alloc(TRUNCATED_ID_BYTES + SHORT_PAYLOAD_BYTES); + payload.writeUInt16LE(DECLARED_ID_BYTES, FIRST_INDEX); + const error: ReturnType = captureProtocolError(() => + decodeDaemonLogChunk(payload) + ); + expect(error.code).toBe(DaemonProtocolErrorCode.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..b9a21792159 --- /dev/null +++ b/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts @@ -0,0 +1,70 @@ +// 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: Buffer | undefined = serializeDaemonEventForSubscription('verbose', envelope); + expect(serialized).toBeDefined(); + expect(JSON.parse(serialized?.toString() ?? '{}')).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..9a79fa4af11 --- /dev/null +++ b/libraries/rush-daemon-protocol/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2019" + } +} 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..bd4533ad992 --- /dev/null +++ b/libraries/rush-daemon-transport/LICENSE @@ -0,0 +1,24 @@ +@rushstack/operation-graph + +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..f4ecbffd0f2 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts @@ -0,0 +1,91 @@ +// 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 domain socket + * or Windows named pipe). + * + * @remarks + * Incoming bytes are decoded into frames in wire order. Outgoing frames are + * written with backpressure: {@link DaemonFrameConnection.sendFrameAsync} only + * resolves once the socket has accepted the bytes (awaiting `drain` when the + * kernel buffer is full), so a slow consumer cannot lose frames. + * + * @beta + */ +export class DaemonFrameConnection { + private readonly _socket: net.Socket; + private readonly _decoder: 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; + this._decoder = new DaemonFrameDecoder(); + socket.on('data', (chunk: Buffer) => this._onData(chunk)); + socket.on('error', (error: Error) => this._onError(error)); + socket.on('close', () => this._onClose()); + } + + /** Registers the single 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, if any. */ + 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} with code `transportClosed` when the + * socket closes (or errors) before the bytes are accepted. + */ + public async sendFrameAsync(frame: IDaemonFrame): Promise { + this._assertOpen(); + const canContinue: boolean = this._socket.write(encodeDaemonFrame(frame)); + if (!canContinue) { + await once(this._socket, 'drain'); + } + } + + private _assertOpen(): void { + if (this._closedError !== undefined || this._socket.closed) { + throw new DaemonTransportError( + DaemonTransportErrorCode.transportClosed, + 'Cannot send a frame on a closed connection.' + ); + } + } + + /** Half-closes the writable side and releases the socket. */ + public async closeAsync(): Promise { + this._socket.end(); + this._socket.destroySoon(); + } + + private _onData(chunk: Buffer): void { + for (const frame of this._decoder.push(chunk)) { + this._frameHandler?.(frame); + } + } + + 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..4c1393ce067 --- /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); + 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..918c5f4905f --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonLockfile.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 * 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; + +/** + * 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 }); + } +} + +/** + * 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; +} + +/** + * 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; + } +} + +/** + * Reads and parses a daemon lockfile, or returns `undefined` when absent or unreadable. + * + * @beta + */ +export function readDaemonLockfile(lockfilePath: string): IDaemonLockfile | undefined { + try { + return JSON.parse(fs.readFileSync(lockfilePath, UTF8)) as IDaemonLockfile; + } catch { + return undefined; + } +} + +/** + * Atomically-ish writes the daemon lockfile, creating the runtime directory + * (mode `0700`) 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. Missing + * files are ignored so callers can invoke this idempotently during reclaim. + * + * @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/DaemonReclaim.ts b/libraries/rush-daemon-transport/src/DaemonReclaim.ts new file mode 100644 index 00000000000..34399fc8069 --- /dev/null +++ b/libraries/rush-daemon-transport/src/DaemonReclaim.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 { connectDaemonAsync } from './DaemonConnector'; +import type { DaemonFrameConnection } from './DaemonFrameConnection'; +import { + type IDaemonLockfile, + isDaemonProcessAlive, + readDaemonLockfile, + removeDaemonArtifacts +} from './DaemonLockfile'; +import type { IDaemonPaths } from './DaemonPaths'; +import { DaemonTransportError, DaemonTransportErrorCode } from './DaemonTransportError'; + +/** + * Reclaims the socket/pipe path when it is held by a dead daemon (stale + * socket file whose lockfile PID is gone and which refuses connections). + * + * @remarks + * Stale detection is deliberately two-factor: the lockfile PID must be dead + * *and* a connect probe must fail, so a daemon that is alive but momentarily + * unresponsive (for example mid-startup) is never reclaimed underneath itself. + * + * @throws {@link DaemonTransportError} with code `daemonAlreadyRunning` when a + * live (or plausibly live) daemon owns the path. + * + * @beta + */ +export async function reclaimStaleDaemonAsync(paths: IDaemonPaths): Promise { + const lockfile: IDaemonLockfile | undefined = readDaemonLockfile(paths.lockfilePath); + if (isLockfilePidAlive(lockfile)) { + 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: IDaemonLockfile | undefined): 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/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..497a821eec0 --- /dev/null +++ b/libraries/rush-daemon-transport/src/index.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. + +/** + * 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 { 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..c6c9e0395b8 --- /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({ + type: 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(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/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..6cea26ea1dc --- /dev/null +++ b/libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.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 { + DAEMON_PROTOCOL_VERSION, + DaemonFrameType, + createDaemonHello, + decodeDaemonControlMessage, + encodeDaemonControlMessage, + negotiateDaemonHello +} 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 { IDeferred, ITestDaemonPair } from './TestDaemonFixture'; + +const NEWER_MAJOR: number = 1; + +function helloFrame(major: number): IDaemonFrame { + return { + type: 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 = outcome.accepted + ? outcome.ack + : { kind: 'error' as const, code: outcome.error.code, message: outcome.error.message }; + void server + .sendFrameAsync({ type: 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); + expect(reply.kind).toBe('helloAck'); + expect(reply.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 + ); + expect(reply.kind).toBe('error'); + expect(reply.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..736624e703d --- /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({ type: DaemonFrameType.logStdout, payload: BINARY_PAYLOAD }); + const reply: IDaemonFrame = await echoed.promise; + expect(reply.type).toBe(DaemonFrameType.logStdout); + expect(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..9a79fa4af11 --- /dev/null +++ b/libraries/rush-daemon-transport/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2019" + } +} 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..dc9bad574ae --- /dev/null +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.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. + +import type { ITerminalChunk } from '@rushstack/terminal'; + +import type { IOperationExecutionResult } from './IOperationExecutionResult'; +import type { OperationStatus } from './OperationStatus'; + +/** + * Provenance of a status line emitted via + * {@link IOperationGraphEventSink.onActivity}. + * + * @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..bd4533ad992 --- /dev/null +++ b/libraries/rush-terminal-renderer/LICENSE @@ -0,0 +1,24 @@ +@rushstack/operation-graph + +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..d18ed93ab68 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/DaemonRendererHost.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 { + 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; +} + +/** + * 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: Buffer): 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.toString('utf8') }); + } + + /** 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..f8187f63907 --- /dev/null +++ b/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.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 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 { + this._terminal?.write(`${text}\n`, '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..9a79fa4af11 --- /dev/null +++ b/libraries/rush-terminal-renderer/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", + + "compilerOptions": { + "target": "ES2019" + } +} 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", From a4b54cd395728c2fc3e0fc5d296145c713f2be74 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 20:51:54 +0000 Subject: [PATCH 2/8] [rushd] Address WS1 review: protocol hardening, performance, and safety Review pass over the wire-layer packages (#5922): Protocol (@rushstack/rush-daemon-protocol): - kind/payload naming throughout (frames + control messages); control messages are a uniform { kind, payload } discriminated union so kind reads stay monomorphic. - Uint8Array wire payloads instead of Buffer, so the protocol is platform-agnostic and drops the @types/node peer dependency. - FrameDecoder uses ECMAScript private fields and accumulates received bytes in a SegmentBuffer (no per-push Buffer.concat); payloads copy out once per completed frame. - encodeDaemonFrames returns a Uint8Array[] (no batch concat); the transport writes parts sequentially. - Containment checks use numeric ranges/Sets; the event-type list is as-const with the union derived from it (list and type cannot drift). - Error codes are a plain string union; DaemonProtocolError accepts { cause } per the standard Error convention. - decodeDaemonEventFrame structurally validates envelopes (typed error instead of routing malformed input); new isDaemonEventEnvelope guard. - Envelope optional fields moved to the end of the layout; log chunk encoding measures the id once and allocates the payload once; TS target ES2022. Transport (@rushstack/rush-daemon-transport): - Decoder/handler failures in the socket callback now fail the connection closed instead of crashing the daemon (new ConnectionRobustness test). - Reclaim is serialized through a dedicated .reclaim mutex (wx create, dead-PID steal) so a concurrent starter cannot unlink a socket another process just bound; the daemon lockfile is written after bind so a stale record never reads as a live owner. Renderer/e2e: track the renames and Uint8Array decode boundary. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- .../src/test/FrameDispatch.ts | 10 +- .../src/test/WireAdapter.ts | 6 +- .../src/test/WireRawStreams.test.ts | 2 +- build-tests/rushd-wire-e2e-test/tsconfig.json | 3 +- .../reviews/api/rush-daemon-protocol.api.md | 130 ++++++++++++------ .../reviews/api/rush-daemon-transport.api.md | 13 ++ .../reviews/api/rush-terminal-renderer.api.md | 2 +- libraries/rush-daemon-protocol/AGENTS.md | 9 +- libraries/rush-daemon-protocol/LICENSE | 2 +- libraries/rush-daemon-protocol/package.json | 8 -- .../src/ControlFrameCodec.ts | 22 ++- .../src/ControlMessageValidation.ts | 62 ++++----- .../src/DaemonClientCaps.ts | 25 ++++ .../src/DaemonControlMessage.ts | 96 +++++++------ .../src/DaemonEventEnvelope.ts | 26 ++-- .../src/DaemonEventFrameCodec.ts | 35 ++--- .../src/DaemonEventType.ts | 59 ++++---- .../src/DaemonEventValidation.ts | 71 ++++++++++ .../rush-daemon-protocol/src/DaemonFrame.ts | 11 +- .../src/DaemonFrameType.ts | 21 +-- .../src/DaemonHandshake.ts | 14 +- .../src/DaemonProtocolError.ts | 47 ++++--- .../src/DaemonRushdExtensions.ts | 28 +++- .../src/DaemonVerbosityFilter.ts | 3 +- .../src/DaemonWireText.ts | 13 ++ .../rush-daemon-protocol/src/FrameDecoder.ts | 82 ++++++----- .../rush-daemon-protocol/src/FrameEncoder.ts | 32 +++-- .../rush-daemon-protocol/src/LogFrameCodec.ts | 42 +++--- .../rush-daemon-protocol/src/SegmentBuffer.ts | 93 +++++++++++++ libraries/rush-daemon-protocol/src/index.ts | 107 ++++---------- .../src/test/ControlFrame.test.ts | 37 +++-- .../src/test/EventValidation.test.ts | 41 ++++++ .../src/test/FrameCodec.test.ts | 60 +++++--- .../src/test/Handshake.test.ts | 18 ++- .../src/test/LogFrameCodec.test.ts | 85 +++++++----- .../src/test/VerbosityFilter.test.ts | 5 +- libraries/rush-daemon-protocol/tsconfig.json | 3 +- libraries/rush-daemon-transport/LICENSE | 2 +- .../src/DaemonFrameConnection.ts | 71 +++++----- .../src/DaemonListener.ts | 10 +- .../src/DaemonLockfile.ts | 64 ++++----- .../src/DaemonRawWrite.ts | 14 ++ .../src/DaemonReclaim.ts | 49 +++++-- .../src/DaemonReclaimLock.ts | 87 ++++++++++++ libraries/rush-daemon-transport/src/index.ts | 1 + .../src/test/Backpressure.test.ts | 4 +- .../src/test/ConnectionRobustness.test.ts | 39 ++++++ .../src/test/HandshakeOverWire.test.ts | 23 ++-- .../src/test/SocketExchange.test.ts | 6 +- libraries/rush-daemon-transport/tsconfig.json | 3 +- .../logic/operations/OperationEventSink.ts | 3 +- libraries/rush-terminal-renderer/LICENSE | 2 +- .../src/DaemonRendererHost.ts | 9 +- .../rush-terminal-renderer/tsconfig.json | 3 +- 54 files changed, 1137 insertions(+), 576 deletions(-) create mode 100644 libraries/rush-daemon-protocol/src/DaemonClientCaps.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonEventValidation.ts create mode 100644 libraries/rush-daemon-protocol/src/DaemonWireText.ts create mode 100644 libraries/rush-daemon-protocol/src/SegmentBuffer.ts create mode 100644 libraries/rush-daemon-protocol/src/test/EventValidation.test.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonRawWrite.ts create mode 100644 libraries/rush-daemon-transport/src/DaemonReclaimLock.ts create mode 100644 libraries/rush-daemon-transport/src/test/ConnectionRobustness.test.ts diff --git a/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts b/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts index 63c328366b0..b8d5b71d1dc 100644 --- a/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts +++ b/build-tests/rushd-wire-e2e-test/src/test/FrameDispatch.ts @@ -7,19 +7,21 @@ import { DaemonFrameType, decodeDaemonEventFrame, decodeDaemonLogChunk } from '@ 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.type === DaemonFrameType.logStdout || frame.type === DaemonFrameType.logStderr; + 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.type === DaemonFrameType.logStderr ? 'stderr' : 'stdout'; + 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.type === DaemonFrameType.event) { + if (frame.kind === DaemonFrameType.event) { host.handleEvent(decodeDaemonEventFrame(frame.payload)); return; } @@ -36,6 +38,6 @@ export function collectLogChunk(perOperation: Map, frame: IDae } const log: IDaemonLogChunk = decodeDaemonLogChunk(frame.payload); const chunks: string[] = perOperation.get(log.operationId) ?? []; - chunks.push(log.chunk.toString('utf8')); + chunks.push(WIRE_DECODER.decode(log.chunk)); perOperation.set(log.operationId, chunks); } diff --git a/build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts b/build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts index 3af5b522611..bb6eb46f1cc 100644 --- a/build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts +++ b/build-tests/rushd-wire-e2e-test/src/test/WireAdapter.ts @@ -55,10 +55,10 @@ export class WireAdapter implements IOperationGraphEventSink { } public onOperationChunk(operationId: string, chunk: ITerminalChunk): void { - const type: DaemonFrameType = + const kind: DaemonFrameType = chunk.kind === TerminalChunkKind.Stderr ? DaemonFrameType.logStderr : DaemonFrameType.logStdout; this.frames.push({ - type, + kind, payload: encodeDaemonLogChunk({ operationId, chunk: Buffer.from(chunk.text, UTF8) }) }); } @@ -94,6 +94,6 @@ export class WireAdapter implements IOperationGraphEventSink { options ); this._sequence += 1; - this.frames.push({ type: DaemonFrameType.event, payload: encodeDaemonEventFrame(envelope) }); + this.frames.push({ kind: DaemonFrameType.event, payload: encodeDaemonEventFrame(envelope) }); } } 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 index 64979160d41..05f66d8092a 100644 --- a/build-tests/rushd-wire-e2e-test/src/test/WireRawStreams.test.ts +++ b/build-tests/rushd-wire-e2e-test/src/test/WireRawStreams.test.ts @@ -36,7 +36,7 @@ interface ICapturedStream { function captureFrame(captured: ICapturedStream, frame: IDaemonFrame): void { collectLogChunk(captured.perOperation, frame); - if (frame.type === DaemonFrameType.event) { + if (frame.kind === DaemonFrameType.event) { captured.events.push(decodeDaemonEventFrame(frame.payload)); } } diff --git a/build-tests/rushd-wire-e2e-test/tsconfig.json b/build-tests/rushd-wire-e2e-test/tsconfig.json index 9a79fa4af11..6a778ab9aff 100644 --- a/build-tests/rushd-wire-e2e-test/tsconfig.json +++ b/build-tests/rushd-wire-e2e-test/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", "compilerOptions": { - "target": "ES2019" + "target": "ES2022", + "lib": ["ES2022"] } } diff --git a/common/reviews/api/rush-daemon-protocol.api.md b/common/reviews/api/rush-daemon-protocol.api.md index 6e096ccfedf..ba0929455cf 100644 --- a/common/reviews/api/rush-daemon-protocol.api.md +++ b/common/reviews/api/rush-daemon-protocol.api.md @@ -14,7 +14,7 @@ export function createDaemonHello(protocolVersion: IDaemonProtocolVersion): IDae export function createDaemonHelloAck(protocolVersion: IDaemonProtocolVersion, sessionId: string): IDaemonHelloAckMessage; // @beta -export const DAEMON_CONTROL_MESSAGE_KINDS: readonly string[]; +export const DAEMON_CONTROL_MESSAGE_KINDS: readonly DaemonControlMessageKind[]; // @beta export const DAEMON_EVENT_TYPES: readonly DaemonEventType[]; @@ -22,26 +22,27 @@ export const DAEMON_EVENT_TYPES: readonly DaemonEventType[]; // @beta export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion; -// Warning: (ae-forgotten-export) The symbol "IDaemonErrorMessage" needs to be exported by the entry point index.d.ts +// @beta +export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonUnsubscribeMessage | IDaemonPingMessage | IDaemonPongMessage | IDaemonErrorMessage; + +// Warning: (ae-forgotten-export) The symbol "CONTROL_KIND_LIST" needs to be exported by the entry point index.d.ts // // @beta -export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonErrorMessage | { - readonly kind: 'unsubscribe'; -} | { - readonly kind: 'ping'; -} | { - readonly kind: 'pong'; - readonly uptimeMs: number; -}; +export type DaemonControlMessageKind = (typeof CONTROL_KIND_LIST)[number]; // @beta export type DaemonDiagnosticSeverity = 'debug' | 'info' | 'warning' | 'error'; +// @beta +export type DaemonEmptyPayload = Record; + // @beta export type DaemonEventPrivacy = 'public' | 'local-sensitive' | 'secret'; +// Warning: (ae-forgotten-export) The symbol "EVENT_TYPE_LIST" needs to be exported by the entry point index.d.ts +// // @beta -export type DaemonEventType = 'sessionStarted' | 'sessionCompleted' | 'commandStarted' | 'commandCompleted' | 'operationRegistered' | 'operationStatusChanged' | 'activityChanged' | 'watchCycleCompleted' | 'diagnosticEmitted' | 'externalProcessStarted' | 'externalOutput' | 'externalProcessCompleted' | 'artifactAvailable' | 'commandResult' | 'extension'; +export type DaemonEventType = (typeof EVENT_TYPE_LIST)[number]; // @beta export type DaemonExtensionEventName = string; @@ -49,7 +50,7 @@ export type DaemonExtensionEventName = string; // @beta export class DaemonFrameDecoder { constructor(options?: IDaemonFrameDecoderOptions); - push(chunk: Buffer): IDaemonFrame[]; + push(chunk: Uint8Array): IDaemonFrame[]; reset(): void; } @@ -81,48 +82,42 @@ export type DaemonJsonValue = string | number | boolean | DaemonJsonNull | reado // @beta export class DaemonProtocolError extends Error { - constructor(code: DaemonProtocolErrorCode, message: string); + constructor(code: DaemonProtocolErrorCode, message: string, options?: IDaemonProtocolErrorOptions); readonly code: DaemonProtocolErrorCode; } // @beta -export enum DaemonProtocolErrorCode { - frameTooLarge = "frameTooLarge", - malformedControlMessage = "malformedControlMessage", - malformedPayload = "malformedPayload", - protocolVersionMismatch = "protocolVersionMismatch", - unknownFrameType = "unknownFrameType" -} +export type DaemonProtocolErrorCode = 'frameTooLarge' | 'unknownFrameType' | 'malformedPayload' | 'malformedControlMessage' | 'protocolVersionMismatch'; // @beta export type DaemonVerbosity = 'quiet' | 'normal' | 'verbose' | 'debug'; // @beta -export function decodeDaemonControlMessage(payload: Buffer): DaemonControlMessage; +export function decodeDaemonControlMessage(payload: Uint8Array): DaemonControlMessage; // @beta -export function decodeDaemonEventFrame(payload: Buffer): IDaemonEventEnvelope; +export function decodeDaemonEventFrame(payload: Uint8Array): IDaemonEventEnvelope; // @beta -export function decodeDaemonLogChunk(payload: Buffer): IDaemonLogChunk; +export function decodeDaemonLogChunk(payload: Uint8Array): IDaemonLogChunk; // @beta export const DEFAULT_MAX_PAYLOAD_BYTES: number; // @beta -export function encodeDaemonControlMessage(message: DaemonControlMessage): Buffer; +export function encodeDaemonControlMessage(message: DaemonControlMessage): Uint8Array; // @beta -export function encodeDaemonEventFrame(envelope: IDaemonEventEnvelope): Buffer; +export function encodeDaemonEventFrame(envelope: IDaemonEventEnvelope): Uint8Array; // @beta -export function encodeDaemonFrame(frame: IDaemonFrame): Buffer; +export function encodeDaemonFrame(frame: IDaemonFrame): Uint8Array; // @beta -export function encodeDaemonFrames(frames: readonly IDaemonFrame[]): Buffer; +export function encodeDaemonFrames(frames: readonly IDaemonFrame[]): Uint8Array[]; // @beta -export function encodeDaemonLogChunk(log: IDaemonLogChunk): Buffer; +export function encodeDaemonLogChunk(log: IDaemonLogChunk): Uint8Array; // @beta export const FRAME_HEADER_BYTES: number; @@ -147,6 +142,17 @@ export interface IDaemonDiagnosticPayload { 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; @@ -191,8 +197,8 @@ export interface IDaemonExtensionEventPayload { // @beta export interface IDaemonFrame { - readonly payload: Buffer; - readonly type: DaemonFrameType; + readonly kind: DaemonFrameType; + readonly payload: Uint8Array; } // @beta @@ -205,9 +211,10 @@ export interface IDaemonHelloAckMessage { // (undocumented) readonly kind: 'helloAck'; // (undocumented) - readonly protocolVersion: IDaemonProtocolVersion; - // (undocumented) - readonly sessionId: string; + readonly payload: { + readonly protocolVersion: IDaemonProtocolVersion; + readonly sessionId: string; + }; } // @beta @@ -215,12 +222,14 @@ export interface IDaemonHelloMessage { // (undocumented) readonly kind: 'hello'; // (undocumented) - readonly protocolVersion: IDaemonProtocolVersion; + readonly payload: { + readonly protocolVersion: IDaemonProtocolVersion; + }; } // @beta export interface IDaemonLogChunk { - readonly chunk: Buffer; + readonly chunk: Uint8Array; readonly operationId: string; } @@ -249,6 +258,29 @@ 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; @@ -257,12 +289,29 @@ export interface IDaemonProtocolVersion { // @beta export interface IDaemonSubscribeMessage { - // (undocumented) - readonly caps: IDaemonClientCaps; // (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; @@ -304,7 +353,7 @@ export const PAYLOAD_OFFSET: number; // @beta export class ProtocolVersionMismatchError extends DaemonProtocolError { - constructor(expectedMajor: number, actualMajor: number); + constructor(expectedMajor: number, actualMajor: number, options?: IDaemonProtocolErrorOptions); readonly actualMajor: number; readonly expectedMajor: number; } @@ -319,7 +368,7 @@ export const RUSHD_OPERATION_HEADER: 'rushd.operation-header'; export const RUSHD_OPERATION_STREAM_CLOSED: 'rushd.operation-stream-closed'; // @beta -export function serializeDaemonEventForSubscription(verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope): Buffer | undefined; +export function serializeDaemonEventForSubscription(verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope): Uint8Array | undefined; // @beta export function shouldSerializeDaemonEvent(verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope): boolean; @@ -333,4 +382,7 @@ 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 index 3ed60b8b15a..c69a18ee094 100644 --- a/common/reviews/api/rush-daemon-transport.api.md +++ b/common/reviews/api/rush-daemon-transport.api.md @@ -21,6 +21,8 @@ export class DaemonFrameConnection { onClosed(handler: (error: Error | undefined) => void): void; onFrame(handler: (frame: IDaemonFrame) => void): void; sendFrameAsync(frame: IDaemonFrame): Promise; + // @internal + get socket(): net.Socket; } // @beta @@ -29,6 +31,14 @@ export class DaemonFrameListener { 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); @@ -106,6 +116,9 @@ export function resolveDaemonPaths(environment: IDaemonPathEnvironment, workspac // @beta export function resolveDaemonPathsFromProcess(workspaceKey: string): IDaemonPaths; +// @beta +export function tryAcquireReclaimLock(lockfilePath: string): DaemonReclaimLockOutcome; + // @beta export const WORKSPACE_KEY_LENGTH: number; diff --git a/common/reviews/api/rush-terminal-renderer.api.md b/common/reviews/api/rush-terminal-renderer.api.md index 236254ffd48..e13568ec407 100644 --- a/common/reviews/api/rush-terminal-renderer.api.md +++ b/common/reviews/api/rush-terminal-renderer.api.md @@ -18,7 +18,7 @@ export class DaemonRendererHost { constructor(options: IDaemonRendererHostOptions); closeAsync(): Promise; handleEvent(envelope: IDaemonEventEnvelope): void; - handleLogChunk(operationId: string, stream: 'stdout' | 'stderr', chunk: Buffer): void; + handleLogChunk(operationId: string, stream: 'stdout' | 'stderr', chunk: Uint8Array): void; initializeAsync(): Promise; } diff --git a/libraries/rush-daemon-protocol/AGENTS.md b/libraries/rush-daemon-protocol/AGENTS.md index 89bbe2cb437..a8383b330ae 100644 --- a/libraries/rush-daemon-protocol/AGENTS.md +++ b/libraries/rush-daemon-protocol/AGENTS.md @@ -46,10 +46,15 @@ toolchain and are **not yet enabled** (the user will wire them up later): `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/events/` contains **placeholder** event-contract types that mirror +- `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: Node.js builtins only; no `rush-lib`. +- 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 index bd4533ad992..b96d274e023 100644 --- a/libraries/rush-daemon-protocol/LICENSE +++ b/libraries/rush-daemon-protocol/LICENSE @@ -1,4 +1,4 @@ -@rushstack/operation-graph +@rushstack/rush-daemon-protocol Copyright (c) Microsoft Corporation. All rights reserved. diff --git a/libraries/rush-daemon-protocol/package.json b/libraries/rush-daemon-protocol/package.json index 297c70c105a..d03678ee7d9 100644 --- a/libraries/rush-daemon-protocol/package.json +++ b/libraries/rush-daemon-protocol/package.json @@ -51,13 +51,5 @@ "eslint": "~9.37.0", "local-node-rig": "workspace:*" }, - "peerDependencies": { - "@types/node": "*" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } - }, "sideEffects": false } diff --git a/libraries/rush-daemon-protocol/src/ControlFrameCodec.ts b/libraries/rush-daemon-protocol/src/ControlFrameCodec.ts index efdb66e9bed..f6700eb4843 100644 --- a/libraries/rush-daemon-protocol/src/ControlFrameCodec.ts +++ b/libraries/rush-daemon-protocol/src/ControlFrameCodec.ts @@ -3,27 +3,25 @@ import { validateDaemonControlMessage } from './ControlMessageValidation'; import type { DaemonControlMessage } from './DaemonControlMessage'; -import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; - -const UTF8: BufferEncoding = 'utf8'; +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): Buffer { - return Buffer.from(JSON.stringify(message), UTF8); +export function encodeDaemonControlMessage(message: DaemonControlMessage): Uint8Array { + return WIRE_TEXT_ENCODER.encode(JSON.stringify(message)); } -function parseControlJson(payload: Buffer): unknown { +function parseControlJson(payload: Uint8Array): unknown { try { - return JSON.parse(payload.toString(UTF8)) as unknown; + return JSON.parse(WIRE_TEXT_DECODER.decode(payload)) as unknown; } catch (error) { - throw new DaemonProtocolError( - DaemonProtocolErrorCode.malformedControlMessage, - `Control frame payload is not valid JSON: ${(error as Error).message}` - ); + throw new DaemonProtocolError('malformedControlMessage', 'Control frame payload is not valid JSON.', { + cause: error + }); } } @@ -35,7 +33,7 @@ function parseControlJson(payload: Buffer): unknown { * * @beta */ -export function decodeDaemonControlMessage(payload: Buffer): DaemonControlMessage { +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 index 926e3c6cf1c..8f3f6dcfacc 100644 --- a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts +++ b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { DAEMON_CONTROL_MESSAGE_KINDS } from './DaemonControlMessage'; -import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; +import { isDaemonControlMessageKind } from './DaemonControlMessage'; +import { DaemonProtocolError } from './DaemonProtocolError'; import { isDaemonVerbosity } from './DaemonVerbosity'; /** Returns `true` when `value` is a plain object. @beta */ @@ -11,7 +11,7 @@ export function isDaemonControlRecord(value: unknown): value is Record, field: string): Record { @@ -34,67 +34,63 @@ function requireNumberField(record: Record, field: string): voi } } -function requireVersion(record: Record): void { - const version: Record = requireRecordField(record, 'protocolVersion'); +function requireVersion(payload: Record): void { + const version: Record = requireRecordField(payload, 'protocolVersion'); requireNumberField(version, 'major'); requireNumberField(version, 'minor'); } -function requireCapsVerbosity(caps: Record): void { - if (caps.verbosity !== undefined && !isDaemonVerbosity(caps.verbosity)) { - fail('Subscribe message caps.verbosity is not a known verbosity level.'); - } +function validateHelloAck(payload: Record): void { + requireVersion(payload); + requireStringField(payload, 'sessionId'); } -function validateCaps(record: Record): void { - const caps: Record = requireRecordField(record, 'caps'); - if (typeof caps.isTTY !== 'boolean') { - fail('Subscribe message caps.isTTY must be a boolean.'); +function validateSubscribe(payload: Record): void { + if (typeof payload.isTTY !== 'boolean') { + fail('Subscribe message payload.isTTY must be a boolean.'); } - requireCapsVerbosity(caps); + requireSubscribeVerbosity(payload); } -function validateHelloAck(record: Record): void { - requireVersion(record); - requireStringField(record, 'sessionId'); +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(record: Record): void { - requireStringField(record, 'code'); - requireStringField(record, 'message'); +function validateError(payload: Record): void { + requireStringField(payload, 'code'); + requireStringField(payload, 'message'); } -type ControlValidator = (record: Record) => void; +type ControlValidator = (payload: Record) => void; const noopValidator: ControlValidator = () => undefined; const VALIDATORS_BY_KIND: Record = { hello: requireVersion, helloAck: validateHelloAck, - subscribe: validateCaps, + subscribe: validateSubscribe, unsubscribe: noopValidator, ping: noopValidator, - pong: (record: Record) => requireNumberField(record, 'uptimeMs'), + pong: (payload: Record) => requireNumberField(payload, 'uptimeMs'), error: validateError }; -function requireKnownKind(record: Record): string { - const kind: unknown = record.kind; - if (typeof kind !== 'string' || !DAEMON_CONTROL_MESSAGE_KINDS.includes(kind)) { - fail('Control message has an unknown kind.'); - } - return kind; -} - /** * 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.'); } - const kind: string = requireKnownKind(value); - VALIDATORS_BY_KIND[kind](value); + 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 index 773e35d5b36..48f24aa98e7 100644 --- a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts +++ b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts @@ -1,77 +1,79 @@ // 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'; -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 { - /** The verbosity subset this client receives. Defaults to `normal`. */ - readonly verbosity?: DaemonVerbosity; - /** Whether the client's output is an interactive TTY. */ - readonly isTTY: boolean; - /** 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; -} +/** 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 protocolVersion: IDaemonProtocolVersion; + readonly payload: { readonly protocolVersion: IDaemonProtocolVersion }; } /** The server's accepting reply to a compatible `hello`. @beta */ export interface IDaemonHelloAckMessage { readonly kind: 'helloAck'; - readonly protocolVersion: IDaemonProtocolVersion; - readonly sessionId: string; + readonly payload: { + readonly protocolVersion: IDaemonProtocolVersion; + readonly sessionId: string; + }; } -/** Subscribes the connection to event and log streams with the given capabilities. @beta */ +/** Subscribes the connection with the given client capabilities. @beta */ export interface IDaemonSubscribeMessage { readonly kind: 'subscribe'; - readonly caps: IDaemonClientCaps; + 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 code: DaemonProtocolErrorCode; - readonly message: string; + 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 - | IDaemonErrorMessage - | { readonly kind: 'unsubscribe' } - | { readonly kind: 'ping' } - | { readonly kind: 'pong'; readonly uptimeMs: number }; + | IDaemonUnsubscribeMessage + | IDaemonPingMessage + | IDaemonPongMessage + | IDaemonErrorMessage; -/** - * The runtime list of control message `kind` discriminants. - * - * @beta - */ -export const DAEMON_CONTROL_MESSAGE_KINDS: readonly string[] = [ +const CONTROL_KIND_LIST = [ 'hello', 'helloAck', 'subscribe', @@ -79,4 +81,20 @@ export const DAEMON_CONTROL_MESSAGE_KINDS: readonly string[] = [ 'ping', 'pong', 'error' -]; +] as const; + +/** The union of control message `kind` discriminants, derived from the list. @beta */ +export type DaemonControlMessageKind = (typeof CONTROL_KIND_LIST)[number]; + +/** + * The runtime list of control message `kind` discriminants (single source of truth). + * @beta + */ +export const DAEMON_CONTROL_MESSAGE_KINDS: readonly DaemonControlMessageKind[] = CONTROL_KIND_LIST; + +const CONTROL_KIND_SET: ReadonlySet = new Set(CONTROL_KIND_LIST); + +/** 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 index ff037e729ad..37da517480c 100644 --- a/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts +++ b/libraries/rush-daemon-protocol/src/DaemonEventEnvelope.ts @@ -4,7 +4,7 @@ // 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. +// swap is mechanical (field order differs: optional fields are declared last). import type { DaemonEventType } from './DaemonEventType'; @@ -41,11 +41,6 @@ export interface IDaemonEventScope { /** * Classifies how sensitive a value is, and therefore which destinations may receive it. * - * @remarks - * - `public` values may be written to any destination, including telemetry. - * - `local-sensitive` values may appear in local reporter output but never in telemetry. - * - `secret` values must never reach any local log or telemetry. - * * @beta */ export type DaemonEventPrivacy = 'public' | 'local-sensitive' | 'secret'; @@ -55,7 +50,8 @@ export type DaemonEventPrivacy = 'public' | 'local-sensitive' | 'secret'; * * @remarks * Envelopes are immutable and JSON-serializable. `sequence` is authoritative for - * ordering; `timestamp` is informational only. + * ordering; `timestamp` is informational only. Required fields are declared + * before optional fields to keep the object layout monomorphic. * * @beta */ @@ -66,20 +62,12 @@ export interface IDaemonEventEnvelope { readonly eventId: string; /** The identifier of the session that produced this event. */ readonly sessionId: string; - /** 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; /** The authoritative monotonic ordering value assigned by the producer's manager. */ readonly sequence: number; - /** For child sessions, the producer's original local sequence value. */ - readonly sourceSequence?: 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 command, operation, project, and phase this event belongs to. */ - readonly scope?: IDaemonEventScope; /** 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. */ @@ -88,4 +76,12 @@ export interface IDaemonEventEnvelope { 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 index 83e105484af..d566550e0f7 100644 --- a/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts +++ b/libraries/rush-daemon-protocol/src/DaemonEventFrameCodec.ts @@ -2,41 +2,44 @@ // See LICENSE in the project root for license information. import type { IDaemonEventEnvelope } from './DaemonEventEnvelope'; -import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; +import { validateDaemonEventEnvelope } from './DaemonEventValidation'; +import { DaemonProtocolError } from './DaemonProtocolError'; import type { DaemonVerbosity } from './DaemonVerbosity'; import { shouldSerializeDaemonEvent } from './DaemonVerbosityFilter'; - -const UTF8: BufferEncoding = 'utf8'; +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): Buffer { - return Buffer.from(JSON.stringify(envelope), UTF8); +export function encodeDaemonEventFrame(envelope: IDaemonEventEnvelope): Uint8Array { + return WIRE_TEXT_ENCODER.encode(JSON.stringify(envelope)); } /** - * Parses the payload of a `0x05` event frame. + * Parses and structurally validates the payload of a `0x05` event frame. * * @remarks - * Performs JSON parsing plus minimal envelope shape validation; unknown - * optional fields introduced by newer minor protocol versions are preserved. + * 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: Buffer): IDaemonEventEnvelope { +export function decodeDaemonEventFrame(payload: Uint8Array): IDaemonEventEnvelope { let parsed: unknown; try { - parsed = JSON.parse(payload.toString(UTF8)) as unknown; + parsed = JSON.parse(WIRE_TEXT_DECODER.decode(payload)) as unknown; } catch (error) { - throw new DaemonProtocolError( - DaemonProtocolErrorCode.malformedPayload, - `Event frame payload is not valid JSON: ${(error as Error).message}` - ); + throw new DaemonProtocolError('malformedPayload', 'Event frame payload is not valid JSON.', { + cause: error + }); } - return parsed as IDaemonEventEnvelope; + return validateDaemonEventEnvelope(parsed); } /** @@ -53,7 +56,7 @@ export function decodeDaemonEventFrame(payload: Buffer): IDaemonEventEnvelope { export function serializeDaemonEventForSubscription( verbosity: DaemonVerbosity, envelope: IDaemonEventEnvelope -): Buffer | undefined { +): Uint8Array | undefined { if (!shouldSerializeDaemonEvent(verbosity, envelope)) { return undefined; } diff --git a/libraries/rush-daemon-protocol/src/DaemonEventType.ts b/libraries/rush-daemon-protocol/src/DaemonEventType.ts index 8d00a8cd84d..77a80f09b6a 100644 --- a/libraries/rush-daemon-protocol/src/DaemonEventType.ts +++ b/libraries/rush-daemon-protocol/src/DaemonEventType.ts @@ -5,39 +5,16 @@ // `@rushstack/reporter` once that package merges into main (#5858). /** - * The closed set of core event type identifiers carried by `0x05` event frames. + * The runtime list of every core event type, in canonical order. * * @remarks - * 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 (see {@link isDaemonExtensionEventName}) rather than adding a new - * core type. - * - * @beta - */ -export type DaemonEventType = - | 'sessionStarted' - | 'sessionCompleted' - | 'commandStarted' - | 'commandCompleted' - | 'operationRegistered' - | 'operationStatusChanged' - | 'activityChanged' - | 'watchCycleCompleted' - | 'diagnosticEmitted' - | 'externalProcessStarted' - | 'externalOutput' - | 'externalProcessCompleted' - | 'artifactAvailable' - | 'commandResult' - | 'extension'; - -/** - * The runtime list of every core event type, in canonical order. + * 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 */ -export const DAEMON_EVENT_TYPES: readonly DaemonEventType[] = [ +const EVENT_TYPE_LIST = [ 'sessionStarted', 'sessionCompleted', 'commandStarted', @@ -53,7 +30,29 @@ export const DAEMON_EVENT_TYPES: readonly DaemonEventType[] = [ 'artifactAvailable', 'commandResult', 'extension' -]; +] as const; + +/** + * The closed set of core event type identifiers carried by `0x05` event frames. + * + * @remarks + * Derived from the canonical list, so the list and the type can never drift + * apart. 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 EVENT_TYPE_LIST)[number]; + +/** + * The runtime list of every core event type, in canonical order. + * + * @beta + */ +export const DAEMON_EVENT_TYPES: readonly DaemonEventType[] = EVENT_TYPE_LIST; + +const DAEMON_EVENT_TYPE_SET: ReadonlySet = new Set(DAEMON_EVENT_TYPES); /** * Returns `true` when `value` is a core event type identifier. @@ -61,5 +60,5 @@ export const DAEMON_EVENT_TYPES: readonly DaemonEventType[] = [ * @beta */ export function isDaemonEventType(value: unknown): value is DaemonEventType { - return typeof value === 'string' && (DAEMON_EVENT_TYPES as readonly string[]).includes(value); + 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/DaemonFrame.ts b/libraries/rush-daemon-protocol/src/DaemonFrame.ts index 66de6675861..42719f575f2 100644 --- a/libraries/rush-daemon-protocol/src/DaemonFrame.ts +++ b/libraries/rush-daemon-protocol/src/DaemonFrame.ts @@ -4,7 +4,7 @@ import type { DaemonFrameType } from './DaemonFrameType'; /** - * A single decoded wire frame: a type byte plus its opaque payload bytes. + * A single decoded wire frame: a kind byte plus its opaque payload bytes. * * @remarks * The frame layer never interprets payloads. Interpretation (JSON control @@ -12,16 +12,19 @@ import type { DaemonFrameType } from './DaemonFrameType'; * 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 type byte. + * The frame kind byte. */ - readonly type: DaemonFrameType; + readonly kind: DaemonFrameType; /** * The payload bytes. Never a view onto a larger shared buffer. */ - readonly payload: Buffer; + readonly payload: Uint8Array; } diff --git a/libraries/rush-daemon-protocol/src/DaemonFrameType.ts b/libraries/rush-daemon-protocol/src/DaemonFrameType.ts index 715ce65b56f..95a4dd1cd8c 100644 --- a/libraries/rush-daemon-protocol/src/DaemonFrameType.ts +++ b/libraries/rush-daemon-protocol/src/DaemonFrameType.ts @@ -2,7 +2,7 @@ // See LICENSE in the project root for license information. /** - * The wire frame type byte of the rushd protocol. + * The wire frame kind byte of the rushd protocol. * * @remarks * The taxonomy is fixed by the protocol specification: @@ -23,19 +23,20 @@ export enum DaemonFrameType { event = 0x05 } -const ALL_FRAME_TYPES: readonly DaemonFrameType[] = [ - DaemonFrameType.controlJson, - DaemonFrameType.logStdout, - DaemonFrameType.logStderr, - DaemonFrameType.stdin, - DaemonFrameType.event -]; +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 type. + * 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 (ALL_FRAME_TYPES as readonly number[]).includes(value); + 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 index e2788d65565..236b1a48bf0 100644 --- a/libraries/rush-daemon-protocol/src/DaemonHandshake.ts +++ b/libraries/rush-daemon-protocol/src/DaemonHandshake.ts @@ -12,22 +12,19 @@ import { isDaemonProtocolCompatible } from './DaemonProtocolVersion'; * @beta */ export function createDaemonHello(protocolVersion: IDaemonProtocolVersion): IDaemonHelloMessage { - return { kind: 'hello', protocolVersion }; + return { kind: 'hello', payload: { protocolVersion } }; } /** * Creates the `helloAck` message a server replies with when versions match. * - * @param protocolVersion - the server's own protocol version - * @param sessionId - the session identifier assigned to the connection - * * @beta */ export function createDaemonHelloAck( protocolVersion: IDaemonProtocolVersion, sessionId: string ): IDaemonHelloAckMessage { - return { kind: 'helloAck', protocolVersion, sessionId }; + return { kind: 'helloAck', payload: { protocolVersion, sessionId } }; } /** @@ -50,10 +47,13 @@ export function negotiateDaemonHello( localVersion: IDaemonProtocolVersion, sessionId: string ): DaemonHandshakeOutcome { - if (!isDaemonProtocolCompatible(localVersion, hello.protocolVersion)) { + if (!isDaemonProtocolCompatible(localVersion, hello.payload.protocolVersion)) { return { accepted: false, - error: new ProtocolVersionMismatchError(localVersion.major, hello.protocolVersion.major) + error: new ProtocolVersionMismatchError( + localVersion.major, + hello.payload.protocolVersion.major + ) }; } return { accepted: true, ack: createDaemonHelloAck(localVersion, sessionId) }; diff --git a/libraries/rush-daemon-protocol/src/DaemonProtocolError.ts b/libraries/rush-daemon-protocol/src/DaemonProtocolError.ts index 9d7d03c14b0..ad3b91a078e 100644 --- a/libraries/rush-daemon-protocol/src/DaemonProtocolError.ts +++ b/libraries/rush-daemon-protocol/src/DaemonProtocolError.ts @@ -6,17 +6,23 @@ * * @beta */ -export enum DaemonProtocolErrorCode { - /** A frame header declared a payload larger than the configured maximum. */ - frameTooLarge = 'frameTooLarge', - /** A frame header carried a type byte outside the protocol taxonomy. */ - unknownFrameType = 'unknownFrameType', - /** A frame payload was malformed for its frame type. */ - malformedPayload = 'malformedPayload', - /** A control frame did not contain a well-formed control message. */ - malformedControlMessage = 'malformedControlMessage', - /** The peer's protocol major version differs from the local one. */ - protocolVersionMismatch = 'protocolVersionMismatch' +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; } /** @@ -35,8 +41,12 @@ export class DaemonProtocolError extends Error { */ public readonly code: DaemonProtocolErrorCode; - public constructor(code: DaemonProtocolErrorCode, message: string) { - super(message); + public constructor( + code: DaemonProtocolErrorCode, + message: string, + options?: IDaemonProtocolErrorOptions + ) { + super(message, options); this.name = 'DaemonProtocolError'; this.code = code; } @@ -59,10 +69,15 @@ export class ProtocolVersionMismatchError extends DaemonProtocolError { */ public readonly actualMajor: number; - public constructor(expectedMajor: number, actualMajor: number) { + public constructor( + expectedMajor: number, + actualMajor: number, + options?: IDaemonProtocolErrorOptions + ) { super( - DaemonProtocolErrorCode.protocolVersionMismatch, - `Unsupported rushd protocol major version ${actualMajor}; this peer requires major version ${expectedMajor}.` + 'protocolVersionMismatch', + `Unsupported rushd protocol major version ${actualMajor}; this peer requires major version ${expectedMajor}.`, + options ); this.name = 'ProtocolVersionMismatchError'; this.expectedMajor = expectedMajor; diff --git a/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts b/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts index 44434d6b5cc..9b0f1ede45c 100644 --- a/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts +++ b/libraries/rush-daemon-protocol/src/DaemonRushdExtensions.ts @@ -13,11 +13,35 @@ export interface IDaemonExtensionEventPayload { readonly data: TData; } -/** Extension event name: an operation's output stream was closed. @beta */ +/** + * 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 header was displayed. @beta */ +/** + * 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 */ diff --git a/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts b/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts index 54bf8f93875..b147cec2e15 100644 --- a/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts +++ b/libraries/rush-daemon-protocol/src/DaemonVerbosityFilter.ts @@ -15,6 +15,7 @@ export interface IDaemonDiagnosticPayload { } 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 @@ -42,7 +43,7 @@ const NORMAL_TYPES: ReadonlySet = new Set([ ]); function isDiagnosticSeverity(value: unknown): value is DaemonDiagnosticSeverity { - return typeof value === 'string' && (DIAGNOSTIC_SEVERITIES as readonly string[]).includes(value); + return typeof value === 'string' && DIAGNOSTIC_SEVERITY_SET.has(value); } function readDiagnosticSeverity(payload: unknown): DaemonDiagnosticSeverity | undefined { 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/FrameDecoder.ts b/libraries/rush-daemon-protocol/src/FrameDecoder.ts index 5e393bd58ea..84910a6fab5 100644 --- a/libraries/rush-daemon-protocol/src/FrameDecoder.ts +++ b/libraries/rush-daemon-protocol/src/FrameDecoder.ts @@ -3,96 +3,94 @@ import type { IDaemonFrame } from './DaemonFrame'; import { isDaemonFrameType } from './DaemonFrameType'; -import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; +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 EMPTY_LENGTH: number = 0; -const EMPTY_BUFFER: Buffer = Buffer.alloc(EMPTY_LENGTH); 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. Payloads are copied out of the - * receive buffer, so retained frames never pin a larger slab. + * 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 { - private _pending: Buffer; - private readonly _maxPayloadBytes: number; + #pending: SegmentBuffer; + readonly #maxPayloadBytes: number; public constructor(options?: IDaemonFrameDecoderOptions) { - this._pending = EMPTY_BUFFER; - this._maxPayloadBytes = options?.maxPayloadBytes ?? DEFAULT_MAX_PAYLOAD_BYTES; + 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: Buffer): IDaemonFrame[] { - const pending: Buffer = this._pending; - this._pending = pending.length === EMPTY_LENGTH ? chunk : Buffer.concat([pending, chunk]); + /** 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(); + let frame: IDaemonFrame | undefined = this.#tryExtractFrame(); while (frame !== undefined) { frames.push(frame); - frame = this._tryExtractFrame(); + frame = this.#tryExtractFrame(); } return frames; } /** Discards any buffered partial frame. */ public reset(): void { - this._pending = EMPTY_BUFFER; + this.#pending.clear(); } - private _tryExtractFrame(): IDaemonFrame | undefined { - if (this._pending.length < FRAME_HEADER_BYTES) { + #tryExtractFrame(): IDaemonFrame | undefined { + if (this.#pending.byteLength < FRAME_HEADER_BYTES) { return undefined; } - const payloadLength: number = this._pending.readUInt32LE(LENGTH_FIELD_OFFSET); - this._assertPayloadLength(payloadLength); + const payloadLength: number = this.#readPayloadLength(); + this.#assertPayloadLength(payloadLength); const frameBytes: number = FRAME_HEADER_BYTES + payloadLength; - if (this._pending.length < frameBytes) { - return undefined; - } - return this._takeFrame(frameBytes); + 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); } - private _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(DaemonProtocolErrorCode.frameTooLarge, message); + #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); } } - private _takeFrame(frameBytes: number): IDaemonFrame { - const typeByte: number = this._pending.readUInt8(TYPE_FIELD_OFFSET); - this._assertKnownType(typeByte); - const payload: Buffer = Buffer.from(this._pending.subarray(FRAME_HEADER_BYTES, frameBytes)); - this._pending = this._pending.subarray(frameBytes); - return { type: typeByte, payload }; + #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 }; } - private _assertKnownType(typeByte: number): void { - if (!isDaemonFrameType(typeByte)) { + #assertKnownKind(kindByte: number): void { + if (!isDaemonFrameType(kindByte)) { throw new DaemonProtocolError( - DaemonProtocolErrorCode.unknownFrameType, - `Frame declared an unknown frame type byte 0x${typeByte.toString(HEX_RADIX)}.` + '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 index 1049196b5a7..472bc2212ec 100644 --- a/libraries/rush-daemon-protocol/src/FrameEncoder.ts +++ b/libraries/rush-daemon-protocol/src/FrameEncoder.ts @@ -5,38 +5,44 @@ import type { IDaemonFrame } from './DaemonFrame'; import { FRAME_HEADER_BYTES, LENGTH_FIELD_OFFSET, - PAYLOAD_OFFSET, TYPE_FIELD_OFFSET } from './FrameConstants'; +const LITTLE_ENDIAN: boolean = true; + /** - * Serializes a single frame as `[u32 LE payloadLength][u8 frameType][payload]`. + * 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 buffer, safe to retain or mutate by the caller. + * is a freshly allocated array, safe to retain or mutate by the caller. * * @beta */ -export function encodeDaemonFrame(frame: IDaemonFrame): Buffer { - const serialized: Buffer = Buffer.alloc(FRAME_HEADER_BYTES + frame.payload.length); - serialized.writeUInt32LE(frame.payload.length, LENGTH_FIELD_OFFSET); - serialized.writeUInt8(frame.type, TYPE_FIELD_OFFSET); - frame.payload.copy(serialized, PAYLOAD_OFFSET); +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 one contiguous buffer. + * Serializes a sequence of frames into an array of per-frame byte arrays, in + * wire order. * - * @param frames - the frames to serialize, 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[]): Buffer { - const parts: Buffer[] = []; +export function encodeDaemonFrames(frames: readonly IDaemonFrame[]): Uint8Array[] { + const parts: Uint8Array[] = []; for (const frame of frames) { parts.push(encodeDaemonFrame(frame)); } - return Buffer.concat(parts); + return parts; } diff --git a/libraries/rush-daemon-protocol/src/LogFrameCodec.ts b/libraries/rush-daemon-protocol/src/LogFrameCodec.ts index 91753244553..cacc6b67b59 100644 --- a/libraries/rush-daemon-protocol/src/LogFrameCodec.ts +++ b/libraries/rush-daemon-protocol/src/LogFrameCodec.ts @@ -1,14 +1,15 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. -import { DaemonProtocolError, DaemonProtocolErrorCode } from './DaemonProtocolError'; +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 UTF8: BufferEncoding = 'utf8'; +const LITTLE_ENDIAN: boolean = true; /** * An id-tagged chunk of one operation's raw output stream. @@ -24,29 +25,33 @@ export interface IDaemonLogChunk { /** * The raw stream bytes. May contain arbitrary (including non-UTF-8) content. */ - readonly chunk: Buffer; + 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): Buffer { - const idBytes: Buffer = Buffer.from(log.operationId, UTF8); +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( - DaemonProtocolErrorCode.malformedPayload, + 'malformedPayload', `Operation id is ${idBytes.length} bytes, exceeding the maximum of ${MAX_OPERATION_ID_BYTES}.` ); } - const payload: Buffer = Buffer.alloc(OPERATION_ID_LENGTH_BYTES + idBytes.length + log.chunk.length); - payload.writeUInt16LE(idBytes.length, OPERATION_ID_LENGTH_OFFSET); - idBytes.copy(payload, OPERATION_ID_LENGTH_BYTES); - log.chunk.copy(payload, OPERATION_ID_LENGTH_BYTES + idBytes.length); + 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; } @@ -58,22 +63,27 @@ export function encodeDaemonLogChunk(log: IDaemonLogChunk): Buffer { * * @beta */ -export function decodeDaemonLogChunk(payload: Buffer): IDaemonLogChunk { +export function decodeDaemonLogChunk(payload: Uint8Array): IDaemonLogChunk { if (payload.length < OPERATION_ID_LENGTH_BYTES) { throw new DaemonProtocolError( - DaemonProtocolErrorCode.malformedPayload, + 'malformedPayload', 'Log frame payload is too short to contain an operation id length.' ); } - const idLength: number = payload.readUInt16LE(OPERATION_ID_LENGTH_OFFSET); + 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( - DaemonProtocolErrorCode.malformedPayload, + 'malformedPayload', `Log frame declared an operation id of ${idLength} bytes but the payload is ${payload.length} bytes.` ); } - const operationId: string = payload.toString(UTF8, OPERATION_ID_LENGTH_BYTES, chunkOffset); - const chunk: Buffer = Buffer.from(payload.subarray(chunkOffset)); + 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 index 25391f36179..4de98d0bb1b 100644 --- a/libraries/rush-daemon-protocol/src/index.ts +++ b/libraries/rush-daemon-protocol/src/index.ts @@ -7,94 +7,45 @@ * connection handshake with version negotiation. * * @remarks - * This package is engine-agnostic and has no `rush-lib` dependency. The event - * contract currently mirrors `@rushstack/reporter`'s envelope as a placeholder - * and will reference it directly once the reporter package merges into main. - * + * 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 + 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, - DaemonProtocolErrorCode, - ProtocolVersionMismatchError -} from './DaemonProtocolError'; -export { - DAEMON_PROTOCOL_VERSION, - isDaemonProtocolCompatible, - type IDaemonProtocolVersion -} from './DaemonProtocolVersion'; -export { - DAEMON_CONTROL_MESSAGE_KINDS, - type DaemonControlMessage, - type IDaemonClientCaps, - type IDaemonHelloAckMessage, - type IDaemonHelloMessage, - type IDaemonSubscribeMessage -} from './DaemonControlMessage'; -export { validateDaemonControlMessage } from './ControlMessageValidation'; +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, - type DaemonHandshakeOutcome -} from './DaemonHandshake'; +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 { - isDaemonExtensionEventName, - isRushdExtensionEventName, - RUSHD_EXTENSION_NAMESPACE, - type DaemonExtensionEventName -} from './DaemonExtensionEventName'; +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, - type DaemonDiagnosticSeverity, - type IDaemonDiagnosticPayload -} from './DaemonVerbosityFilter'; -export { - decodeDaemonEventFrame, - encodeDaemonEventFrame, - serializeDaemonEventForSubscription -} from './DaemonEventFrameCodec'; -export type { - IDaemonActivityPayload, - IDaemonOperationRegisteredPayload, - IDaemonOperationStatusChangedPayload -} from './DaemonOperationPayloads'; -export { - RUSHD_OPERATION_HEADER, - RUSHD_OPERATION_STREAM_CLOSED, - type IDaemonExtensionEventPayload, - type IDaemonOperationHeaderPayload, - type IDaemonOperationStreamClosedPayload -} from './DaemonRushdExtensions'; +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 index 3cc92757c4a..7a73101e744 100644 --- a/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts +++ b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts @@ -3,7 +3,6 @@ import { decodeDaemonControlMessage, encodeDaemonControlMessage } from '../ControlFrameCodec'; import type { DaemonControlMessage } from '../DaemonControlMessage'; -import { DaemonProtocolErrorCode } from '../DaemonProtocolError'; import { DAEMON_PROTOCOL_VERSION } from '../DaemonProtocolVersion'; import { captureProtocolError } from './TestVectors'; @@ -12,13 +11,13 @@ const UPTIME_MS: number = 42; const COLUMNS: number = 120; const MESSAGES: readonly DaemonControlMessage[] = [ - { kind: 'hello', protocolVersion: DAEMON_PROTOCOL_VERSION }, - { kind: 'helloAck', protocolVersion: DAEMON_PROTOCOL_VERSION, sessionId: 's-1' }, - { kind: 'subscribe', caps: { isTTY: true, verbosity: 'verbose', columns: COLUMNS } }, - { kind: 'unsubscribe' }, - { kind: 'ping' }, - { kind: 'pong', uptimeMs: UPTIME_MS }, - { kind: 'error', code: DaemonProtocolErrorCode.malformedPayload, message: 'bad' } + { 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', () => { @@ -31,27 +30,35 @@ it('rejects a non-JSON control payload', () => { const error: ReturnType = captureProtocolError(() => decodeDaemonControlMessage(Buffer.from('not-json')) ); - expect(error.code).toBe(DaemonProtocolErrorCode.malformedControlMessage); + 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"}')) + decodeDaemonControlMessage(Buffer.from('{"kind":"teleport","payload":{}}')) ); - expect(error.code).toBe(DaemonProtocolErrorCode.malformedControlMessage); + expect(error.code).toBe('malformedControlMessage'); }); -it('rejects a hello without a version', () => { +it('rejects a control message without a payload object', () => { const error: ReturnType = captureProtocolError(() => decodeDaemonControlMessage(Buffer.from('{"kind":"hello"}')) ); - expect(error.code).toBe(DaemonProtocolErrorCode.malformedControlMessage); + 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","caps":{"isTTY":true,"verbosity":"loud"}}'; + const json: string = '{"kind":"subscribe","payload":{"isTTY":true,"verbosity":"loud"}}'; const error: ReturnType = captureProtocolError(() => decodeDaemonControlMessage(Buffer.from(json)) ); - expect(error.code).toBe(DaemonProtocolErrorCode.malformedControlMessage); + expect(error.code).toBe('malformedControlMessage'); }); 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 index e9b3d067b9c..0a07e3cd330 100644 --- a/libraries/rush-daemon-protocol/src/test/FrameCodec.test.ts +++ b/libraries/rush-daemon-protocol/src/test/FrameCodec.test.ts @@ -3,7 +3,6 @@ import type { IDaemonFrame } from '../DaemonFrame'; import { DaemonFrameType } from '../DaemonFrameType'; -import { DaemonProtocolErrorCode } from '../DaemonProtocolError'; import { FRAME_HEADER_BYTES, LENGTH_FIELD_OFFSET, TYPE_FIELD_OFFSET } from '../FrameConstants'; import { DaemonFrameDecoder } from '../FrameDecoder'; import { encodeDaemonFrame, encodeDaemonFrames } from '../FrameEncoder'; @@ -17,7 +16,7 @@ import { captureProtocolError } from './TestVectors'; -const ALL_TYPES: readonly DaemonFrameType[] = [ +const ALL_KINDS: readonly DaemonFrameType[] = [ DaemonFrameType.controlJson, DaemonFrameType.logStdout, DaemonFrameType.logStderr, @@ -25,40 +24,57 @@ const ALL_TYPES: readonly DaemonFrameType[] = [ DaemonFrameType.event ]; const TINY_LIMIT: number = 8; -const UNKNOWN_TYPE_BYTE: number = 0x7e; +const UNKNOWN_KIND_BYTE: number = 0x7e; const FIRST_SPLIT: number = 1; -function roundTrip(type: DaemonFrameType, payload: Buffer): IDaemonFrame { - const frames: IDaemonFrame[] = new DaemonFrameDecoder().push(encodeDaemonFrame({ type, payload })); +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]; } -it('round-trips every frame type with non-UTF-8 payloads', () => { - for (const type of ALL_TYPES) { - const frame: IDaemonFrame = roundTrip(type, NON_UTF8_BYTES); - expect(frame.type).toBe(type); - expect(frame.payload.equals(NON_UTF8_BYTES)).toBe(true); +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 = { type: DaemonFrameType.logStdout, payload: Buffer.from('a') }; - const second: IDaemonFrame = { type: DaemonFrameType.logStderr, payload: NON_UTF8_BYTES }; - const frames: IDaemonFrame[] = new DaemonFrameDecoder().push(encodeDaemonFrames([first, second])); - expect(frames).toHaveLength(PAIR_COUNT); - expect(frames[FIRST_INDEX].type).toBe(DaemonFrameType.logStdout); - expect(frames[SINGLE_COUNT].payload.equals(NON_UTF8_BYTES)).toBe(true); + 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: Buffer = encodeDaemonFrame({ type: DaemonFrameType.stdin, payload: NON_UTF8_BYTES }); + 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); - expect(frames[FIRST_INDEX].payload.equals(NON_UTF8_BYTES)).toBe(true); + expectBytesEqual(frames[FIRST_INDEX].payload, NON_UTF8_BYTES); } }); @@ -68,13 +84,13 @@ it('rejects an oversized payload declaration', () => { 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(DaemonProtocolErrorCode.frameTooLarge); + expect(error.code).toBe('frameTooLarge'); }); -it('rejects an unknown frame type byte', () => { +it('rejects an unknown frame kind byte', () => { const header: Buffer = Buffer.alloc(FRAME_HEADER_BYTES); - header.writeUInt8(UNKNOWN_TYPE_BYTE, TYPE_FIELD_OFFSET); + header.writeUInt8(UNKNOWN_KIND_BYTE, TYPE_FIELD_OFFSET); const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); const error: ReturnType = captureProtocolError(() => decoder.push(header)); - expect(error.code).toBe(DaemonProtocolErrorCode.unknownFrameType); + 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 index ce1dce8c925..da22780c1cf 100644 --- a/libraries/rush-daemon-protocol/src/test/Handshake.test.ts +++ b/libraries/rush-daemon-protocol/src/test/Handshake.test.ts @@ -3,7 +3,7 @@ import { createDaemonHello, negotiateDaemonHello } from '../DaemonHandshake'; import type { DaemonHandshakeOutcome } from '../DaemonHandshake'; -import { DaemonProtocolErrorCode, ProtocolVersionMismatchError } from '../DaemonProtocolError'; +import { ProtocolVersionMismatchError } from '../DaemonProtocolError'; import { DAEMON_PROTOCOL_VERSION } from '../DaemonProtocolVersion'; const NEWER_MAJOR: number = 1; @@ -19,8 +19,8 @@ it('accepts a matching major version', () => { const outcome: DaemonHandshakeOutcome = negotiateDaemonHello(hello, DAEMON_PROTOCOL_VERSION, SESSION_ID); expect(outcome.accepted).toBe(true); if (outcome.accepted) { - expect(outcome.ack.sessionId).toBe(SESSION_ID); - expect(outcome.ack.protocolVersion).toEqual(DAEMON_PROTOCOL_VERSION); + expect(outcome.ack.payload.sessionId).toBe(SESSION_ID); + expect(outcome.ack.payload.protocolVersion).toEqual(DAEMON_PROTOCOL_VERSION); } }); @@ -33,8 +33,18 @@ it('rejects a mismatched major version with a typed error', () => { expect(outcome.accepted).toBe(false); if (!outcome.accepted) { expect(outcome.error).toBeInstanceOf(ProtocolVersionMismatchError); - expect(outcome.error.code).toBe(DaemonProtocolErrorCode.protocolVersionMismatch); + 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 index c389af7af2f..bab5512c8c7 100644 --- a/libraries/rush-daemon-protocol/src/test/LogFrameCodec.test.ts +++ b/libraries/rush-daemon-protocol/src/test/LogFrameCodec.test.ts @@ -1,8 +1,8 @@ // 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 { DaemonProtocolErrorCode } from '../DaemonProtocolError'; import { MAX_OPERATION_ID_BYTES } from '../FrameConstants'; import { DaemonFrameDecoder } from '../FrameDecoder'; import { encodeDaemonFrame } from '../FrameEncoder'; @@ -13,63 +13,86 @@ import { FIRST_INDEX, NON_UTF8_BYTES, SINGLE_COUNT, captureProtocolError } from 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_ID_BYTES: number = 2; -const STREAM_PARITY: number = 2; +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'); - expect(decoded.chunk.equals(NON_UTF8_BYTES)).toBe(true); + 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 firstA: Buffer = Buffer.from('a1'); - const secondA: Buffer = Buffer.from('a2'); - const firstB: Buffer = Buffer.from('b1'); - const wire: Buffer = Buffer.concat( - [firstA, firstB, secondA].map((chunk: Buffer, index: number) => - encodeDaemonFrame({ - type: index % STREAM_PARITY === FIRST_INDEX ? DaemonFrameType.logStdout : DaemonFrameType.logStderr, - payload: encodeDaemonLogChunk({ - operationId: index % STREAM_PARITY === FIRST_INDEX ? 'op-a' : 'op-b', - chunk - }) - }) - )); const decoder: DaemonFrameDecoder = new DaemonFrameDecoder(); - const stdout: string[] = []; - const stderr: string[] = []; - for (const frame of decoder.push(wire)) { - const log: ReturnType = decodeDaemonLogChunk(frame.payload); - const sink: string[] = log.operationId === 'op-a' ? stdout : stderr; - sink.push(log.chunk.toString()); + const sinks: Map = new Map([ + ['op-a', []], + ['op-b', []] + ]); + for (const step of INTERLEAVE_PLAN) { + routeStep(decoder, step, sinks); } - expect(stdout).toEqual(['a1', 'a2']); - expect(stderr).toEqual(['b1']); + 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: Buffer.alloc(FIRST_INDEX) }) + encodeDaemonLogChunk({ operationId, chunk: new Uint8Array(EMPTY_BYTES) }) ); - expect(error.code).toBe(DaemonProtocolErrorCode.malformedPayload); + expect(error.code).toBe('malformedPayload'); }); it('rejects a truncated log payload', () => { const error: ReturnType = captureProtocolError(() => - decodeDaemonLogChunk(Buffer.alloc(SHORT_PAYLOAD_BYTES)) + decodeDaemonLogChunk(new Uint8Array(SHORT_PAYLOAD_BYTES)) ); - expect(error.code).toBe(DaemonProtocolErrorCode.malformedPayload); + expect(error.code).toBe('malformedPayload'); }); it('rejects a log payload whose id prefix overruns it', () => { - const payload: Buffer = Buffer.alloc(TRUNCATED_ID_BYTES + SHORT_PAYLOAD_BYTES); + 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(DaemonProtocolErrorCode.malformedPayload); + expect(error.code).toBe('malformedPayload'); }); diff --git a/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts b/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts index b9a21792159..c4ce477708c 100644 --- a/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts +++ b/libraries/rush-daemon-protocol/src/test/VerbosityFilter.test.ts @@ -49,9 +49,10 @@ it('never filters required events regardless of verbosity', () => { it('serialization returns undefined for filtered subscriptions and bytes otherwise', () => { const envelope: IDaemonEventEnvelope = createTestEnvelope({ type: 'operationStatusChanged' }); expect(serializeDaemonEventForSubscription('quiet', envelope)).toBeUndefined(); - const serialized: Buffer | undefined = serializeDaemonEventForSubscription('verbose', envelope); + const serialized: Uint8Array | undefined = serializeDaemonEventForSubscription('verbose', envelope); expect(serialized).toBeDefined(); - expect(JSON.parse(serialized?.toString() ?? '{}')).toMatchObject({ type: 'operationStatusChanged' }); + 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', () => { diff --git a/libraries/rush-daemon-protocol/tsconfig.json b/libraries/rush-daemon-protocol/tsconfig.json index 9a79fa4af11..6a778ab9aff 100644 --- a/libraries/rush-daemon-protocol/tsconfig.json +++ b/libraries/rush-daemon-protocol/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", "compilerOptions": { - "target": "ES2019" + "target": "ES2022", + "lib": ["ES2022"] } } diff --git a/libraries/rush-daemon-transport/LICENSE b/libraries/rush-daemon-transport/LICENSE index bd4533ad992..56ad0fec060 100644 --- a/libraries/rush-daemon-transport/LICENSE +++ b/libraries/rush-daemon-transport/LICENSE @@ -1,4 +1,4 @@ -@rushstack/operation-graph +@rushstack/rush-daemon-transport Copyright (c) Microsoft Corporation. All rights reserved. diff --git a/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts b/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts index f4ecbffd0f2..dce6c5a545d 100644 --- a/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts +++ b/libraries/rush-daemon-transport/src/DaemonFrameConnection.ts @@ -9,57 +9,53 @@ 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 domain socket - * or Windows named pipe). - * +/** One end of a framed rushd connection over a `net` socket (Unix socket or named pipe). * @remarks - * Incoming bytes are decoded into frames in wire order. Outgoing frames are - * written with backpressure: {@link DaemonFrameConnection.sendFrameAsync} only - * resolves once the socket has accepted the bytes (awaiting `drain` when the - * kernel buffer is full), so a slow consumer cannot lose frames. - * - * @beta - */ + * 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; + 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; - this._decoder = new DaemonFrameDecoder(); socket.on('data', (chunk: Buffer) => this._onData(chunk)); socket.on('error', (error: Error) => this._onError(error)); socket.on('close', () => this._onClose()); } - /** Registers the single frame handler invoked for each decoded frame. */ + /** 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, if any. */ + /** 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} with code `transportClosed` when the - * socket closes (or errors) before the bytes are accepted. - */ + /** 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(); - const canContinue: boolean = this._socket.write(encodeDaemonFrame(frame)); - if (!canContinue) { + 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( @@ -69,18 +65,31 @@ export class DaemonFrameConnection { } } - /** Half-closes the writable side and releases the socket. */ - public async closeAsync(): Promise { - this._socket.end(); - this._socket.destroySoon(); - } - private _onData(chunk: Buffer): void { - for (const frame of this._decoder.push(chunk)) { + 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; } diff --git a/libraries/rush-daemon-transport/src/DaemonListener.ts b/libraries/rush-daemon-transport/src/DaemonListener.ts index 4c1393ce067..f4b17aefb43 100644 --- a/libraries/rush-daemon-transport/src/DaemonListener.ts +++ b/libraries/rush-daemon-transport/src/DaemonListener.ts @@ -15,6 +15,7 @@ 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). */ @@ -25,15 +26,12 @@ export interface IDaemonListenerOptions { readonly onConnection: (connection: DaemonFrameConnection) => void; } -/** - * The daemon-side framed listener bound to a workspace's socket/pipe path. - * +/** 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 - */ + * @beta */ export class DaemonFrameListener { private readonly _server: net.Server; private readonly _paths: IDaemonPaths; @@ -51,6 +49,8 @@ export class DaemonFrameListener { }); 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, diff --git a/libraries/rush-daemon-transport/src/DaemonLockfile.ts b/libraries/rush-daemon-transport/src/DaemonLockfile.ts index 918c5f4905f..f81fbd1ef76 100644 --- a/libraries/rush-daemon-transport/src/DaemonLockfile.ts +++ b/libraries/rush-daemon-transport/src/DaemonLockfile.ts @@ -13,23 +13,7 @@ const NO_SIGNAL: number = 0; const DIR_MODE: number = 0o700; const FILE_MODE: number = 0o600; -/** - * 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 }); - } -} - -/** - * The on-disk contents of a daemon PID/lock file. - * - * @beta - */ +/** The on-disk contents of a daemon PID/lock file. @beta */ export interface IDaemonLockfile { /** The process id of the daemon. */ readonly pid: number; @@ -41,11 +25,15 @@ export interface IDaemonLockfile { readonly socketPath: string; } -/** - * Returns `true` when a process with `pid` exists and is signalable. - * - * @beta - */ +/** 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); @@ -55,36 +43,32 @@ export function isDaemonProcessAlive(pid: number): boolean { } } -/** - * Reads and parses a daemon lockfile, or returns `undefined` when absent or unreadable. - * - * @beta - */ +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 { - return JSON.parse(fs.readFileSync(lockfilePath, UTF8)) as IDaemonLockfile; + parsed = JSON.parse(fs.readFileSync(lockfilePath, UTF8)); } catch { return undefined; } + return isDaemonLockfileRecord(parsed) ? parsed : undefined; } -/** - * Atomically-ish writes the daemon lockfile, creating the runtime directory - * (mode `0700`) when needed. - * - * @beta - */ +/** 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. Missing - * files are ignored so callers can invoke this idempotently during reclaim. - * - * @beta - */ +/** 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 { 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 index 34399fc8069..e70d12f0f22 100644 --- a/libraries/rush-daemon-transport/src/DaemonReclaim.ts +++ b/libraries/rush-daemon-transport/src/DaemonReclaim.ts @@ -1,34 +1,63 @@ // 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 { - type IDaemonLockfile, 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 (stale - * socket file whose lockfile PID is gone and which refuses connections). + * Reclaims the socket/pipe path when it is held by a dead daemon. * * @remarks - * Stale detection is deliberately two-factor: the lockfile PID must be dead - * *and* a connect probe must fail, so a daemon that is alive but momentarily - * unresponsive (for example mid-startup) is never reclaimed underneath itself. + * 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. + * live (or plausibly live) daemon owns the path, or when another starter holds + * the reclaim lock. * * @beta */ export async function reclaimStaleDaemonAsync(paths: IDaemonPaths): Promise { - const lockfile: IDaemonLockfile | undefined = readDaemonLockfile(paths.lockfilePath); - if (isLockfilePidAlive(lockfile)) { + // 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); @@ -38,7 +67,7 @@ export async function reclaimStaleDaemonAsync(paths: IDaemonPaths): Promise): boolean { return lockfile !== undefined && isDaemonProcessAlive(lockfile.pid); } 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/index.ts b/libraries/rush-daemon-transport/src/index.ts index 497a821eec0..4674d34b024 100644 --- a/libraries/rush-daemon-transport/src/index.ts +++ b/libraries/rush-daemon-transport/src/index.ts @@ -25,6 +25,7 @@ export { 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'; diff --git a/libraries/rush-daemon-transport/src/test/Backpressure.test.ts b/libraries/rush-daemon-transport/src/test/Backpressure.test.ts index c6c9e0395b8..0538b2f6869 100644 --- a/libraries/rush-daemon-transport/src/test/Backpressure.test.ts +++ b/libraries/rush-daemon-transport/src/test/Backpressure.test.ts @@ -22,7 +22,7 @@ async function sendLargeFramesAsync(serverSide: Promise): const server: DaemonFrameConnection = await serverSide; for (let index: number = FIRST_INDEX; index < FRAME_COUNT; index++) { await server.sendFrameAsync({ - type: DaemonFrameType.logStdout, + kind: DaemonFrameType.logStdout, payload: Buffer.alloc(MEBIBYTE, FILL_BYTE) }); } @@ -35,7 +35,7 @@ it('delivers every frame intact when the writer outpaces the reader', async () = const pair: ITestDaemonPair = await startTestDaemonPair(paths); try { pair.client.onFrame((frame: IDaemonFrame) => { - received.push(frame.payload); + received.push(Buffer.from(frame.payload)); if (received.length === FRAME_COUNT) { allReceived.resolve(); } 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/HandshakeOverWire.test.ts b/libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.ts index 6cea26ea1dc..aab9a836c57 100644 --- a/libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.ts +++ b/libraries/rush-daemon-transport/src/test/HandshakeOverWire.test.ts @@ -9,7 +9,8 @@ import { encodeDaemonControlMessage, negotiateDaemonHello } from '@rushstack/rush-daemon-protocol'; -import type { IDaemonFrame } from '@rushstack/rush-daemon-protocol'; +import type { IDaemonFrame , + createDaemonHelloAck} from '@rushstack/rush-daemon-protocol'; import type { DaemonFrameConnection } from '../DaemonFrameConnection'; import type { IDaemonPaths } from '../DaemonPaths'; @@ -21,7 +22,7 @@ const NEWER_MAJOR: number = 1; function helloFrame(major: number): IDaemonFrame { return { - type: DaemonFrameType.controlJson, + kind: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage( createDaemonHello({ major, minor: DAEMON_PROTOCOL_VERSION.minor }) ) @@ -40,11 +41,15 @@ function replyToHello(server: DaemonFrameConnection, frame: IDaemonFrame): void DAEMON_PROTOCOL_VERSION, 'session-e2e' ); - const reply = outcome.accepted - ? outcome.ack - : { kind: 'error' as const, code: outcome.error.code, message: outcome.error.message }; + const reply: ReturnType | ReturnType = + outcome.accepted + ? outcome.ack + : { + kind: 'error' as const, + payload: { code: outcome.error.code, message: outcome.error.message } + }; void server - .sendFrameAsync({ type: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage(reply) }) + .sendFrameAsync({ kind: DaemonFrameType.controlJson, payload: encodeDaemonControlMessage(reply) }) .then(() => server.closeAsync()); } @@ -70,14 +75,16 @@ async function runHandshakeAsync(major: number): Promise 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(reply.sessionId).toBe('session-e2e'); + 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(reply.code).toBe('protocolVersionMismatch'); + expect(payload.code).toBe('protocolVersionMismatch'); }); diff --git a/libraries/rush-daemon-transport/src/test/SocketExchange.test.ts b/libraries/rush-daemon-transport/src/test/SocketExchange.test.ts index 736624e703d..df726ec163a 100644 --- a/libraries/rush-daemon-transport/src/test/SocketExchange.test.ts +++ b/libraries/rush-daemon-transport/src/test/SocketExchange.test.ts @@ -32,10 +32,10 @@ it('exchanges a frame over the workspace socket with a written lockfile', async serverSide.onFrame((frame: IDaemonFrame) => { void serverSide.sendFrameAsync(frame); }); - await client.sendFrameAsync({ type: DaemonFrameType.logStdout, payload: BINARY_PAYLOAD }); + await client.sendFrameAsync({ kind: DaemonFrameType.logStdout, payload: BINARY_PAYLOAD }); const reply: IDaemonFrame = await echoed.promise; - expect(reply.type).toBe(DaemonFrameType.logStdout); - expect(reply.payload.equals(BINARY_PAYLOAD)).toBe(true); + 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(); diff --git a/libraries/rush-daemon-transport/tsconfig.json b/libraries/rush-daemon-transport/tsconfig.json index 9a79fa4af11..6a778ab9aff 100644 --- a/libraries/rush-daemon-transport/tsconfig.json +++ b/libraries/rush-daemon-transport/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", "compilerOptions": { - "target": "ES2019" + "target": "ES2022", + "lib": ["ES2022"] } } diff --git a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts index dc9bad574ae..cda9311ebb4 100644 --- a/libraries/rush-lib/src/logic/operations/OperationEventSink.ts +++ b/libraries/rush-lib/src/logic/operations/OperationEventSink.ts @@ -7,8 +7,7 @@ import type { IOperationExecutionResult } from './IOperationExecutionResult'; import type { OperationStatus } from './OperationStatus'; /** - * Provenance of a status line emitted via - * {@link IOperationGraphEventSink.onActivity}. + * Provenance of a status line emitted via the sink's activity callback. * * @internal */ diff --git a/libraries/rush-terminal-renderer/LICENSE b/libraries/rush-terminal-renderer/LICENSE index bd4533ad992..42ac55381f9 100644 --- a/libraries/rush-terminal-renderer/LICENSE +++ b/libraries/rush-terminal-renderer/LICENSE @@ -1,4 +1,4 @@ -@rushstack/operation-graph +@rushstack/rush-terminal-renderer Copyright (c) Microsoft Corporation. All rights reserved. diff --git a/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts b/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts index d18ed93ab68..cf024c7c033 100644 --- a/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts +++ b/libraries/rush-terminal-renderer/src/DaemonRendererHost.ts @@ -23,6 +23,8 @@ 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. @@ -61,13 +63,16 @@ export class DaemonRendererHost { } /** Feeds one decoded `0x02`/`0x03` log chunk into the collator. */ - public handleLogChunk(operationId: string, stream: 'stdout' | 'stderr', chunk: Buffer): void { + 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.toString('utf8') }); + this._streams.writeChunk(operationId, { + kind: toChunkKind(stream), + text: CHUNK_DECODER.decode(chunk) + }); } /** Flushes and closes the renderer. */ diff --git a/libraries/rush-terminal-renderer/tsconfig.json b/libraries/rush-terminal-renderer/tsconfig.json index 9a79fa4af11..6a778ab9aff 100644 --- a/libraries/rush-terminal-renderer/tsconfig.json +++ b/libraries/rush-terminal-renderer/tsconfig.json @@ -2,6 +2,7 @@ "extends": "./node_modules/local-node-rig/profiles/default/tsconfig-base.json", "compilerOptions": { - "target": "ES2019" + "target": "ES2022", + "lib": ["ES2022"] } } From 90d32d6c3155dfd07373e2840494e9d2359160d2 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 21:23:22 +0000 Subject: [PATCH 3/8] [rushd] Fix CI: annotate literal lists to clear typedef warnings rush retest runs with warnings-as-failures; the reviewer-requested as-const literal lists tripped the friendly-locals @typescript-eslint/typedef rule (variableDeclaration). Annotate DAEMON_EVENT_TYPES and DAEMON_CONTROL_MESSAGE_KINDS with explicit literal-tuple types and derive the unions from them, preserving the single-source-of-truth invariant (adding a member requires updating the tuple annotation, which the compiler enforces via the derived union) with zero lint warnings. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- .../reviews/api/rush-daemon-protocol.api.md | 40 ++++++++++++---- .../src/DaemonControlMessage.ts | 20 ++++---- .../src/DaemonEventType.ts | 47 +++++++++++++------ 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/common/reviews/api/rush-daemon-protocol.api.md b/common/reviews/api/rush-daemon-protocol.api.md index ba0929455cf..54d3ad5bfed 100644 --- a/common/reviews/api/rush-daemon-protocol.api.md +++ b/common/reviews/api/rush-daemon-protocol.api.md @@ -14,10 +14,34 @@ export function createDaemonHello(protocolVersion: IDaemonProtocolVersion): IDae export function createDaemonHelloAck(protocolVersion: IDaemonProtocolVersion, sessionId: string): IDaemonHelloAckMessage; // @beta -export const DAEMON_CONTROL_MESSAGE_KINDS: readonly DaemonControlMessageKind[]; - -// @beta -export const DAEMON_EVENT_TYPES: readonly DaemonEventType[]; +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; @@ -25,10 +49,8 @@ export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion; // @beta export type DaemonControlMessage = IDaemonHelloMessage | IDaemonHelloAckMessage | IDaemonSubscribeMessage | IDaemonUnsubscribeMessage | IDaemonPingMessage | IDaemonPongMessage | IDaemonErrorMessage; -// Warning: (ae-forgotten-export) The symbol "CONTROL_KIND_LIST" needs to be exported by the entry point index.d.ts -// // @beta -export type DaemonControlMessageKind = (typeof CONTROL_KIND_LIST)[number]; +export type DaemonControlMessageKind = (typeof DAEMON_CONTROL_MESSAGE_KINDS)[number]; // @beta export type DaemonDiagnosticSeverity = 'debug' | 'info' | 'warning' | 'error'; @@ -39,10 +61,8 @@ export type DaemonEmptyPayload = Record; // @beta export type DaemonEventPrivacy = 'public' | 'local-sensitive' | 'secret'; -// Warning: (ae-forgotten-export) The symbol "EVENT_TYPE_LIST" needs to be exported by the entry point index.d.ts -// // @beta -export type DaemonEventType = (typeof EVENT_TYPE_LIST)[number]; +export type DaemonEventType = (typeof DAEMON_EVENT_TYPES)[number]; // @beta export type DaemonExtensionEventName = string; diff --git a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts index 48f24aa98e7..36b592b5e3b 100644 --- a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts +++ b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts @@ -73,7 +73,13 @@ export type DaemonControlMessage = | IDaemonPongMessage | IDaemonErrorMessage; -const CONTROL_KIND_LIST = [ +/** + * 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', @@ -81,18 +87,12 @@ const CONTROL_KIND_LIST = [ 'ping', 'pong', 'error' -] as const; +] = ['hello', 'helloAck', 'subscribe', 'unsubscribe', 'ping', 'pong', 'error']; /** The union of control message `kind` discriminants, derived from the list. @beta */ -export type DaemonControlMessageKind = (typeof CONTROL_KIND_LIST)[number]; - -/** - * The runtime list of control message `kind` discriminants (single source of truth). - * @beta - */ -export const DAEMON_CONTROL_MESSAGE_KINDS: readonly DaemonControlMessageKind[] = CONTROL_KIND_LIST; +export type DaemonControlMessageKind = (typeof DAEMON_CONTROL_MESSAGE_KINDS)[number]; -const CONTROL_KIND_SET: ReadonlySet = new Set(CONTROL_KIND_LIST); +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 { diff --git a/libraries/rush-daemon-protocol/src/DaemonEventType.ts b/libraries/rush-daemon-protocol/src/DaemonEventType.ts index 77a80f09b6a..78362e406d0 100644 --- a/libraries/rush-daemon-protocol/src/DaemonEventType.ts +++ b/libraries/rush-daemon-protocol/src/DaemonEventType.ts @@ -14,7 +14,32 @@ * * @beta */ -const EVENT_TYPE_LIST = [ +/** + * 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', @@ -30,29 +55,21 @@ const EVENT_TYPE_LIST = [ 'artifactAvailable', 'commandResult', 'extension' -] as const; +]; /** * The closed set of core event type identifiers carried by `0x05` event frames. * * @remarks - * Derived from the canonical list, so the list and the type can never drift - * apart. 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 EVENT_TYPE_LIST)[number]; - -/** - * The runtime list of every core event type, in canonical order. + * 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 const DAEMON_EVENT_TYPES: readonly DaemonEventType[] = EVENT_TYPE_LIST; +export type DaemonEventType = (typeof DAEMON_EVENT_TYPES)[number]; -const DAEMON_EVENT_TYPE_SET: ReadonlySet = new Set(DAEMON_EVENT_TYPES); +const DAEMON_EVENT_TYPE_SET: ReadonlySet = new Set(DAEMON_EVENT_TYPES); /** * Returns `true` when `value` is a core event type identifier. From 84ed729155fd1adcec5dd9548b2440106d8a3d12 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 21:38:42 +0000 Subject: [PATCH 4/8] chore: regenerate README package table (repo-toolbox readme) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9de0b29238b..42c57f7f2ab 100644 --- a/README.md +++ b/README.md @@ -100,11 +100,11 @@ These GitHub repositories provide supplementary resources for Rush Stack: | [/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-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/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) | @@ -229,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. | From 0eec8710af0880dc7cd67fa05bcdcb5b638e756e Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 22:07:09 +0000 Subject: [PATCH 5/8] [rushd] Fix e2e golden comparison on Windows (OS newline normalization) The renderer pipeline normalizes newlines to the OS default (CRLF on Windows) via colorsNewlinesTransform, but the e2e test sink compared the raw LF-carrying chunks, so the byte-parity assertions failed only on Windows. Normalize the captured golden to the OS newline before comparing so the test is platform-correct. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts index 82e43ff7e2c..5b6f1968a86 100644 --- a/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts +++ b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts @@ -28,11 +28,16 @@ export class TestWritable extends TerminalWritable { } } +const OS_NEWLINE: string = process.platform === 'win32' ? '\r\n' : '\n'; +const LF: string = '\n'; + function collectByKind(chunks: readonly ITerminalChunk[], kind: TerminalChunkKind): string { return chunks .filter((chunk: ITerminalChunk) => chunk.kind === kind) .map((chunk: ITerminalChunk) => chunk.text) - .join(''); + .join('') + .split(LF) + .join(OS_NEWLINE); } /** An in-memory renderer terminal (client side). */ From 5dad76bed6b9c3b9f3d79e4de471ff39240fe9a5 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 22:32:52 +0000 Subject: [PATCH 6/8] [rushd] Fix e2e golden newline normalization to not double-apply CRLF The previous OS-newline normalization mapped every LF to CRLF, producing CRCRLF on Windows for text that already carried CRLF. Normalize only lone-LF newlines (leave existing CRLF intact) so the golden comparison is correct on both platforms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- .../src/test/TestWritable.ts | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts index 5b6f1968a86..4158619bd45 100644 --- a/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts +++ b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts @@ -28,16 +28,31 @@ export class TestWritable extends TerminalWritable { } } -const OS_NEWLINE: string = process.platform === 'win32' ? '\r\n' : '\n'; +const WINDOWS_PLATFORM: string = 'win32'; +const CARRIAGE_RETURN: string = '\r'; const LF: string = '\n'; +// Normalize lone-LF newlines to the OS default (the engine's pipeline emits OS +// newlines to the real console). Already-CRLF sequences are left intact so we +// never produce CRCRLF on Windows. +function toOsNewlines(text: string): string { + if (process.platform !== WINDOWS_PLATFORM) { + return text; + } + return text.split(LF).map(appendCarriageReturnUnlessPresent).join(LF); +} + +function appendCarriageReturnUnlessPresent(part: string): string { + return part.endsWith(CARRIAGE_RETURN) ? part : `${part}${CARRIAGE_RETURN}`; +} + function collectByKind(chunks: readonly ITerminalChunk[], kind: TerminalChunkKind): string { - return chunks - .filter((chunk: ITerminalChunk) => chunk.kind === kind) - .map((chunk: ITerminalChunk) => chunk.text) - .join('') - .split(LF) - .join(OS_NEWLINE); + return toOsNewlines( + chunks + .filter((chunk: ITerminalChunk) => chunk.kind === kind) + .map((chunk: ITerminalChunk) => chunk.text) + .join('') + ); } /** An in-memory renderer terminal (client side). */ From 432229a65e93e64a982ede203284af214376a984 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 22:56:20 +0000 Subject: [PATCH 7/8] [rushd] Make e2e golden newline normalization idempotent Collapse existing CRLF to LF before re-applying the OS newline, so the golden comparison is correct on Windows (no CRCRLF) and a no-op on POSIX. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- .../rushd-wire-e2e-test/src/test/TestWritable.ts | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts index 4158619bd45..e3ae58c602f 100644 --- a/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts +++ b/build-tests/rushd-wire-e2e-test/src/test/TestWritable.ts @@ -29,21 +29,17 @@ export class TestWritable extends TerminalWritable { } const WINDOWS_PLATFORM: string = 'win32'; -const CARRIAGE_RETURN: string = '\r'; +const CRLF: string = '\r\n'; const LF: string = '\n'; -// Normalize lone-LF newlines to the OS default (the engine's pipeline emits OS -// newlines to the real console). Already-CRLF sequences are left intact so we -// never produce CRCRLF on Windows. +// 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(LF).map(appendCarriageReturnUnlessPresent).join(LF); -} - -function appendCarriageReturnUnlessPresent(part: string): string { - return part.endsWith(CARRIAGE_RETURN) ? part : `${part}${CARRIAGE_RETURN}`; + return text.split(CRLF).join(LF).split(LF).join(CRLF); } function collectByKind(chunks: readonly ITerminalChunk[], kind: TerminalChunkKind): string { From 821ac769e9544962a38288af84677901b2679758 Mon Sep 17 00:00:00 2001 From: TheLarkInn Date: Fri, 14 Aug 2026 23:25:56 +0000 Subject: [PATCH 8/8] [rushd] Emit OS newline for renderer global activity lines The legacy collated pipeline normalizes to OS newlines via TextRewriterTransform(OsDefault), but the renderer wrote global activityChanged lines with a raw LF, producing mixed LF/CRLF output on Windows. Write the client OS newline instead so global status lines and collated blocks are byte-consistent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 47388e8f-8d41-4ca4-819b-688ea6c510a2 --- .../rush-terminal-renderer/src/LegacyCollatedRenderer.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts b/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts index f8187f63907..85b86607524 100644 --- a/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts +++ b/libraries/rush-terminal-renderer/src/LegacyCollatedRenderer.ts @@ -1,6 +1,8 @@ // 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'; @@ -46,7 +48,10 @@ export class LegacyCollatedRenderer implements IDaemonRenderer { } private _writeLine(text: string): void { - this._terminal?.write(`${text}\n`, 'stdout'); + // 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} */