From 4bcc06d0bfb08c0134118866e98cd24d7e9e4799 Mon Sep 17 00:00:00 2001 From: Jack Hsu Date: Mon, 8 May 2023 11:32:17 -0400 Subject: [PATCH] feat(js): improve @nx/js:node executor to be more resilient to many file change events --- .../generated/packages/js/executors/node.json | 7 +- packages/js/executors.json | 2 +- .../js/src/executors/node/node-v2.impl.ts | 252 ++++++++++++++++++ packages/js/src/executors/node/schema.json | 5 + 4 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 packages/js/src/executors/node/node-v2.impl.ts diff --git a/docs/generated/packages/js/executors/node.json b/docs/generated/packages/js/executors/node.json index 54aa404a860275..7b3babe6a02e2e 100644 --- a/docs/generated/packages/js/executors/node.json +++ b/docs/generated/packages/js/executors/node.json @@ -1,6 +1,6 @@ { "name": "node", - "implementation": "/packages/js/src/executors/node/node.impl.ts", + "implementation": "/packages/js/src/executors/node/node-v2.impl.ts", "schema": { "version": 2, "outputCapture": "direct-nodejs", @@ -59,6 +59,11 @@ "type": "boolean", "description": "Enable re-building when files change.", "default": true + }, + "debounce": { + "type": "number", + "description": "Delay in milliseconds to wait before restarting. Useful to batch multiple file changes events together. Set to zero (0) to disable.", + "default": 1000 } }, "additionalProperties": false, diff --git a/packages/js/executors.json b/packages/js/executors.json index bfedf70d9d752d..787f76308f1ebc 100644 --- a/packages/js/executors.json +++ b/packages/js/executors.json @@ -12,7 +12,7 @@ "description": "Build a project using SWC." }, "node": { - "implementation": "./src/executors/node/node.impl", + "implementation": "./src/executors/node/node-v2.impl", "schema": "./src/executors/node/schema.json", "description": "Execute a Node application." } diff --git a/packages/js/src/executors/node/node-v2.impl.ts b/packages/js/src/executors/node/node-v2.impl.ts new file mode 100644 index 00000000000000..f0e515edb32977 --- /dev/null +++ b/packages/js/src/executors/node/node-v2.impl.ts @@ -0,0 +1,252 @@ +import * as chalk from 'chalk'; +import { ChildProcess, exec, spawn } from 'child_process'; +import { + ExecutorContext, + joinPathFragments, + logger, + parseTargetString, +} from '@nx/devkit'; +import { daemonClient } from 'nx/src/daemon/client/client'; +import { randomUUID } from 'crypto'; +import { join } from 'path'; + +import { InspectType, NodeExecutorOptions } from './schema'; +import { createAsyncIterable } from '@nx/devkit/src/utils/async-iterable'; +import { calculateProjectDependencies } from '@nx/js/src/utils/buildable-libs-utils'; + +interface ActiveTask { + id: string; + killed: boolean; + promise: Promise; + childProcess: null | ChildProcess; + start: () => Promise; + stop: () => Promise; +} + +function debounce(fn: () => void, wait: number) { + let timeoutId: NodeJS.Timeout; + return () => { + clearTimeout(timeoutId); + timeoutId = setTimeout(fn, wait); + }; +} + +export async function* nodeExecutor( + options: NodeExecutorOptions, + context: ExecutorContext +) { + const project = context.projectGraph.nodes[context.projectName]; + const buildTarget = parseTargetString( + options.buildTarget, + context.projectGraph + ); + + const buildOptions = project.data.targets[buildTarget.target]?.options; + if (!buildOptions) { + throw new Error( + `Cannot find build target ${chalk.bold( + options.buildTarget + )} for project ${chalk.bold(context.projectName)}` + ); + } + + // Re-map buildable workspace projects to their output directory. + const mappings = calculateResolveMappings(context, options); + const fileToRun = join( + context.root, + buildOptions.outputPath, + buildOptions.outputFileName ?? 'main.js' + ); + + const tasks: ActiveTask[] = []; + let currentTask: ActiveTask = null; + + yield* createAsyncIterable<{ success: boolean }>( + async ({ done, next, error }) => { + const processQueue = async () => { + if (tasks.length === 0) return; + + const previousTask = currentTask; + const task = tasks.shift(); + currentTask = task; + await previousTask?.stop(); + await task.start(); + }; + + const debouncedProcessQueue = debounce(processQueue, 1_000); + + const addToQueue = async () => { + const task: ActiveTask = { + id: randomUUID(), + killed: false, + childProcess: null, + promise: null, + start: async () => { + // Run the build + task.promise = new Promise(async (resolve, reject) => { + task.childProcess = exec( + `npx nx run ${context.projectName}:${buildTarget.target}${ + buildTarget.configuration + ? `:${buildTarget.configuration}` + : '' + }`, + { + cwd: context.root, + }, + (error, stdout, stderr) => { + if ( + // Build succeeded + !error || + // If process receive termination signal, it means another task has started. + error.signal === 'SIGTERM' + ) { + resolve(); + return; + } + + if (process.env.NX_VERBOSE_LOGGING === 'true') { + logger.error(error); + } + + if (options.watch) { + logger.error( + `Build failed, waiting for changes to restart...` + ); + resolve(); // Don't reject because it'll error out and kill the Nx process. + } else { + logger.error(`Build failed. See above for errors.`); + reject(); + } + } + ); + }); + + // Wait for build to finish + await task.promise; + + // Task may have been stopped due to another running task + if (task.killed) return; + + // Run the program + task.promise = new Promise((resolve, reject) => { + task.childProcess = spawn( + joinPathFragments(__dirname, 'node-with-require-overrides'), + getExecArgv(options), + { + stdio: 'inherit', + env: { + ...process.env, + NX_FILE_TO_RUN: fileToRun, + NX_MAPPINGS: JSON.stringify(mappings), + }, + } + ); + + task.childProcess.once('exit', (code) => { + if (options.watch && !task.killed) { + logger.info( + `process exited with code ${code}, waiting for changes to restart...` + ); + } + if (!options.watch) done(); + resolve(); + }); + + next({ success: true }); + }); + }, + stop: async () => { + task.killed = true; + // Request termination and wait for process to finish gracefully. + // NOTE: `childProcess` may not have been set yet if the task did not have a chance to start. + // e.g. multiple file change events in a short time (like git checkout). + task.childProcess?.kill('SIGTERM'); + await task.promise; + }, + }; + + tasks.push(task); + }; + + const stopWatch = await daemonClient.registerFileWatcher( + { + watchProjects: [context.projectName], + includeDependentProjects: true, + }, + async (err, data) => { + if (err === 'closed') { + logger.error(`Watch error: Daemon closed the connection`); + process.exit(1); + } else if (err) { + logger.error(`Watch error: ${err?.message ?? 'Unknown'}`); + } else { + logger.info(chalk.bold(`File change detected. Restarting...`)); + await addToQueue(); + await debouncedProcessQueue(); + } + } + ); + + const stopAllTasks = () => { + for (const task of tasks) { + task.stop(); + } + }; + + process.on('SIGTERM', async () => { + stopWatch(); + stopAllTasks(); + process.exit(128 + 15); + }); + process.on('SIGINT', async () => { + stopWatch(); + stopAllTasks(); + process.exit(128 + 2); + }); + process.on('SIGHUP', async () => { + stopWatch(); + stopAllTasks(); + process.exit(128 + 1); + }); + + await addToQueue(); + await processQueue(); + } + ); +} + +function getExecArgv(options: NodeExecutorOptions) { + const args = [...options.runtimeArgs]; + + if (options.inspect === true) { + options.inspect = InspectType.Inspect; + } + + if (options.inspect) { + args.push(`--${options.inspect}=${options.host}:${options.port}`); + } + + return args; +} + +function calculateResolveMappings( + context: ExecutorContext, + options: NodeExecutorOptions +) { + const parsed = parseTargetString(options.buildTarget, context.projectGraph); + const { dependencies } = calculateProjectDependencies( + context.projectGraph, + context.root, + parsed.project, + parsed.target, + parsed.configuration + ); + return dependencies.reduce((m, c) => { + if (c.node.type !== 'npm' && c.outputs[0] != null) { + m[c.name] = joinPathFragments(context.root, c.outputs[0]); + } + return m; + }, {}); +} + +export default nodeExecutor; diff --git a/packages/js/src/executors/node/schema.json b/packages/js/src/executors/node/schema.json index 288313b4d609c3..302562492b8e53 100644 --- a/packages/js/src/executors/node/schema.json +++ b/packages/js/src/executors/node/schema.json @@ -67,6 +67,11 @@ "type": "boolean", "description": "Enable re-building when files change.", "default": true + }, + "debounce": { + "type": "number", + "description": "Delay in milliseconds to wait before restarting. Useful to batch multiple file changes events together. Set to zero (0) to disable.", + "default": 500 } }, "additionalProperties": false,