diff --git a/docs/generated/packages/js/executors/node.json b/docs/generated/packages/js/executors/node.json index 57200acfaabcc2..9abadd2d47e53f 100644 --- a/docs/generated/packages/js/executors/node.json +++ b/docs/generated/packages/js/executors/node.json @@ -28,12 +28,14 @@ "host": { "type": "string", "default": "localhost", - "description": "The host to inspect the process on." + "description": "The host to inspect the process on.", + "x-priority": "important" }, "port": { "type": "number", "default": 9229, - "description": "The port to inspect the process on. Setting port to 0 will assign random free ports to all forked processes." + "description": "The port to inspect the process on. Setting port to 0 will assign random free ports to all forked processes.", + "x-priority": "important" }, "inspect": { "oneOf": [ @@ -41,33 +43,51 @@ { "type": "boolean" } ], "description": "Ensures the app is starting with debugging.", - "default": "inspect" + "default": "inspect", + "x-priority": "important" }, "runtimeArgs": { "type": "array", "description": "Extra args passed to the node process.", "default": [], - "items": { "type": "string" } + "items": { "type": "string" }, + "x-priority": "important" }, "args": { "type": "array", "description": "Extra args when starting the app.", "default": [], - "items": { "type": "string" } + "items": { "type": "string" }, + "x-priority": "important" }, "watch": { "type": "boolean", "description": "Enable re-building when files change.", - "default": true + "default": true, + "x-priority": "important" + }, + "watchIgnore": { + "type": "array", + "description": "List of glob patterns to ignore for file watching.", + "items": { "type": "string" }, + "default": [], + "x-priority": "important" }, "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 + "default": 500, + "x-priority": "important" + }, + "watchRunBuildTargetDependencies": { + "type": "boolean", + "description": "Whether to run dependencies before running the build. Set this to true if the project does not build libraries from source (e.g. 'buildLibsFromSource: false').", + "default": false } }, "additionalProperties": false, "required": ["buildTarget"], + "examplesFile": "---\ntitle: JS Node executor examples\ndescription: This page contains examples for the @nx/js:node executor.\n---\n\nThe `@nx/js:node` executor runs the output of a build target. For example, an application uses esbuild ([`@nx/esbuild:esbuild`](/packages/esbuild/executors/esbuild)) to output the bundle to `dist/my-app` folder, which can then be executed by `@nx/js:node`.\n\n`project.json`:\n\n```json\n\"my-app\": {\n \"targets\": {\n \"serve\": {\n \"executor\": \"@nx/js:node\",\n \"options\": {\n \"buildTarget\": \"my-app:build\"\n }\n },\n \"build\": {\n \"executor\": \"@nx/esbuild:esbuild\",\n \"options\": {\n \"main\": \"my-app/src/main.ts\",\n \"output\": [\"dist/my-app\"],\n //...\n }\n },\n }\n}\n```\n\n```bash\nnpx nx serve my-app\n```\n\n## Examples\n\n{% tabs %}\n{% tab label=\"Pass extra Node CLI arguments\" %}\n\nUsing `runtimeArgs`, you can pass arguments to the underlying `node` command. For example, if you want to set [`--no-warnings`](https://nodejs.org/api/cli.html#--no-warnings) to silence all Node warnings, then add the following to the `project.json` file.\n\n```json\n\"my-app\": {\n \"targets\": {\n \"serve\": {\n \"executor\": \"@nx/js:node\",\n \"options\": {\n \"runtimeArgs\": [\"--no-warnings\"],\n //...\n },\n },\n }\n}\n```\n\n{% /tab %}\n\n{% tab label=\"Ignore files during watch\" %}\n\nIf you have project files that do not affect the application, you can set `watchIgnore` to avoid kicking off a rebuild and restart when the only file changes are the ones you ignore. For example, if you have a `README.md` files, you can safely ignore them with `**/README.md`.\n\nNote that the glob patterns are matched relative to the workspace root.\n\n```json\n\"my-app\": {\n \"targets\": {\n \"serve\": {\n \"executor\": \"@nx/js:node\",\n \"options\": {\n \"watchIgnore\": [\"**/README.md\"]\n //...\n },\n },\n }\n}\n```\n\n{% /tab %}\n\n{% tab label=\"Run all task dependencies\" %}\n\nIf your application build depends on other tasks, and you want those tasks to also be executed, then set the `watchRunBuildTargetDependencies` to `true`. For example, a library may have a task to generate GraphQL schemas, which is consume by the application. In this case, you want to run the generate task before building and running the application.\n\nThis option is also useful when the build consumes a library from its output, not its source. For example, if an executor that supports `buildLibsFromSource` option has it set to `false` (e.g. [`@nx/webpack:webpack`](/packages/webpack/executors/webpack)).\n\nNote that this option will increase the build time, so use it only when necessary.\n\n```json\n\"my-app\": {\n \"targets\": {\n \"serve\": {\n \"executor\": \"@nx/js:node\",\n \"options\": {\n \"watchRunBuildTargetDependencies\": true,\n //...\n },\n },\n }\n}\n```\n\n{% /tab %}\n\n{% /tabs %}\n", "presets": [] }, "description": "Execute a Node application.", diff --git a/e2e/node/src/node-webpack.test.ts b/e2e/node/src/node-webpack.test.ts index c1ecda50cad9f1..d0e57c5f0e129c 100644 --- a/e2e/node/src/node-webpack.test.ts +++ b/e2e/node/src/node-webpack.test.ts @@ -5,9 +5,11 @@ import { readFile, runCLI, runCLIAsync, + runCommandUntil, tmpProjPath, uniq, updateFile, + updateProjectConfig, } from '@nx/e2e/utils'; import { execSync } from 'child_process'; @@ -47,5 +49,59 @@ describe('Node Applications + webpack', () => { await runCLIAsync(`build ${app} --optimization`); const optimizedContent = readFile(`dist/apps/${app}/main.js`); expect(optimizedContent).toContain('console.log("foo "+"bar")'); - }, 300_000); + + // Test that serve can re-run dependency builds. + const lib = uniq('nodelib'); + runCLI(`generate @nx/js:lib ${lib} --bundler=esbuild --no-interactive`); + + updateProjectConfig(app, (config) => { + // Since we read from lib from dist, we should re-build it when lib changes. + config.targets.build.options.buildLibsFromSource = false; + config.targets.serve.options.watchIgnore = ['**/*.md']; + config.targets.serve.options.watchRunBuildTargetDependencies = true; + return config; + }); + + updateFile( + `apps/${app}/src/main.ts`, + ` + import { ${lib} } from '@proj/${lib}'; + console.log('Hello ' + ${lib}()); + ` + ); + + const serveProcess = await runCommandUntil( + `serve ${app} --watch --watchRunBuildTargetDependencies`, + (output) => { + return output.includes(`Hello`); + } + ); + + // Update library source and check that it triggers rebuild. + const terminalOutputs: string[] = []; + const promise = new Promise((resolve) => { + serveProcess.stdout.on('data', (chunk) => { + const data = chunk.toString(); + terminalOutputs.push(data); + if (data.includes('should rebuild lib')) resolve(); + }); + }); + updateFile( + `libs/${lib}/README.md`, // This file is in `watchIgnore` so should not trigger rebuild. + `This is a readme file` + ); + updateFile( + `libs/${lib}/src/index.ts`, + `export function ${lib}() { return 'should rebuild lib'; }` + ); + await promise; + + // Only one rebuild triggered sine README.md is ignored. + const fileChangedDetectedOutputs = terminalOutputs.filter((output) => + output.includes(`File change detected`) + ); + expect(fileChangedDetectedOutputs.length).toBe(1); + + serveProcess.kill(); + }, 500_000); }); diff --git a/packages/js/docs/node-examples.md b/packages/js/docs/node-examples.md new file mode 100644 index 00000000000000..b6b39f2b55414d --- /dev/null +++ b/packages/js/docs/node-examples.md @@ -0,0 +1,104 @@ +--- +title: JS Node executor examples +description: This page contains examples for the @nx/js:node executor. +--- + +The `@nx/js:node` executor runs the output of a build target. For example, an application uses esbuild ([`@nx/esbuild:esbuild`](/packages/esbuild/executors/esbuild)) to output the bundle to `dist/my-app` folder, which can then be executed by `@nx/js:node`. + +`project.json`: + +```json +"my-app": { + "targets": { + "serve": { + "executor": "@nx/js:node", + "options": { + "buildTarget": "my-app:build" + } + }, + "build": { + "executor": "@nx/esbuild:esbuild", + "options": { + "main": "my-app/src/main.ts", + "output": ["dist/my-app"], + //... + } + }, + } +} +``` + +```bash +npx nx serve my-app +``` + +## Examples + +{% tabs %} +{% tab label="Pass extra Node CLI arguments" %} + +Using `runtimeArgs`, you can pass arguments to the underlying `node` command. For example, if you want to set [`--no-warnings`](https://nodejs.org/api/cli.html#--no-warnings) to silence all Node warnings, then add the following to the `project.json` file. + +```json +"my-app": { + "targets": { + "serve": { + "executor": "@nx/js:node", + "options": { + "runtimeArgs": ["--no-warnings"], + //... + }, + }, + } +} +``` + +{% /tab %} + +{% tab label="Ignore files during watch" %} + +If you have project files that do not affect the application, you can set `watchIgnore` to avoid kicking off a rebuild and restart when the only file changes are the ones you ignore. For example, if you have a `README.md` files, you can safely ignore them with `**/README.md`. + +Note that the glob patterns are matched relative to the workspace root. + +```json +"my-app": { + "targets": { + "serve": { + "executor": "@nx/js:node", + "options": { + "watchIgnore": ["**/README.md"] + //... + }, + }, + } +} +``` + +{% /tab %} + +{% tab label="Run all task dependencies" %} + +If your application build depends on other tasks, and you want those tasks to also be executed, then set the `watchRunBuildTargetDependencies` to `true`. For example, a library may have a task to generate GraphQL schemas, which is consume by the application. In this case, you want to run the generate task before building and running the application. + +This option is also useful when the build consumes a library from its output, not its source. For example, if an executor that supports `buildLibsFromSource` option has it set to `false` (e.g. [`@nx/webpack:webpack`](/packages/webpack/executors/webpack)). + +Note that this option will increase the build time, so use it only when necessary. + +```json +"my-app": { + "targets": { + "serve": { + "executor": "@nx/js:node", + "options": { + "watchRunBuildTargetDependencies": true, + //... + }, + }, + } +} +``` + +{% /tab %} + +{% /tabs %} diff --git a/packages/js/src/executors/node/lib/any-matching-files.spec.ts b/packages/js/src/executors/node/lib/any-matching-files.spec.ts new file mode 100644 index 00000000000000..fbb97801281e1f --- /dev/null +++ b/packages/js/src/executors/node/lib/any-matching-files.spec.ts @@ -0,0 +1,10 @@ +import { anyMatchingFiles } from './any-matching-files'; + +describe('anyMatchingFiles', () => { + it('should return true if a file matches any of the patterns', () => { + const fn = anyMatchingFiles(['**/*.txt']); + + expect(fn([{ path: 'a.ts' }, { path: 'b.ts' }])).toBe(false); + expect(fn([{ path: 'a.txt' }, { path: 'b.ts' }])).toBe(true); + }); +}); diff --git a/packages/js/src/executors/node/lib/any-matching-files.ts b/packages/js/src/executors/node/lib/any-matching-files.ts new file mode 100644 index 00000000000000..b4950d225a9f81 --- /dev/null +++ b/packages/js/src/executors/node/lib/any-matching-files.ts @@ -0,0 +1,14 @@ +import * as minimatch from 'minimatch'; + +export function anyMatchingFiles(patterns: string[]) { + const filters = patterns.map((p) => minimatch.filter(p)); + return (files: { path: string }[]) => { + // Worst-case is nested loop through both files and patterns, but neither should be large. + for (const filter of filters) { + for (const file of files) { + if (filter(file.path)) return true; + } + } + return false; + }; +} diff --git a/packages/js/src/executors/node/node.impl.ts b/packages/js/src/executors/node/node.impl.ts index ded44973ea31cb..cc1d60a9f63d2a 100644 --- a/packages/js/src/executors/node/node.impl.ts +++ b/packages/js/src/executors/node/node.impl.ts @@ -20,6 +20,7 @@ import { calculateProjectDependencies } from '../../utils/buildable-libs-utils'; import { killTree } from './lib/kill-tree'; import { fileExists } from 'nx/src/utils/fileutils'; import { getMainFileDirRelativeToProjectRoot } from '../../utils/get-main-file-dir'; +import { anyMatchingFiles } from './lib/any-matching-files'; interface ActiveTask { id: string; @@ -114,48 +115,64 @@ export async function* nodeExecutor( childProcess: null, promise: null, start: async () => { - let buildFailed = false; - // 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 task was killed then another build process has started, ignore errors. - task.killed - ) { - resolve(); - return; - } - - logger.info(stdout); - buildFailed = true; - 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(); + if (options.watchRunBuildTargetDependencies) { + // If task dependencies are to be run, then we need to run through CLI since `runExecutor` doesn't support it. + task.promise = new Promise(async (resolve, reject) => { + task.childProcess = fork( + require.resolve('nx'), + [ + 'run', + `${context.projectName}:${buildTarget.target}${ + buildTarget.configuration + ? `:${buildTarget.configuration}` + : '' + }`, + ], + { + cwd: context.root, + stdio: 'inherit', } - } + ); + task.childProcess.once('exit', (code) => { + if (code === 0) resolve(); + else reject(); + }); + }); + } else { + const output = await runExecutor( + buildTarget, + buildOptions, + context ); - }); + task.promise = new Promise(async (resolve, reject) => { + let error = false; + let event; + do { + event = await output.next(); + if (event.value?.success === false) { + error = true; + } + } while (!event.done); + if (error) reject(); + else resolve(); + }); + } - // Wait for build to finish - await task.promise; + // Wait for build to finish. + try { + await task.promise; + } catch { + // If in watch-mode, don't throw or else the process exits. + if (options.watch) { + logger.error(`Build failed, waiting for changes to restart...`); + return; + } else { + throw new Error(`Build failed. See above for errors.`); + } + } - // Task may have been stopped due to another running task. - // OR build failed, so don't start the process. - if (task.killed || buildFailed) return; + // Before running the program, check if the task has been killed (by a new change during watch). + if (task.killed) return; // Run the program task.promise = new Promise((resolve, reject) => { @@ -173,16 +190,17 @@ export async function* nodeExecutor( } ); - task.childProcess.stderr.on('data', (data) => { + const handleStdErr = (data) => { // Don't log out error if task is killed and new one has started. // This could happen if a new build is triggered while new process is starting, since the operation is not atomic. // Log the error in normal mode if (!options.watch || !task.killed) { logger.error(data.toString()); } - }); - + }; + task.childProcess.stderr.on('data', handleStdErr); task.childProcess.once('exit', (code) => { + task.childProcess.off('data', handleStdErr); if (options.watch && !task.killed) { logger.info( `NX Process exited with code ${code}, waiting for changes to restart...` @@ -203,7 +221,11 @@ export async function* nodeExecutor( if (task.childProcess) { await killTree(task.childProcess.pid, signal); } - await task.promise; + try { + await task.promise; + } catch { + // Doesn't matter if task fails, we just need to wait until it finishes. + } }, }; @@ -211,6 +233,7 @@ export async function* nodeExecutor( }; if (options.watch) { + const ignoreFiles = anyMatchingFiles(options.watchIgnore); const stopWatch = await daemonClient.registerFileWatcher( { watchProjects: [context.projectName], @@ -222,7 +245,7 @@ export async function* nodeExecutor( process.exit(1); } else if (err) { logger.error(`Watch error: ${err?.message ?? 'Unknown'}`); - } else { + } else if (!ignoreFiles(data.changedFiles)) { logger.info(`NX File change detected. Restarting...`); await addToQueue(); await debouncedProcessQueue(); diff --git a/packages/js/src/executors/node/schema.d.ts b/packages/js/src/executors/node/schema.d.ts index 08b8ebb072e8d0..892436218af11a 100644 --- a/packages/js/src/executors/node/schema.d.ts +++ b/packages/js/src/executors/node/schema.d.ts @@ -13,5 +13,7 @@ export interface NodeExecutorOptions { host: string; port: number; watch?: boolean; + watchIgnore?: string[]; debounce?: number; + watchRunBuildTargetDependencies?: boolean; } diff --git a/packages/js/src/executors/node/schema.json b/packages/js/src/executors/node/schema.json index 302562492b8e53..64990a853087e2 100644 --- a/packages/js/src/executors/node/schema.json +++ b/packages/js/src/executors/node/schema.json @@ -27,12 +27,14 @@ "host": { "type": "string", "default": "localhost", - "description": "The host to inspect the process on." + "description": "The host to inspect the process on.", + "x-priority": "important" }, "port": { "type": "number", "default": 9229, - "description": "The port to inspect the process on. Setting port to 0 will assign random free ports to all forked processes." + "description": "The port to inspect the process on. Setting port to 0 will assign random free ports to all forked processes.", + "x-priority": "important" }, "inspect": { "oneOf": [ @@ -45,7 +47,8 @@ } ], "description": "Ensures the app is starting with debugging.", - "default": "inspect" + "default": "inspect", + "x-priority": "important" }, "runtimeArgs": { "type": "array", @@ -53,7 +56,8 @@ "default": [], "items": { "type": "string" - } + }, + "x-priority": "important" }, "args": { "type": "array", @@ -61,19 +65,37 @@ "default": [], "items": { "type": "string" - } + }, + "x-priority": "important" }, "watch": { "type": "boolean", "description": "Enable re-building when files change.", - "default": true + "default": true, + "x-priority": "important" + }, + "watchIgnore": { + "type": "array", + "description": "List of glob patterns to ignore for file watching.", + "items": { + "type": "string" + }, + "default": [], + "x-priority": "important" }, "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 + "default": 500, + "x-priority": "important" + }, + "watchRunBuildTargetDependencies": { + "type": "boolean", + "description": "Whether to run dependencies before running the build. Set this to true if the project does not build libraries from source (e.g. 'buildLibsFromSource: false').", + "default": false } }, "additionalProperties": false, - "required": ["buildTarget"] + "required": ["buildTarget"], + "examplesFile": "../../../docs/node-examples.md" }