Skip to content

Commit b6bab0b

Browse files
ndcunninghamnx-cloud[bot]graphite-app[bot]
authored
feat(js): add esm support for esbuild and running serve with the node executor (#31965)
This PR enables the Node.js executor to run ESM-only packages (like `node-fetch`@3) alongside CommonJS modules. ### Changes: - ESM loader: Implemented custom ESM resolver to handle Nx workspace library mappings in ESM contexts - Dynamic execution: Node executor now switches between CommonJS and ESM loaders based on the detected module format - esbuild updates: Enhanced esbuild executor to properly handle ESM output formats and creating a proper `package.json` closes: #10296 --------- Co-authored-by: nx-cloud[bot] <71083854+nx-cloud[bot]@users.noreply.github.com> Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
1 parent 18a93cf commit b6bab0b

10 files changed

Lines changed: 1722 additions & 15 deletions

File tree

e2e/js/src/js-esm-support.test.ts

Lines changed: 421 additions & 0 deletions
Large diffs are not rendered by default.

e2e/node/src/node-esm-support.test.ts

Lines changed: 576 additions & 0 deletions
Large diffs are not rendered by default.

packages/esbuild/src/executors/esbuild/esbuild.impl.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,12 @@ export async function* esbuildExecutor(
9292

9393
const cpjOptions: CopyPackageJsonOptions = {
9494
...options,
95+
format: options.format,
9596
// TODO(jack): make types generate with esbuild
9697
skipTypings: true,
9798
generateLockfile: true,
98-
outputFileExtensionForCjs: getOutExtension('cjs', options),
99+
outputFileExtensionForCjs: getOutExtension('cjs', options, context),
100+
outputFileExtensionForEsm: getOutExtension('esm', options, context),
99101
excludeLibsInPackageJson: !options.thirdParty,
100102
// TODO(jack): Remove the need to pass updateBuildableProjectDepsInPackageJson option when overrideDependencies or extraDependencies are passed.
101103
// Add this back to fix a regression.

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

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export function buildEsbuildOptions(
2323
options: NormalizedEsBuildExecutorOptions,
2424
context: ExecutorContext
2525
): esbuild.BuildOptions {
26-
const outExtension = getOutExtension(format, options);
26+
const outExtension = getOutExtension(format, options, context);
2727

2828
const esbuildOptions: esbuild.BuildOptions = {
2929
...options.userDefinedBuildOptions,
@@ -74,7 +74,7 @@ export function buildEsbuildOptions(
7474
esbuildOptions.entryPoints = entryPoints;
7575
} else if (options.platform === 'node' && format === 'cjs') {
7676
// When target platform Node and target format is CJS, then also transpile workspace libs used by the app.
77-
// Provide a `require` override in the main entry file so workspace libs can be loaded when running the app.
77+
// Provide a loader override in the main entry file so workspace libs can be loaded when running the app.
7878
const paths = options.isTsSolutionSetup
7979
? createPathsFromTsConfigReferences(context)
8080
: getTsConfigCompilerPaths(context);
@@ -91,7 +91,13 @@ export function buildEsbuildOptions(
9191

9292
esbuildOptions.entryPoints = [
9393
// Write a main entry file that registers workspace libs and then calls the user-defined main.
94-
writeTmpEntryWithRequireOverrides(paths, outExtension, options, context),
94+
writeTmpEntryWithRequireOverrides(
95+
paths,
96+
outExtension,
97+
options,
98+
context,
99+
format
100+
),
95101
...entryPointsFromProjects.map((f) => {
96102
/**
97103
* Maintain same directory structure as the workspace, so that other workspace libs may be used by the project.
@@ -255,12 +261,14 @@ function getProjectEntryPoint(projectPkgJson: any, projectPath: string) {
255261

256262
export function getOutExtension(
257263
format: 'cjs' | 'esm',
258-
options: Pick<NormalizedEsBuildExecutorOptions, 'userDefinedBuildOptions'>
264+
options: Pick<NormalizedEsBuildExecutorOptions, 'userDefinedBuildOptions'>,
265+
context?: ExecutorContext
259266
): '.cjs' | '.mjs' | '.js' {
260267
const userDefinedExt = options.userDefinedBuildOptions?.outExtension?.['.js'];
261268
// Allow users to change the output extensions from default CJS and ESM extensions.
262269
// CJS -> .js
263270
// ESM -> .mjs
271+
264272
return userDefinedExt === '.js' && format === 'cjs'
265273
? '.js'
266274
: userDefinedExt === '.mjs' && format === 'esm'
@@ -275,7 +283,7 @@ export function getOutfile(
275283
options: NormalizedEsBuildExecutorOptions,
276284
context: ExecutorContext
277285
) {
278-
const ext = getOutExtension(format, options);
286+
const ext = getOutExtension(format, options, context);
279287
const candidate = joinPathFragments(
280288
context.target.options.outputPath,
281289
options.outputFileName
@@ -288,7 +296,8 @@ function writeTmpEntryWithRequireOverrides(
288296
paths: Record<string, string[]>,
289297
outExtension: '.cjs' | '.js' | '.mjs',
290298
options: NormalizedEsBuildExecutorOptions,
291-
context: ExecutorContext
299+
context: ExecutorContext,
300+
format: 'cjs' | 'esm' = 'cjs'
292301
): { in: string; out: string } {
293302
const project = context.projectGraph?.nodes[context.projectName];
294303
// Write a temp main entry source that registers workspace libs.
@@ -309,7 +318,7 @@ function writeTmpEntryWithRequireOverrides(
309318

310319
writeFileSync(
311320
mainWithRequireOverridesInPath,
312-
getRegisterFileContent(project, paths, mainFile, outExtension)
321+
getRegisterFileContent(project, paths, mainFile, outExtension, format)
313322
);
314323

315324
let mainWithRequireOverridesOutPath: string;
@@ -335,7 +344,8 @@ export function getRegisterFileContent(
335344
project: ProjectGraphProjectNode,
336345
paths: Record<string, string[]>,
337346
mainFile: string,
338-
outExtension = '.js'
347+
outExtension = '.js',
348+
format: 'cjs' | 'esm' = 'cjs'
339349
) {
340350
mainFile = normalizePath(mainFile);
341351

@@ -364,7 +374,58 @@ export function getRegisterFileContent(
364374
return acc;
365375
}, []);
366376

367-
return `
377+
if (format === 'esm') {
378+
return `
379+
/**
380+
* IMPORTANT: Do not modify this file.
381+
* This file allows the app to run without bundling in workspace libraries.
382+
* Must be contained in the ".nx" folder inside the output path.
383+
*/
384+
import { pathToFileURL } from 'node:url';
385+
import { dirname, join } from 'node:path';
386+
import { fileURLToPath } from 'node:url';
387+
import { existsSync } from 'node:fs';
388+
389+
const __filename = fileURLToPath(import.meta.url);
390+
const __dirname = dirname(__filename);
391+
const distPath = __dirname;
392+
const manifest = ${JSON.stringify(manifest)};
393+
394+
// Resolver for workspace libs
395+
const originalResolve = import.meta.resolve;
396+
if (originalResolve) {
397+
import.meta.resolve = function(specifier, parent) {
398+
const matchingEntry = manifest.find(
399+
(entry) => specifier === entry.module || specifier.startsWith(entry.module + '/')
400+
);
401+
402+
if (matchingEntry) {
403+
if (matchingEntry.exactMatch) {
404+
const candidate = join(distPath, matchingEntry.exactMatch);
405+
if (existsSync(candidate)) {
406+
return pathToFileURL(candidate).href;
407+
}
408+
} else {
409+
const re = new RegExp(matchingEntry.module.replace(/\\*$/, "(?<rest>.*)"));
410+
const match = specifier.match(re);
411+
if (match?.groups) {
412+
const candidate = join(distPath, matchingEntry.pattern.replace("*", ""), match.groups.rest);
413+
if (existsSync(candidate)) {
414+
return pathToFileURL(candidate).href;
415+
}
416+
}
417+
}
418+
}
419+
420+
return originalResolve.call(this, specifier, parent);
421+
};
422+
}
423+
424+
// Call the user-defined main.
425+
await import(pathToFileURL(join(distPath, '${mainFile}')).href);
426+
`;
427+
} else {
428+
return `
368429
/**
369430
* IMPORTANT: Do not modify this file.
370431
* This file allows the app to run without bundling in workspace libraries.
@@ -420,6 +481,7 @@ function isFile(s) {
420481
// Call the user-defined main.
421482
module.exports = require('${mainFile}');
422483
`;
484+
}
423485
}
424486

425487
function getPrefixLength(pattern: string): number {
@@ -438,8 +500,13 @@ function getPrefixLength(pattern: string): number {
438500
function getTsConfigCompilerPaths(context: ExecutorContext): {
439501
[key: string]: string[];
440502
} {
503+
const rootTsConfigPath = getRootTsConfigPath(context);
504+
if (!rootTsConfigPath) {
505+
return {};
506+
}
507+
441508
const tsconfigPaths = require('tsconfig-paths');
442-
const tsConfigResult = tsconfigPaths.loadConfig(getRootTsConfigPath(context));
509+
const tsConfigResult = tsconfigPaths.loadConfig(rootTsConfigPath);
443510
if (tsConfigResult.resultType !== 'success') {
444511
throw new Error('Cannot load tsconfig file');
445512
}
@@ -454,7 +521,5 @@ function getRootTsConfigPath(context: ExecutorContext): string | null {
454521
}
455522
}
456523

457-
throw new Error(
458-
'Could not find a root tsconfig.json or tsconfig.base.json file.'
459-
);
524+
return null;
460525
}

0 commit comments

Comments
 (0)