UNPKG

@plugjs/plug

Version:
259 lines 13 kB
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) { if (typeof path === "string" && /^\.\.?\//.test(path)) { return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) { return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js"); }); } return path; }; import { fork } from 'node:child_process'; import { Console } from 'node:console'; import { Writable } from 'node:stream'; import { assert, BuildFailure } from "./asserts.js"; import { runAsync } from "./async.js"; import { Files } from "./files.js"; import { $gry, $p, $red, logOptions, NOTICE, WARN } from "./logging.js"; import { emit, emitForked } from "./logging/emit.js"; import { requireFilename, resolveFile } from "./paths.js"; import { Context, install } from "./pipe.js"; /* ========================================================================== * * PARENT PROCESS SIDE OF THE FORKING PLUG IMPLEMENTATION * * ========================================================================== */ export class ForkingPlug { _scriptFile; _arguments; _exportName; constructor(_scriptFile, _arguments, _exportName) { this._scriptFile = _scriptFile; this._arguments = _arguments; this._exportName = _exportName; } pipe(files, context) { const request = { 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(import.meta.filename); 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((resolve, reject) => { let response = undefined; child.on('error', (error) => { context.log.error('Forked plug process error', error); return done || reject(BuildFailure.fail()); }); child.on('message', (message) => { 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._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(import.meta.filename)) && (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) => { 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) => new class extends Writable { _write(chunk, _, callback) { const 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(__rewriteRelativeImportExtension(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); 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 = result ? { failed: false, filesDir: result.directory, filesList: [...result.absolutePaths()] } : { failed: false }; return new Promise((resolve, reject) => { process.send(message, (err) => err ? reject(err) : resolve()); }); }, (error) => { context.log.error(error); return new Promise((resolve, reject) => { process.send({ failed: true }, (err) => 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(plugName, scriptFile, exportName = 'default') { /** Extend out our ForkingPlug below */ const ctor = class extends ForkingPlug { constructor(...args) { super(scriptFile, args, exportName); } }; /** Install the plug in */ install(plugName, ctor); } //# sourceMappingURL=fork.js.map