spawn-rx
Version:
An Rx-version of child_process.spawn
451 lines • 17.3 kB
JavaScript
;
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