teen_process
Version:
A grown up version of Node's spawn/exec
192 lines (175 loc) • 6.13 kB
text/typescript
import {spawn} from 'node:child_process';
import {quote} from 'shell-quote';
import {formatEnoent} from './helpers';
import {CircularBuffer, MAX_BUFFER_SIZE} from './circular-buffer';
import type {
TeenProcessExecOptions,
TeenProcessExecResult,
BufferProp,
ExecError,
StreamName,
} from './types';
/**
* Spawns a child process and collects its output.
*
* This is a promisified version of Node's spawn that collects stdout and stderr,
* handles timeouts, and provides error context.
*
* @template T - The options type extending TeenProcessExecOptions
* @param cmd - The command to execute
* @param args - Array of arguments to pass to the command (default: [])
* @param originalOpts - Execution options including timeout, encoding, environment, etc.
* @returns Promise resolving to an object with stdout, stderr, and exit code
*
* @throws {ExecError} When the process exits with non-zero code or times out
*
* @example
* ```typescript
* // Simple execution
* const {stdout, stderr, code} = await exec('ls', ['-la']);
*
* // With timeout and custom encoding
* const result = await exec('long-running-cmd', [], {
* timeout: 5000,
* encoding: 'utf8',
* cwd: '/custom/path'
* });
*
* // Return output as Buffer
* const {stdout} = await exec('cat', ['image.png'], {isBuffer: true});
* ```
*/
export async function exec<T extends TeenProcessExecOptions = TeenProcessExecOptions>(
cmd: string,
args: string[] = [],
originalOpts: T = {} as T,
): Promise<TeenProcessExecResult<BufferProp<T>>> {
// get a quoted representation of the command for error strings
const rep = quote([cmd, ...args]);
const defaults: TeenProcessExecOptions = {
timeout: undefined,
encoding: 'utf8',
killSignal: 'SIGTERM',
cwd: undefined,
env: process.env,
ignoreOutput: false,
stdio: 'inherit',
isBuffer: false,
shell: undefined,
logger: undefined,
maxStdoutBufferSize: MAX_BUFFER_SIZE,
maxStderrBufferSize: MAX_BUFFER_SIZE,
};
const opts = applyDefaults(defaults, originalOpts);
const isBuffer = Boolean(opts.isBuffer);
const spawnOpts = buildSpawnOptions(opts, originalOpts);
return await new Promise<TeenProcessExecResult<BufferProp<T>>>((resolve, reject) => {
const proc = spawn(cmd, args, spawnOpts);
const stdoutBuffer = new CircularBuffer(opts.maxStdoutBufferSize);
const stderrBuffer = new CircularBuffer(opts.maxStderrBufferSize);
let timer: NodeJS.Timeout | null = null;
proc.on('error', async (err: NodeJS.ErrnoException) => {
let error = err;
if (error.code === 'ENOENT') {
error = await formatEnoent(error, cmd, opts.cwd?.toString());
}
reject(error);
});
if (proc.stdin) {
proc.stdin.on('error', (err: NodeJS.ErrnoException) => {
reject(new Error(`Standard input '${err.syscall}' error: ${err.stack}`));
});
}
const handleStream = (streamType: StreamName, buffer: CircularBuffer) => {
const stream = proc[streamType];
if (!stream) {
return;
}
stream.on('error', (err: NodeJS.ErrnoException) => {
const capitalizedStreamType =
streamType.charAt(0).toUpperCase() + streamType.slice(1).toLowerCase();
reject(new Error(`${capitalizedStreamType} '${err.syscall}' error: ${err.stack}`));
});
if (opts.ignoreOutput) {
// https://github.com/nodejs/node/issues/4236
stream.on('data', () => {});
return;
}
stream.on('data', (chunk: Buffer) => {
buffer.add(chunk);
if (typeof opts.logger?.debug === 'function') {
opts.logger.debug(chunk.toString());
}
});
};
handleStream('stdout', stdoutBuffer);
handleStream('stderr', stderrBuffer);
function getStdio<U extends boolean>(
wantBuffer: U,
): U extends true ? {stdout: Buffer; stderr: Buffer} : {stdout: string; stderr: string} {
const stdout = wantBuffer
? stdoutBuffer.value()
: stdoutBuffer.value().toString(opts.encoding);
const stderr = wantBuffer
? stderrBuffer.value()
: stderrBuffer.value().toString(opts.encoding);
return {stdout, stderr} as U extends true
? {stdout: Buffer; stderr: Buffer}
: {stdout: string; stderr: string};
}
proc.on('close', (code: number | null) => {
if (timer) {
clearTimeout(timer);
}
const {stdout, stderr} = getStdio(isBuffer);
if (code === 0) {
resolve({stdout, stderr, code} as TeenProcessExecResult<BufferProp<T>>);
} else {
const err = Object.assign(new Error(`Command '${rep}' exited with code ${code}`), {
stdout,
stderr,
code,
}) as ExecError;
reject(err);
}
});
if (opts.timeout) {
timer = setTimeout(() => {
const {stdout, stderr} = getStdio(isBuffer);
const err = Object.assign(new Error(`Command '${rep}' timed out after ${opts.timeout}ms`), {
stdout,
stderr,
code: null,
}) as ExecError;
reject(err);
proc.kill(opts.killSignal ?? 'SIGTERM');
}, opts.timeout);
}
});
}
function applyDefaults<T extends TeenProcessExecOptions>(
defaults: TeenProcessExecOptions,
originalOpts: T,
): T {
const normalizedOriginalOpts =
originalOpts !== null && typeof originalOpts === 'object' ? originalOpts : ({} as T);
const definedOriginalOpts = Object.fromEntries(
Object.entries(normalizedOriginalOpts).filter(([, value]) => value !== undefined),
) as Partial<T>;
return {...defaults, ...definedOriginalOpts} as T;
}
function buildSpawnOptions<T extends TeenProcessExecOptions>(opts: T, originalOpts: T) {
return {
cwd: opts.cwd,
env: opts.env,
shell: opts.shell,
argv0: opts.argv0,
uid: opts.uid,
gid: opts.gid,
detached: opts.detached,
windowsHide: opts.windowsHide,
windowsVerbatimArguments: opts.windowsVerbatimArguments,
signal: opts.signal,
...(Object.prototype.hasOwnProperty.call(originalOpts, 'stdio') ? {stdio: opts.stdio} : {}),
};
}