From 06089663c686cc84de17d728347040857ad48fb7 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Wed, 18 Jun 2025 13:50:01 +0100 Subject: [PATCH] feat(js): add copy-workspace-modules executor (#31545) ## Current Behavior When building applications that depend on workspace libraries for deployment (particularly in containerized environments like Docker), developers must manually handle copying workspace dependencies and updating package.json references. This creates friction when trying to deploy applications that consume workspace libraries, as the build output doesn't contain the necessary workspace dependencies and the package.json still references them with `workspace:` protocol which doesn't work outside the workspace context. ## Expected Behavior With the new `@nx/js:copy-workspace-modules` executor, developers can automatically prepare their built applications for deployment by: 1. **Automatically copying workspace dependencies**: The executor scans the application's package.json for workspace dependencies (those with `workspace:` or `file:` version specifiers) and copies the source code of these dependencies into a `workspace_modules` directory within the build output --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> --- docs/generated/manifests/menus.json | 8 + docs/generated/manifests/new-nx-api.json | 9 + docs/generated/packages-metadata.json | 9 + .../js/executors/copy-workspace-modules.json | 30 +++ docs/shared/reference/sitemap.md | 1 + ...js-executor-copy-workspace-modules.test.ts | 254 ++++++++++++++++++ packages/js/executors.json | 5 + .../copy-workspace-modules.ts | 131 +++++++++ .../copy-workspace-modules/schema.d.ts | 4 + .../copy-workspace-modules/schema.json | 20 ++ .../get-workspace-packages-from-graph.ts | 12 + 11 files changed, 483 insertions(+) create mode 100644 docs/generated/packages/js/executors/copy-workspace-modules.json create mode 100644 e2e/js/src/js-executor-copy-workspace-modules.test.ts create mode 100644 packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts create mode 100644 packages/js/src/executors/copy-workspace-modules/schema.d.ts create mode 100644 packages/js/src/executors/copy-workspace-modules/schema.json create mode 100644 packages/js/src/utils/package-json/get-workspace-packages-from-graph.ts diff --git a/docs/generated/manifests/menus.json b/docs/generated/manifests/menus.json index 7e894af363..5aae8ec520 100644 --- a/docs/generated/manifests/menus.json +++ b/docs/generated/manifests/menus.json @@ -1024,6 +1024,14 @@ "path": "/technologies/typescript/api/executors", "name": "executors", "children": [ + { + "id": "copy-workspace-modules", + "path": "/technologies/typescript/api/executors/copy-workspace-modules", + "name": "copy-workspace-modules", + "children": [], + "isExternal": false, + "disableCollapsible": false + }, { "id": "tsc", "path": "/technologies/typescript/api/executors/tsc", diff --git a/docs/generated/manifests/new-nx-api.json b/docs/generated/manifests/new-nx-api.json index a1667b6f2e..6e622f49b9 100644 --- a/docs/generated/manifests/new-nx-api.json +++ b/docs/generated/manifests/new-nx-api.json @@ -2260,6 +2260,15 @@ "root": "/packages/js", "source": "/packages/js/src", "executors": { + "/technologies/typescript/api/executors/copy-workspace-modules": { + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "file": "generated/packages/js/executors/copy-workspace-modules.json", + "hidden": false, + "name": "copy-workspace-modules", + "originalFilePath": "/packages/js/src/executors/copy-workspace-modules/schema.json", + "path": "/technologies/typescript/api/executors/copy-workspace-modules", + "type": "executor" + }, "/technologies/typescript/api/executors/tsc": { "description": "Build a project using TypeScript.", "file": "generated/packages/js/executors/tsc.json", diff --git a/docs/generated/packages-metadata.json b/docs/generated/packages-metadata.json index 2eedb3ee53..8fcccbe1be 100644 --- a/docs/generated/packages-metadata.json +++ b/docs/generated/packages-metadata.json @@ -2455,6 +2455,15 @@ } ], "executors": [ + { + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "file": "generated/packages/js/executors/copy-workspace-modules.json", + "hidden": false, + "name": "copy-workspace-modules", + "originalFilePath": "/packages/js/src/executors/copy-workspace-modules/schema.json", + "path": "js/executors/copy-workspace-modules", + "type": "executor" + }, { "description": "Build a project using TypeScript.", "file": "generated/packages/js/executors/tsc.json", diff --git a/docs/generated/packages/js/executors/copy-workspace-modules.json b/docs/generated/packages/js/executors/copy-workspace-modules.json new file mode 100644 index 0000000000..83c43c71e6 --- /dev/null +++ b/docs/generated/packages/js/executors/copy-workspace-modules.json @@ -0,0 +1,30 @@ +{ + "name": "copy-workspace-modules", + "implementation": "/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts", + "schema": { + "version": 2, + "outputCapture": "direct-nodejs", + "title": "Copy Workspace Modules", + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "cli": "nx", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "The build target that produces the output directory to transform.", + "default": "build" + }, + "outputPath": { + "type": "string", + "description": "The output path to transform. Usually inferred from the outputs of the buildTarget." + } + }, + "required": ["buildTarget"], + "presets": [] + }, + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "aliases": [], + "hidden": false, + "path": "/packages/js/src/executors/copy-workspace-modules/schema.json", + "type": "executor" +} diff --git a/docs/shared/reference/sitemap.md b/docs/shared/reference/sitemap.md index 4896f6a98c..6ec7c02d25 100644 --- a/docs/shared/reference/sitemap.md +++ b/docs/shared/reference/sitemap.md @@ -121,6 +121,7 @@ - [Use JavaScript instead TypeScript](/technologies/typescript/recipes/js-and-ts) - [API](/technologies/typescript/api) - [executors](/technologies/typescript/api/executors) + - [copy-workspace-modules](/technologies/typescript/api/executors/copy-workspace-modules) - [tsc](/technologies/typescript/api/executors/tsc) - [swc](/technologies/typescript/api/executors/swc) - [node](/technologies/typescript/api/executors/node) diff --git a/e2e/js/src/js-executor-copy-workspace-modules.test.ts b/e2e/js/src/js-executor-copy-workspace-modules.test.ts new file mode 100644 index 0000000000..1e07c66c1d --- /dev/null +++ b/e2e/js/src/js-executor-copy-workspace-modules.test.ts @@ -0,0 +1,254 @@ +import { + checkFilesExist, + cleanupProject, + newProject, + readFile, + readJson, + runCLI, + uniq, + updateJson, + updateFile, + runCommand, +} from '@nx/e2e/utils'; + +describe('@nx/js:copy-workspace-modules', () => { + let scope: string; + + beforeAll(() => { + scope = newProject({ + packages: ['@nx/node', '@nx/js'], + preset: 'ts', + packageManager: 'pnpm', + }); + }); + + afterAll(() => { + cleanupProject(); + }); + + it('should copy a single workspace library to build output directory', async () => { + const nodeapp = uniq('nodeapp'); + const nodelib = uniq('nodelib'); + + // Generate a node application + runCLI( + `generate @nx/node:app ${nodeapp} --linter=eslint --unitTestRunner=jest` + ); + + // Generate a workspace library + runCLI( + `generate @nx/js:lib ${nodelib} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + + // Update the library to export something testable + updateFile( + `${nodelib}/src/lib/${nodelib}.ts`, + `export function ${nodelib}() { + return '${nodelib} works!'; +}` + ); + + updateFile( + `${nodelib}/src/index.ts`, + `export * from './lib/${nodelib}.js';` + ); + + // Add workspace dependency to the app's package.json + updateJson(`${nodeapp}/package.json`, (json) => { + json.dependencies = { + ...json.dependencies, + [`@${scope}/${nodelib}`]: 'workspace:*', + }; + return json; + }); + runCommand(`pnpm install`); + + // Update the app to use the library + updateFile( + `${nodeapp}/src/main.ts`, + `import { ${nodelib} } from '@${scope}/${nodelib}'; +console.log('Hello World!'); +console.log(${nodelib}());` + ); + + // Build the application first (required for copy-workspace-modules) + runCLI(`build ${nodeapp}`); + + // Verify build output exists (should be in {projectRoot}/dist) + checkFilesExist(`${nodeapp}/dist/main.js`); + + // Add copy-workspace-modules target to the app's package.json nx targets + updateJson(`${nodeapp}/package.json`, (json) => { + if (!json.nx) { + json.nx = {}; + } + if (!json.nx.targets) { + json.nx.targets = {}; + } + json.nx.targets['copy-workspace-modules'] = { + executor: '@nx/js:copy-workspace-modules', + options: { + buildTarget: 'build', + }, + }; + return json; + }); + + // Run the copy-workspace-modules executor + const result = runCLI(`run ${nodeapp}:copy-workspace-modules`); + expect(result).toContain('Success!'); + + // Verify workspace_modules directory was created in the build output + checkFilesExist(`${nodeapp}/dist/workspace_modules`); + + // Verify the library was copied to workspace_modules + checkFilesExist( + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib}/package.json` + ); + }, 300_000); + + it('should copy multiple workspace libraries correctly', async () => { + const nodeapp = uniq('nodeapp'); + const nodelib1 = uniq('nodelib1'); + const nodelib2 = uniq('nodelib2'); + + // Generate a node application + runCLI( + `generate @nx/node:app ${nodeapp} --linter=eslint --unitTestRunner=jest` + ); + + // Generate workspace libraries + runCLI( + `generate @nx/js:lib ${nodelib1} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + runCLI( + `generate @nx/js:lib ${nodelib2} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + + // Update libraries to export something testable + updateFile( + `${nodelib1}/src/lib/${nodelib1}.ts`, + `export function ${nodelib1}() { + return '${nodelib1} works!'; +}` + ); + updateFile( + `${nodelib1}/src/index.ts`, + `export * from './lib/${nodelib1}.js';` + ); + + updateFile( + `${nodelib2}/src/lib/${nodelib2}.ts`, + `export function ${nodelib2}() { + return '${nodelib2} works!'; +}` + ); + updateFile( + `${nodelib2}/src/index.ts`, + `export * from './lib/${nodelib2}.js';` + ); + + // Add workspace dependencies to the app's package.json + updateJson(`${nodeapp}/package.json`, (json) => { + json.dependencies = { + ...json.dependencies, + [`@${scope}/${nodelib1}`]: 'workspace:*', + [`@${scope}/${nodelib2}`]: 'workspace:*', + }; + return json; + }); + + // Update the app to use both libraries + updateFile( + `${nodeapp}/src/main.ts`, + `import { ${nodelib1} } from '@${scope}/${nodelib1}'; +import { ${nodelib2} } from '@${scope}/${nodelib2}'; +console.log('Hello World!'); +console.log(${nodelib1}()); +console.log(${nodelib2}());` + ); + runCommand(`pnpm install`); + + // Build the application + runCLI(`build ${nodeapp}`); + + // Add copy-workspace-modules target + updateJson(`${nodeapp}/package.json`, (json) => { + if (!json.nx) { + json.nx = {}; + } + if (!json.nx.targets) { + json.nx.targets = {}; + } + json.nx.targets['copy-workspace-modules'] = { + executor: '@nx/js:copy-workspace-modules', + options: { + buildTarget: 'build', + }, + }; + return json; + }); + + // Run the copy-workspace-modules executor + const result = runCLI(`run ${nodeapp}:copy-workspace-modules`); + expect(result).toContain('Success!'); + + // Verify both libraries were copied + checkFilesExist( + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib1}/package.json`, + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib2}/package.json` + ); + }, 300_000); + + it('should handle file: protocol dependencies', async () => { + const nodeapp = uniq('nodeapp'); + const nodelib = uniq('nodelib'); + + // Generate a node application and library + runCLI( + `generate @nx/node:app ${nodeapp} --linter=eslint --unitTestRunner=jest` + ); + runCLI( + `generate @nx/js:lib ${nodelib} --bundler=tsc --linter=eslint --unitTestRunner=jest` + ); + + // Add file: protocol dependency + updateJson(`${nodeapp}/package.json`, (json) => { + json.dependencies = { + ...json.dependencies, + [`@${scope}/${nodelib}`]: `file:../${nodelib}`, + }; + return json; + }); + runCommand(`pnpm install`); + + // Build the application + runCLI(`build ${nodeapp}`); + + // Add copy-workspace-modules target + updateJson(`${nodeapp}/package.json`, (json) => { + if (!json.nx) { + json.nx = {}; + } + if (!json.nx.targets) { + json.nx.targets = {}; + } + json.nx.targets['copy-workspace-modules'] = { + executor: '@nx/js:copy-workspace-modules', + options: { + buildTarget: 'build', + }, + }; + return json; + }); + + // Run the copy-workspace-modules executor + const result = runCLI(`run ${nodeapp}:copy-workspace-modules`); + expect(result).toContain('Success!'); + + // Verify library was copied + checkFilesExist( + `${nodeapp}/dist/workspace_modules/@${scope}/${nodelib}/package.json` + ); + }, 300_000); +}); diff --git a/packages/js/executors.json b/packages/js/executors.json index 248c555e9b..a2d4f51122 100644 --- a/packages/js/executors.json +++ b/packages/js/executors.json @@ -1,6 +1,11 @@ { "$schema": "https://json-schema.org/schema", "executors": { + "copy-workspace-modules": { + "implementation": "./src/executors/copy-workspace-modules/copy-workspace-modules", + "schema": "./src/executors/copy-workspace-modules/schema.json", + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives." + }, "tsc": { "implementation": "./src/executors/tsc/tsc.impl", "batchImplementation": "./src/executors/tsc/tsc.batch-impl", diff --git a/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts new file mode 100644 index 0000000000..cf86e81592 --- /dev/null +++ b/packages/js/src/executors/copy-workspace-modules/copy-workspace-modules.ts @@ -0,0 +1,131 @@ +import { + type ExecutorContext, + logger, + parseTargetString, + type ProjectGraph, + readJsonFile, + workspaceRoot, +} from '@nx/devkit'; +import { interpolate } from 'nx/src/tasks-runner/utils'; +import { type CopyWorkspaceModulesOptions } from './schema'; +import { cpSync, existsSync, mkdirSync } from 'node:fs'; +import { dirname, join } from 'path'; +import { lstatSync } from 'fs'; +import { getWorkspacePackagesFromGraph } from '../../utils/package-json/get-workspace-packages-from-graph'; + +export default async function copyWorkspaceModules( + schema: CopyWorkspaceModulesOptions, + context: ExecutorContext +) { + logger.log('Copying Workspace Modules to Build Directory...'); + const outputDirectory = getOutputDir(schema, context); + const packageJson = getPackageJson(schema, context); + createWorkspaceModules(outputDirectory); + handleWorkspaceModules(outputDirectory, packageJson, context.projectGraph); + logger.log('Success!'); + return { success: true }; +} + +function handleWorkspaceModules( + outputDirectory: string, + packageJson: { + dependencies?: Record; + }, + projectGraph: ProjectGraph +) { + if (!packageJson.dependencies) { + return; + } + const workspaceModules = getWorkspacePackagesFromGraph(projectGraph); + + for (const [pkgName] of Object.entries(packageJson.dependencies)) { + if (workspaceModules.has(pkgName)) { + logger.verbose(`Copying ${pkgName}.`); + const workspaceModuleProject = workspaceModules.get(pkgName); + const workspaceModuleRoot = workspaceModuleProject.data.root; + const newWorkspaceModulePath = join( + outputDirectory, + 'workspace_modules', + pkgName + ); + mkdirSync(newWorkspaceModulePath, { recursive: true }); + cpSync(workspaceModuleRoot, newWorkspaceModulePath, { + filter: (src) => !src.includes('node_modules'), + recursive: true, + }); + logger.verbose(`Copied ${pkgName} successfully.`); + } + } +} + +function createWorkspaceModules(outputDirectory: string) { + mkdirSync(join(outputDirectory, 'workspace_modules'), { recursive: true }); +} + +function getPackageJson( + schema: CopyWorkspaceModulesOptions, + context: ExecutorContext +) { + const target = parseTargetString(schema.buildTarget, context); + const project = context.projectGraph.nodes[target.project].data; + const packageJsonPath = join(workspaceRoot, project.root, 'package.json'); + if (!existsSync(packageJsonPath)) { + throw new Error(`${packageJsonPath} does not exist.`); + } + + const packageJson = readJsonFile(packageJsonPath); + return packageJson; +} + +function getOutputDir( + schema: CopyWorkspaceModulesOptions, + context: ExecutorContext +) { + let outputDir = schema.outputPath; + if (outputDir) { + outputDir = normalizeOutputPath(outputDir); + if (existsSync(outputDir)) { + return outputDir; + } + } + const target = parseTargetString(schema.buildTarget, context); + const project = context.projectGraph.nodes[target.project].data; + const buildTarget = project.targets[target.target]; + let maybeOutputPath = + buildTarget.outputs?.[0] ?? + buildTarget.options.outputPath ?? + buildTarget.options.outputDir; + + if (!maybeOutputPath) { + throw new Error( + `Could not infer an output directory from the '${schema.buildTarget}' target. Please provide 'outputPath'.` + ); + } + + maybeOutputPath = interpolate(maybeOutputPath, { + workspaceRoot, + projectRoot: project.root, + projectName: project.name, + options: { + ...(buildTarget.options ?? {}), + }, + }); + + outputDir = normalizeOutputPath(maybeOutputPath); + if (!existsSync(outputDir)) { + throw new Error( + `The output directory '${outputDir}' inferred from the '${schema.buildTarget}' target does not exist.\nPlease ensure a build has run first, and that the path is correct. Otherwise, please provide 'outputPath'.` + ); + } + return outputDir; +} + +function normalizeOutputPath(outputPath: string) { + if (!outputPath.startsWith(workspaceRoot)) { + outputPath = join(workspaceRoot, outputPath); + } + if (!lstatSync(outputPath).isDirectory()) { + outputPath = dirname(outputPath); + } + return outputPath; +} diff --git a/packages/js/src/executors/copy-workspace-modules/schema.d.ts b/packages/js/src/executors/copy-workspace-modules/schema.d.ts new file mode 100644 index 0000000000..1292fdc822 --- /dev/null +++ b/packages/js/src/executors/copy-workspace-modules/schema.d.ts @@ -0,0 +1,4 @@ +export interface CopyWorkspaceModulesOptions { + buildTarget: string; + outputPath?: string; +} diff --git a/packages/js/src/executors/copy-workspace-modules/schema.json b/packages/js/src/executors/copy-workspace-modules/schema.json new file mode 100644 index 0000000000..58adf19277 --- /dev/null +++ b/packages/js/src/executors/copy-workspace-modules/schema.json @@ -0,0 +1,20 @@ +{ + "version": 2, + "outputCapture": "direct-nodejs", + "title": "Copy Workspace Modules", + "description": "Copies Workspace Modules into the output directory after a build to prepare it for use with Docker or alternatives.", + "cli": "nx", + "type": "object", + "properties": { + "buildTarget": { + "type": "string", + "description": "The build target that produces the output directory to transform.", + "default": "build" + }, + "outputPath": { + "type": "string", + "description": "The output path to transform. Usually inferred from the outputs of the buildTarget." + } + }, + "required": ["buildTarget"] +} diff --git a/packages/js/src/utils/package-json/get-workspace-packages-from-graph.ts b/packages/js/src/utils/package-json/get-workspace-packages-from-graph.ts new file mode 100644 index 0000000000..239d6d86c9 --- /dev/null +++ b/packages/js/src/utils/package-json/get-workspace-packages-from-graph.ts @@ -0,0 +1,12 @@ +import { type ProjectGraph, ProjectGraphProjectNode } from '@nx/devkit'; + +export function getWorkspacePackagesFromGraph(graph: ProjectGraph) { + const workspacePackages: Map = new Map(); + for (const [projectName, project] of Object.entries(graph.nodes)) { + const pkgName = project.data?.metadata?.js?.packageName; + if (pkgName) { + workspacePackages.set(pkgName, project); + } + } + return workspacePackages; +}