Skip to content

Commit

Permalink
feat(js): improve @nx/js:node executor to be more resilient to many f…
Browse files Browse the repository at this point in the history
…ile change events
  • Loading branch information
jaysoo committed May 11, 2023
1 parent 1e891f5 commit 4bcc06d
Show file tree
Hide file tree
Showing 4 changed files with 264 additions and 2 deletions.
7 changes: 6 additions & 1 deletion docs/generated/packages/js/executors/node.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/js/executors.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
}
Expand Down
252 changes: 252 additions & 0 deletions packages/js/src/executors/node/node-v2.impl.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
childProcess: null | ChildProcess;
start: () => Promise<void>;
stop: () => Promise<void>;
}

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<void>(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<void>((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;
5 changes: 5 additions & 0 deletions packages/js/src/executors/node/schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down

0 comments on commit 4bcc06d

Please sign in to comment.