teen_process
Version:
A grown up version of Node's spawn/exec
277 lines • 11.7 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.SubProcess = void 0;
const child_process_1 = require("child_process");
const events_1 = require("events");
const bluebird_1 = __importDefault(require("bluebird"));
const shell_quote_1 = require("shell-quote");
const lodash_1 = __importDefault(require("lodash"));
const helpers_1 = require("./helpers");
const node_readline_1 = require("node:readline");
/**
* @template {SubProcessOptions} TSubProcessOptions
*/
class SubProcess extends events_1.EventEmitter {
/**
* @param {string} cmd
* @param {string[]} [args=[]]
* @param {TSubProcessOptions} [opts]
*/
constructor(cmd, args = [], opts) {
super();
if (!cmd)
throw new Error('Command is required'); // eslint-disable-line curly
if (!lodash_1.default.isString(cmd))
throw new Error('Command must be a string'); // eslint-disable-line curly
if (!lodash_1.default.isArray(args))
throw new Error('Args must be an array'); // eslint-disable-line curly
this.cmd = cmd;
this.args = args;
this.proc = null;
this.opts = opts ?? {};
this.expectingExit = false;
// get a quoted representation of the command for error strings
this.rep = (0, shell_quote_1.quote)([cmd, ...args]);
}
get isRunning() {
// presence of `proc` means we have connected and started
return !!this.proc;
}
/**
*
* @param {string} streamName
* @param {Iterable<string>|string} lines
*/
emitLines(streamName, lines) {
const doEmit = (/** @type {string} */ line) => this.emit('stream-line', `[${streamName.toUpperCase()}] ${line}`);
if (lodash_1.default.isString(lines)) {
doEmit(lines);
}
else {
for (const line of lines) {
doEmit(line);
}
}
}
/**
* spawn the subprocess and return control whenever we deem that it has fully
* "started"
*
* @param {StartDetector|number?} startDetector
* @param {number?} timeoutMs
* @param {boolean} detach
* @returns {Promise<void>}
*/
async start(startDetector = null, timeoutMs = null, detach = false) {
let startDelay = 10;
const genericStartDetector = /** @type {StartDetector} */ (function genericStartDetector(stdout, stderr) {
return stdout || stderr;
});
// the default start detector simply returns true when we get any output
if (startDetector === null) {
startDetector = genericStartDetector;
}
// if the user passes a number, then we simply delay a certain amount of
// time before returning control, rather than waiting for a condition
if (lodash_1.default.isNumber(startDetector)) {
startDelay = startDetector;
startDetector = null;
}
// if the user passes in a boolean as one of the arguments, use it for `detach`
if (lodash_1.default.isBoolean(startDetector) && startDetector) {
if (!this.opts.detached) {
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
detach = true;
startDetector = genericStartDetector;
}
else if (lodash_1.default.isBoolean(timeoutMs) && timeoutMs) {
if (!this.opts.detached) {
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
detach = true;
timeoutMs = null;
}
// return a promise so we can wrap the async behavior
return await new bluebird_1.default((resolve, reject) => {
// actually spawn the subproc
this.proc = (0, child_process_1.spawn)(this.cmd, this.args, this.opts);
// this function handles output that we collect from the subproc
/**
* @param { {
* stdout: TSubProcessOptions extends TIsBufferOpts ? Buffer : string,
* stderr: TSubProcessOptions extends TIsBufferOpts ? Buffer : string
* } } streams
*/
const handleOutput = (streams) => {
const { stdout, stderr } = streams;
// if we have a startDetector, run it on the output so we can resolve/
// reject and move on from start
try {
if (lodash_1.default.isFunction(startDetector) && startDetector(stdout, stderr)) {
startDetector = null;
resolve();
}
}
catch (e) {
reject(e);
}
// emit the actual output for whomever's listening
this.emit('output', stdout, stderr);
};
// if we get an error spawning the proc, reject and clean up the proc
this.proc.on('error', /** @param {NodeJS.ErrnoException} err */ async (err) => {
this.proc?.removeAllListeners('exit');
this.proc?.kill('SIGINT');
if (err.code === 'ENOENT') {
err = await (0, helpers_1.formatEnoent)(err, this.cmd, this.opts?.cwd?.toString());
}
reject(err);
this.proc?.unref();
this.proc = null;
});
const handleStreamLines = (/** @type {string} */ streamName, /** @type {import('stream').Readable} */ input) => {
const rl = (0, node_readline_1.createInterface)({ input });
rl.on('line', (line) => {
// This event is a legacy one
// It always produces a single-item array
if (this.listenerCount(`lines-${streamName}`)) {
this.emit(`lines-${streamName}`, [line]);
}
this.emit(`line-${streamName}`, line);
if (this.listenerCount('stream-line')) {
this.emitLines(streamName, line);
}
});
};
const isBuffer = Boolean(this.opts.isBuffer);
const encoding = this.opts.encoding || 'utf8';
if (this.proc.stdout) {
this.proc.stdout.on('data', (chunk) => handleOutput({
stdout: isBuffer ? chunk : chunk.toString(encoding),
// @ts-ignore This is OK
stderr: isBuffer ? Buffer.alloc(0) : '',
}));
handleStreamLines('stdout', this.proc.stdout);
}
if (this.proc.stderr) {
this.proc.stderr.on('data', (chunk) => handleOutput({
// @ts-ignore This is OK
stdout: isBuffer ? Buffer.alloc(0) : '',
stderr: isBuffer ? chunk : chunk.toString(encoding)
}));
handleStreamLines('stderr', this.proc.stderr);
}
// when the proc exits, we might still have a buffer of lines we were
// waiting on more chunks to complete. Go ahead and emit those, then
// re-emit the exit so a listener can handle the possibly-unexpected exit
this.proc.on('exit', (code, signal) => {
this.emit('exit', code, signal);
// in addition to the bare exit event, also emit one of three other
// events that contain more helpful information:
// 'stop': we stopped this
// 'die': the process ended out of our control with a non-zero exit
// 'end': the process ended out of our control with a zero exit
let event = this.expectingExit ? 'stop' : 'die';
if (!this.expectingExit && code === 0) {
event = 'end';
}
this.emit(event, code, signal);
// finally clean up the proc and make sure to reset our exit
// expectations
this.proc = null;
this.expectingExit = false;
});
// if the user hasn't given us a startDetector, instead just resolve
// when startDelay ms have passed
if (!startDetector) {
setTimeout(() => { resolve(); }, startDelay);
}
// if the user has given us a timeout, start the clock for rejecting
// the promise if we take too long to start
if (lodash_1.default.isNumber(timeoutMs)) {
setTimeout(() => {
reject(new Error(`The process did not start within ${timeoutMs}ms ` +
`(cmd: '${this.rep}')`));
}, timeoutMs);
}
}).finally(() => {
if (detach && this.proc) {
this.proc.unref();
}
});
}
/**
* @deprecated This method is deprecated and will be removed
*/
handleLastLines() {
// TODO: THis is a noop left for backward compatibility.
// TODO: Remove it after the major version bump
}
/**
*
* @param {NodeJS.Signals} signal
* @param {number} timeout
* @returns {Promise<void>}
*/
async stop(signal = 'SIGTERM', timeout = 10000) {
if (!this.isRunning) {
throw new Error(`Can't stop process; it's not currently running (cmd: '${this.rep}')`);
}
return await new bluebird_1.default((resolve, reject) => {
this.proc?.on('close', resolve);
this.expectingExit = true;
this.proc?.kill(signal);
// this timeout needs unref() or node will wait for the timeout to fire before
// exiting the process.
setTimeout(() => {
reject(new Error(`Process didn't end after ${timeout}ms (cmd: '${this.rep}')`));
}, timeout).unref();
});
}
async join(allowedExitCodes = [0]) {
if (!this.isRunning) {
throw new Error(`Cannot join process; it is not currently running (cmd: '${this.rep}')`);
}
return await new bluebird_1.default((resolve, reject) => {
this.proc?.on('exit', (code) => {
if (code !== null && allowedExitCodes.indexOf(code) === -1) {
reject(new Error(`Process ended with exitcode ${code} (cmd: '${this.rep}')`));
}
else {
resolve(code);
}
});
});
}
/*
* This will only work if the process is created with the `detached` option
*/
detachProcess() {
if (!this.opts.detached) {
// this means that there is a misconfiguration in the calling code
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
if (this.proc) {
this.proc.unref();
}
}
get pid() {
return this.proc ? this.proc.pid : null;
}
}
exports.SubProcess = SubProcess;
exports.default = SubProcess;
/**
* @typedef {Object} SubProcessCustomOptions
* @property {boolean} [isBuffer]
* @property {string} [encoding]
*/
/**
* @typedef {SubProcessCustomOptions & import('child_process').SpawnOptionsWithoutStdio} SubProcessOptions
* @typedef {{isBuffer: true}} TIsBufferOpts
*/
//# sourceMappingURL=subprocess.js.map