Skip to content

Commit 7da48d6

Browse files
authored
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 #
1 parent 1c32313 commit 7da48d6

File tree

6 files changed

+332
-109
lines changed

6 files changed

+332
-109
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { names } from '@nx/devkit';
2+
import {
3+
cleanupProject,
4+
getPackageManagerCommand,
5+
getSelectedPackageManager,
6+
newProject,
7+
readFile,
8+
runCLI,
9+
runCommand,
10+
uniq,
11+
updateFile,
12+
updateJson,
13+
} from '@nx/e2e/utils';
14+
15+
let originalEnvPort;
16+
17+
describe('Node Esbuild Applications', () => {
18+
beforeAll(() => {
19+
originalEnvPort = process.env.PORT;
20+
newProject({
21+
preset: 'ts',
22+
});
23+
});
24+
25+
afterAll(() => {
26+
process.env.PORT = originalEnvPort;
27+
cleanupProject();
28+
});
29+
30+
it('it should generate an app that cosumes a non-buildable ts library', () => {
31+
const nodeapp = uniq('nodeapp');
32+
const lib = uniq('lib');
33+
const port = getRandomPort();
34+
process.env.PORT = `${port}`;
35+
36+
runCLI(
37+
`generate @nx/node:app apps/${nodeapp} --port=${port} --bundler=esbuild --framework=fastify --no-interactive`
38+
);
39+
40+
runCLI(
41+
`generate @nx/js:lib packages/${lib} --bundler=none --e2eTestRunner=none --unitTestRunner=none`
42+
);
43+
44+
updateFile(
45+
`apps/${nodeapp}/src/main.ts`,
46+
(content) => `import { ${names(lib).propertyName} } from '@proj/${lib}';
47+
48+
console.log(${names(lib).propertyName}());
49+
50+
${content}
51+
`
52+
);
53+
54+
// App is CJS by default so lets update the lib to follow the same pattern
55+
updateJson(`packages/${lib}/tsconfig.lib.json`, (json) => {
56+
json.compilerOptions.module = 'commonjs';
57+
json.compilerOptions.moduleResolution = 'node';
58+
return json;
59+
});
60+
61+
updateJson('tsconfig.base.json', (json) => {
62+
json.compilerOptions.moduleResolution = 'node';
63+
json.compilerOptions.module = 'esnext';
64+
return json;
65+
});
66+
67+
const pm = getSelectedPackageManager();
68+
if (pm === 'pnpm') {
69+
updateJson(`apps/${nodeapp}/package.json`, (json) => {
70+
json.dependencies ??= {};
71+
json.dependencies[`@proj/${lib}`] = 'workspace:*';
72+
return json;
73+
});
74+
75+
const pmc = getPackageManagerCommand({ packageManager: pm });
76+
runCommand(pmc.install);
77+
}
78+
79+
runCLI('sync');
80+
81+
// check build
82+
expect(runCLI(`build ${nodeapp}`)).toContain(
83+
`Successfully ran target build for project ${nodeapp}`
84+
);
85+
});
86+
});
87+
88+
function getRandomPort() {
89+
return Math.floor(1000 + Math.random() * 7000);
90+
}

packages/esbuild/src/executors/esbuild/lib/build-esbuild-options.ts

Lines changed: 132 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import * as esbuild from 'esbuild';
22
import * as path from 'path';
3-
import { existsSync, mkdirSync, writeFileSync } from 'fs';
3+
import { existsSync, mkdirSync, writeFileSync, lstatSync } from 'fs';
44
import {
55
ExecutorContext,
66
joinPathFragments,
77
normalizePath,
88
ProjectGraphProjectNode,
9+
readJsonFile,
910
workspaceRoot,
1011
} from '@nx/devkit';
1112

