@rushstack/heft
Version:
Build all your JavaScript projects the same way: A way that works.
337 lines • 16.6 kB
JavaScript
;
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
const child_process = __importStar(require("child_process"));
const process = __importStar(require("process"));
const node_core_library_1 = require("@rushstack/node-core-library");
const CoreConfigFiles_1 = require("../utilities/CoreConfigFiles");
const PLUGIN_NAME = 'node-service-plugin';
const SERVE_PARAMETER_LONG_NAME = '--serve';
var State;
(function (State) {
/**
* The service process is not running, and _activeChildProcess is undefined.
*
* In this state, there may or may not be a timeout scheduled that will later restart the service.
*/
State[State["Stopped"] = 0] = "Stopped";
/**
* The service process is running normally.
*/
State[State["Running"] = 1] = "Running";
/**
* The SIGTERM signal has been sent to the service process, and we are waiting for it
* to shut down gracefully.
*
* NOTE: On Windows OS, SIGTERM is skipped and we proceed directly to SIGKILL.
*/
State[State["Stopping"] = 2] = "Stopping";
/**
* The SIGKILL signal has been sent to forcibly terminate the service process, and we are waiting
* to confirm that the operation has completed.
*/
State[State["Killing"] = 3] = "Killing";
})(State || (State = {}));
class NodeServicePlugin {
constructor() {
this._state = State.Stopped;
/**
* The state machine schedules at most one setInterval() timeout at any given time. It is for:
*
* - waitForTerminateMs in State.Stopping
* - waitForKillMs in State.Killing
*/
this._timeout = undefined;
/**
* The data read from the node-service.json config file, or "undefined" if the file is missing.
*/
this._rawConfiguration = undefined;
this._pluginEnabled = false;
}
apply(taskSession, heftConfiguration) {
var _a, _b;
// Set this immediately to make it available to the internal methods that use it
this._logger = taskSession.logger;
const isServeMode = taskSession.parameters.getFlagParameter(SERVE_PARAMETER_LONG_NAME).value;
if (isServeMode && !taskSession.parameters.watch) {
throw new Error(`The ${JSON.stringify(SERVE_PARAMETER_LONG_NAME)} parameter is only available when running in watch mode.` +
` Try replacing "${(_a = taskSession.parsedCommandLine) === null || _a === void 0 ? void 0 : _a.unaliasedCommandName}" with` +
` "${(_b = taskSession.parsedCommandLine) === null || _b === void 0 ? void 0 : _b.unaliasedCommandName}-watch" in your Heft command line.`);
}
if (!isServeMode) {
taskSession.logger.terminal.writeVerboseLine(`Not launching the service because the "${SERVE_PARAMETER_LONG_NAME}" parameter was not specified`);
return;
}
taskSession.hooks.runIncremental.tapPromise(PLUGIN_NAME, async (runIncrementalOptions) => {
await this._runCommandAsync(taskSession, heftConfiguration);
});
}
async _loadStageConfigurationAsync(taskSession, heftConfiguration) {
if (!this._rawConfiguration) {
this._rawConfiguration = await CoreConfigFiles_1.CoreConfigFiles.tryLoadNodeServiceConfigurationFileAsync(taskSession.logger.terminal, heftConfiguration.buildFolderPath, heftConfiguration.rigConfig);
// defaults
this._configuration = {
commandName: 'serve',
ignoreMissingScript: false,
waitForTerminateMs: 2000,
waitForKillMs: 2000
};
// TODO: @rushstack/heft-config-file should be able to read a *.defaults.json file
if (this._rawConfiguration) {
this._pluginEnabled = true;
if (this._rawConfiguration.commandName !== undefined) {
this._configuration.commandName = this._rawConfiguration.commandName;
}
if (this._rawConfiguration.ignoreMissingScript !== undefined) {
this._configuration.ignoreMissingScript = this._rawConfiguration.ignoreMissingScript;
}
if (this._rawConfiguration.waitForTerminateMs !== undefined) {
this._configuration.waitForTerminateMs = this._rawConfiguration.waitForTerminateMs;
}
if (this._rawConfiguration.waitForKillMs !== undefined) {
this._configuration.waitForKillMs = this._rawConfiguration.waitForKillMs;
}
this._shellCommand = (heftConfiguration.projectPackageJson.scripts || {})[this._configuration.commandName];
if (this._shellCommand === undefined) {
if (this._configuration.ignoreMissingScript) {
taskSession.logger.terminal.writeLine(`The node service cannot be started because the project's package.json` +
` does not have a "${this._configuration.commandName}" script`);
}
else {
throw new Error(`The node service cannot be started because the project's package.json ` +
`does not have a "${this._configuration.commandName}" script`);
}
this._pluginEnabled = false;
}
}
else {
throw new Error('The node service cannot be started because the task config file was not found: ' +
CoreConfigFiles_1.CoreConfigFiles.nodeServiceConfigurationProjectRelativeFilePath);
}
}
}
async _runCommandAsync(taskSession, heftConfiguration) {
await this._loadStageConfigurationAsync(taskSession, heftConfiguration);
if (!this._pluginEnabled) {
return;
}
this._logger.terminal.writeLine(`Starting Node service...`);
await this._stopChildAsync();
this._startChild();
}
async _stopChildAsync() {
if (this._state !== State.Running) {
if (this._childProcessExitPromise) {
// If we have an active process but are not in the running state, we must be in the process of
// terminating or the process is already stopped.
await this._childProcessExitPromise;
}
return;
}
if (NodeServicePlugin._isWindows) {
// On Windows, SIGTERM can kill Cmd.exe and leave its children running in the background
this._transitionToKilling();
}
else {
if (!this._activeChildProcess) {
// All the code paths that set _activeChildProcess=undefined should also leave the Running state
throw new node_core_library_1.InternalError('_activeChildProcess should not be undefined');
}
this._state = State.Stopping;
this._logger.terminal.writeVerboseLine('Sending SIGTERM to gracefully shut down the service process');
// Passing a negative PID terminates the entire group instead of just the one process.
// This works because we set detached=true for child_process.spawn()
const pid = this._activeChildProcess.pid;
if (pid !== undefined) {
// If pid was undefined, the process failed to spawn
process.kill(-pid, 'SIGTERM');
}
this._clearTimeout();
this._timeout = setTimeout(() => {
try {
if (this._state !== State.Stopped) {
this._logger.terminal.writeWarningLine('The service process is taking too long to terminate');
this._transitionToKilling();
}
}
catch (e) {
this._childProcessExitPromiseRejectFn(e);
}
}, this._configuration.waitForTerminateMs);
}
await this._childProcessExitPromise;
}
_transitionToKilling() {
this._state = State.Killing;
if (!this._activeChildProcess) {
// All the code paths that set _activeChildProcess=undefined should also leave the Running state
throw new node_core_library_1.InternalError('_activeChildProcess should not be undefined');
}
this._logger.terminal.writeVerboseLine('Attempting to killing the service process');
node_core_library_1.SubprocessTerminator.killProcessTree(this._activeChildProcess, node_core_library_1.SubprocessTerminator.RECOMMENDED_OPTIONS);
this._clearTimeout();
this._timeout = setTimeout(() => {
try {
if (this._state !== State.Stopped) {
this._logger.terminal.writeErrorLine('Abandoning the service process because it could not be killed');
this._transitionToStopped();
}
}
catch (e) {
this._childProcessExitPromiseRejectFn(e);
}
}, this._configuration.waitForKillMs);
}
_transitionToStopped() {
// Failed to start
this._state = State.Stopped;
this._clearTimeout();
this._activeChildProcess = undefined;
this._childProcessExitPromiseResolveFn();
}
_startChild() {
if (this._state !== State.Stopped) {
throw new node_core_library_1.InternalError('Invalid state');
}
this._state = State.Running;
this._clearTimeout();
this._logger.terminal.writeLine(`Invoking command: "${this._shellCommand}"`);
const childProcess = child_process.spawn(this._shellCommand, {
shell: true,
...node_core_library_1.SubprocessTerminator.RECOMMENDED_OPTIONS
});
node_core_library_1.SubprocessTerminator.killProcessTreeOnExit(childProcess, node_core_library_1.SubprocessTerminator.RECOMMENDED_OPTIONS);
const childPid = childProcess.pid;
if (childPid === undefined) {
throw new node_core_library_1.InternalError(`Failed to spawn child process`);
}
this._logger.terminal.writeVerboseLine(`Started service process #${childPid}`);
// Create a promise that resolves when the child process exits
this._childProcessExitPromise = new Promise((resolve, reject) => {
var _a, _b;
this._childProcessExitPromiseResolveFn = resolve;
this._childProcessExitPromiseRejectFn = reject;
(_a = childProcess.stdout) === null || _a === void 0 ? void 0 : _a.on('data', (data) => {
this._logger.terminal.write(data.toString());
});
(_b = childProcess.stderr) === null || _b === void 0 ? void 0 : _b.on('data', (data) => {
this._logger.terminal.writeError(data.toString());
});
childProcess.on('close', (exitCode, signal) => {
try {
// The 'close' event is emitted after a process has ended and the stdio streams of a child process
// have been closed. This is distinct from the 'exit' event, since multiple processes might share the
// same stdio streams. The 'close' event will always emit after 'exit' was already emitted,
// or 'error' if the child failed to spawn.
if (this._state === State.Running) {
this._logger.terminal.writeWarningLine(`The service process #${childPid} terminated unexpectedly` +
this._formatCodeOrSignal(exitCode, signal));
this._transitionToStopped();
return;
}
if (this._state === State.Stopping || this._state === State.Killing) {
this._logger.terminal.writeVerboseLine(`The service process #${childPid} terminated successfully` +
this._formatCodeOrSignal(exitCode, signal));
this._transitionToStopped();
return;
}
}
catch (e) {
reject(e);
}
});
childProcess.on('exit', (code, signal) => {
try {
// Under normal conditions we don't reject the promise here, because 'data' events can continue
// to fire as data is flushed, before finally concluding with the 'close' event.
this._logger.terminal.writeVerboseLine(`The service process fired its "exit" event` + this._formatCodeOrSignal(code, signal));
}
catch (e) {
reject(e);
}
});
childProcess.on('error', (err) => {
try {
// "The 'error' event is emitted whenever:
// 1. The process could not be spawned, or
// 2. The process could not be killed, or
// 3. Sending a message to the child process failed.
//
// The 'exit' event may or may not fire after an error has occurred. When listening to both the 'exit'
// and 'error' events, guard against accidentally invoking handler functions multiple times."
if (this._state === State.Running) {
this._logger.terminal.writeErrorLine(`Failed to start: ` + err.toString());
this._transitionToStopped();
return;
}
if (this._state === State.Stopping) {
this._logger.terminal.writeWarningLine(`The service process #${childPid} rejected the shutdown signal: ` + err.toString());
this._transitionToKilling();
return;
}
if (this._state === State.Killing) {
this._logger.terminal.writeErrorLine(`The service process #${childPid} could not be killed: ` + err.toString());
this._transitionToStopped();
return;
}
}
catch (e) {
reject(e);
}
});
});
this._activeChildProcess = childProcess;
}
_clearTimeout() {
if (this._timeout) {
clearTimeout(this._timeout);
this._timeout = undefined;
}
}
_formatCodeOrSignal(code, signal) {
if (signal) {
return ` (signal=${code})`;
}
if (typeof code === 'number') {
return ` (exit code ${code})`;
}
return '';
}
}
NodeServicePlugin._isWindows = process.platform === 'win32';
exports.default = NodeServicePlugin;
//# sourceMappingURL=NodeServicePlugin.js.map