UNPKG

@plugjs/plug

Version:
334 lines (295 loc) 12.9 kB
import { fork } from 'node:child_process' import { Console } from 'node:console' import { Writable } from 'node:stream' import { assert, BuildFailure } from './asserts' import { runAsync } from './async' import { Files } from './files' import { $gry, $p, $red, logOptions, NOTICE, WARN } from './logging' import { emit, emitForked } from './logging/emit' import { requireFilename, resolveFile } from './paths' import { Context, install } from './pipe' import type { LogLevel } from './logging' import type { ForkedLogMessage } from './logging/emit' import type { AbsolutePath } from './paths' import type { Plug, PlugName, PlugResult } from './pipe' /** * Options accepted by {@link ForkingPlug}'s instrumenting how the process * will be spawned (environment variables to be passed to the child process). */ export interface ForkOptions { /** The directory where coverage data will be saved */ coverageDir?: string, /** Force the specified module type when dynamically transpiling TypeScript */ forceModule?: 'commonjs' | 'module' } /** Fork data, from parent to child process */ export interface ForkData { /** Script name for the Plug to execute */ scriptFile: AbsolutePath, /** Export name in the script for the Plug to execute */ exportName: string, /** Plug constructor arguments */ constructorArgs: any[], /** Task name (for logs) */ taskName: string, /** Build file name */ buildFile: AbsolutePath, /** Files directory */ filesDir: AbsolutePath, /** All files to pipe */ filesList: AbsolutePath[], logIndent: number, } /** Fork result, from child to parent process */ export interface ForkResult { /** If this is `true` we _might_ have `filesDir` and `filesList` */ failed: boolean, /** Files directory of the result */ filesDir?: AbsolutePath | undefined, /** All files returned by the plug */ filesList?: AbsolutePath[] | undefined, } /* ========================================================================== * * PARENT PROCESS SIDE OF THE FORKING PLUG IMPLEMENTATION * * ========================================================================== */ export abstract class ForkingPlug implements Plug<PlugResult> { constructor( private readonly _scriptFile: AbsolutePath, private readonly _arguments: any[], private readonly _exportName: string, ) {} pipe(files: Files, context: Context): Promise<PlugResult> { const request: ForkData = { scriptFile: this._scriptFile, exportName: this._exportName, constructorArgs: this._arguments, taskName: context.taskName, buildFile: context.buildFile, filesDir: files.directory, filesList: [ ...files.absolutePaths() ], logIndent: context.log.indent, } /* Get _this_ filename to spawn */ const script = requireFilename(__fileurl) context.log.debug('About to fork plug from', $p(this._scriptFile)) /* Environment variables */ const env = { ...process.env, ...logOptions.forkEnv(context.taskName) } /* Check our args (reversed) to see if the last specifies `coverageDir` */ for (let i = this._arguments.length - 1; i >= 0; i --) { if (this._arguments[i] == null) continue // null or undefined... optionals if (typeof this._arguments[i] === 'object') { if (typeof this._arguments[i].coverageDir === 'string') { const dir = env.NODE_V8_COVERAGE = context.resolve(this._arguments[i].coverageDir) context.log.debug('Forked process will produce coverage in', $p(dir)) } if (typeof this._arguments[i].forceModule === 'string') { const force = env.__TS_LOADER_FORCE_TYPE = this._arguments[i].forceModule context.log.debug('Forked process will force module type as', $p(force)) } } } /* Run our script in a _separate_ process */ const child = fork(script, { stdio: [ 'ignore', 'inherit', 'inherit', 'ipc' ], serialization: 'advanced', env, }) /* Do some logging... */ context.log.info('Running', $p(script), $gry(`(pid=${child.pid})`)) /* Return a promise from the child process events */ let done = false // this will be fixed up in "finally" below return new Promise<PlugResult>((resolve, reject) => { let response: ForkResult | undefined = undefined child.on('error', (error) => { context.log.error('Forked plug process error', error) return done || reject(BuildFailure.fail()) }) child.on('message', (message: ForkResult | ForkedLogMessage) => { if ('logLevel' in message) { const { logLevel, taskName, lines } = message lines.forEach((line) => { // this is _a hack_, as we want to reuse the indent and prefix of // the current log, but the task name from the forked plug!!! (context.log as any)._emit(logLevel, [ line ], taskName) }) } else { context.log.debug('Message from forked plug process with PID', child.pid, message) response = message } }) child.on('exit', (code, signal) => { if (signal) { context.log.error(`Forked plug process exited with signal ${signal}`, $gry(`(pid=${child.pid})`)) return done || reject(BuildFailure.fail()) } else if (code !== 0) { context.log.error(`Forked plug process exited with code ${code}`, $gry(`(pid=${child.pid})`)) return done || reject(BuildFailure.fail()) } else if (! response) { context.log.error('Forked plug process exited with no result', $gry(`(pid=${child.pid})`)) return done || reject(BuildFailure.fail()) } else if (response.failed) { // definitely logged on the child side return done || reject(BuildFailure.fail()) } /* We definitely have a successful result! */ return done || resolve(response.filesDir && response.filesList ? Files.builder(response.filesDir).add(...response.filesList).build() : undefined) }) /* After the handlers have been setup, send the message */ child.on('spawn', () => { try { /* Ignore the return value from `child.send(...)` here. The docs allow * it to return `false` _when the backlog of unsent messages exceeds a * threshold that makes it unwise to send more_. Bascially, on large * file lists, this returns `false`... The only thing we can do is to * handle any callback error, and hope for the best! */ child.send(request, (error) => { if (error) { context.log.error('Error sending message to forked plug process (callback failure)', error) reject(BuildFailure.fail()) } }) } catch (error) { context.log.error('Error sending message to forked plug process (exception caught)', error) reject(BuildFailure.fail()) } }) }).finally(() => done = true) } } /* ========================================================================== * * CHILD PROCESS SIDE OF THE FORKING PLUG IMPLEMENTATION * * ========================================================================== */ /* * If we were started as ourselves (fork.js) and we have an IPC channel to the * parent process, we can safely assume we need to run our plug... So we wait * for the message and respond once the plug returns _something_! */ if ((process.argv[1] === requireFilename(__fileurl)) && (process.send)) { /* Save the original console, we'll replace it on "message" */ const originalConsole = globalThis.console /* Unhandled exceptions and graceful termination */ process.on('uncaughtException', (error, origin) => { originalConsole.error( $red('\n= UNCAUGHT EXCEPTION ========================================='), `\nError (${origin}):`, error, `\nNode.js ${process.version} (pid=${process.pid})\n`) process.nextTick(() => process.exit(3)) }) /* If we haven't processed our message in 5 seconds, fail _badly_ */ const timeout = setTimeout(() => { originalConsole.error('Fork not initialized in 5 seconds') process.exit(2) }, 5000).unref() /* Await our message to initialize and run the plug */ process.on('message', (message: ForkData) => { clearTimeout(timeout) const { scriptFile, exportName, constructorArgs, taskName, buildFile, filesDir, filesList, logIndent, } = message /* Before anything else, capture logs! */ emit.emitter = emitForked // replace the log emitter... /* Create a couple of writers for our fake "stdout" and "stderr" */ const makeWritable = (level: LogLevel): Writable => new class extends Writable { _write(chunk: any, _: BufferEncoding, callback: (error?: Error | null) => void): void { const string: string = chunk.toString() const message = string.endsWith('\n') ? string.slice(0, -1) : string emit.emitter({ level, taskName }, [ message ]) callback() } } /* Replace the console with our own sending messages as logs */ globalThis.console = new Console(makeWritable(NOTICE), makeWritable(WARN)) /* First of all, our plug context */ const context = new Context(buildFile, taskName) context.log.indent = logIndent context.log.debug('Message from parent process for PID', process.pid, message) /* Contextualize this run, and go! */ process.exitCode = 0 const result = runAsync(context, async () => { /* Check that we have a proper script file name */ assert(resolveFile(scriptFile), `Script file ${$p(scriptFile)} not found`) const script = await import(scriptFile) /* Figure out the constructor, in the "default" chain */ let Ctor if (exportName === 'default') { Ctor = script while (Ctor && (typeof Ctor !== 'function')) Ctor = Ctor.default assert(typeof Ctor === 'function', `Script ${$p(scriptFile)} does not export a default constructor`) } else { Ctor = script[exportName] if ((! Ctor) && (script.default)) Ctor = script.default[exportName] assert(typeof Ctor === 'function', `Script ${$p(scriptFile)} does not export "${exportName}"`) } /* Create the Plug instance and our Files instance */ const plug = new Ctor(...constructorArgs) as Plug<PlugResult> const files = Files.builder(filesDir).add(...filesList).build() /* Run and return the result */ return plug.pipe(files, context) }) /* The result promise generates a message back to the parent process */ const promise = result.then((result) => { const message: ForkResult = result ? { failed: false, filesDir: result.directory, filesList: [ ...result.absolutePaths() ] } : { failed: false } return new Promise<void>((resolve, reject) => { process.send!(message, (err?: Error | null) => err ? reject(err) : resolve()) }) }, (error) => { context.log.error(error) return new Promise<void>((resolve, reject) => { process.send!({ failed: true }, (err?: Error | null) => err ? reject(err) : resolve()) }) }) /* The promise generated by `process.send()` simply triggers process exit */ promise.then(() => { context.log.debug('Forked plug with pid', process.pid, 'exiting') }, (error) => { originalConsole.error('\n\nError sending message back to parent process', error) process.exitCode = 1 }).finally(() => { process.disconnect() process.exit(process.exitCode) }) }) } /** * Install a _forking_ {@link Plug} in the {@link Pipe}, in other words * execute the plug in a separate process. * * As a contract, if the _last non-null_ parameter of the constructor is an * object and contains the key `coverageDir`, the process will be forked with * the approptiately resolved `NODE_V8_COVERAGE` environment variable. * * Also, forking plugs require some special attention: * * * plug functions are not supported, only classes implementing the * {@link Plug} interface can be used with this. * * * the class itself _MUST_ be exported as the _default_ export for the * `scriptFile` specified below. This is to simplify interoperability between * CommonJS and ESM modules as we use dynamic `import(...)` statements. */ export function installForking<Name extends PlugName>( plugName: Name, scriptFile: AbsolutePath, exportName: string = 'default', ): void { /** Extend out our ForkingPlug below */ const ctor = class extends ForkingPlug { constructor(...args: any[]) { super(scriptFile, args, exportName) } } /** Install the plug in */ install(plugName, ctor as any) }