import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process"; import type { Duplex } from "node:stream"; export { SidecarProcessError, SidecarProcessExited, } from "./sidecar-errors.js"; import { SidecarProcessError, SidecarProcessExited } from "./sidecar-errors.js"; export interface StdioSidecarProcessSpawnOptions { command: string; args?: string[]; cwd?: string; } export class StdioSidecarProcess { readonly child: ChildProcessWithoutNullStreams; readonly control: Duplex; private readonly stderrChunks: Buffer[] = []; private readonly exitListeners = new Set< (error: SidecarProcessExited) => void >(); private readonly errorListeners = new Set< (error: SidecarProcessError) => void >(); private constructor(child: ChildProcessWithoutNullStreams, control: Duplex) { this.child = child; this.control = control; this.child.stderr.on("data", (chunk: Buffer | string) => { this.stderrChunks.push( typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk), ); }); this.child.on("exit", (code, signal) => { const error = new SidecarProcessExited({ exitCode: code, signal, stderr: this.stderrText(), }); for (const listener of this.exitListeners) { listener(error); } }); this.child.on("error", (error) => { const normalized = error instanceof Error ? error : new Error(String(error)); const sidecarError = new SidecarProcessError( normalized, this.stderrText(), ); for (const listener of this.errorListeners) { listener(sidecarError); } }); } static spawn(options: StdioSidecarProcessSpawnOptions): StdioSidecarProcess { const child = spawn(options.command, options.args ?? [], { cwd: options.cwd, stdio: ["pipe", "pipe", "pipe", "pipe"], }) as unknown as ChildProcessWithoutNullStreams; try { return new StdioSidecarProcess(child, requireControlStream(child)); } catch (error) { child.kill("SIGKILL"); throw error; } } static fromChild( child: ChildProcessWithoutNullStreams, control?: Duplex, ): StdioSidecarProcess { return new StdioSidecarProcess( child, control ?? requireControlStream(child), ); } onExit(handler: (error: SidecarProcessExited) => void): () => void { this.exitListeners.add(handler); return () => { this.exitListeners.delete(handler); }; } onError(handler: (error: SidecarProcessError) => void): () => void { this.errorListeners.add(handler); return () => { this.errorListeners.delete(handler); }; } stderrText(): string { return Buffer.concat(this.stderrChunks).toString("utf8").trim(); } currentExitError(): SidecarProcessExited | null { if (this.child.exitCode === null && this.child.signalCode === null) { return null; } return new SidecarProcessExited({ exitCode: this.child.exitCode, signal: this.child.signalCode, stderr: this.stderrText(), }); } waitForExit(timeoutMs: number): Promise { return new Promise((resolve) => { let timer: ReturnType | null = null; const cleanup = () => { this.child.off("exit", onExit); this.child.off("close", onClose); if (timer !== null) { clearTimeout(timer); timer = null; } }; const onExit = (code: number | null) => { cleanup(); resolve(code); }; const onClose = (code: number | null) => { cleanup(); resolve(code); }; if (this.child.exitCode !== null || this.child.signalCode !== null) { resolve(this.child.exitCode); return; } this.child.on("exit", onExit); this.child.on("close", onClose); timer = setTimeout(() => { cleanup(); resolve(null); }, timeoutMs); }); } } function requireControlStream(child: ChildProcessWithoutNullStreams): Duplex { const stream = child.stdio[3]; if ( !stream || typeof (stream as Duplex).write !== "function" || typeof (stream as Duplex).on !== "function" || typeof (stream as Duplex).read !== "function" ) { throw new Error("sidecar process did not expose a full-duplex fd 3"); } return stream as Duplex; }