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>
This commit is contained in:
Colum Ferry 2025-06-18 13:50:01 +01:00 committed by GitHub
parent e1dfe6ea09
commit 06089663c6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 483 additions and 0 deletions

View File

@ -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",

View File

@ -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",

View File

@ -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",

View File

@ -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"
}

View File

@ -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)

View File

@ -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);
});

View File

@ -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",

View File

@ -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<string, string>;
},
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;
}

View File

@ -0,0 +1,4 @@
export interface CopyWorkspaceModulesOptions {
buildTarget: string;
outputPath?: string;
}

View File

@ -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"]
}

View File

@ -0,0 +1,12 @@
import { type ProjectGraph, ProjectGraphProjectNode } from '@nx/devkit';
export function getWorkspacePackagesFromGraph(graph: ProjectGraph) {
const workspacePackages: Map<string, ProjectGraphProjectNode> = 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;
}