@@ -74,7 +75,10 @@ export function buildEsbuildOptions(
7475
} else if (options.platform === 'node' && format === 'cjs') {
7576
// When target platform Node and target format is CJS, then also transpile workspace libs used by the app.
7677
// Provide a `require` override in the main entry file so workspace libs can be loaded when running the app.
77-
const paths = getTsConfigCompilerPaths(context);
78+
const paths = options.isTsSolutionSetup
79+
? createPathsFromTsConfigReferences(context)
80+
: getTsConfigCompilerPaths(context);
81+
7882
const entryPointsFromProjects = getEntryPoints(
7983
context.projectName,
8084
context,
@@ -123,6 +127,132 @@ export function buildEsbuildOptions(
123127
return esbuildOptions;
124128
}
125129

130+
/**
131+
* When using TS project references we need to map the paths to the referenced projects.
132+
* This is necessary because esbuild does not support project references out of the box.
133+
* @param context ExecutorContext
134+
*/
135+
export function createPathsFromTsConfigReferences(
136+
context: ExecutorContext
137+
): Record<string, string[]> {
138+
const {
139+
findAllProjectNodeDependencies,
140+
} = require('nx/src/utils/project-graph-utils');
141+
const {
142+
isValidPackageJsonBuildConfig,
143+
} = require('@nx/js/src/plugins/typescript/util');
144+
const { readTsConfig } = require('@nx/js');
145+
const {
146+
findRuntimeTsConfigName,
147+
} = require('@nx/js/src/utils/typescript/ts-solution-setup');
148+
149+
const deps = findAllProjectNodeDependencies(
150+
context.projectName,
151+
context.projectGraph
152+
);
153+
const tsConfig = readJsonFile(
154+
joinPathFragments(context.root, 'tsconfig.json')
155+
);
156+
const referencesAsPaths = new Set(
157+
tsConfig.references.reduce((acc, ref) => {
158+
if (!ref.path) return acc;
159+
160+
const fullPath = joinPathFragments(workspaceRoot, ref.path);
161+
162+
try {
163+
if (lstatSync(fullPath).isDirectory()) {
164+
acc.push(fullPath);
165+
}
166+
} catch {
167+
// Ignore errors (e.g., path doesn't exist)
168+
}
169+
170+
return acc;
171+
}, [])
172+
);
173+
174+
// for each dep we check if it contains a build target
175+
// we only want to add the paths for projects that do not have a build target
176+
return deps.reduce((acc, dep) => {
177+
const projectNode = context.projectGraph.nodes[dep];
178+
const projectPath = joinPathFragments(workspaceRoot, projectNode.data.root);
179+
const resolvedTsConfigPath =
180+
findRuntimeTsConfigName(projectPath) ?? 'tsconfig.json';
181+
const projTsConfig = readTsConfig(resolvedTsConfigPath) as any;
182+
183+
const projectPkgJson = readJsonFile(
184+
joinPathFragments(projectPath, 'package.json')
185+
);
186+
187+
if (
188+
projTsConfig &&
189+
!isValidPackageJsonBuildConfig(
190+
projTsConfig,
191+
workspaceRoot,
192+
projectPath
193+
) &&
194+
projectPkgJson?.name
195+
) {
196+
const entryPoint = getProjectEntryPoint(projectPkgJson, projectPath);
197+
if (referencesAsPaths.has(projectPath)) {
198+
acc[projectPkgJson.name] = [path.relative(workspaceRoot, entryPoint)];
199+
}
200+
}
201+
202+
return acc;
203+
}, {});
204+
}
205+
206+
// Get the entry point for the project
207+
function getProjectEntryPoint(projectPkgJson: any, projectPath: string) {
208+
let entryPoint = null;
209+
if (typeof projectPkgJson.exports === 'string') {
210+
// If exports is a string, use it as the entry point
211+
entryPoint = path.relative(
212+
workspaceRoot,
213+
joinPathFragments(projectPath, projectPkgJson.exports)
214+
);
215+
} else if (
216+
typeof projectPkgJson.exports === 'object' &&
217+
projectPkgJson.exports['.']
218+
) {
219+
// If exports is an object and has a '.' key, process it
220+
const exportEntry = projectPkgJson.exports['.'];
221+
if (typeof exportEntry === 'object') {
222+
entryPoint =
223+
exportEntry.import ||
224+
exportEntry.require ||
225+
exportEntry.default ||
226+
null;
227+
} else if (typeof exportEntry === 'string') {
228+
entryPoint = exportEntry;
229+
}
230+
231+
if (entryPoint) {
232+
entryPoint = path.relative(
233+
workspaceRoot,
234+
joinPathFragments(projectPath, entryPoint)
235+
);
236+
}
237+
}
238+
239+
// If no exports were found, fall back to main and module
240+
if (!entryPoint) {
241+
if (projectPkgJson.main) {
242+
entryPoint = path.relative(
243+
workspaceRoot,
244+
joinPathFragments(projectPath, projectPkgJson.main)
245+
);
246+
} else if (projectPkgJson.module) {
247+
entryPoint = path.relative(
248+
workspaceRoot,
249+
joinPathFragments(projectPath, projectPkgJson.module)
250+
);
251+
}
252+
}
253+
return entryPoint;
254+
}
255+
126256
export function getOutExtension(
127257
format: 'cjs' | 'esm',
128258
options: Pick<NormalizedEsBuildExecutorOptions, 'userDefinedBuildOptions'>

packages/js/src/plugins/typescript/plugin.ts

Lines changed: 6 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@ import { hashArray, hashFile, hashObject } from 'nx/src/hasher/file-hasher';
3535
import { getLockFileName } from 'nx/src/plugins/js/lock-file/lock-file';
3636
import { workspaceDataDirectory } from 'nx/src/utils/cache-directory';
3737
import type { ParsedCommandLine, System } from 'typescript';
38-
import { addBuildAndWatchDepsTargets } from './util';
38+
import {
39+
addBuildAndWatchDepsTargets,
40+
isValidPackageJsonBuildConfig,
41+
ParsedTsconfigData,
42+
} from './util';
3943

4044
export interface TscPluginOptions {
4145
typecheck?:
@@ -72,12 +76,7 @@ interface NormalizedPluginOptions {
7276
}
7377

7478
type TscProjectResult = Pick<ProjectConfiguration, 'targets'>;
75-
type ParsedTsconfigData = Pick<
76-
ParsedCommandLine,
77-
'options' | 'projectReferences' | 'raw'
78-
> & {
79-
extendedConfigFile: { filePath: string; externalPackage?: string } | null;
80-
};
79+
8180
type TsconfigCacheData = {
8281
data: ParsedTsconfigData;
8382
hash: string;
@@ -756,103 +755,6 @@ function getOutputs(
756755
return Array.from(outputs);
757756
}
758757

759-
/**
760-
* Validates the build configuration of a `package.json` file by ensuring that paths in the `exports`, `module`,
761-
* and `main` fields reference valid output paths within the `outDir` defined in the TypeScript configuration.
762-
* Priority is given to the `exports` field, specifically the `.` export if defined. If `exports` is not defined,
763-
* the function falls back to validating `main` and `module` fields. If `outFile` is specified, it validates that the file
764-
* is located within the output directory.
765-
* If no `package.json` file exists, it assumes the configuration is valid.
766-
*
767-
* @param tsConfig The TypeScript configuration object.
768-
* @param workspaceRoot The workspace root path.
769-
* @param projectRoot The project root path.
770-
* @returns `true` if the package has a valid build configuration; otherwise, `false`.
771-
*/
772-
function isValidPackageJsonBuildConfig(
773-
tsConfig: ParsedTsconfigData,
774-
workspaceRoot: string,
775-
projectRoot: string
776-
): boolean {
777-
const packageJsonPath = join(workspaceRoot, projectRoot, 'package.json');
778-
if (!existsSync(packageJsonPath)) {
779-
// If the package.json file does not exist.
780-
// Assume it's valid because it would be using `project.json` instead.
781-
return true;
782-
}
783-
const packageJson = readJsonFile(packageJsonPath);
784-
785-
const outDir = tsConfig.options.outFile
786-
? dirname(tsConfig.options.outFile)
787-
: tsConfig.options.outDir;
788-
const resolvedOutDir = outDir
789-
? resolve(workspaceRoot, projectRoot, outDir)
790-
: undefined;
791-
792-
const isPathSourceFile = (path: string): boolean => {
793-
if (resolvedOutDir) {
794-
const pathToCheck = resolve(workspaceRoot, projectRoot, path);
795-
return !pathToCheck.startsWith(resolvedOutDir);
796-
}
797-
798-
const ext = extname(path);
799-
// Check that the file extension is a TS file extension. As the source files are in the same directory as the output files.
800-
return ['.ts', '.tsx', '.cts', '.mts'].includes(ext);
801-
};
802-
803-
// Checks if the value is a path within the `src` directory.
804-
const containsInvalidPath = (
805-
value: string | Record<string, string>
806-
): boolean => {
807-
if (typeof value === 'string') {
808-
return isPathSourceFile(value);
809-
} else if (typeof value === 'object') {
810-
return Object.entries(value).some(([currentKey, subValue]) => {
811-
// Skip types field
812-
if (currentKey === 'types') {
813-
return false;
814-
}
815-
if (typeof subValue === 'string') {
816-
return isPathSourceFile(subValue);
817-
}
818-
return false;
819-
});
820-
}
821-
return false;
822-
};
823-
824-
const exports = packageJson?.exports;
825-
826-
// Check the `.` export if `exports` is defined.
827-
if (exports) {
828-
if (typeof exports === 'string') {
829-
return !isPathSourceFile(exports);
830-
}
831-
if (typeof exports === 'object' && '.' in exports) {
832-
return !containsInvalidPath(exports['.']);
833-
}
834-
835-
// Check other exports if `.` is not defined or valid.
836-
for (const key in exports) {
837-
if (key !== '.' && containsInvalidPath(exports[key])) {
838-
return false;
839-
}
840-
}
841-
842-
return true;
843-
}
844-
845-
// If `exports` is not defined, fallback to `main` and `module` fields.
846-
const buildPaths = ['main', 'module'];
847-
for (const field of buildPaths) {
848-
if (packageJson[field] && isPathSourceFile(packageJson[field])) {
849-
return false;
850-
}
851-
}
852-
853-
return true;
854-
}
855-
856758
function pathToInputOrOutput(
857759
path: string,
858760
workspaceRoot: string,

0 commit comments

Comments
 (0)