import { ChildProcess, IOType } from 'child_process' import child_process from 'child_process' import Stream, { Pipe, Readable, Writable } from 'stream' type StdioElement = IOType | Stream | number | null | undefined type StdioOptions = | IOType | [StdioElement, StdioElement, StdioElement, ...Array] | Array interface IOOptions { silent?: boolean stdio?: StdioOptions encoding?: BufferEncoding | 'buffer' | null maxBuffer?: number } type ChunkTypeHelper = MaxBuffer extends number ? Encoding extends BufferEncoding ? string : Buffer : Encoding extends BufferEncoding ? string : Encoding extends 'buffer' ? Buffer : undefined type ChunkType = ChunkTypeHelper< 'maxBuffer' extends keyof Options ? Options['maxBuffer'] : undefined, 'encoding' extends keyof Options ? Options['encoding'] : undefined > type IsPipeHelper = Stdio extends unknown[] ? Stdio[Fd] extends infer Value ? Value extends 'pipe' | 'overlapped' ? true : false : false : Stdio extends null | undefined | 'pipe' | 'overlapped' ? true : Stdio extends null | undefined ? Silent extends true ? false : true : false type IsPipe = IsPipeHelper< 'stdio' extends keyof Options ? Options['stdio'] : undefined, 'silent' extends keyof Options ? Options['silent'] : undefined, Fd > type Contains = A extends [infer Head, ...infer Tail] ? Head extends T ? true : Contains : false export interface ChildProcessResult { stdout: IsPipe extends true ? ChunkType : undefined stderr: IsPipe extends true ? ChunkType : undefined code: number | null signal: string | null killed: boolean } type StdioStreams = { [K in keyof Stdio]: Stdio[K] extends 'pipe' | 'overlapped' ? K extends 0 | '0' ? Writable : Readable : null } export interface ChildProcessPromise extends ChildProcess, Promise> { stdin: IsPipe extends true ? Writable : null stdout: IsPipe extends true ? Readable : null stderr: IsPipe extends true ? Readable : null readonly channel: Options['stdio'] extends infer Stdio ? Contains extends true ? Pipe : undefined : undefined readonly stdio: Options['stdio'] extends infer Stdio ? Stdio extends unknown[] ? StdioStreams : Stdio extends null | undefined | 'pipe' | 'overlapped' ? [Writable, Readable, Readable, undefined, undefined] : [null, null, null, undefined, undefined] : [null, null, null, undefined, undefined] } interface PromisifyChildProcessBaseOptions extends IOOptions { killSignal?: NodeJS.Signals | number } function joinChunks( chunks: Buffer[] | undefined, encoding: BufferEncoding | 'buffer' | null | undefined ): string | Buffer | undefined { if (!chunks) return undefined const buffer = Buffer.concat(chunks) return encoding && encoding !== 'buffer' ? buffer.toString(encoding) : buffer } export function promisifyChildProcess< Options extends PromisifyChildProcessBaseOptions, >(child: ChildProcess, options?: Options): ChildProcessPromise { const promise = new Promise>( (resolve, reject) => { const encoding = options?.encoding const killSignal = options?.killSignal const captureStdio = encoding != null || options?.maxBuffer != null const maxBuffer = options?.maxBuffer ?? 1024 * 1024 let bufferSize = 0 let error: Error | undefined const stdoutChunks: Buffer[] | undefined = captureStdio && child.stdout ? [] : undefined const stderrChunks: Buffer[] | undefined = captureStdio && child.stderr ? [] : undefined const capture = (chunks: Buffer[]) => (data: string | Buffer) => { if (typeof data === 'string') data = Buffer.from(data) const remaining = Math.max(0, maxBuffer - bufferSize) bufferSize += Math.min(remaining, data.length) if (data.length > remaining) { error = new Error('maxBuffer exceeded') child.kill(killSignal ?? 'SIGTERM') data = data.subarray(0, remaining) } chunks.push(data) } const captureStdout = stdoutChunks ? capture(stdoutChunks) : undefined const captureStderr = stderrChunks ? capture(stderrChunks) : undefined if (captureStdout) child.stdout?.on('data', captureStdout) if (captureStderr) child.stderr?.on('data', captureStderr) function onError(err: Error) { error = err done() } child.on('error', onError) function done(code: number | null = null, signal: string | null = null) { child.removeListener('error', onError) child.removeListener('close', done) if (captureStdout) child.stdout?.removeListener('data', captureStdout) if (captureStderr) child.stderr?.removeListener('data', captureStderr) const stdout = joinChunks( stdoutChunks, encoding ) as ChildProcessResult['stdout'] const stderr = joinChunks( stderrChunks, encoding ) as ChildProcessResult['stderr'] if (error || (code != null && code != 0) || signal != null) { reject( Object.assign( error || new Error( signal != null ? `Process was killed with ${signal}` : `Process exited with code ${code}` ), { code, signal, killed: signal != null, stdout, stderr, } ) ) } else { resolve({ stderr, stdout, code, signal, killed: false }) } } child.on('close', done) } ) return Object.create(child, { then: { value: promise.then.bind(promise) }, catch: { value: promise.catch.bind(promise) }, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition finally: { value: promise.finally?.bind(promise) }, }) } export interface SpawnOptions extends child_process.SpawnOptions { stdio?: StdioOptions encoding?: BufferEncoding maxBuffer?: number } function isArray(t: unknown): t is unknown[] | readonly unknown[] { return Array.isArray(t) } export function spawn( command: string, args: readonly string[], options?: Options ): ChildProcessPromise export function spawn( command: string, options?: Options ): ChildProcessPromise export function spawn( command: string, args?: readonly string[] | Options, options?: Options ): ChildProcessPromise { if (!isArray(args)) { options = args args = [] } return promisifyChildProcess( child_process.spawn(command, args, options as child_process.SpawnOptions), options ) } export interface ForkOptions extends child_process.ForkOptions { stdio?: StdioOptions encoding?: BufferEncoding maxBuffer?: number } export function fork( module: string, args: Array, options?: Options ): ChildProcessPromise export function fork( module: string, options?: Options ): ChildProcessPromise export function fork( module: string, args?: Array | Options, options?: Options ): ChildProcessPromise { if (!isArray(args)) { options = args args = [] } return promisifyChildProcess( child_process.fork(module, args, options), options ) } function promisifyExecMethod(method: (...args: any[]) => ChildProcess) { return (...args: any[]): ChildProcessPromise => { let child: ChildProcess | undefined const promise = new Promise>((resolve, reject) => { child = method( ...args, ( err: Error | null, stdout: string | Buffer, stderr: string | Buffer ) => { if (err) { reject(Object.assign(err, { stdout, stderr })) } else { resolve({ code: 0, signal: null, killed: false, stdout: stdout as any, stderr: stderr as any, }) } } ) }) if (!child) { throw new Error('unexpected error: child has not been initialized') } return Object.create(child, { then: { value: promise.then.bind(promise) }, catch: { value: promise.catch.bind(promise) }, // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition finally: { value: promise.finally?.bind(promise) }, }) } } export interface ExecOptions extends child_process.ExecOptions { encoding?: BufferEncoding | 'buffer' } export const exec: ( command: string, options?: Options ) => ChildProcessPromise = promisifyExecMethod(child_process.exec) export interface ExecFileOptions extends child_process.ExecFileOptions { encoding?: BufferEncoding | 'buffer' } export const execFile: { ( file: string, args: readonly string[], options?: Options ): ChildProcessPromise ( file: string, options?: Options ): ChildProcessPromise } = promisifyExecMethod(child_process.execFile) export function isChildProcessError(error: unknown): error is Error & { code: number | null signal: NodeJS.Signals | null killed: boolean stdout?: string | Buffer stderr?: string | Buffer } { return error instanceof Error && 'code' in error && 'signal' in error }