Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-vitest-path-aliases.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/builders": patch
---

Resolve path aliases when externalizing non-step imports
71 changes: 71 additions & 0 deletions packages/builders/src/swc-esbuild-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,77 @@ 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');
});
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

This test uses esbuild alias to set up the path alias, but the more common real-world scenario is tsconfig paths (which is how most Next.js / Vite projects configure @/* aliases). Consider adding a test that uses a tsconfig.json with compilerOptions.paths and passes it via esbuild's tsconfig option — this would exercise the same build.resolve() fallback path but through the tsconfig resolution pipeline that most users actually hit.

I verified locally that this does work (wrote a test using tsconfig: join(testRoot, 'tsconfig.json') with paths: { "@/*": ["./src/*"] } instead of alias), but having it in the test suite would increase confidence.


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',
Expand Down
54 changes: 38 additions & 16 deletions packages/builders/src/swc-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -122,21 +124,44 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin {
}

try {
let resolvedPath: string | false | undefined = args.path;
const specifier = args.path;
const specifierIsPath =
specifier.startsWith('.') || specifier.startsWith('/');

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;

// handle local imports e.g. ./hello or ../another
if (args.path.startsWith('.')) {
resolvedPath = await enhancedResolve(args.resolveDir, args.path);
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

// Fall back to esbuild for aliases/tsconfig paths,
// but only accept project-local results
if (!resolvedPath) {
const esbuildResult = await build.resolve(specifier, {
resolveDir: args.resolveDir,
kind: args.kind,
pluginData: { skipSwcPlugin: true },
});
const didResolve =
!!esbuildResult.path && !esbuildResult.errors.length;
const isProjectLocalFile =
didResolve &&
!esbuildResult.path
.replace(/\\/g, '/')
.includes('/node_modules/');
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Nit

The /node_modules/ string check works for the common case but could theoretically match a project directory literally named node_modules in its path (e.g. /home/user/my-node_modules-tools/src/lib.ts). This is extremely unlikely and I don't think it warrants a change, but worth noting for future awareness.

if (isProjectLocalFile) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

When the esbuild fallback resolves an aliased path that also appears in sideEffectEntries, the hasSideEffects check (line 170) uses normalizedResolvedPath — which at this point is correctly set from esbuildResult.path. So the sideEffects flag should propagate correctly for aliased imports. However, there's no test coverage for this interaction. A test where an aliased import matches a sideEffectEntries entry would be valuable.

resolvedPath = esbuildResult.path;
shouldMakeRelative = true;
}
}
}

if (!resolvedPath) return null;
Expand Down Expand Up @@ -182,11 +207,8 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin {
: null;
}

const isFilePath =
args.path.startsWith('.') || args.path.startsWith('/');

let externalPath: string;
if (isFilePath) {
if (shouldMakeRelative) {
externalPath = relative(
options.outdir || process.cwd(),
resolvedPath
Expand All @@ -201,7 +223,7 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin {
.replace(/\.cts$/, '.cjs');
}
} else {
externalPath = args.path;
externalPath = specifier;
}

return {
Expand Down
Loading