From 98d5dd1bd1857a4619c3b54d9b9be27ea0d54bf4 Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Sat, 2 May 2026 19:44:16 +0900 Subject: [PATCH 1/2] [builders] Fix transitive local TS dep externalization 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. Add explicit symlinks: true to the discover-entries resolver to align with swc-esbuild-plugin's NODE_ESM_RESOLVE_OPTIONS for monorepos with symlinked packages. 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 | 23 ++++++- .../builders/src/swc-esbuild-plugin.test.ts | 67 +++++++++++++++++++ packages/builders/src/swc-esbuild-plugin.ts | 9 ++- 5 files changed, 154 insertions(+), 2 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..d088d0a790 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: new Set(), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set(), + }; + + 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..4399825d8b 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -9,7 +9,28 @@ 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'], + // Match swc-esbuild-plugin's resolver so both plugins resolve the same + // paths — important for parentHasChild() graph lookups. + symlinks: true, + }) +); export const jsTsRegex = /\.(ts|tsx|js|jsx|mjs|cjs|mts|cts)$/; diff --git a/packages/builders/src/swc-esbuild-plugin.test.ts b/packages/builders/src/swc-esbuild-plugin.test.ts index 2af9759900..8b4e9f8d96 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()); @@ -226,6 +230,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: new Set(), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set(), + }; + 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 fb0d5824a2..3db8363547 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -188,13 +188,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 e72d917a63015fb64b4c697fec5ef23d2f6926dc Mon Sep 17 00:00:00 2001 From: Peter Wielander Date: Sat, 2 May 2026 20:33:54 +0900 Subject: [PATCH 2/2] [backport] Adapt to stable's pre-#1669 builders state PR #1609 was authored on top of #1669 (`fix(builders): improve step bundle discovery and externalization for SDK serde classes`), which is on `main` but not on `stable`. Bring along just the bits #1609 depends on: - Broaden discover-entries onResolve filter from `jsTsRegex` to `/.*/` so the import graph captures bare and extensionless imports. - Switch the new test fixtures from `Set` back to `string[]` to match stable's still-array-based state interface in `createDiscoverEntriesPlugin`. --- .../builders/src/discover-entries-esbuild-plugin.test.ts | 6 +++--- packages/builders/src/discover-entries-esbuild-plugin.ts | 7 ++++++- packages/builders/src/swc-esbuild-plugin.test.ts | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/builders/src/discover-entries-esbuild-plugin.test.ts b/packages/builders/src/discover-entries-esbuild-plugin.test.ts index d088d0a790..95284e2816 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.test.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.test.ts @@ -134,9 +134,9 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { ); const state = { - discoveredSteps: new Set(), - discoveredWorkflows: new Set(), - discoveredSerdeFiles: new Set(), + discoveredSteps: [] as string[], + discoveredWorkflows: [] as string[], + discoveredSerdeFiles: [] as string[], }; await esbuild.build({ diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index 4399825d8b..fc03dff4c0 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -90,7 +90,12 @@ export function createDiscoverEntriesPlugin( return { name: 'discover-entries-esbuild-plugin', setup(build) { - build.onResolve({ filter: jsTsRegex }, async (args) => { + // Track parent→child import relationships for ALL imports (not just + // those with file extensions) so that `parentHasChild()` can correctly + // identify transitive parents of serde/step files even when the + // dependency chain passes through bare specifier or extensionless + // relative imports. + build.onResolve({ filter: /.*/ }, 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 8b4e9f8d96..8b0aba16e3 100644 --- a/packages/builders/src/swc-esbuild-plugin.test.ts +++ b/packages/builders/src/swc-esbuild-plugin.test.ts @@ -250,9 +250,9 @@ describe('createSwcPlugin externalizeNonSteps', () => { // Run discovery first to populate importParents importParents.clear(); const state = { - discoveredSteps: new Set(), - discoveredWorkflows: new Set(), - discoveredSerdeFiles: new Set(), + discoveredSteps: [] as string[], + discoveredWorkflows: [] as string[], + discoveredSerdeFiles: [] as string[], }; await esbuild.build({ entryPoints: [stepFile],