UNPKG

spawn-rx

Version:

An Rx-version of child_process.spawn

584 lines (520 loc) 19 kB
import { type SpawnOptions, spawn as spawnOg } from "node:child_process"; import * as sfs from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import Debug from "debug"; import { LRUCache } from "lru-cache"; import type { Observer, Subject } from "rxjs"; import { AsyncSubject, merge, Observable, of, Subscription, timer } from "rxjs"; import { map, reduce, retry as rxRetry } from "rxjs/operators"; const isWindows = process.platform === "win32"; const d = Debug("spawn-rx"); // tslint:disable-line:no-var-requires /** * Custom error class for spawn operations with additional metadata */ export class SpawnError extends Error { public readonly exitCode: number; public readonly code: number; public readonly stdout?: string; public readonly stderr?: string; public readonly command: string; public readonly args: string[]; constructor(message: string, exitCode: number, command: string, args: string[], stdout?: string, stderr?: string) { super(message); this.name = "SpawnError"; this.exitCode = exitCode; this.code = exitCode; this.stdout = stdout; this.stderr = stderr; this.command = command; this.args = args; // Maintains proper stack trace for where our error was thrown (only available on V8) // eslint-disable-next-line @typescript-eslint/no-explicit-any if ((Error as any).captureStackTrace) { // eslint-disable-next-line @typescript-eslint/no-explicit-any (Error as any).captureStackTrace(this, SpawnError); } } } /** * Process metadata tracked during execution */ export interface ProcessMetadata { pid: number; startTime: number; command: string; args: string[]; } /** * stat a file but don't throw if it doesn't exist * * @param {string} file The path to a file * @return {Stats} The stats structure * * @private */ export function statSyncNoException(file: string): sfs.Stats | null { try { return sfs.statSync(file); } catch { return null; } } /** * stat a file but don't throw if it doesn't exist * * @param {string} file The path to a file * @return {Stats} The stats structure * * @private */ export function statNoException(file: string): Promise<sfs.Stats | null> { return fs.stat(file).catch(() => null); } /** * Cache for resolved executable paths */ const pathCache = new LRUCache<string, string>({ max: 512 }); /** * Search PATH to see if a file exists in any of the path folders. * * @param {string} exe The file to search for * @return {string} A fully qualified path, or the original path if nothing * is found * * @private */ function runDownPath(exe: string): string { // Check cache first const cached = pathCache.get(exe); if (cached !== undefined) { d(`Cache hit for executable: ${exe} -> ${cached}`); return cached; } // NB: Windows won't search PATH looking for executables in spawn like // Posix does // Files with any directory path don't get this applied if (exe.match(/[\\/]/)) { d("Path has slash in directory, bailing"); pathCache.set(exe, exe); return exe; } const target = path.join(".", exe); if (statSyncNoException(target)) { d(`Found executable in current directory: ${target}`); // XXX: Some very Odd programs decide to use args[0] as a parameter // to determine what to do, and also symlink themselves, so we can't // use realpathSync here like we used to pathCache.set(exe, target); return target; } const haystack = process.env.PATH?.split(isWindows ? ";" : ":"); if (haystack) { for (const p of haystack) { const needle = path.join(p, exe); if (statSyncNoException(needle)) { // NB: Same deal as above pathCache.set(exe, needle); return needle; } } } d("Failed to find executable anywhere in path"); pathCache.set(exe, exe); return exe; } export type CmdWithArgs = { cmd: string; args: string[]; }; /** * Finds the actual executable and parameters to run on Windows. This method * mimics the POSIX behavior of being able to run scripts as executables by * replacing the passed-in executable with the script runner, for PowerShell, * CMD, and node scripts. * * This method also does the work of running down PATH, which spawn on Windows * also doesn't do, unlike on POSIX. * * @param {string} exe The executable to run * @param {string[]} args The arguments to run * * @return {Object} The cmd and args to run * @property {string} cmd The command to pass to spawn * @property {string[]} args The arguments to pass to spawn */ export function findActualExecutable(exe: string, args: string[]): CmdWithArgs { // POSIX can just execute scripts directly, no need for silly goosery if (process.platform !== "win32") { return { cmd: runDownPath(exe), args: args }; } if (!sfs.existsSync(exe)) { // NB: When you write something like `surf-client ... -- surf-build` on Windows, // a shell would normally convert that to surf-build.cmd, but since it's passed // in as an argument, it doesn't happen const possibleExts = [".exe", ".bat", ".cmd", ".ps1"]; for (const ext of possibleExts) { const possibleFullPath = runDownPath(`${exe}${ext}`); if (sfs.existsSync(possibleFullPath)) { return findActualExecutable(possibleFullPath, args); } } } if (exe.match(/\.ps1$/i)) { const cmd = path.join(process.env.SYSTEMROOT!, "System32", "WindowsPowerShell", "v1.0", "PowerShell.exe"); const psargs = ["-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NonInteractive", "-File", exe]; return { cmd: cmd, args: psargs.concat(args) }; } if (exe.match(/\.(bat|cmd)$/i)) { const cmd = path.join(process.env.SYSTEMROOT!, "System32", "cmd.exe"); const cmdArgs = ["/C", exe, ...args]; return { cmd: cmd, args: cmdArgs }; } if (exe.match(/\.(js)$/i)) { const cmd = process.execPath; const nodeArgs = [exe]; return { cmd: cmd, args: nodeArgs.concat(args) }; } // Dunno lol return { cmd: exe, args: args }; } export type SpawnRxExtras = { stdin?: Observable<string>; echoOutput?: boolean; split?: boolean; encoding?: BufferEncoding; /** * Timeout in milliseconds. If the process doesn't complete within this time, * it will be killed and the observable will error with a TimeoutError. */ timeout?: number; /** * Number of retry attempts if the process fails (non-zero exit code). * Defaults to 0 (no retries). */ retries?: number; /** * Delay in milliseconds between retry attempts. Defaults to 1000ms. */ retryDelay?: number; }; export type OutputLine = { source: "stdout" | "stderr"; text: string; }; /** * Utility type to extract the return type based on split option */ export type SpawnResult<T extends SpawnRxExtras> = T extends { split: true } ? Observable<OutputLine> : Observable<string>; /** * Utility type to extract the promise return type based on split option */ export type SpawnPromiseResult<T extends SpawnRxExtras> = T extends { split: true; } ? Promise<[string, string]> : Promise<string>; /** * Spawns a process attached as a child of the current process. * * @param {string} exe The executable to run * @param {string[]} params The parameters to pass to the child * @param {SpawnOptions & SpawnRxExtras} opts Options to pass to spawn. * * @return {Observable<OutputLine>} Returns an Observable that when subscribed * to, will create a child process. The * process output will be streamed to this * Observable, and if unsubscribed from, the * process will be terminated early. If the * process terminates with a non-zero value, * the Observable will terminate with onError. */ export function spawn( exe: string, params: string[], opts: SpawnOptions & SpawnRxExtras & { split: true }, ): Observable<OutputLine>; /** * Spawns a process attached as a child of the current process. * * @param {string} exe The executable to run * @param {string[]} params The parameters to pass to the child * @param {SpawnOptions & SpawnRxExtras} opts Options to pass to spawn. * * @return {Observable<string>} Returns an Observable that when subscribed * to, will create a child process. The * process output will be streamed to this * Observable, and if unsubscribed from, the * process will be terminated early. If the * process terminates with a non-zero value, * the Observable will terminate with onError. */ export function spawn( exe: string, params: string[], opts?: SpawnOptions & SpawnRxExtras & { split: false | undefined }, ): Observable<string>; /** * Spawns a process attached as a child of the current process. * * @param {string} exe The executable to run * @param {string[]} params The parameters to pass to the child * @param {SpawnOptions & SpawnRxExtras} opts Options to pass to spawn. * * @return {Observable<string>} Returns an Observable that when subscribed * to, will create a child process. The * process output will be streamed to this * Observable, and if unsubscribed from, the * process will be terminated early. If the * process terminates with a non-zero value, * the Observable will terminate with onError. */ export function spawn( exe: string, params: string[], opts?: SpawnOptions & SpawnRxExtras, ): Observable<string> | Observable<OutputLine> { opts = opts ?? {}; const spawnObs: Observable<OutputLine> = new Observable((subj: Observer<OutputLine>) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { encoding, timeout, ...spawnOpts } = opts; const { cmd, args } = findActualExecutable(exe, params); d(`spawning process: ${cmd} ${args.join()}, ${JSON.stringify(spawnOpts)}`); const proc = spawnOg(cmd, args, spawnOpts); // Process metadata is tracked but not currently exposed // Could be added to SpawnError or returned in a future enhancement // const _processMetadata: ProcessMetadata = { // pid: proc.pid ?? 0, // startTime: Date.now(), // command: cmd, // args: args, // }; // Set up timeout if specified let timeoutHandle: NodeJS.Timeout | null = null; if (timeout && timeout > 0) { timeoutHandle = setTimeout(() => { d(`Process timeout reached: ${cmd} ${args.join()}`); if (!proc.killed) { proc.kill(); } const error = new SpawnError(`Process timed out after ${timeout}ms`, -1, cmd, args); subj.error(error); }, timeout); } const bufHandler = (source: "stdout" | "stderr") => (b: string | Buffer) => { if (b.length < 1) { return; } if (opts.echoOutput) { (source === "stdout" ? process.stdout : process.stderr).write(b); } let chunk = "<< String sent back was too long >>"; try { if (typeof b === "string") { chunk = b.toString(); } else { chunk = b.toString(encoding || "utf8"); } } catch { chunk = `<< Lost chunk of process output for ${exe} - length was ${b.length}>>`; } subj.next({ source: source, text: chunk }); }; const ret = new Subscription(); if (opts.stdin) { if (proc.stdin) { const stdin = proc.stdin; ret.add( opts.stdin.subscribe({ next: (x) => stdin.write(x), error: subj.error.bind(subj), complete: () => stdin.end(), }), ); } else { subj.error(new Error(`opts.stdio conflicts with provided spawn opts.stdin observable, 'pipe' is required`)); } } let stderrCompleted: Subject<boolean> | Observable<boolean> | null = null; let stdoutCompleted: Subject<boolean> | Observable<boolean> | null = null; let noClose = false; if (proc.stdout) { stdoutCompleted = new AsyncSubject<boolean>(); proc.stdout.on("data", bufHandler("stdout")); proc.stdout.on("close", () => { (stdoutCompleted as Subject<boolean>).next(true); (stdoutCompleted as Subject<boolean>).complete(); }); } else { stdoutCompleted = of(true); } if (proc.stderr) { stderrCompleted = new AsyncSubject<boolean>(); proc.stderr.on("data", bufHandler("stderr")); proc.stderr.on("close", () => { (stderrCompleted as Subject<boolean>).next(true); (stderrCompleted as Subject<boolean>).complete(); }); } else { stderrCompleted = of(true); } proc.on("error", (e: Error) => { noClose = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } subj.error(e); }); proc.on("close", (code: number) => { noClose = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } const pipesClosed = merge(stdoutCompleted, stderrCompleted).pipe(reduce((_acc: boolean) => true, true)); if (code === 0) { pipesClosed.subscribe(() => subj.complete()); } else { pipesClosed.subscribe(() => { const error = new SpawnError(`Process failed with exit code: ${code}`, code, cmd, args); subj.error(error); }); } }); ret.add( new Subscription(() => { if (noClose) { return; } if (timeoutHandle) { clearTimeout(timeoutHandle); } d(`Killing process: ${cmd} ${args.join()}`); proc.kill(); }), ); return ret; }); let resultObs: Observable<OutputLine> = spawnObs; // Apply retry logic if specified if (opts.retries && opts.retries > 0) { const retryCount = opts.retries; const delay = opts.retryDelay ?? 1000; resultObs = resultObs.pipe( rxRetry({ count: retryCount, delay: (error: unknown, retryIndex: number) => { // Only retry on SpawnError with non-zero exit codes if (error instanceof SpawnError && error.exitCode !== 0) { d(`Retrying process (attempt ${retryIndex + 1}/${retryCount}): ${exe}`); return timer(delay); } // Don't retry on other errors throw error; }, }), ); } return opts.split ? resultObs : resultObs.pipe(map((x: OutputLine) => x?.text)); } function wrapObservableInPromise(obs: Observable<string>) { return new Promise<string>((res, rej) => { let out = ""; obs.subscribe({ next: (x: string) => { out += x; }, error: (e: unknown) => { if (e instanceof SpawnError) { const err = new SpawnError(`${out}\n${e.message}`, e.exitCode, e.command, e.args, out, e.stderr); rej(err); } else { const err = new Error(`${out}\n${e instanceof Error ? e.message : String(e)}`); rej(err); } }, complete: () => res(out), }); }); } function wrapObservableInSplitPromise(obs: Observable<OutputLine>) { return new Promise<[string, string]>((res, rej) => { let out = ""; let err = ""; obs.subscribe({ next: (x: OutputLine) => { if (x.source === "stdout") { out += x.text; } else { err += x.text; } }, error: (e: unknown) => { if (e instanceof SpawnError) { const error = new SpawnError(`${out}\n${e.message}`, e.exitCode, e.command, e.args, out, err); rej(error); } else { const error = new Error(`${out}\n${e instanceof Error ? e.message : String(e)}`); rej(error); } }, complete: () => res([out, err]), }); }); } /** * Spawns a process as a child process. * * @param {string} exe The executable to run * @param {string[]} params The parameters to pass to the child * @param {SpawnOptions & SpawnRxExtras} opts Options to pass to spawn. * * @return {Promise<[string, string]>} Returns an Promise that represents a child * process. The value returned is the process * output. If the process terminates with a * non-zero value, the Promise will resolve with * an Error. */ export function spawnPromise( exe: string, params: string[], opts: SpawnOptions & SpawnRxExtras & { split: true }, ): Promise<[string, string]>; /** * Spawns a process as a child process. * * @param {string} exe The executable to run * @param {string[]} params The parameters to pass to the child * @param {SpawnOptions & SpawnRxExtras} opts Options to pass to spawn. * * @return {Promise<string>} Returns an Promise that represents a child * process. The value returned is the process * output. If the process terminates with a * non-zero value, the Promise will resolve with * an Error. */ export function spawnPromise(exe: string, params: string[], opts?: SpawnOptions & SpawnRxExtras): Promise<string>; /** * Spawns a process as a child process. * * @param {string} exe The executable to run * @param {string[]} params The parameters to pass to the child * @param {Object} opts Options to pass to spawn. * * @return {Promise<string>} Returns an Promise that represents a child * process. The value returned is the process * output. If the process terminates with a * non-zero value, the Promise will resolve with * an Error. */ export function spawnPromise( exe: string, params: string[], opts?: SpawnOptions & SpawnRxExtras, ): Promise<string> | Promise<[string, string]> { if (opts?.split) { return wrapObservableInSplitPromise(spawn(exe, params, { ...(opts ?? {}), split: true })); } return wrapObservableInPromise(spawn(exe, params, { ...(opts ?? {}), split: false })); }