diff --git a/common/changes/@rushstack/rush-daemon-protocol/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json b/common/changes/@rushstack/rush-daemon-protocol/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json
new file mode 100644
index 0000000000..f75faa8561
--- /dev/null
+++ b/common/changes/@rushstack/rush-daemon-protocol/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "packageName": "@rushstack/rush-daemon-protocol",
+ "comment": "Include daemon and protocol version metadata in pong control messages.",
+ "type": "minor"
+ }
+ ],
+ "packageName": "@rushstack/rush-daemon-protocol",
+ "email": "mojazayeri@users.noreply.github.com"
+}
diff --git a/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json b/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json
new file mode 100644
index 0000000000..10842acf1e
--- /dev/null
+++ b/common/changes/@rushstack/rush-daemon/mojazayeri-rush-daemon-host-bootstrap_2026-08-17-23-30.json
@@ -0,0 +1,11 @@
+{
+ "changes": [
+ {
+ "packageName": "@rushstack/rush-daemon",
+ "comment": "Add the rushd executable and workspace-keyed daemon host bootstrap with handshake, liveness, readiness, and clean shutdown lifecycle.",
+ "type": "minor"
+ }
+ ],
+ "packageName": "@rushstack/rush-daemon",
+ "email": "mojazayeri@users.noreply.github.com"
+}
diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json
index 772dfea492..b8fdee53a1 100644
--- a/common/config/rush/browser-approved-packages.json
+++ b/common/config/rush/browser-approved-packages.json
@@ -52,7 +52,7 @@
},
{
"name": "@rushstack/rush-daemon-transport",
- "allowedCategories": [ "tests" ]
+ "allowedCategories": [ "libraries", "tests" ]
},
{
"name": "@rushstack/rush-serve-dashboard",
diff --git a/common/config/subspaces/default/pnpm-lock.yaml b/common/config/subspaces/default/pnpm-lock.yaml
index edeffd5d18..a0167a937a 100644
--- a/common/config/subspaces/default/pnpm-lock.yaml
+++ b/common/config/subspaces/default/pnpm-lock.yaml
@@ -4078,6 +4078,16 @@ importers:
version: 9.37.0
../../../libraries/rush-daemon:
+ dependencies:
+ '@rushstack/node-core-library':
+ specifier: workspace:*
+ version: link:../node-core-library
+ '@rushstack/rush-daemon-protocol':
+ specifier: workspace:*
+ version: link:../rush-daemon-protocol
+ '@rushstack/rush-daemon-transport':
+ specifier: workspace:*
+ version: link:../rush-daemon-transport
devDependencies:
'@rushstack/heft':
specifier: workspace:*
diff --git a/common/reviews/api/rush-daemon-protocol.api.md b/common/reviews/api/rush-daemon-protocol.api.md
index 54d3ad5bfe..37834b8ba5 100644
--- a/common/reviews/api/rush-daemon-protocol.api.md
+++ b/common/reviews/api/rush-daemon-protocol.api.md
@@ -292,6 +292,8 @@ export interface IDaemonPongMessage {
readonly kind: 'pong';
// (undocumented)
readonly payload: {
+ readonly daemonVersion?: string;
+ readonly protocolVersion?: IDaemonProtocolVersion;
readonly uptimeMs: number;
};
}
diff --git a/common/reviews/api/rush-daemon.api.md b/common/reviews/api/rush-daemon.api.md
index e32d01c6bc..ef178340ec 100644
--- a/common/reviews/api/rush-daemon.api.md
+++ b/common/reviews/api/rush-daemon.api.md
@@ -6,6 +6,8 @@
///
+import type { IDaemonPaths } from '@rushstack/rush-daemon-transport';
+
// @public
export interface IRequestLease {
// (undocumented)
@@ -23,6 +25,21 @@ export interface IRequestSchedulerAcquireOptions {
waitTimeoutMs?: number;
}
+// @beta
+export interface IRushDaemonHostOptions {
+ readonly daemonVersion: string;
+ readonly onError?: (error: Error) => void;
+ readonly repoRoot: string;
+ readonly rushVersion: string;
+ readonly startupOptions?: Readonly>;
+}
+
+// @beta
+export interface IRushDaemonServeOptions extends IRushDaemonHostOptions {
+ readonly onReady?: (host: RushDaemonHost) => void | Promise;
+ readonly shutdownSignal?: AbortSignal;
+}
+
// @public
export enum RequestExclusivityClass {
// (undocumented)
@@ -57,6 +74,17 @@ export enum RequestSchedulerErrorCode {
WaitTimeout = "WAIT_TIMEOUT"
}
+// @beta
+export class RushDaemonHost {
+ closeAsync(): Promise;
+ // (undocumented)
+ readonly paths: IDaemonPaths;
+ static startAsync(options: IRushDaemonHostOptions): Promise;
+}
+
+// @beta
+export function serveRushDaemonAsync(options: IRushDaemonServeOptions): Promise;
+
// (No @packageDocumentation comment for this package)
```
diff --git a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts
index 8f3f6dcfac..49d87c195a 100644
--- a/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts
+++ b/libraries/rush-daemon-protocol/src/ControlMessageValidation.ts
@@ -5,7 +5,7 @@ import { isDaemonControlMessageKind } from './DaemonControlMessage';
import { DaemonProtocolError } from './DaemonProtocolError';
import { isDaemonVerbosity } from './DaemonVerbosity';
-/** Returns `true` when `value` is a plain object. @beta */
+/** Returns `true` when `value` is a non-null object usable as a control record. @beta */
export function isDaemonControlRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null;
}
@@ -45,6 +45,12 @@ function validateHelloAck(payload: Record): void {
requireStringField(payload, 'sessionId');
}
+function validatePong(payload: Record): void {
+ if (payload.daemonVersion !== undefined) requireStringField(payload, 'daemonVersion');
+ if (payload.protocolVersion !== undefined) requireVersion(payload);
+ requireNumberField(payload, 'uptimeMs');
+}
+
function validateSubscribe(payload: Record): void {
if (typeof payload.isTTY !== 'boolean') {
fail('Subscribe message payload.isTTY must be a boolean.');
@@ -73,13 +79,11 @@ const VALIDATORS_BY_KIND: Record = {
subscribe: validateSubscribe,
unsubscribe: noopValidator,
ping: noopValidator,
- pong: (payload: Record) => requireNumberField(payload, 'uptimeMs'),
+ pong: validatePong,
error: validateError
};
-/**
- * Structurally validates a parsed control message.
- *
+/** Structurally validates a parsed control message.
* @throws {@link DaemonProtocolError} when the value is not a well-formed control message.
*
* @beta
diff --git a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts
index 36b592b5e3..c20a04a3a5 100644
--- a/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts
+++ b/libraries/rush-daemon-protocol/src/DaemonControlMessage.ts
@@ -2,6 +2,7 @@
// See LICENSE in the project root for license information.
import type { IDaemonClientCaps } from './DaemonClientCaps';
+import type { IDaemonPongMessage } from './DaemonPongMessage';
import type { DaemonProtocolErrorCode } from './DaemonProtocolError';
import type { IDaemonProtocolVersion } from './DaemonProtocolVersion';
@@ -41,12 +42,6 @@ export interface IDaemonPingMessage {
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';
@@ -89,7 +84,7 @@ export const DAEMON_CONTROL_MESSAGE_KINDS: readonly [
'error'
] = ['hello', 'helloAck', 'subscribe', 'unsubscribe', 'ping', 'pong', 'error'];
-/** The union of control message `kind` discriminants, derived from the list. @beta */
+/** The union of control message `kind` discriminants, derived from the runtime list. @beta */
export type DaemonControlMessageKind = (typeof DAEMON_CONTROL_MESSAGE_KINDS)[number];
const CONTROL_KIND_SET: ReadonlySet = new Set(DAEMON_CONTROL_MESSAGE_KINDS);
diff --git a/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts b/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts
new file mode 100644
index 0000000000..0538bf9189
--- /dev/null
+++ b/libraries/rush-daemon-protocol/src/DaemonPongMessage.ts
@@ -0,0 +1,16 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import type { IDaemonProtocolVersion } from './DaemonProtocolVersion';
+
+/** The liveness reply. @beta */
+export interface IDaemonPongMessage {
+ readonly kind: 'pong';
+ readonly payload: {
+ /** The daemon implementation version, when reported by protocol 0.2 or newer. */
+ readonly daemonVersion?: string;
+ /** The daemon wire protocol version, when reported by protocol 0.2 or newer. */
+ readonly protocolVersion?: IDaemonProtocolVersion;
+ readonly uptimeMs: number;
+ };
+}
diff --git a/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts
index 1bc51b155f..e4053bdc79 100644
--- a/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts
+++ b/libraries/rush-daemon-protocol/src/DaemonProtocolVersion.ts
@@ -34,7 +34,7 @@ export interface IDaemonProtocolVersion {
*/
export const DAEMON_PROTOCOL_VERSION: IDaemonProtocolVersion = {
major: 0,
- minor: 1
+ minor: 2
};
/**
diff --git a/libraries/rush-daemon-protocol/src/index.ts b/libraries/rush-daemon-protocol/src/index.ts
index 4de98d0bb1..4ef7a7107c 100644
--- a/libraries/rush-daemon-protocol/src/index.ts
+++ b/libraries/rush-daemon-protocol/src/index.ts
@@ -30,7 +30,8 @@ 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 type { IDaemonPingMessage, IDaemonSubscribeMessage, IDaemonUnsubscribeMessage } from './DaemonControlMessage';
+export type { IDaemonPongMessage } from './DaemonPongMessage';
export { isDaemonControlRecord, validateDaemonControlMessage } from './ControlMessageValidation';
export { decodeDaemonControlMessage, encodeDaemonControlMessage } from './ControlFrameCodec';
export { decodeDaemonLogChunk, encodeDaemonLogChunk, type IDaemonLogChunk } from './LogFrameCodec';
diff --git a/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts
index 7a73101e74..11a854a0ba 100644
--- a/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts
+++ b/libraries/rush-daemon-protocol/src/test/ControlFrame.test.ts
@@ -9,6 +9,7 @@ import { captureProtocolError } from './TestVectors';
const UPTIME_MS: number = 42;
const COLUMNS: number = 120;
+const DAEMON_VERSION: string = '5.178.1';
const MESSAGES: readonly DaemonControlMessage[] = [
{ kind: 'hello', payload: { protocolVersion: DAEMON_PROTOCOL_VERSION } },
@@ -17,6 +18,14 @@ const MESSAGES: readonly DaemonControlMessage[] = [
{ kind: 'unsubscribe', payload: {} },
{ kind: 'ping', payload: {} },
{ kind: 'pong', payload: { uptimeMs: UPTIME_MS } },
+ {
+ kind: 'pong',
+ payload: {
+ daemonVersion: DAEMON_VERSION,
+ protocolVersion: DAEMON_PROTOCOL_VERSION,
+ uptimeMs: UPTIME_MS
+ }
+ },
{ kind: 'error', payload: { code: 'malformedPayload', message: 'bad' } }
];
diff --git a/libraries/rush-daemon/README.md b/libraries/rush-daemon/README.md
index 5dba14abaf..3e895e2ee2 100644
--- a/libraries/rush-daemon/README.md
+++ b/libraries/rush-daemon/README.md
@@ -1,5 +1,8 @@
# @rushstack/rush-daemon
-The long-lived Rush workspace daemon host.
+The long-lived Rush workspace daemon host, including workspace-keyed listener bootstrap,
+protocol handshake and liveness control, and explicit serve/shutdown lifecycle APIs.
-This package is under active development and is not yet integrated with the Rush command line.
+The package provides an opt-in `rushd` executable. Run it from a Rush workspace to start the host
+for the nearest `rush.json`; it does not change the default behavior of `rush`, `rushx`, or
+`rush-pnpm`.
diff --git a/libraries/rush-daemon/package.json b/libraries/rush-daemon/package.json
index ae76afb22b..7daacb9ae7 100644
--- a/libraries/rush-daemon/package.json
+++ b/libraries/rush-daemon/package.json
@@ -5,6 +5,9 @@
"main": "./lib-commonjs/index.js",
"module": "./lib-esm/index.js",
"types": "./dist/rush-daemon.d.ts",
+ "bin": {
+ "rushd": "./lib-commonjs/start.js"
+ },
"exports": {
".": {
"types": "./dist/rush-daemon.d.ts",
@@ -41,6 +44,11 @@
"_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/rush-daemon-transport": "workspace:*"
+ },
"devDependencies": {
"@rushstack/heft": "workspace:*",
"eslint": "~9.37.0",
diff --git a/libraries/rush-daemon/src/DaemonControlSession.ts b/libraries/rush-daemon/src/DaemonControlSession.ts
new file mode 100644
index 0000000000..85c3821132
--- /dev/null
+++ b/libraries/rush-daemon/src/DaemonControlSession.ts
@@ -0,0 +1,117 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { randomUUID } from 'node:crypto';
+
+import {
+ DAEMON_PROTOCOL_VERSION,
+ DaemonFrameType,
+ DaemonProtocolError,
+ decodeDaemonControlMessage,
+ encodeDaemonControlMessage,
+ negotiateDaemonHello
+} from '@rushstack/rush-daemon-protocol';
+import type {
+ DaemonControlMessage,
+ IDaemonErrorMessage,
+ IDaemonFrame,
+ IDaemonPongMessage
+} from '@rushstack/rush-daemon-protocol';
+import type { DaemonFrameConnection } from '@rushstack/rush-daemon-transport';
+
+export interface IDaemonControlSessionOptions {
+ readonly daemonVersion: string;
+ readonly startedAtMs: number;
+ readonly onClosed: (session: DaemonControlSession, error: Error | undefined) => void;
+ readonly onError: (error: Error) => void;
+}
+
+export class DaemonControlSession {
+ private readonly _connection: DaemonFrameConnection;
+ private readonly _options: IDaemonControlSessionOptions;
+ private _handshakeComplete: boolean = false;
+ private _sendQueue: Promise = Promise.resolve();
+
+ public constructor(connection: DaemonFrameConnection, options: IDaemonControlSessionOptions) {
+ this._connection = connection;
+ this._options = options;
+ connection.onFrame((frame: IDaemonFrame) => this._onFrame(frame));
+ connection.onClosed((error: Error | undefined) => options.onClosed(this, error));
+ }
+
+ public closeAsync(): Promise {
+ return this._connection.closeAsync();
+ }
+
+ private _onFrame(frame: IDaemonFrame): void {
+ if (frame.kind !== DaemonFrameType.controlJson) {
+ throw new DaemonProtocolError(
+ 'malformedControlMessage',
+ 'A daemon control connection only accepts control frames.'
+ );
+ }
+ const message: DaemonControlMessage = decodeDaemonControlMessage(frame.payload);
+ if (!this._handshakeComplete) {
+ this._handleHello(message);
+ } else if (message.kind === 'ping') {
+ this._send(this._createPong());
+ } else {
+ throw new DaemonProtocolError(
+ 'malformedControlMessage',
+ `Control message "${message.kind}" is not valid in this daemon host state.`
+ );
+ }
+ }
+
+ private _handleHello(message: DaemonControlMessage): void {
+ if (message.kind !== 'hello') {
+ throw new DaemonProtocolError(
+ 'malformedControlMessage',
+ 'The first control message on a connection must be hello.'
+ );
+ }
+ const outcome: ReturnType = negotiateDaemonHello(
+ message,
+ DAEMON_PROTOCOL_VERSION,
+ randomUUID()
+ );
+ if (outcome.accepted) {
+ this._handshakeComplete = true;
+ this._send(outcome.ack);
+ } else {
+ const errorMessage: IDaemonErrorMessage = {
+ kind: 'error',
+ payload: { code: outcome.error.code, message: outcome.error.message }
+ };
+ this._send(errorMessage, true);
+ }
+ }
+
+ private _createPong(): IDaemonPongMessage {
+ return {
+ kind: 'pong',
+ payload: {
+ daemonVersion: this._options.daemonVersion,
+ protocolVersion: DAEMON_PROTOCOL_VERSION,
+ uptimeMs: Date.now() - this._options.startedAtMs
+ }
+ };
+ }
+
+ private _send(message: DaemonControlMessage, closeAfterSend: boolean = false): void {
+ const frame: IDaemonFrame = {
+ kind: DaemonFrameType.controlJson,
+ payload: encodeDaemonControlMessage(message)
+ };
+ this._sendQueue = this._sendQueue
+ .then(() => this._connection.sendFrameAsync(frame))
+ .then(() => (closeAfterSend ? this._connection.closeAsync() : undefined))
+ .catch((error: unknown) => this._handleSendErrorAsync(error));
+ }
+
+ private async _handleSendErrorAsync(error: unknown): Promise {
+ const normalizedError: Error = error instanceof Error ? error : new Error(String(error));
+ this._options.onError(normalizedError);
+ await this._connection.closeAsync();
+ }
+}
diff --git a/libraries/rush-daemon/src/RushDaemonCommandLine.ts b/libraries/rush-daemon/src/RushDaemonCommandLine.ts
new file mode 100644
index 0000000000..097f933027
--- /dev/null
+++ b/libraries/rush-daemon/src/RushDaemonCommandLine.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 path from 'node:path';
+
+import { FileSystem, JsonFile, PackageJsonLookup } from '@rushstack/node-core-library';
+import type { IPackageJson } from '@rushstack/node-core-library';
+
+import { serveRushDaemonAsync } from './serveRushDaemon';
+
+const RUSH_JSON_FILENAME: string = 'rush.json';
+
+export interface IRushDaemonWorkspace {
+ readonly repoRoot: string;
+ readonly rushVersion: string;
+}
+
+export function resolveRushDaemonWorkspace(startingFolder: string): IRushDaemonWorkspace {
+ const rushJsonPath: string = findRushJsonPath(startingFolder);
+ const rushJson: { rushVersion?: unknown } = JsonFile.load(rushJsonPath);
+ if (typeof rushJson.rushVersion !== 'string') {
+ throw new Error(`The "rushVersion" field in "${rushJsonPath}" must be a string.`);
+ }
+ return {
+ repoRoot: path.dirname(rushJsonPath),
+ rushVersion: rushJson.rushVersion
+ };
+}
+
+export async function launchRushDaemonAsync(startingFolder: string = process.cwd()): Promise {
+ const workspace: IRushDaemonWorkspace = resolveRushDaemonWorkspace(startingFolder);
+ const packageJson: IPackageJson | undefined =
+ PackageJsonLookup.instance.tryLoadPackageJsonFor(__dirname);
+ if (!packageJson) {
+ throw new Error('Unable to determine the @rushstack/rush-daemon package version.');
+ }
+ await serveRushDaemonAsync({
+ daemonVersion: packageJson.version,
+ repoRoot: workspace.repoRoot,
+ rushVersion: workspace.rushVersion,
+ onError: (error: Error) => process.stderr.write(`${error.stack ?? error.message}\n`),
+ onReady: (host) => {
+ process.stdout.write(`rushd ready at ${host.paths.socketPath}\n`);
+ }
+ });
+}
+
+function findRushJsonPath(startingFolder: string): string {
+ let currentFolder: string = path.resolve(startingFolder);
+ while (true) {
+ const candidatePath: string = path.join(currentFolder, RUSH_JSON_FILENAME);
+ if (FileSystem.exists(candidatePath)) {
+ return candidatePath;
+ }
+ const parentFolder: string = path.dirname(currentFolder);
+ if (parentFolder === currentFolder) {
+ throw new Error(`Unable to find ${RUSH_JSON_FILENAME} in "${startingFolder}" or its parents.`);
+ }
+ currentFolder = parentFolder;
+ }
+}
diff --git a/libraries/rush-daemon/src/RushDaemonHost.ts b/libraries/rush-daemon/src/RushDaemonHost.ts
new file mode 100644
index 0000000000..f3a9cd4251
--- /dev/null
+++ b/libraries/rush-daemon/src/RushDaemonHost.ts
@@ -0,0 +1,108 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { realpath } from 'node:fs/promises';
+
+import { DAEMON_PROTOCOL_VERSION } from '@rushstack/rush-daemon-protocol';
+import {
+ computeDaemonWorkspaceKey,
+ DaemonFrameListener,
+ resolveDaemonPathsFromProcess
+} from '@rushstack/rush-daemon-transport';
+import type {
+ DaemonFrameConnection,
+ IDaemonPaths
+} from '@rushstack/rush-daemon-transport';
+
+import { DaemonControlSession } from './DaemonControlSession';
+
+/**
+ * Options for starting one workspace daemon host.
+ *
+ * @beta
+ */
+export interface IRushDaemonHostOptions {
+ /** The daemon implementation version reported by `pong`. */
+ readonly daemonVersion: string;
+ /** Reports connection-level failures. */
+ readonly onError?: (error: Error) => void;
+ /** The repository root containing rush.json. */
+ readonly repoRoot: string;
+ /** The selected Rush version used to isolate the workspace transport. */
+ readonly rushVersion: string;
+ /** Additional stable options that distinguish daemon instances. */
+ readonly startupOptions?: Readonly>;
+}
+
+/**
+ * A bound, workspace-keyed Rush daemon host.
+ *
+ * @beta
+ */
+export class RushDaemonHost {
+ private readonly _listener: DaemonFrameListener;
+ private readonly _sessions: Set;
+ private readonly _lifecycle: { closing: boolean };
+ public readonly paths: IDaemonPaths;
+ private _closePromise: Promise | undefined;
+
+ private constructor(
+ listener: DaemonFrameListener,
+ paths: IDaemonPaths,
+ sessions: Set,
+ lifecycle: { closing: boolean }
+ ) {
+ this._listener = listener;
+ this.paths = paths;
+ this._sessions = sessions;
+ this._lifecycle = lifecycle;
+ }
+
+ /** Resolves only after the transport is bound and its lockfile has been written. */
+ public static async startAsync(options: IRushDaemonHostOptions): Promise {
+ const canonicalRepoRoot: string = await realpath(options.repoRoot);
+ const workspaceKey: string = computeDaemonWorkspaceKey({
+ canonicalRepoRoot,
+ rushVersion: options.rushVersion,
+ startupOptions: options.startupOptions
+ });
+ const paths: IDaemonPaths = resolveDaemonPathsFromProcess(workspaceKey);
+ const sessions: Set = new Set();
+ const lifecycle: { closing: boolean } = { closing: false };
+ const startedAtMs: number = Date.now();
+ const listener: DaemonFrameListener = await DaemonFrameListener.listenAsync(paths, {
+ protocolVersion: DAEMON_PROTOCOL_VERSION,
+ startedAt: new Date(startedAtMs).toISOString(),
+ onConnection: (connection: DaemonFrameConnection) => {
+ const session: DaemonControlSession = new DaemonControlSession(connection, {
+ daemonVersion: options.daemonVersion,
+ startedAtMs,
+ onClosed: (closedSession: DaemonControlSession, error: Error | undefined) => {
+ sessions.delete(closedSession);
+ if (error) {
+ options.onError?.(error);
+ }
+ },
+ onError: (error: Error) => options.onError?.(error)
+ });
+ sessions.add(session);
+ if (lifecycle.closing) {
+ void session.closeAsync();
+ }
+ }
+ });
+ return new RushDaemonHost(listener, paths, sessions, lifecycle);
+ }
+
+ /** Closes active connections, stops listening, and removes transport artifacts. */
+ public closeAsync(): Promise {
+ this._closePromise ??= this._closeOnceAsync();
+ return this._closePromise;
+ }
+
+ private async _closeOnceAsync(): Promise {
+ this._lifecycle.closing = true;
+ await Promise.all(Array.from(this._sessions, (session: DaemonControlSession) => session.closeAsync()));
+ await this._listener.closeAsync();
+ }
+}
diff --git a/libraries/rush-daemon/src/index.ts b/libraries/rush-daemon/src/index.ts
index 1da4d820f9..a9178e9749 100644
--- a/libraries/rush-daemon/src/index.ts
+++ b/libraries/rush-daemon/src/index.ts
@@ -11,3 +11,5 @@ export {
RequestSchedulerError,
RequestSchedulerErrorCode
} from './RequestScheduler';
+export { RushDaemonHost, type IRushDaemonHostOptions } from './RushDaemonHost';
+export { serveRushDaemonAsync, type IRushDaemonServeOptions } from './serveRushDaemon';
diff --git a/libraries/rush-daemon/src/serveRushDaemon.ts b/libraries/rush-daemon/src/serveRushDaemon.ts
new file mode 100644
index 0000000000..a597058686
--- /dev/null
+++ b/libraries/rush-daemon/src/serveRushDaemon.ts
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { RushDaemonHost } from './RushDaemonHost';
+import type { IRushDaemonHostOptions } from './RushDaemonHost';
+
+/**
+ * Options for the daemon serve lifecycle.
+ *
+ * @beta
+ */
+export interface IRushDaemonServeOptions extends IRushDaemonHostOptions {
+ /** Called after the listener is bound and the lockfile is available. */
+ readonly onReady?: (host: RushDaemonHost) => void | Promise;
+ /** Requests a clean shutdown. Process signals are used when omitted. */
+ readonly shutdownSignal?: AbortSignal;
+}
+
+/**
+ * Starts a daemon host, signals readiness, and serves until shutdown is requested.
+ *
+ * @beta
+ */
+export async function serveRushDaemonAsync(options: IRushDaemonServeOptions): Promise {
+ const signalRegistration: IShutdownSignalRegistration = options.shutdownSignal
+ ? { signal: options.shutdownSignal, dispose: () => undefined }
+ : createProcessShutdownSignal();
+ let host: RushDaemonHost | undefined;
+ try {
+ host = await RushDaemonHost.startAsync(options);
+ await options.onReady?.(host);
+ await waitForAbortAsync(signalRegistration.signal);
+ } finally {
+ signalRegistration.dispose();
+ await host?.closeAsync();
+ }
+}
+
+interface IShutdownSignalRegistration {
+ readonly signal: AbortSignal;
+ readonly dispose: () => void;
+}
+
+function createProcessShutdownSignal(): IShutdownSignalRegistration {
+ const controller: AbortController = new AbortController();
+ const onSignal: () => void = () => controller.abort();
+ process.once('SIGINT', onSignal);
+ process.once('SIGTERM', onSignal);
+ return {
+ signal: controller.signal,
+ dispose: () => {
+ process.off('SIGINT', onSignal);
+ process.off('SIGTERM', onSignal);
+ }
+ };
+}
+
+function waitForAbortAsync(signal: AbortSignal): Promise {
+ if (signal.aborted) {
+ return Promise.resolve();
+ }
+ return new Promise((resolve: () => void) => signal.addEventListener('abort', () => resolve(), { once: true }));
+}
diff --git a/libraries/rush-daemon/src/start.ts b/libraries/rush-daemon/src/start.ts
new file mode 100644
index 0000000000..7f1b6378c2
--- /dev/null
+++ b/libraries/rush-daemon/src/start.ts
@@ -0,0 +1,11 @@
+#!/usr/bin/env node
+
+// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
+// See LICENSE in the project root for license information.
+
+import { launchRushDaemonAsync } from './RushDaemonCommandLine';
+
+launchRushDaemonAsync().catch((error: Error) => {
+ process.stderr.write(`${error.stack ?? error.message}\n`);
+ process.exitCode = 1;
+});
diff --git a/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts b/libraries/rush-daemon/src/test/RushDaemonCommandLine.test.ts
new file mode 100644
index 0000000000..e8c8306839
--- /dev/null
+++ b/libraries/rush-daemon/src/test/RushDaemonCommandLine.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 * as path from 'node:path';
+import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+
+import {
+ resolveRushDaemonWorkspace,
+ type IRushDaemonWorkspace
+} from '../RushDaemonCommandLine';
+
+describe(resolveRushDaemonWorkspace.name, () => {
+ let tempFolder: string;
+
+ beforeEach(async () => {
+ tempFolder = await mkdtemp(path.join(tmpdir(), 'rushd-cli-'));
+ });
+
+ afterEach(async () => {
+ await rm(tempFolder, { force: true, recursive: true });
+ });
+
+ it('finds and reads the nearest rush.json from a nested folder', async () => {
+ const nestedFolder: string = path.join(tempFolder, 'apps', 'example');
+ await mkdir(nestedFolder, { recursive: true });
+ await writeFile(
+ path.join(tempFolder, 'rush.json'),
+ '{\n // The selected Rush version\n "rushVersion": "5.178.0"\n}\n'
+ );
+
+ const workspace: IRushDaemonWorkspace = resolveRushDaemonWorkspace(nestedFolder);
+
+ expect(workspace).toEqual({
+ repoRoot: tempFolder,
+ rushVersion: '5.178.0'
+ });
+ });
+
+ it('rejects a rush.json without a string rushVersion', async () => {
+ await writeFile(path.join(tempFolder, 'rush.json'), '{ "rushVersion": 5 }\n');
+
+ expect(() => resolveRushDaemonWorkspace(tempFolder)).toThrow(
+ /The "rushVersion" field .* must be a string/
+ );
+ });
+
+ it('reports when no rush.json exists', () => {
+ expect(() => resolveRushDaemonWorkspace(tempFolder)).toThrow(/Unable to find rush\.json/);
+ });
+});
diff --git a/libraries/rush-daemon/src/test/RushDaemonHost.test.ts b/libraries/rush-daemon/src/test/RushDaemonHost.test.ts
new file mode 100644
index 0000000000..768d95e10e
--- /dev/null
+++ b/libraries/rush-daemon/src/test/RushDaemonHost.test.ts
@@ -0,0 +1,149 @@
+// 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 os from 'node:os';
+import * as path from 'node:path';
+
+import {
+ DAEMON_PROTOCOL_VERSION,
+ DaemonFrameType,
+ createDaemonHello,
+ decodeDaemonControlMessage,
+ encodeDaemonControlMessage
+} from '@rushstack/rush-daemon-protocol';
+import type {
+ DaemonControlMessage,
+ IDaemonFrame
+} from '@rushstack/rush-daemon-protocol';
+import {
+ connectDaemonAsync,
+ readDaemonLockfile
+} from '@rushstack/rush-daemon-transport';
+import type {
+ DaemonFrameConnection,
+ IDaemonPaths
+} from '@rushstack/rush-daemon-transport';
+
+import { RushDaemonHost } from '../RushDaemonHost';
+import { serveRushDaemonAsync } from '../serveRushDaemon';
+
+const RUSH_VERSION: string = '5.178.1';
+const DAEMON_VERSION: string = '0.1.0-test';
+const WINDOWS_PIPE_PREFIX: string = '\\\\.\\pipe\\rushd-';
+const REPO_PREFIX: string = 'rush-daemon-host-test-';
+
+const testRepoRoots: Set = new Set();
+
+afterEach(() => {
+ for (const repoRoot of testRepoRoots) {
+ fs.rmSync(repoRoot, { force: true, recursive: true });
+ }
+ testRepoRoots.clear();
+});
+
+function createTestRepoRoot(): string {
+ const repoRoot: string = fs.mkdtempSync(path.join(os.tmpdir(), REPO_PREFIX));
+ testRepoRoots.add(repoRoot);
+ return repoRoot;
+}
+
+function createHostOptions(repoRoot: string): {
+ daemonVersion: string;
+ repoRoot: string;
+ rushVersion: string;
+} {
+ return { daemonVersion: DAEMON_VERSION, repoRoot, rushVersion: RUSH_VERSION };
+}
+
+async function exchangeControlAsync(
+ connection: DaemonFrameConnection,
+ message: DaemonControlMessage
+): Promise {
+ const response: Promise = new Promise(
+ (resolve: (message: DaemonControlMessage) => void) => {
+ connection.onFrame((frame: IDaemonFrame) => resolve(decodeDaemonControlMessage(frame.payload)));
+ }
+ );
+ await connection.sendFrameAsync({
+ kind: DaemonFrameType.controlJson,
+ payload: encodeDaemonControlMessage(message)
+ });
+ return response;
+}
+
+describe(RushDaemonHost.name, () => {
+ it('binds the workspace transport and handles hello plus ping', async () => {
+ const host: RushDaemonHost = await RushDaemonHost.startAsync(
+ createHostOptions(createTestRepoRoot())
+ );
+ const client: DaemonFrameConnection = await connectDaemonAsync(host.paths.socketPath);
+ try {
+ expect(readDaemonLockfile(host.paths.lockfilePath)).toMatchObject({
+ pid: process.pid,
+ protocolVersion: DAEMON_PROTOCOL_VERSION,
+ socketPath: host.paths.socketPath
+ });
+ if (process.platform === 'win32') {
+ expect(host.paths.socketPath.startsWith(WINDOWS_PIPE_PREFIX)).toBe(true);
+ }
+ const helloAck: DaemonControlMessage = await exchangeControlAsync(
+ client,
+ createDaemonHello(DAEMON_PROTOCOL_VERSION)
+ );
+ expect(helloAck).toMatchObject({
+ kind: 'helloAck',
+ payload: { protocolVersion: DAEMON_PROTOCOL_VERSION }
+ });
+ const pong: DaemonControlMessage = await exchangeControlAsync(client, {
+ kind: 'ping',
+ payload: {}
+ });
+ expect(pong).toMatchObject({
+ kind: 'pong',
+ payload: {
+ daemonVersion: DAEMON_VERSION,
+ protocolVersion: DAEMON_PROTOCOL_VERSION
+ }
+ });
+ } finally {
+ await client.closeAsync();
+ await host.closeAsync();
+ }
+ });
+
+ it('signals readiness only after the listener and lockfile are available', async () => {
+ const controller: AbortController = new AbortController();
+ let readyPaths: IDaemonPaths | undefined;
+ const onReadyAsync: (host: RushDaemonHost) => Promise = async (host: RushDaemonHost) => {
+ readyPaths = host.paths;
+ expect(readDaemonLockfile(host.paths.lockfilePath)).toBeDefined();
+ const client: DaemonFrameConnection = await connectDaemonAsync(host.paths.socketPath);
+ await client.closeAsync();
+ controller.abort();
+ };
+ await serveRushDaemonAsync({
+ ...createHostOptions(createTestRepoRoot()),
+ shutdownSignal: controller.signal,
+ onReady: onReadyAsync
+ });
+ if (!readyPaths) {
+ throw new Error('The daemon did not signal readiness.');
+ }
+ expect(readDaemonLockfile(readyPaths.lockfilePath)).toBeUndefined();
+ });
+
+ it('closes active connections and removes transport artifacts', async () => {
+ const host: RushDaemonHost = await RushDaemonHost.startAsync(
+ createHostOptions(createTestRepoRoot())
+ );
+ const client: DaemonFrameConnection = await connectDaemonAsync(host.paths.socketPath);
+ const closed: Promise = new Promise((resolve: () => void) => client.onClosed(() => resolve()));
+ await host.closeAsync();
+ await closed;
+ expect(readDaemonLockfile(host.paths.lockfilePath)).toBeUndefined();
+ await expect(connectDaemonAsync(host.paths.socketPath)).rejects.toMatchObject({
+ code: 'connectionRefused'
+ });
+ });
+});