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:
parent
e1dfe6ea09
commit
06089663c6
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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)
|
||||
|
||||
254
e2e/js/src/js-executor-copy-workspace-modules.test.ts
Normal file
254
e2e/js/src/js-executor-copy-workspace-modules.test.ts
Normal 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);
|
||||
});
|
||||
@ -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",
|
||||
|
||||
@ -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;
|
||||
}
|
||||
4
packages/js/src/executors/copy-workspace-modules/schema.d.ts
vendored
Normal file
4
packages/js/src/executors/copy-workspace-modules/schema.d.ts
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
export interface CopyWorkspaceModulesOptions {
|
||||
buildTarget: string;
|
||||
outputPath?: string;
|
||||
}
|
||||
20
packages/js/src/executors/copy-workspace-modules/schema.json
Normal file
20
packages/js/src/executors/copy-workspace-modules/schema.json
Normal 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"]
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user