UNPKG

spawn-rx

Version:

An Rx-version of child_process.spawn

451 lines 17.3 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __rest = (this && this.__rest) || function (s, e) { var t = {}; for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p) && e.indexOf(p) < 0) t[p] = s[p]; if (s != null && typeof Object.getOwnPropertySymbols === "function") for (var i = 0, p = Object.getOwnPropertySymbols(s); i < p.length; i++) { if (e.indexOf(p[i]) < 0 && Object.prototype.propertyIsEnumerable.call(s, p[i])) t[p[i]] = s[p[i]]; } return t; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.SpawnError = void 0; exports.statSyncNoException = statSyncNoException; exports.statNoException = statNoException; exports.findActualExecutable = findActualExecutable; exports.spawn = spawn; exports.spawnPromise = spawnPromise; const node_child_process_1 = require("node:child_process"); const sfs = __importStar(require("node:fs")); const fs = __importStar(require("node:fs/promises")); const path = __importStar(require("node:path")); const debug_1 = __importDefault(require("debug")); const lru_cache_1 = require("lru-cache"); const rxjs_1 = require("rxjs"); const operators_1 = require("rxjs/operators"); const isWindows = process.platform === "win32"; const d = (0, debug_1.default)("spawn-rx"); // tslint:disable-line:no-var-requires /** * Custom error class for spawn operations with additional metadata */ class SpawnError extends Error { exitCode; code; stdout; stderr; command; args; constructor(message, exitCode, command, args, stdout, stderr) { 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.captureStackTrace) { // eslint-disable-next-line @typescript-eslint/no-explicit-any Error.captureStackTrace(this, SpawnError); } } } exports.SpawnError = SpawnError; /** * 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 */ function statSyncNoException(file) { try { return sfs.statSync(file); } catch (_a) { 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 */ function statNoException(file) { return fs.stat(file).catch(() => null); } /** * Cache for resolved executable paths */ const pathCache = new lru_cache_1.LRUCache({ 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) { var _a; // 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 = (_a = process.env.PATH) === null || _a === void 0 ? void 0 : _a.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; } /** * 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 */ function findActualExecutable(exe, args) { // 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 }; } /** * 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. */ function spawn(exe, params, opts) { var _a; opts = opts !== null && opts !== void 0 ? opts : {}; const spawnObs = new rxjs_1.Observable((subj) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { encoding, timeout } = opts, spawnOpts = __rest(opts, ["encoding", "timeout"]); const { cmd, args } = findActualExecutable(exe, params); d(`spawning process: ${cmd} ${args.join()}, ${JSON.stringify(spawnOpts)}`); const proc = (0, node_child_process_1.spawn)(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 = 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) => (b) => { 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 (_a) { chunk = `<< Lost chunk of process output for ${exe} - length was ${b.length}>>`; } subj.next({ source: source, text: chunk }); }; const ret = new rxjs_1.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 = null; let stdoutCompleted = null; let noClose = false; if (proc.stdout) { stdoutCompleted = new rxjs_1.AsyncSubject(); proc.stdout.on("data", bufHandler("stdout")); proc.stdout.on("close", () => { stdoutCompleted.next(true); stdoutCompleted.complete(); }); } else { stdoutCompleted = (0, rxjs_1.of)(true); } if (proc.stderr) { stderrCompleted = new rxjs_1.AsyncSubject(); proc.stderr.on("data", bufHandler("stderr")); proc.stderr.on("close", () => { stderrCompleted.next(true); stderrCompleted.complete(); }); } else { stderrCompleted = (0, rxjs_1.of)(true); } proc.on("error", (e) => { noClose = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } subj.error(e); }); proc.on("close", (code) => { noClose = true; if (timeoutHandle) { clearTimeout(timeoutHandle); } const pipesClosed = (0, rxjs_1.merge)(stdoutCompleted, stderrCompleted).pipe((0, operators_1.reduce)((_acc) => 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 rxjs_1.Subscription(() => { if (noClose) { return; } if (timeoutHandle) { clearTimeout(timeoutHandle); } d(`Killing process: ${cmd} ${args.join()}`); proc.kill(); })); return ret; }); let resultObs = spawnObs; // Apply retry logic if specified if (opts.retries && opts.retries > 0) { const retryCount = opts.retries; const delay = (_a = opts.retryDelay) !== null && _a !== void 0 ? _a : 1000; resultObs = resultObs.pipe((0, operators_1.retry)({ count: retryCount, delay: (error, retryIndex) => { // 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 (0, rxjs_1.timer)(delay); } // Don't retry on other errors throw error; }, })); } return opts.split ? resultObs : resultObs.pipe((0, operators_1.map)((x) => x === null || x === void 0 ? void 0 : x.text)); } function wrapObservableInPromise(obs) { return new Promise((res, rej) => { let out = ""; obs.subscribe({ next: (x) => { out += x; }, error: (e) => { 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) { return new Promise((res, rej) => { let out = ""; let err = ""; obs.subscribe({ next: (x) => { if (x.source === "stdout") { out += x.text; } else { err += x.text; } }, error: (e) => { 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 {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. */ function spawnPromise(exe, params, opts) { if (opts === null || opts === void 0 ? void 0 : opts.split) { return wrapObservableInSplitPromise(spawn(exe, params, Object.assign(Object.assign({}, (opts !== null && opts !== void 0 ? opts : {})), { split: true }))); } return wrapObservableInPromise(spawn(exe, params, Object.assign(Object.assign({}, (opts !== null && opts !== void 0 ? opts : {})), { split: false }))); } //# sourceMappingURL=index.js.map