155 lines · 4.0 KB · TypeScript
RawDownload
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<number | null> {		return new Promise<number | null>((resolve) => {			let timer: ReturnType<typeof setTimeout> | 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;}