wireit
Version:
Upgrade your npm scripts to make them smarter and more efficient
270 lines • 11.1 kB
JavaScript
/**
* @license
* Copyright 2022 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import * as pathlib from 'path';
import { spawn } from 'child_process';
import { augmentProcessEnvSafelyIfOnWindows, IS_WINDOWS, } from './util/windows.js';
import { Deferred } from './util/deferred.js';
/**
* The PATH environment variable of this process, minus all of the leading
* "node_modules/.bin" entries that the incoming "npm run" command already set.
*
* We want full control over which "node_modules/.bin" paths are in the PATH of
* the processes we spawn, so that cross-package dependencies act as though we
* are running "npm run" with each package as the cwd.
*
* We only need to do this once per Wireit process, because process.env never
* changes.
*/
const PATH_ENV_SUFFIX = (() => {
const path = process.env.PATH ?? '';
// Note the PATH delimiter is platform-dependent.
const entries = path.split(pathlib.delimiter);
const nodeModulesBinSuffix = pathlib.join('node_modules', '.bin');
const endOfNodeModuleBins = entries.findIndex((entry) => !entry.endsWith(nodeModulesBinSuffix));
return entries.slice(endOfNodeModuleBins).join(pathlib.delimiter);
})();
/**
* A child process spawned during execution of a script.
*/
export class ScriptChildProcess {
#script;
#child;
#started;
#completed;
#state;
get stdout() {
return this.#child.stdout;
}
get stderr() {
return this.#child.stderr;
}
constructor(script) {
this.#started = new Deferred();
this.#completed = new Deferred();
this.#state = 'starting';
/**
* Resolves when this process starts
*/
this.started = this.#started.promise;
/**
* Resolves when this child process ends.
*/
this.completed = this.#completed.promise;
// Copy only the fields we actually require from the script config, because
// the full script config contains references to the full config, which we
// want to allow to be garbage-collected across watch iterations.
this.#script = {
packageDir: script.packageDir,
name: script.name,
command: script.command,
extraArgs: script.extraArgs,
env: script.env,
};
// TODO(aomarks) Update npm_ environment variables to reflect the new
// package.
this.#child = spawn(this.#script.command.value, this.#script.extraArgs, {
cwd: this.#script.packageDir,
// Conveniently, "shell:true" has the same shell-selection behavior as
// "npm run", where on macOS and Linux it is "sh", and on Windows it is
// %COMSPEC% || "cmd.exe".
//
// References:
// https://nodejs.org/api/child_process.html#child_processspawncommand-args-options
// https://nodejs.org/api/child_process.html#default-windows-shell
// https://github.com/npm/run-script/blob/a5b03bdfc3a499bf7587d7414d5ea712888bfe93/lib/make-spawn-args.js#L11
shell: true,
env: augmentProcessEnvSafelyIfOnWindows({
FORCE_COLOR: process.stdout.isTTY && process.env.FORCE_COLOR === undefined
? 'true'
: process.env.FORCE_COLOR,
PATH: this.#pathEnvironmentVariable,
...this.#script.env,
}),
// Set "detached" on Linux and macOS so that we create a new process
// group, instead of being added to the process group for this Wireit
// process.
//
// We need a new process group so that we can use "kill(-pid)" to kill all
// of the processes in the process group, instead of just the group leader
// "sh" process. "sh" does not forward signals to child processes, so a
// regular "kill(pid)" would not kill the actual process we care about.
//
// On Windows this works differently, and we use the "\t" flag to
// "taskkill" to kill child processes. However, if we do set "detached" on
// Windows, it causes the child process to open in a new terminal window,
// which we don't want.
detached: !IS_WINDOWS,
});
this.#child.on('spawn', () => {
switch (this.#state) {
case 'starting': {
this.#started.resolve({ ok: true, value: undefined });
this.#state = 'started';
break;
}
case 'killing': {
this.#started.resolve({ ok: true, value: undefined });
// We received a kill request while we were still starting. Kill now
// that we're started.
this.#actuallyKill();
break;
}
case 'started':
case 'stopped': {
const exception = new Error(`Internal error: Expected ScriptChildProcessState ` +
`to be "started" or "killing" but was "${this.#state}"`);
this.#started.reject(exception);
this.#completed.reject(exception);
break;
}
default: {
const never = this.#state;
const exception = new Error(`Internal error: unexpected ScriptChildProcessState: ${String(never)}`);
this.#started.reject(exception);
this.#completed.reject(exception);
}
}
});
this.#child.on('error', (error) => {
const result = {
ok: false,
error: {
script,
type: 'failure',
reason: 'spawn-error',
message: error.message,
},
};
this.#started.resolve(result);
this.#completed.resolve(result);
this.#state = 'stopped';
});
this.#child.on('close', (status, signal) => {
if (this.#state === 'killing') {
this.#completed.resolve({
ok: false,
error: {
script,
type: 'failure',
reason: 'killed',
},
});
}
else if (signal !== null) {
this.#completed.resolve({
ok: false,
error: {
script,
type: 'failure',
reason: 'signal',
signal,
},
});
}
else if (status !== 0) {
this.#completed.resolve({
ok: false,
error: {
script,
type: 'failure',
reason: 'exit-non-zero',
// status should only ever be null if signal was not null, but
// this isn't reflected in the TypeScript types. Just in case, and
// to make TypeScript happy, fall back to -1 (which is a
// conventional exit status used for "exited with signal").
status: status ?? -1,
},
});
}
else {
this.#completed.resolve({ ok: true, value: undefined });
}
this.#state = 'stopped';
});
}
/**
* Kill this child process. On Linux/macOS, sends a `SIGINT` signal. On
* Windows, invokes `taskkill /pid PID /t`.
*
* Note this function returns immediately. To find out when the process was
* actually killed, use the {@link completed} promise.
*/
kill() {
switch (this.#state) {
case 'started': {
this.#actuallyKill();
return;
}
case 'starting': {
// We're still starting up, and it's not possible to abort. When we get
// the "spawn" event, we'll notice the "killing" state and actually kill
// then.
this.#state = 'killing';
return;
}
case 'killing':
case 'stopped': {
// No-op.
return;
}
default: {
const never = this.#state;
throw new Error(`Internal error: unexpected ScriptChildProcessState: ${String(never)}`);
}
}
}
#actuallyKill() {
if (this.#child.pid === undefined) {
throw new Error(`Internal error: Can't kill child process because it has no pid. ` +
`Command: ${JSON.stringify(this.#script.command)}.`);
}
if (IS_WINDOWS) {
// Windows doesn't have signals. Node ChildProcess.kill() sort of emulates
// the behavior of SIGKILL (and ignores the signal you pass in), but this
// doesn't end child processes. We have child processes because the parent
// process is the shell (cmd.exe or PowerShell).
// https://docs.microsoft.com/en-us/windows-server/administration/windows-commands/taskkill
spawn('taskkill', [
'/pid',
this.#child.pid.toString(),
/* End child processes */ '/t',
/* Force. Killing does not seem reliable otherwise. */ '/f',
]);
}
else {
// We used "detached" when we spawned, so our child is the leader of a
// process group. Passing the negative of a pid kills all processes in
// that group (without the negative, only the leader "sh" process would be
// killed).
process.kill(-this.#child.pid, 'SIGINT');
}
this.#state = 'killing';
}
/**
* Generates the PATH environment variable that should be set when this
* script's command is spawned.
*/
get #pathEnvironmentVariable() {
// Given package "/foo/bar", walk up the path hierarchy to generate
// "/foo/bar/node_modules/.bin:/foo/node_modules/.bin:/node_modules/.bin".
const entries = [];
let cur = this.#script.packageDir;
while (true) {
entries.push(pathlib.join(cur, 'node_modules', '.bin'));
const parent = pathlib.dirname(cur);
if (parent === cur) {
break;
}
cur = parent;
}
// Add the inherited PATH variable, minus any "node_modules/.bin" entries
// that were set by the "npm run" command that spawned Wireit.
entries.push(PATH_ENV_SUFFIX);
// Note the PATH delimiter is platform-dependent.
return entries.join(pathlib.delimiter);
}
}
//# sourceMappingURL=script-child-process.js.map