UNPKG

@rushstack/heft

Version:

Build all your JavaScript projects the same way: A way that works.

337 lines 16.6 kB
"use strict"; // 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