fix(webpack): add support for retrieving all transitive non-buildable dependencies (#31343)

This PR improves dependency resolution for Node.js apps using Webpack or
Rspack.

While we already handle direct dependencies for non-buildable libraries,
this update ensures that **transitive dependencies** are also properly
included. This guarantees that all necessary dependencies are bundled
when the main app/library is built.

closes: https://github.com/nrwl/nx/issues/31334
This commit is contained in:
Nicholas Cunningham 2025-05-27 00:53:45 -06:00 committed by GitHub
parent 4a94841916
commit f1171191dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 266 additions and 48 deletions

View File

@ -198,7 +198,68 @@ describe('Node Applications', () => {
} }
runCLI(`sync`); runCLI(`sync`);
console.log(readJson(`apps/${nestApp}/package.json`)); runCLI(`build ${nestApp} --verbose`);
checkFilesExist(`apps/${nestApp}/dist/main.js`);
const p = await runCommandUntil(
`serve ${nestApp}`,
(output) =>
output.includes(
`Application is running on: http://localhost:${port}/api`
),
{
env: {
NX_DAEMON: 'true',
},
}
);
const result = await getData(port, '/api');
expect(result.message).toMatch('Hello');
try {
await promisifiedTreeKill(p.pid, 'SIGKILL');
expect(await killPorts(port)).toBeTruthy();
} catch (err) {
expect(err).toBeFalsy();
}
}, 300_000);
// Dependencies that are not directly imported but are still required for the build to succeed
// App -> LibA -> LibB
// LibB is transitive, it's not imported in the app, but is required by LibA
it('should be able to work with transitive non-dependencies', async () => {
const nestApp = uniq('nestApp');
const nestLibA = uniq('nestliba');
const nestLibB = uniq('nestlibb');
const port = getRandomPort();
process.env.PORT = `${port}`;
runCLI(`generate @nx/nest:app apps/${nestApp} --no-interactive`);
runCLI(`generate @nx/nest:lib packages/${nestLibA} --no-interactive`);
runCLI(`generate @nx/nest:lib packages/${nestLibB} --no-interactive`);
if (pm === 'pnpm') {
updateJson(`apps/${nestApp}/package.json`, (json) => {
json.dependencies = {
[`@${workspaceName}/${nestLibA}`]: 'workspace:*',
};
return json;
});
updateJson(`packages/${nestLibA}/package.json`, (json) => {
json.dependencies = {
[`@${workspaceName}/${nestLibB}`]: 'workspace:*',
};
return json;
});
runCommand(`${getPackageManagerCommand().install}`);
}
runCLI(`sync`);
runCLI(`build ${nestApp} --verbose`); runCLI(`build ${nestApp} --verbose`);
checkFilesExist(`apps/${nestApp}/dist/main.js`); checkFilesExist(`apps/${nestApp}/dist/main.js`);

View File

@ -19,7 +19,7 @@ import { getTerserEcmaVersion } from './get-terser-ecma-version';
import nodeExternals = require('webpack-node-externals'); import nodeExternals = require('webpack-node-externals');
import { NormalizedNxAppRspackPluginOptions } from './models'; import { NormalizedNxAppRspackPluginOptions } from './models';
import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup'; import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
import { isBuildableLibrary } from './is-lib-buildable'; import { getNonBuildableLibs } from './get-non-buildable-libs';
const IGNORED_RSPACK_WARNINGS = [ const IGNORED_RSPACK_WARNINGS = [
/The comment file/i, /The comment file/i,
@ -357,32 +357,13 @@ function applyNxDependentConfig(
const graph = options.projectGraph; const graph = options.projectGraph;
const projectName = options.projectName; const projectName = options.projectName;
const deps = graph?.dependencies?.[projectName] ?? [];
// Collect non-buildable TS project references so that they are bundled // Collect non-buildable TS project references so that they are bundled
// in the final output. This is needed for projects that are not buildable // in the final output. This is needed for projects that are not buildable
// but are referenced by buildable projects. This is needed for the new TS // but are referenced by buildable projects. This is needed for the new TS
// solution setup. // solution setup.
const nonBuildableWorkspaceLibs = isUsingTsSolution const nonBuildableWorkspaceLibs = isUsingTsSolution
? deps ? getNonBuildableLibs(graph, projectName)
.filter((dep) => {
const node = graph.nodes?.[dep.target];
if (!node || node.type !== 'lib') return false;
const hasBuildTarget = 'build' in (node.data?.targets ?? {});
if (hasBuildTarget) {
return false;
}
// If there is no build target we check the package exports to see if they reference
// source files
return !isBuildableLibrary(node);
})
.map(
(dep) => graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName
)
.filter((name): name is string => !!name)
: []; : [];
externals.push( externals.push(

View File

@ -0,0 +1,45 @@
import { type ProjectGraph } from '@nx/devkit';
import { getAllTransitiveDeps } from './get-transitive-deps';
import { isBuildableLibrary } from './is-lib-buildable';
/**
* Get all non-buildable libraries in the project graph for a given project.
* This function retrieves all direct and transitive dependencies of a project,
* filtering out only those that are libraries and not buildable.
* @param graph Project graph
* @param projectName The project name to get dependencies for
* @returns A list of all non-buildable libraries that the project depends on, including transitive dependencies.
*/
export function getNonBuildableLibs(
graph: ProjectGraph,
projectName: string
): string[] {
const deps = graph?.dependencies?.[projectName] ?? [];
const allNonBuildable = new Set<string>();
// First, find all direct non-buildable deps and add them App -> library
const directNonBuildable = deps.filter((dep) => {
const node = graph.nodes?.[dep.target];
if (!node || node.type !== 'lib') return false;
const hasBuildTarget = 'build' in (node.data?.targets ?? {});
if (hasBuildTarget) return false;
return !isBuildableLibrary(node);
});
// Add direct non-buildable dependencies
for (const dep of directNonBuildable) {
const packageName =
graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName;
if (packageName) {
allNonBuildable.add(packageName);
}
// Get all transitive non-buildable dependencies App -> library1 -> library2
const transitiveDeps = getAllTransitiveDeps(graph, dep.target);
transitiveDeps.forEach((pkg) => allNonBuildable.add(pkg));
}
return Array.from(allNonBuildable);
}

View File

@ -0,0 +1,56 @@
import { type ProjectGraph } from '@nx/devkit';
import { isBuildableLibrary } from './is-lib-buildable';
/**
* Get all transitive dependencies of a target that are non-buildable libraries.
* This function traverses the project graph to find all dependencies of a given target,
* @param graph Graph of the project
* @param targetName The project name to get dependencies for
* @param visited Set to keep track of visited nodes to prevent infinite loops in circular dependencies
* @returns string[] - List of all transitive dependencies that are non-buildable libraries
*/
export function getAllTransitiveDeps(
graph: ProjectGraph,
targetName: string,
visited = new Set<string>()
): string[] {
if (visited.has(targetName)) {
return [];
}
visited.add(targetName);
const node = graph.nodes?.[targetName];
if (!node) {
return [];
}
// Get direct dependencies of this target
const directDeps = graph.dependencies?.[targetName] || [];
const transitiveDeps: string[] = [];
for (const dep of directDeps) {
const depNode = graph.nodes?.[dep.target];
// Only consider library dependencies
if (!depNode || depNode.type !== 'lib') {
continue;
}
// Check if this dependency is non-buildable
const hasBuildTarget = 'build' in (depNode.data?.targets ?? {});
const isBuildable = hasBuildTarget || isBuildableLibrary(depNode);
if (!isBuildable) {
const packageName = depNode.data?.metadata?.js?.packageName;
if (packageName) {
transitiveDeps.push(packageName);
}
const nestedDeps = getAllTransitiveDeps(graph, dep.target, visited);
transitiveDeps.push(...nestedDeps);
}
}
return transitiveDeps;
}

View File

@ -20,7 +20,7 @@ import { NormalizedNxAppWebpackPluginOptions } from '../nx-app-webpack-plugin-op
import TerserPlugin = require('terser-webpack-plugin'); import TerserPlugin = require('terser-webpack-plugin');
import nodeExternals = require('webpack-node-externals'); import nodeExternals = require('webpack-node-externals');
import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup'; import { isUsingTsSolutionSetup } from '@nx/js/src/utils/typescript/ts-solution-setup';
import { isBuildableLibrary } from './utils'; import { getNonBuildableLibs } from './utils';
const IGNORED_WEBPACK_WARNINGS = [ const IGNORED_WEBPACK_WARNINGS = [
/The comment file/i, /The comment file/i,
@ -355,32 +355,12 @@ function applyNxDependentConfig(
const graph = options.projectGraph; const graph = options.projectGraph;
const projectName = options.projectName; const projectName = options.projectName;
const deps = graph?.dependencies?.[projectName] ?? [];
// Collect non-buildable TS project references so that they are bundled // Collect non-buildable TS project references so that they are bundled
// in the final output. This is needed for projects that are not buildable // in the final output. This is needed for projects that are not buildable
// but are referenced by buildable projects. This is needed for the new TS // but are referenced by buildable projects.
// solution setup.
const nonBuildableWorkspaceLibs = isUsingTsSolution const nonBuildableWorkspaceLibs = isUsingTsSolution
? deps ? getNonBuildableLibs(graph, projectName)
.filter((dep) => {
const node = graph.nodes?.[dep.target];
if (!node || node.type !== 'lib') return false;
const hasBuildTarget = 'build' in (node.data?.targets ?? {});
if (hasBuildTarget) {
return false;
}
// If there is no build target we check the package exports to see if they reference
// source files
return !isBuildableLibrary(node);
})
.map(
(dep) => graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName
)
.filter((name): name is string => !!name)
: []; : [];
externals.push( externals.push(

View File

@ -1,4 +1,4 @@
import { type ProjectGraphProjectNode } from '@nx/devkit'; import type { ProjectGraph, ProjectGraphProjectNode } from '@nx/devkit';
function isSourceFile(path: string): boolean { function isSourceFile(path: string): boolean {
return ['.ts', '.tsx', '.mts', '.cts'].some((ext) => path.endsWith(ext)); return ['.ts', '.tsx', '.mts', '.cts'].some((ext) => path.endsWith(ext));
@ -56,3 +56,98 @@ export function isBuildableLibrary(node: ProjectGraphProjectNode): boolean {
!isSourceFile(packageMain) !isSourceFile(packageMain)
); );
} }
/**
* Get all transitive dependencies of a target that are non-buildable libraries.
* This function traverses the project graph to find all dependencies of a given target,
* @param graph Graph of the project
* @param targetName The project name to get dependencies for
* @param visited Set to keep track of visited nodes to prevent infinite loops in circular dependencies
* @returns string[] - List of all transitive dependencies that are non-buildable libraries
*/
export function getAllTransitiveDeps(
graph: ProjectGraph,
targetName: string,
visited = new Set<string>()
): string[] {
if (visited.has(targetName)) {
return [];
}
visited.add(targetName);
const node = graph.nodes?.[targetName];
if (!node) {
return [];
}
// Get direct dependencies of this target
const directDeps = graph.dependencies?.[targetName] || [];
const transitiveDeps: string[] = [];
for (const dep of directDeps) {
const depNode = graph.nodes?.[dep.target];
// Only consider library dependencies
if (!depNode || depNode.type !== 'lib') {
continue;
}
// Check if this dependency is non-buildable
const hasBuildTarget = 'build' in (depNode.data?.targets ?? {});
const isBuildable = hasBuildTarget || isBuildableLibrary(depNode);
if (!isBuildable) {
const packageName = depNode.data?.metadata?.js?.packageName;
if (packageName) {
transitiveDeps.push(packageName);
}
const nestedDeps = getAllTransitiveDeps(graph, dep.target, visited);
transitiveDeps.push(...nestedDeps);
}
}
return transitiveDeps;
}
/**
* Get all non-buildable libraries in the project graph for a given project.
* This function retrieves all direct and transitive dependencies of a project,
* filtering out only those that are libraries and not buildable.
* @param graph Project graph
* @param projectName The project name to get dependencies for
* @returns A list of all non-buildable libraries that the project depends on, including transitive dependencies.
*/
export function getNonBuildableLibs(
graph: ProjectGraph,
projectName: string
): string[] {
const deps = graph?.dependencies?.[projectName] ?? [];
const allNonBuildable = new Set<string>();
// First, find all direct non-buildable deps and add them App -> library
const directNonBuildable = deps.filter((dep) => {
const node = graph.nodes?.[dep.target];
if (!node || node.type !== 'lib') return false;
const hasBuildTarget = 'build' in (node.data?.targets ?? {});
if (hasBuildTarget) return false;
return !isBuildableLibrary(node);
});
// Add direct non-buildable dependencies
for (const dep of directNonBuildable) {
const packageName =
graph.nodes?.[dep.target]?.data?.metadata?.js?.packageName;
if (packageName) {
allNonBuildable.add(packageName);
}
// Get all transitive non-buildable dependencies App -> library1 -> library2
const transitiveDeps = getAllTransitiveDeps(graph, dep.target);
transitiveDeps.forEach((pkg) => allNonBuildable.add(pkg));
}
return Array.from(allNonBuildable);
}