From c3fb54774c043b1b9f45acd2afd6fd79cdecbf62 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Fri, 3 Apr 2026 14:49:35 -0700 Subject: [PATCH 1/2] [builders] Fix transitive local TS deps being externalized in step bundles Two issues caused local transitive dependencies to be externalized instead of bundled in step/workflow bundles: 1. The discover-entries plugin's onResolve filter only matched imports with explicit file extensions (jsTsRegex). Extensionless imports like `./helpers` were never tracked in the import graph. 2. The swc plugin only checked if a file was an ancestor of an entry (parentHasChild(resolved, entry)) but never checked if it was a descendant (parentHasChild(entry, resolved)). So even with a correct import graph, transitive local deps got externalized. Fixes: configure enhanced-resolve with TS extensions, broaden the onResolve filter to catch all relative imports, and add the reverse parentHasChild check. Closes #1179 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fix-transitive-local-dep-bundling.md | 5 ++ .../discover-entries-esbuild-plugin.test.ts | 52 ++++++++++++++ .../src/discover-entries-esbuild-plugin.ts | 27 +++++++- .../builders/src/swc-esbuild-plugin.test.ts | 67 +++++++++++++++++++ packages/builders/src/swc-esbuild-plugin.ts | 9 ++- 5 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-transitive-local-dep-bundling.md diff --git a/.changeset/fix-transitive-local-dep-bundling.md b/.changeset/fix-transitive-local-dep-bundling.md new file mode 100644 index 0000000000..f631299640 --- /dev/null +++ b/.changeset/fix-transitive-local-dep-bundling.md @@ -0,0 +1,5 @@ +--- +'@workflow/builders': patch +--- + +Fix transitive local TS dependencies being externalized in step bundles instead of bundled diff --git a/packages/builders/src/discover-entries-esbuild-plugin.test.ts b/packages/builders/src/discover-entries-esbuild-plugin.test.ts index cf2e0dfbee..95284e2816 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.test.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.test.ts @@ -113,6 +113,58 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { ); }); + it('tracks extensionless relative imports in the import graph', async () => { + const workflowFile = join( + testRoot, + 'server', + 'workflows', + 'my-workflow.ts' + ); + const constantsFile = join(testRoot, 'shared', 'constants.ts'); + const helpersFile = join(testRoot, 'shared', 'helpers.ts'); + + writeFile(helpersFile, `export const HELLO = "world";`); + writeFile( + constantsFile, + `import { HELLO } from './helpers';\nexport const MSG = HELLO;` + ); + writeFile( + workflowFile, + `import { MSG } from '../../shared/constants';\nexport function myWorkflow() {\n "use workflow";\n return MSG;\n}` + ); + + const state = { + discoveredSteps: [] as string[], + discoveredWorkflows: [] as string[], + discoveredSerdeFiles: [] as string[], + }; + + await esbuild.build({ + entryPoints: [workflowFile], + absWorkingDir: testRoot, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + plugins: [createDiscoverEntriesPlugin(state, testRoot)], + }); + + const normalizedWorkflow = normalizeSlashes(workflowFile); + const normalizedConstants = normalizeSlashes(constantsFile); + const normalizedHelpers = normalizeSlashes(helpersFile); + + // my-workflow.ts -> shared/constants.ts (extensionless import) + const workflowChildren = importParents.get(normalizedWorkflow); + expect(workflowChildren).toBeDefined(); + expect(workflowChildren!.has(normalizedConstants)).toBe(true); + + // shared/constants.ts -> shared/helpers.ts (extensionless import) + const constantsChildren = importParents.get(normalizedConstants); + expect(constantsChildren).toBeDefined(); + expect(constantsChildren!.has(normalizedHelpers)).toBe(true); + }); + it('defaults discovery transforms to absWorkingDir when projectRoot is omitted', async () => { const fixture = setupFixture(); const normalizedWorkflowFile = normalizeSlashes(fixture.workflowFile); diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index 37a1224836..0db45b1389 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -9,10 +9,33 @@ import { isWorkflowSdkFile, } from './transform-utils.js'; -const enhancedResolve = promisify(enhancedResolveOriginal); +const enhancedResolve = promisify( + enhancedResolveOriginal.create({ + extensions: [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.cjs', + '.mjs', + '.js', + '.jsx', + '.json', + '.node', + ], + mainFields: ['main'], + mainFiles: ['index'], + conditionNames: ['node', 'import'], + }) +); export const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/; +// Matches relative imports (./foo, ../bar, /abs) regardless of extension. +// Used to track local dependency edges for the import graph without pulling +// in bare node_modules specifiers. +const relativeImportRegex = /^[./]/; + function isGeneratedBuildArtifactPath(filePath: string): boolean { const normalizedPath = filePath.replace(/\\/g, '/'); return ( @@ -69,7 +92,7 @@ export function createDiscoverEntriesPlugin( return { name: 'discover-entries-esbuild-plugin', setup(build) { - build.onResolve({ filter: jsTsRegex }, async (args) => { + build.onResolve({ filter: relativeImportRegex }, async (args) => { try { const resolved = await enhancedResolve(args.resolveDir, args.path); diff --git a/packages/builders/src/swc-esbuild-plugin.test.ts b/packages/builders/src/swc-esbuild-plugin.test.ts index 66435071d4..15dffb3a9a 100644 --- a/packages/builders/src/swc-esbuild-plugin.test.ts +++ b/packages/builders/src/swc-esbuild-plugin.test.ts @@ -19,6 +19,10 @@ vi.mock('./apply-swc-transform.js', () => ({ })); import { createSwcPlugin } from './swc-esbuild-plugin.js'; +import { + importParents, + createDiscoverEntriesPlugin, +} from './discover-entries-esbuild-plugin.js'; const realTmpdir = realpathSync(tmpdir()); @@ -119,6 +123,69 @@ describe('createSwcPlugin externalizeNonSteps', () => { const output = result.outputFiles[0].text; expect(output).toContain(`/dep${inputExt}`); }); + + it('bundles transitive local dependencies of entries instead of externalizing them', async () => { + const outdir = join(testRoot, 'out'); + const stepFile = join(testRoot, 'server', 'workflows', 'my-step.ts'); + const constantsFile = join(testRoot, 'shared', 'constants.ts'); + const helpersFile = join(testRoot, 'shared', 'helpers.ts'); + + writeFile(helpersFile, `export const HELLO = "world";`); + writeFile( + constantsFile, + `import { HELLO } from './helpers';\nexport const MSG = HELLO;` + ); + writeFile( + stepFile, + `import { MSG } from '../../shared/constants';\nconsole.log(MSG);` + ); + + // Run discovery first to populate importParents + importParents.clear(); + const state = { + discoveredSteps: [] as string[], + discoveredWorkflows: [] as string[], + discoveredSerdeFiles: [] as string[], + }; + await esbuild.build({ + entryPoints: [stepFile], + absWorkingDir: testRoot, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + plugins: [createDiscoverEntriesPlugin(state, testRoot)], + }); + + // Now build with swc plugin — transitive deps should be bundled + const result = await esbuild.build({ + entryPoints: [stepFile], + absWorkingDir: testRoot, + outdir, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + resolveExtensions: ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs'], + plugins: [ + createSwcPlugin({ + mode: 'step', + entriesToBundle: [stepFile], + outdir, + }), + ], + }); + + expect(result.errors).toHaveLength(0); + const output = result.outputFiles[0].text; + // Both constants.ts and helpers.ts should be inlined, not externalized + expect(output).toContain('world'); + expect(output).not.toContain('../shared/constants'); + expect(output).not.toContain('./helpers'); + + importParents.clear(); + }); }); describe('createSwcPlugin sideEffectEntries', () => { diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index 49347b5057..41a79900a5 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -162,13 +162,20 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { break; } - // if the current entry imports a child that needs + // if the current file imports a child that needs // to be bundled then it needs to also be bundled so // that the child can have our transform applied if (parentHasChild(normalizedResolvedPath, normalizedEntry)) { shouldBundle = true; break; } + + // if an entry transitively imports this file, bundle it + // so the step bundle is self-contained for local deps + if (parentHasChild(normalizedEntry, normalizedResolvedPath)) { + shouldBundle = true; + break; + } } if (shouldBundle) { From cca9905305126e033a51309c82433bd1dd6d1e80 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Thu, 9 Apr 2026 10:02:14 -0700 Subject: [PATCH 2/2] Add explicit symlinks: true to discover-entries resolver Aligns with swc-esbuild-plugin's NODE_ESM_RESOLVE_OPTIONS to ensure both plugins resolve the same paths for parentHasChild() graph lookups, particularly in monorepo setups with symlinked packages. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/builders/src/discover-entries-esbuild-plugin.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index 05db9decbd..8c61654dc6 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -26,6 +26,9 @@ const enhancedResolve = promisify( mainFields: ['main'], mainFiles: ['index'], conditionNames: ['node', 'import'], + // Match swc-esbuild-plugin's resolver so both plugins resolve the same + // paths — important for parentHasChild() graph lookups. + symlinks: true, }) );