From 7c8e89bfd3594d141c7bda1ced45de49c1b37a45 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 6 Apr 2026 14:58:40 +0900 Subject: [PATCH 1/4] [builders] resolve path aliases when externalizing non-step imports --- .../builders/src/swc-esbuild-plugin.test.ts | 37 +++++++++++++++++++ packages/builders/src/swc-esbuild-plugin.ts | 23 ++++++++++-- 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/builders/src/swc-esbuild-plugin.test.ts b/packages/builders/src/swc-esbuild-plugin.test.ts index 66435071d4..508be08e9d 100644 --- a/packages/builders/src/swc-esbuild-plugin.test.ts +++ b/packages/builders/src/swc-esbuild-plugin.test.ts @@ -85,6 +85,43 @@ describe('createSwcPlugin externalizeNonSteps', () => { expect(output).not.toContain(`/dep${inputExt}`); }); + it('rewrites path-aliased imports to relative paths', async () => { + const outdir = join(testRoot, 'out'); + const srcDir = join(testRoot, 'src'); + const libDir = join(srcDir, 'lib'); + const stepFile = join(srcDir, 'step.ts'); + + writeFile(join(libDir, 'config.ts'), 'export const config = {};'); + writeFile( + stepFile, + `import { config } from '@/lib/config';\nconsole.log(config);` + ); + + const result = await esbuild.build({ + entryPoints: [stepFile], + absWorkingDir: testRoot, + outdir, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + alias: { '@': srcDir }, + plugins: [ + createSwcPlugin({ + mode: 'step', + entriesToBundle: [stepFile], + outdir, + rewriteTsExtensions: true, + }), + ], + }); + + expect(result.errors).toHaveLength(0); + const output = result.outputFiles[0].text; + expect(output).toContain('/lib/config.js'); + expect(output).not.toContain('@/lib/config'); + }); + it.each([ '.ts', '.tsx', diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index 49347b5057..f78db70e95 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { relative } from 'node:path'; +import { isAbsolute, relative } from 'node:path'; import { promisify } from 'node:util'; import enhancedResolveOrig from 'enhanced-resolve'; import type { Plugin } from 'esbuild'; @@ -106,6 +106,8 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { ); build.onResolve({ filter: /.*/ }, async (args) => { + if (args.pluginData?.skipSwcPlugin) return null; + if ( !options.entriesToBundle && normalizedSideEffectEntries.size === 0 @@ -136,7 +138,20 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { // it's parent has been bundled build.initialOptions.absWorkingDir || process.cwd(), args.path - ); + ).catch(() => undefined); + + // enhanced-resolve doesn't handle esbuild aliases or tsconfig + // paths. Fall back to esbuild's own resolver which does. + if (!resolvedPath) { + const esbuildResult = await build.resolve(args.path, { + resolveDir: args.resolveDir, + kind: args.kind, + pluginData: { skipSwcPlugin: true }, + }); + if (esbuildResult.path && !esbuildResult.errors.length) { + resolvedPath = esbuildResult.path; + } + } } if (!resolvedPath) return null; @@ -183,7 +198,9 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { } const isFilePath = - args.path.startsWith('.') || args.path.startsWith('/'); + args.path.startsWith('.') || + args.path.startsWith('/') || + isAbsolute(resolvedPath as string); let externalPath: string; if (isFilePath) { From c92904d3b10f550b97e6981fe7dd10e849f27493 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 6 Apr 2026 15:20:41 +0900 Subject: [PATCH 2/4] fix(builders): scope isAbsolute check to esbuild-resolved paths The isAbsolute(resolvedPath) condition for detecting file paths was too broad, matching bare npm specifiers (e.g. lodash.chunk) that enhanced-resolve already resolved to absolute node_modules paths. This caused Rollup to reject the relativized paths in Nitro builds. Gate the check behind resolvedViaEsbuild so it only applies to alias/tsconfig paths resolved by the esbuild fallback. --- .changeset/fix-vitest-path-aliases.md | 5 +++++ packages/builders/src/swc-esbuild-plugin.ts | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-vitest-path-aliases.md diff --git a/.changeset/fix-vitest-path-aliases.md b/.changeset/fix-vitest-path-aliases.md new file mode 100644 index 0000000000..295b0031c2 --- /dev/null +++ b/.changeset/fix-vitest-path-aliases.md @@ -0,0 +1,5 @@ +--- +"@workflow/builders": patch +--- + +Resolve path aliases when externalizing non-step imports diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index f78db70e95..1df2147b29 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -125,6 +125,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { try { let resolvedPath: string | false | undefined = args.path; + let resolvedViaEsbuild = false; // handle local imports e.g. ./hello or ../another if (args.path.startsWith('.')) { @@ -150,6 +151,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { }); if (esbuildResult.path && !esbuildResult.errors.length) { resolvedPath = esbuildResult.path; + resolvedViaEsbuild = true; } } } @@ -200,7 +202,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { const isFilePath = args.path.startsWith('.') || args.path.startsWith('/') || - isAbsolute(resolvedPath as string); + (resolvedViaEsbuild && isAbsolute(resolvedPath)); let externalPath: string; if (isFilePath) { From a8695053721558b5b45e5101e2da953af07ba622 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 6 Apr 2026 15:24:29 +0900 Subject: [PATCH 3/4] fix(builders): add comment explaining catch on enhanced-resolve --- packages/builders/src/swc-esbuild-plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index 1df2147b29..6b1a3f6576 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -139,7 +139,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { // it's parent has been bundled build.initialOptions.absWorkingDir || process.cwd(), args.path - ).catch(() => undefined); + ).catch(() => undefined); // swallow so esbuild fallback below can try // enhanced-resolve doesn't handle esbuild aliases or tsconfig // paths. Fall back to esbuild's own resolver which does. From 60ed9e5f5bc35939e36997f3a14a55faf764afbd Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Tue, 7 Apr 2026 08:25:52 +0900 Subject: [PATCH 4/4] fix(builders): guard esbuild fallback against node_modules, improve clarity Reject esbuild fallback results that resolve into node_modules to preserve the nested dep bundling invariant. Refactor resolution variables for clarity: separate specifier from resolvedPath, rename isFilePath to shouldMakeRelative, add regression test for aliased imports targeting node_modules. --- .../builders/src/swc-esbuild-plugin.test.ts | 34 +++++++++++++ packages/builders/src/swc-esbuild-plugin.ts | 51 ++++++++++--------- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/packages/builders/src/swc-esbuild-plugin.test.ts b/packages/builders/src/swc-esbuild-plugin.test.ts index 508be08e9d..9725e3f113 100644 --- a/packages/builders/src/swc-esbuild-plugin.test.ts +++ b/packages/builders/src/swc-esbuild-plugin.test.ts @@ -122,6 +122,40 @@ describe('createSwcPlugin externalizeNonSteps', () => { expect(output).not.toContain('@/lib/config'); }); + it('does not externalize aliased imports that resolve into node_modules', async () => { + const outdir = join(testRoot, 'out'); + const srcDir = join(testRoot, 'src'); + const stepFile = join(srcDir, 'step.ts'); + const nodeModulesDir = join(testRoot, 'node_modules', 'some-pkg'); + + writeFile(join(nodeModulesDir, 'index.js'), 'export const pkg = "hello";'); + writeFile(stepFile, `import { pkg } from '@pkg';\nconsole.log(pkg);`); + + const result = await esbuild.build({ + entryPoints: [stepFile], + absWorkingDir: testRoot, + outdir, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + alias: { '@pkg': join(nodeModulesDir, 'index.js') }, + plugins: [ + createSwcPlugin({ + mode: 'step', + entriesToBundle: [stepFile], + outdir, + }), + ], + }); + + expect(result.errors).toHaveLength(0); + const output = result.outputFiles[0].text; + // Should be bundled (inlined), not externalized as a relative node_modules path + expect(output).toContain('hello'); + expect(output).not.toMatch(/from\s+["'].*node_modules/); + }); + it.each([ '.ts', '.tsx', diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index 6b1a3f6576..cc493f8f08 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -1,5 +1,5 @@ import { readFile } from 'node:fs/promises'; -import { isAbsolute, relative } from 'node:path'; +import { relative } from 'node:path'; import { promisify } from 'node:util'; import enhancedResolveOrig from 'enhanced-resolve'; import type { Plugin } from 'esbuild'; @@ -124,34 +124,42 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { } try { - let resolvedPath: string | false | undefined = args.path; - let resolvedViaEsbuild = false; + const specifier = args.path; + const specifierIsPath = + specifier.startsWith('.') || specifier.startsWith('/'); - // handle local imports e.g. ./hello or ../another - if (args.path.startsWith('.')) { - resolvedPath = await enhancedResolve(args.resolveDir, args.path); + let resolvedPath: string | false | undefined; + // Determines whether the external path should be relativized + // (project-local file) or kept as a bare specifier (npm package). + let shouldMakeRelative = specifierIsPath; + + if (specifierIsPath) { + resolvedPath = await enhancedResolve(args.resolveDir, specifier); } else { + // Resolve from project root so nested deps aren't externalized resolvedPath = await enhancedResolve( - // `args.resolveDir` is not used here to ensure we only - // externalize packages that can be resolved in the - // project's working directory e.g. a nested dep can't - // be externalized as we won't be able to resolve it once - // it's parent has been bundled build.initialOptions.absWorkingDir || process.cwd(), - args.path + specifier ).catch(() => undefined); // swallow so esbuild fallback below can try - // enhanced-resolve doesn't handle esbuild aliases or tsconfig - // paths. Fall back to esbuild's own resolver which does. + // Fall back to esbuild for aliases/tsconfig paths, + // but only accept project-local results if (!resolvedPath) { - const esbuildResult = await build.resolve(args.path, { + const esbuildResult = await build.resolve(specifier, { resolveDir: args.resolveDir, kind: args.kind, pluginData: { skipSwcPlugin: true }, }); - if (esbuildResult.path && !esbuildResult.errors.length) { + const didResolve = + !!esbuildResult.path && !esbuildResult.errors.length; + const isProjectLocalFile = + didResolve && + !esbuildResult.path + .replace(/\\/g, '/') + .includes('/node_modules/'); + if (isProjectLocalFile) { resolvedPath = esbuildResult.path; - resolvedViaEsbuild = true; + shouldMakeRelative = true; } } } @@ -199,13 +207,8 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { : null; } - const isFilePath = - args.path.startsWith('.') || - args.path.startsWith('/') || - (resolvedViaEsbuild && isAbsolute(resolvedPath)); - let externalPath: string; - if (isFilePath) { + if (shouldMakeRelative) { externalPath = relative( options.outdir || process.cwd(), resolvedPath @@ -220,7 +223,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { .replace(/\.cts$/, '.cjs'); } } else { - externalPath = args.path; + externalPath = specifier; } return {