From 6ae59caf33e68960dc67e2d063d873e973a32825 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Tue, 7 Apr 2026 13:25:49 -0700 Subject: [PATCH] fix(builders): skip Node.js builtins in esbuild alias fallback PR #1613 added an esbuild fallback for resolving path aliases when enhanced-resolve fails. However, Node.js builtins like `crypto` and `node:path` also fail enhanced-resolve, and esbuild resolves them to absolute paths outside node_modules. The `isProjectLocalFile` check then incorrectly relativizes them (e.g. `../../../../../../crypto`), breaking Next.js builds. Fix: check `isBuiltin(specifier)` before falling through to the esbuild resolver, so builtins are left as bare specifiers. Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/fix-builtin-externalization.md | 5 +++ .../builders/src/swc-esbuild-plugin.test.ts | 36 +++++++++++++++++++ packages/builders/src/swc-esbuild-plugin.ts | 8 +++++ 3 files changed, 49 insertions(+) create mode 100644 .changeset/fix-builtin-externalization.md diff --git a/.changeset/fix-builtin-externalization.md b/.changeset/fix-builtin-externalization.md new file mode 100644 index 0000000000..47a9a07223 --- /dev/null +++ b/.changeset/fix-builtin-externalization.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +Skip Node.js builtins in esbuild alias fallback to prevent them from being relativized diff --git a/packages/builders/src/swc-esbuild-plugin.test.ts b/packages/builders/src/swc-esbuild-plugin.test.ts index 9725e3f113..63a7afbd66 100644 --- a/packages/builders/src/swc-esbuild-plugin.test.ts +++ b/packages/builders/src/swc-esbuild-plugin.test.ts @@ -122,6 +122,42 @@ describe('createSwcPlugin externalizeNonSteps', () => { expect(output).not.toContain('@/lib/config'); }); + it('keeps Node.js builtins as bare specifiers when enhanced-resolve fails', async () => { + const outdir = join(testRoot, 'out'); + const srcDir = join(testRoot, 'src'); + const stepFile = join(srcDir, 'step.ts'); + + writeFile( + stepFile, + `import { randomUUID } from 'crypto';\nimport { join } from 'node:path';\nconsole.log(randomUUID(), join('a', 'b'));` + ); + + const result = await esbuild.build({ + entryPoints: [stepFile], + absWorkingDir: testRoot, + outdir, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + plugins: [ + createSwcPlugin({ + mode: 'step', + entriesToBundle: [stepFile], + outdir, + }), + ], + }); + + expect(result.errors).toHaveLength(0); + const output = result.outputFiles[0].text; + // Builtins must stay as bare specifiers, not be relativized + expect(output).toMatch(/from\s+["']crypto["']/); + expect(output).toMatch(/from\s+["']node:path["']/); + expect(output).not.toMatch(/from\s+["']\..*crypto/); + expect(output).not.toMatch(/from\s+["']\..*node:path/); + }); + it('does not externalize aliased imports that resolve into node_modules', async () => { const outdir = join(testRoot, 'out'); const srcDir = join(testRoot, 'src'); diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index cc493f8f08..f4b2502c72 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -1,4 +1,5 @@ import { readFile } from 'node:fs/promises'; +import { isBuiltin } from 'node:module'; import { relative } from 'node:path'; import { promisify } from 'node:util'; import enhancedResolveOrig from 'enhanced-resolve'; @@ -142,6 +143,13 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { specifier ).catch(() => undefined); // swallow so esbuild fallback below can try + // Node.js builtins (e.g. "crypto", "node:path") aren't resolvable + // by enhanced-resolve either, but must stay as bare specifiers — + // not be handed to esbuild's resolver which would relativize them. + if (!resolvedPath && isBuiltin(specifier)) { + return null; + } + // Fall back to esbuild for aliases/tsconfig paths, // but only accept project-local results if (!resolvedPath) {