import { ChildProcess, ChildProcessWithoutNullStreams, exec, ExecOptions, fork, ForkOptions, spawn, SpawnOptions, } from 'child_process' import through2 from 'through2' import pDefer from 'p-defer' import util from 'util' import { prefixLog, prefixStream } from './prefixStream' import { addChildProcess } from '../tasks/gulpRegistry' import stripAnsi from 'strip-ansi' export type AllSpawnableApps = | `cmd-${string}` | `vite-${string}` | `vite:build-${string}` | `serve:${string}` | 'gql-codegen' | `cy:${string}` interface TapThroughConfig { tapOut?: through2.TransformFunction tapErr?: through2.TransformFunction } interface SpawnedOptions extends TapThroughConfig, SpawnOptions { waitForExit?: boolean waitForData?: boolean } interface SpawnUntilMatchConfig { command: string match: string | RegExp options?: SpawnOptions } export async function spawnUntilMatch ( prefix: AllSpawnableApps, config: SpawnUntilMatchConfig, ) { const dfd = pDefer() let ready = false spawned(prefix, config.command, { ...config.options, tapOut (chunk, enc, cb) { if (!ready && stripAnsi(String(chunk)).match(config.match)) { ready = true setTimeout(() => dfd.resolve(), 20) // flush the rest of the chunks } cb(null, chunk) }, }) return dfd.promise } interface ForkUntilMatchConfig { modulePath: string args: string[] match: string | RegExp options?: SpawnOptions } export async function forkUntilMatch ( prefix: AllSpawnableApps, config: ForkUntilMatchConfig, ) { const dfd = pDefer() let ready = false const cp = await forked(prefix, config.modulePath, config.args, { ...config.options, tapOut (chunk, enc, cb) { if (!ready && String(chunk).match(config.match)) { ready = true setTimeout(() => dfd.resolve(cp), 20) // flush the rest of the chunks } cb(null, chunk) }, }) return dfd.promise } export async function spawned ( prefix: AllSpawnableApps, command: string, opts: SpawnedOptions = {}, ) { const { waitForExit, waitForData, tapErr, tapOut, ...spawnOpts } = opts const [executable, ...rest] = command.split(' ') const cp = universalSpawn(executable, rest, { stdio: 'pipe', ...spawnOpts, env: { FORCE_COLOR: '1', NODE_ENV: 'development', ...process.env, ...spawnOpts.env, }, }) addChildProcess(cp) return streamHandler(cp, { tapErr, tapOut, command, prefix, waitForExit, waitForData, }) } interface ForkedOptions extends TapThroughConfig, ForkOptions { waitForExit?: boolean waitForData?: boolean } export async function forked ( prefix: AllSpawnableApps, modulePath: string, args: string[], opts: ForkedOptions = {}, ) { const { waitForExit, waitForData, tapErr, tapOut, ...spawnOpts } = opts const cp = fork(modulePath, args, { stdio: 'pipe', ...spawnOpts, env: { FORCE_COLOR: '1', NODE_ENV: 'development', ...process.env, ...spawnOpts.env, }, }) addChildProcess(cp) return streamHandler(cp, { tapOut, tapErr, command: modulePath, prefix, waitForExit, waitForData, }) } function writeError (e: Error) { return JSON.stringify({ name: e.name, message: e.message, stack: e.stack }) } const execAsyncLocal = util.promisify(exec) export interface ExecAsyncOptions extends ExecOptions { encoding?: string | null silent?: boolean } export const execAsync = async ( command: string, options: ExecAsyncOptions = {}, ) => { const { silent } = options if (!silent) { console.log(command) } const result = await execAsyncLocal(command, options) if (!silent && typeof result.stdout === 'string' && result.stdout.length) { console.log(result.stdout) } if (!silent && typeof result.stderr === 'string' && result.stderr.length) { console.error(result.stderr) } return result } export interface StreamHandlerConfig extends TapThroughConfig { prefix: string waitForExit?: boolean waitForData?: boolean command: string } function streamHandler (cp: ChildProcess, config: StreamHandlerConfig) { const dfd = pDefer() const { command, tapErr = null, tapOut = null, prefix, waitForExit, waitForData = true } = config const prefixedStdout = cp.stdout?.pipe( through2(function (chunk, enc, cb) { if (tapOut) { tapOut.call(this, chunk, enc, cb) } else { cb(null, chunk) } }), ) .pipe(prefixStream(`${prefix}:${cp.pid}`)) const prefixedStderr = cp.stderr?.pipe( through2(function (chunk, enc, cb) { if (tapErr) { tapErr.call(this, chunk, enc, cb) } else { cb(null, chunk) } }), ) .pipe(prefixStream(`${prefix}:${cp.pid}`)) prefixedStdout?.pipe(process.stdout) prefixedStderr?.pipe(process.stderr) const log = prefixLog(`${prefix}:${cp.pid}`) if (waitForExit) { cp.on('exit', (code, signal) => { log.log(`Exit code: ${code} => ${signal}`) prefixedStdout?.unpipe(process.stdout) prefixedStderr?.unpipe(process.stderr) dfd.resolve(cp) }) cp.once('error', (e) => { log.error(`error executing ${command} ${writeError(e)}`) prefixedStdout?.unpipe(process.stdout) prefixedStderr?.unpipe(process.stderr) dfd.reject(e) }) } else { cp.once('exit', (code, signal) => { log.log(`Exit code: ${code} => ${signal}`) }) cp.once('error', (e) => { log.error(`error executing ${command} ${writeError(e)}`) dfd.reject(e) }) if (waitForData) { cp.stdout?.once('data', () => { dfd.resolve(cp) }) } else { dfd.resolve(cp) } } return dfd.promise } const isWin = process.platform === 'win32' /** * Pretreat commands to make them compatible with windows * @param command * @returns */ export function universalSpawn ( command: string, args: string[], opts?: any, ): ChildProcessWithoutNullStreams { const uCommand = isWin ? `${(command).replace(/\//g, '\\')}.cmd` : command return spawn(uCommand, args, opts) }