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 4467123a12..5df59c7699 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.test.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.test.ts @@ -130,6 +130,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 6ed7d6bc29..aa8de5b9a1 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -9,7 +9,28 @@ import { isGeneratedWorkflowFile, } 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 2a228c2730..3a17f19951 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) {