teen_process
Version:
A grown up version of Node's spawn/exec
306 lines • 11.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.SubProcess = void 0;
const node_child_process_1 = require("node:child_process");
const node_events_1 = require("node:events");
const shell_quote_1 = require("shell-quote");
const helpers_1 = require("./helpers");
const node_readline_1 = require("node:readline");
/**
* A wrapper around Node's spawn that provides event-driven process management.
*
* Extends EventEmitter to provide real-time output streaming and lifecycle events.
*
* @template TSubProcessOptions - Options type extending SubProcessOptions
*
* @fires SubProcess#output - Emitted when stdout or stderr receives data
* @fires SubProcess#line-stdout - Emitted for each line of stdout
* @fires SubProcess#line-stderr - Emitted for each line of stderr
* @fires SubProcess#lines-stdout - Legacy event emitting stdout lines (deprecated)
* @fires SubProcess#lines-stderr - Legacy event emitting stderr lines (deprecated)
* @fires SubProcess#stream-line - Emitted for combined stdout/stderr lines
* @fires SubProcess#exit - Emitted when process exits
* @fires SubProcess#stop - Emitted when process is stopped intentionally
* @fires SubProcess#die - Emitted when process dies unexpectedly with non-zero code
* @fires SubProcess#end - Emitted when process ends normally with code 0
*
* @example
* ```typescript
* const proc = new SubProcess('tail', ['-f', 'logfile.txt']);
*
* proc.on('output', (stdout, stderr) => {
* console.log('Output:', stdout);
* });
*
* proc.on('line-stdout', (line) => {
* console.log('Line:', line);
* });
*
* await proc.start();
* // ... later
* await proc.stop();
* ```
*/
class SubProcess extends node_events_1.EventEmitter {
proc;
rep;
args;
cmd;
opts;
expectingExit;
constructor(cmd, args = [], opts) {
super();
if (!cmd) {
throw new Error('Command is required');
}
if (typeof cmd !== 'string' && !(cmd instanceof String)) {
throw new Error('Command must be a string');
}
if (!Array.isArray(args)) {
throw new Error('Args must be an array');
}
this.cmd = String(cmd);
this.args = args;
this.proc = null;
this.opts = opts ?? {};
this.expectingExit = false;
this.rep = (0, shell_quote_1.quote)([String(cmd), ...args]);
}
get isRunning() {
return !!this.proc;
}
get pid() {
return this.proc?.pid ?? null;
}
/**
* Starts the subprocess and waits for it to be ready.
*
* @param startDetector - Function to detect when process is ready, number for delay in ms,
* boolean true to detach immediately, or null for default behavior
* @param timeoutMs - Maximum time to wait for process to start (in ms), or boolean true to detach
* @param detach - Whether to detach the process (requires 'detached' option)
*
* @throws {Error} When process fails to start or times out
*
* @example
* ```typescript
* // Wait for any output
* await proc.start();
*
* // Wait 100ms then continue
* await proc.start(100);
*
* // Wait for specific output
* await proc.start((stdout) => stdout.includes('Server ready'));
*
* // With timeout
* await proc.start(null, 5000);
* ```
*/
async start(startDetector = null, timeoutMs = null, detach = false) {
let startDelay = 10;
const genericStartDetector = (stdout, stderr) => stdout || stderr;
let detector = null;
if (startDetector === null) {
detector = genericStartDetector;
}
if (typeof startDetector === 'number' || startDetector instanceof Number) {
startDelay = Number(startDetector);
detector = null;
}
else if (typeof startDetector === 'function') {
detector = startDetector;
}
if ((typeof startDetector === 'boolean' || startDetector instanceof Boolean) &&
Boolean(startDetector)) {
if (!this.opts.detached) {
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
detach = true;
detector = genericStartDetector;
}
else if ((typeof timeoutMs === 'boolean' || timeoutMs instanceof Boolean) &&
Boolean(timeoutMs)) {
if (!this.opts.detached) {
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
detach = true;
timeoutMs = null;
}
return await new Promise((resolve, reject) => {
this.proc = (0, node_child_process_1.spawn)(this.cmd, this.args, this.opts);
const handleOutput = (streams) => {
const { stdout, stderr } = streams;
try {
if (detector && detector(stdout, stderr)) {
detector = null;
resolve();
}
}
catch (e) {
reject(e);
}
this.emit('output', stdout, stderr);
};
this.proc.on('error', async (err) => {
this.proc?.removeAllListeners('exit');
this.proc?.kill('SIGINT');
let error = err;
if (error.code === 'ENOENT') {
error = await (0, helpers_1.formatEnoent)(error, this.cmd, this.opts?.cwd?.toString());
}
reject(error);
this.proc?.unref();
this.proc = null;
});
const handleStreamLines = (streamName, input) => {
const rl = (0, node_readline_1.createInterface)({ input });
rl.on('line', (line) => {
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)),
stderr: (isBuffer ? Buffer.alloc(0) : ''),
}));
handleStreamLines('stdout', this.proc.stdout);
}
if (this.proc.stderr) {
this.proc.stderr.on('data', (chunk) => handleOutput({
stdout: (isBuffer ? Buffer.alloc(0) : ''),
stderr: (isBuffer
? chunk
: chunk.toString(encoding)),
}));
handleStreamLines('stderr', this.proc.stderr);
}
this.proc.on('exit', (code, signal) => {
this.emit('exit', code, signal);
let event = this.expectingExit ? 'stop' : 'die';
if (!this.expectingExit && code === 0) {
event = 'end';
}
this.emit(event, code, signal);
this.proc = null;
this.expectingExit = false;
});
if (!detector) {
setTimeout(() => resolve(), startDelay);
}
if (typeof timeoutMs === 'number' || timeoutMs instanceof Number) {
setTimeout(() => {
reject(new Error(`The process did not start within ${timeoutMs}ms (cmd: '${this.rep}')`));
}, Number(timeoutMs));
}
}).finally(() => {
if (detach && this.proc) {
this.proc.unref();
}
});
}
/**
* Stops the running subprocess by sending a signal.
*
* @param signal - Signal to send to the process (default: 'SIGTERM')
* @param timeout - Maximum time to wait for process to exit in ms (default: 10000)
*
* @throws {Error} When process is not running or doesn't exit within timeout
*
* @example
* ```typescript
* // Graceful stop with SIGTERM
* await proc.stop();
*
* // Force kill with SIGKILL
* await proc.stop('SIGKILL');
*
* // Custom timeout
* await proc.stop('SIGTERM', 5000);
* ```
*/
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 Promise((resolve, reject) => {
this.proc?.on('close', () => resolve());
this.expectingExit = true;
this.proc?.kill(signal);
setTimeout(() => {
reject(new Error(`Process didn't end after ${timeout}ms (cmd: '${this.rep}')`));
}, timeout).unref();
});
}
/**
* Waits for the process to exit and validates its exit code.
*
* @param allowedExitCodes - Array of acceptable exit codes (default: [0])
* @returns Promise resolving to the exit code
*
* @throws {Error} When process is not running or exits with disallowed code
*
* @example
* ```typescript
* // Wait for successful exit (code 0)
* const code = await proc.join();
*
* // Allow multiple exit codes
* const code = await proc.join([0, 1, 2]);
* ```
*/
async join(allowedExitCodes = [0]) {
if (!this.isRunning) {
throw new Error(`Cannot join process; it is not currently running (cmd: '${this.rep}')`);
}
return await new Promise((resolve, reject) => {
this.proc?.on('exit', (code) => {
if (code !== null && !allowedExitCodes.includes(code)) {
reject(new Error(`Process ended with exitcode ${code} (cmd: '${this.rep}')`));
}
else {
resolve(code);
}
});
});
}
/**
* Detaches the process so it continues running independently.
*
* The process must have been created with the 'detached' option.
* Once detached, the process will not be killed when the parent exits.
*
* @throws {Error} When process was not created with 'detached' option
*/
detachProcess() {
if (!this.opts.detached) {
throw new Error(`Unable to detach process that is not started with 'detached' option`);
}
if (this.proc) {
this.proc.unref();
}
}
emitLines(streamName, lines) {
const doEmit = (line) => this.emit('stream-line', `[${streamName.toUpperCase()}] ${line}`);
if (typeof lines === 'string' || lines instanceof String) {
doEmit(lines);
}
else {
for (const line of lines) {
doEmit(line);
}
}
}
}
exports.SubProcess = SubProcess;
//# sourceMappingURL=subprocess.js.map