fix(bundling): fix esbuild to work with ts project references (#30230)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
If we are using `esbuild` as our bundler and ts project references
(`--workspaces`) local libraries are not building are not resolved in
the build artifacts.

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
When using ts project references with esbuild all types libraries
(buildable / non-buildable) should work out of the box.

## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Nicholas Cunningham 2025-03-05 13:49:00 -07:00 committed by GitHub
parent 1c323131f8
commit 7da48d6471
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 332 additions and 109 deletions

View File

@ -0,0 +1,90 @@
import { names } from '@nx/devkit';
import {
cleanupProject,
getPackageManagerCommand,
getSelectedPackageManager,
newProject,
readFile,
runCLI,
runCommand,
uniq,
updateFile,
updateJson,
} from '@nx/e2e/utils';
let originalEnvPort;
describe('Node Esbuild Applications', () => {
beforeAll(() => {
originalEnvPort = process.env.PORT;
newProject({
preset: 'ts',
});
});
afterAll(() => {
process.env.PORT = originalEnvPort;
cleanupProject();
});
it('it should generate an app that cosumes a non-buildable ts library', () => {
const nodeapp = uniq('nodeapp');
const lib = uniq('lib');
const port = getRandomPort();
process.env.PORT = `${port}`;
runCLI(
`generate @nx/node:app apps/${nodeapp} --port=${port} --bundler=esbuild --framework=fastify --no-interactive`
);
runCLI(
`generate @nx/js:lib packages/${lib} --bundler=none --e2eTestRunner=none --unitTestRunner=none`
);
updateFile(
`apps/${nodeapp}/src/main.ts`,
(content) => `import { ${names(lib).propertyName} } from '@proj/${lib}';
console.log(${names(lib).propertyName}());
${content}
`
);
// App is CJS by default so lets update the lib to follow the same pattern
updateJson(`packages/${lib}/tsconfig.lib.json`, (json) => {
json.compilerOptions.module = 'commonjs';
json.compilerOptions.moduleResolution = 'node';
return json;
});
updateJson('tsconfig.base.json', (json) => {
json.compilerOptions.moduleResolution = 'node';
json.compilerOptions.module = 'esnext';
return json;
});
const pm = getSelectedPackageManager();
if (pm === 'pnpm') {
updateJson(`apps/${nodeapp}/package.json`, (json) => {
json.dependencies ??= {};
json.dependencies[`@proj/${lib}`] = 'workspace:*';
return json;
});
const pmc = getPackageManagerCommand({ packageManager: pm });
runCommand(pmc.install);
}
runCLI('sync');
// check build
expect(runCLI(`build ${nodeapp}`)).toContain(
`Successfully ran target build for project ${nodeapp}`
);
});
});
function getRandomPort() {
return Math.floor(1000 + Math.random() * 7000);
}

View File

@ -1,11 +1,12 @@
import * as esbuild from 'esbuild';
import * as path from 'path';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { existsSync, mkdirSync, writeFileSync, lstatSync } from 'fs';
import {
ExecutorContext,
joinPathFragments,
normalizePath,
ProjectGraphProjectNode,
readJsonFile,
workspaceRoot,
} from '@nx/devkit';
@ -74,7 +75,10 @@ export function buildEsbuildOptions(
} else if (options.platform === 'node' && format === 'cjs') {
// When target platform Node and target format is CJS, then also transpile workspace libs used by the app.
// Provide a `require` override in the main entry file so workspace libs can be loaded when running the app.
const paths = getTsConfigCompilerPaths(context);
const paths = options.isTsSolutionSetup
? createPathsFromTsConfigReferences(context)
: getTsConfigCompilerPaths(context);
const entryPointsFromProjects = getEntryPoints(
context.projectName,
context,
@ -123,6 +127,132 @@ export function buildEsbuildOptions(
return esbuildOptions;
}
/**
* When using TS project references we need to map the paths to the referenced projects.
* This is necessary because esbuild does not support project references out of the box.
* @param context ExecutorContext
*/
export function createPathsFromTsConfigReferences(
context: ExecutorContext
): Record<string, string[]> {
const {
findAllProjectNodeDependencies,
} = require('nx/src/utils/project-graph-utils');
const {
isValidPackageJsonBuildConfig,
} = require('@nx/js/src/plugins/typescript/util');
const { readTsConfig } = require('@nx/js');
const {
findRuntimeTsConfigName,
} = require('@nx/js/src/utils/typescript/ts-solution-setup');
const deps = findAllProjectNodeDependencies(
context.projectName,
context.projectGraph
);
const tsConfig = readJsonFile(
joinPathFragments(context.root, 'tsconfig.json')
);
const referencesAsPaths = new Set(
tsConfig.references.reduce((acc, ref) => {
if (!ref.path) return acc;
const fullPath = joinPathFragments(workspaceRoot, ref.path);
try {
if (lstatSync(fullPath).isDirectory()) {
acc.push(fullPath);
}
} catch {
// Ignore errors (e.g., path doesn't exist)
}
return acc;
}, [])
);
// for each dep we check if it contains a build target
// we only want to add the paths for projects that do not have a build target
return deps.reduce((acc, dep) => {
const projectNode = context.projectGraph.nodes[dep];
const projectPath = joinPathFragments(workspaceRoot, projectNode.data.root);
const resolvedTsConfigPath =
findRuntimeTsConfigName(projectPath) ?? 'tsconfig.json';
const projTsConfig = readTsConfig(resolvedTsConfigPath) as any;
const projectPkgJson = readJsonFile(
joinPathFragments(projectPath, 'package.json')
);
if (
projTsConfig &&
!isValidPackageJsonBuildConfig(
projTsConfig,
workspaceRoot,
projectPath
) &&
projectPkgJson?.name
) {
const entryPoint = getProjectEntryPoint(projectPkgJson, projectPath);
if (referencesAsPaths.has(projectPath)) {
acc[projectPkgJson.name] = [path.relative(workspaceRoot, entryPoint)];
}
}
return acc;
}, {});
}
// Get the entry point for the project
function getProjectEntryPoint(projectPkgJson: any, projectPath: string) {
let entryPoint = null;
if (typeof projectPkgJson.exports === 'string') {
// If exports is a string, use it as the entry point
entryPoint = path.relative(
workspaceRoot,
joinPathFragments(projectPath, projectPkgJson.exports)
);
} else if (
typeof projectPkgJson.exports === 'object' &&
projectPkgJson.exports['.']
) {
// If exports is an object and has a '.' key, process it
const exportEntry = projectPkgJson.exports['.'];
if (typeof exportEntry === 'object') {
entryPoint =
exportEntry.import ||
exportEntry.require ||
exportEntry.default ||
null;
} else if (typeof exportEntry === 'string') {
entryPoint = exportEntry;
}
if (entryPoint) {
entryPoint = path.relative(
workspaceRoot,
joinPathFragments(projectPath, entryPoint)
);
}
}
// If no exports were found, fall back to main and module
if (!entryPoint) {
if (projectPkgJson.main) {
entryPoint = path.relative(
workspaceRoot,
joinPathFragments(projectPath, projectPkgJson.main)
);
} else if (projectPkgJson.module) {
entryPoint = path.relative(
workspaceRoot,
joinPathFragments(projectPath, projectPkgJson.module)
);
}
}
return entryPoint;
}
export function getOutExtension(
format: 'cjs' | 'esm',
options: Pick<NormalizedEsBuildExecutorOptions, 'userDefinedBuildOptions'>

View File

@ -35,7 +35,11 @@ import { hashArray, hashFile, hashObject } from 'nx/src/hasher/file-hasher';
import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file';
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
import type { ParsedCommandLine, System } from 'typescript';
import { addBuildAndWatchDepsTargets } from './util';
import {
addBuildAndWatchDepsTargets,
isValidPackageJsonBuildConfig,
ParsedTsconfigData,
} from './util';
export interface TscPluginOptions {
typecheck?:
@ -72,12 +76,7 @@ interface NormalizedPluginOptions {
}
type TscProjectResult = Pick<ProjectConfiguration, 'targets'>;
type ParsedTsconfigData = Pick<
ParsedCommandLine,
'options' | 'projectReferences' | 'raw'
> & {
extendedConfigFile: { filePath: string; externalPackage?: string } | null;
};
type TsconfigCacheData = {
data: ParsedTsconfigData;
hash: string;
@ -756,103 +755,6 @@ function getOutputs(
return Array.from(outputs);
}
/**
* Validates the build configuration of a `package.json` file by ensuring that paths in the `exports`, `module`,
* and `main` fields reference valid output paths within the `outDir` defined in the TypeScript configuration.
* Priority is given to the `exports` field, specifically the `.` export if defined. If `exports` is not defined,
* the function falls back to validating `main` and `module` fields. If `outFile` is specified, it validates that the file
* is located within the output directory.
* If no `package.json` file exists, it assumes the configuration is valid.
*
* @param tsConfig The TypeScript configuration object.
* @param workspaceRoot The workspace root path.
* @param projectRoot The project root path.
* @returns `true` if the package has a valid build configuration; otherwise, `false`.
*/
function isValidPackageJsonBuildConfig(
tsConfig: ParsedTsconfigData,
workspaceRoot: string,
projectRoot: string
): boolean {
const packageJsonPath = join(workspaceRoot, projectRoot, 'package.json');
if (!existsSync(packageJsonPath)) {
// If the package.json file does not exist.
// Assume it's valid because it would be using `project.json` instead.
return true;
}
const packageJson = readJsonFile(packageJsonPath);
const outDir = tsConfig.options.outFile
? dirname(tsConfig.options.outFile)
: tsConfig.options.outDir;
const resolvedOutDir = outDir
? resolve(workspaceRoot, projectRoot, outDir)
: undefined;
const isPathSourceFile = (path: string): boolean => {
if (resolvedOutDir) {
const pathToCheck = resolve(workspaceRoot, projectRoot, path);
return !pathToCheck.startsWith(resolvedOutDir);
}
const ext = extname(path);
// Check that the file extension is a TS file extension. As the source files are in the same directory as the output files.
return ['.ts', '.tsx', '.cts', '.mts'].includes(ext);
};
// Checks if the value is a path within the `src` directory.
const containsInvalidPath = (
value: string | Record<string, string>
): boolean => {
if (typeof value === 'string') {
return isPathSourceFile(value);
} else if (typeof value === 'object') {
return Object.entries(value).some(([currentKey, subValue]) => {
// Skip types field
if (currentKey === 'types') {
return false;
}
if (typeof subValue === 'string') {
return isPathSourceFile(subValue);
}
return false;
});
}
return false;
};
const exports = packageJson?.exports;
// Check the `.` export if `exports` is defined.
if (exports) {
if (typeof exports === 'string') {
return !isPathSourceFile(exports);
}
if (typeof exports === 'object' && '.' in exports) {
return !containsInvalidPath(exports['.']);
}
// Check other exports if `.` is not defined or valid.
for (const key in exports) {
if (key !== '.' && containsInvalidPath(exports[key])) {
return false;
}
}
return true;
}
// If `exports` is not defined, fallback to `main` and `module` fields.
const buildPaths = ['main', 'module'];
for (const field of buildPaths) {
if (packageJson[field] && isPathSourceFile(packageJson[field])) {
return false;
}
}
return true;
}
function pathToInputOrOutput(
path: string,
workspaceRoot: string,

View File

@ -1,7 +1,16 @@
import { readJsonFile, type TargetConfiguration } from '@nx/devkit';
import { existsSync } from 'node:fs';
import { dirname, extname, isAbsolute, relative, resolve } from 'node:path';
import { type PackageManagerCommands } from 'nx/src/utils/package-manager';
import { join } from 'path';
import { type ParsedCommandLine } from 'typescript';
export type ParsedTsconfigData = Pick<
ParsedCommandLine,
'options' | 'projectReferences' | 'raw'
> & {
extendedConfigFile: { filePath: string; externalPackage?: string } | null;
};
/**
* Allow uses that use incremental builds to run `nx watch-deps` to continuously build all dependencies.
@ -39,3 +48,94 @@ export function addBuildAndWatchDepsTargets(
};
}
}
export function isValidPackageJsonBuildConfig(
tsConfig: ParsedTsconfigData,
workspaceRoot: string,
projectRoot: string
): boolean {
const resolvedProjectPath = isAbsolute(projectRoot)
? relative(workspaceRoot, projectRoot)
: projectRoot;
const packageJsonPath = join(
workspaceRoot,
resolvedProjectPath,
'package.json'
);
if (!existsSync(packageJsonPath)) {
// If the package.json file does not exist.
// Assume it's valid because it would be using `project.json` instead.
return true;
}
const packageJson = readJsonFile(packageJsonPath);
const outDir = tsConfig.options.outFile
? dirname(tsConfig.options.outFile)
: tsConfig.options.outDir;
const resolvedOutDir = outDir
? resolve(workspaceRoot, resolvedProjectPath, outDir)
: undefined;
const isPathSourceFile = (path: string): boolean => {
if (resolvedOutDir) {
const pathToCheck = resolve(workspaceRoot, resolvedProjectPath, path);
return !pathToCheck.startsWith(resolvedOutDir);
}
const ext = extname(path);
// Check that the file extension is a TS file extension. As the source files are in the same directory as the output files.
return ['.ts', '.tsx', '.cts', '.mts'].includes(ext);
};
// Checks if the value is a path within the `src` directory.
const containsInvalidPath = (
value: string | Record<string, string>
): boolean => {
if (typeof value === 'string') {
return isPathSourceFile(value);
} else if (typeof value === 'object') {
return Object.entries(value).some(([currentKey, subValue]) => {
// Skip types field
if (currentKey === 'types') {
return false;
}
if (typeof subValue === 'string') {
return isPathSourceFile(subValue);
}
return false;
});
}
return false;
};
const exports = packageJson?.exports;
// Check the `.` export if `exports` is defined.
if (exports) {
if (typeof exports === 'string') {
return !isPathSourceFile(exports);
}
if (typeof exports === 'object' && '.' in exports) {
return !containsInvalidPath(exports['.']);
}
// Check other exports if `.` is not defined or valid.
for (const key in exports) {
if (key !== '.' && containsInvalidPath(exports[key])) {
return false;
}
}
return true;
}
// If `exports` is not defined, fallback to `main` and `module` fields.
const buildPaths = ['main', 'module'];
for (const field of buildPaths) {
if (packageJson[field] && isPathSourceFile(packageJson[field])) {
return false;
}
}
return true;
}

View File

@ -108,9 +108,10 @@ export function assertNotUsingTsSolutionSetup(
}
export function findRuntimeTsConfigName(
tree: Tree,
projectRoot: string
projectRoot: string,
tree?: Tree
): string | null {
tree ??= new FsTree(workspaceRoot, false);
if (tree.exists(joinPathFragments(projectRoot, 'tsconfig.app.json')))
return 'tsconfig.app.json';
if (tree.exists(joinPathFragments(projectRoot, 'tsconfig.lib.json')))

View File

@ -235,7 +235,7 @@ export function createStorybookTsconfigFile(
};
if (useTsSolution) {
const runtimeConfig = findRuntimeTsConfigName(tree, projectRoot);
const runtimeConfig = findRuntimeTsConfigName(projectRoot, tree);
if (runtimeConfig) {
storybookTsConfig.references ??= [];
storybookTsConfig.references.push({