From 443ebe020bf63ea611c8c444572813cc23ad1198 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 9 Apr 2026 10:41:18 -0700 Subject: [PATCH 1/2] fix(builders): improve step bundle discovery and externalization for SDK serde classes - Broaden importParents tracking to all imports (not just file extensions) so parentHasChild() works through bare specifier imports - Include workflow/runtime in discovery inputs so SDK serde classes like Run are always discovered - Bundle node_modules deps instead of externalizing with broken relative paths in CJS shims --- .changeset/builders-discovery-fixes.md | 9 +++ packages/builders/src/base-builder.ts | 55 ++++++++++++------- .../src/discover-entries-esbuild-plugin.ts | 7 ++- packages/builders/src/swc-esbuild-plugin.ts | 10 ++++ 4 files changed, 61 insertions(+), 20 deletions(-) create mode 100644 .changeset/builders-discovery-fixes.md diff --git a/.changeset/builders-discovery-fixes.md b/.changeset/builders-discovery-fixes.md new file mode 100644 index 0000000000..ad49ab03c7 --- /dev/null +++ b/.changeset/builders-discovery-fixes.md @@ -0,0 +1,9 @@ +--- +"@workflow/builders": patch +--- + +Fix step bundle discovery and externalization for SDK serde classes + +- Broaden `importParents` tracking to all imports (not just file extensions) so `parentHasChild()` works through bare specifier imports +- Include `workflow/runtime` in discovery inputs so SDK serde classes like `Run` are always discovered +- Bundle node_modules deps instead of externalizing with broken relative paths diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 5d04490777..22b9ff6e54 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -396,11 +396,46 @@ export abstract class BaseBuilder { context: esbuild.BuildContext | undefined; manifest: WorkflowManifest; }> { + const stepsBundleStart = Date.now(); + const workflowManifest: WorkflowManifest = {}; + const builtInSteps = 'workflow/internal/builtins'; + + const resolvedBuiltInSteps = await enhancedResolve( + dirname(outfile), + 'workflow/internal/builtins' + ).catch((err) => { + throw new Error( + [ + chalk.red('Failed to resolve built-in steps sources.'), + `${chalk.yellow.bold('hint:')} run \`${chalk.cyan.italic('npm install workflow')}\` to resolve this issue.`, + '', + `Caused by: ${chalk.red(String(err))}`, + ].join('\n') + ); + }); + + // Resolve the SDK runtime entry point so that the discovery pass + // traces through it and discovers serde classes (like `Run`) that + // live inside SDK packages. Without this, files like `run.js` are + // only discovered when user code happens to import them. + const resolvedWorkflowRuntime = await enhancedResolve( + dirname(outfile), + 'workflow/runtime' + ).catch(() => undefined); + // These need to handle watching for dev to scan for // new entries and changes to existing ones + const discoveryInputs = [...inputFiles]; + if (resolvedWorkflowRuntime) { + discoveryInputs.push(resolvedWorkflowRuntime); + } const discovered = discoveredEntries ?? - (await this.discoverEntries(inputFiles, dirname(outfile), tsconfigPath)); + (await this.discoverEntries( + discoveryInputs, + dirname(outfile), + tsconfigPath + )); const stepFiles = [...discovered.discoveredSteps].sort(); const workflowFiles = [...discovered.discoveredWorkflows].sort(); const serdeFiles = [...discovered.discoveredSerdeFiles].sort(); @@ -418,24 +453,6 @@ export abstract class BaseBuilder { serdeOnlyFiles, }); - const stepsBundleStart = Date.now(); - const workflowManifest: WorkflowManifest = {}; - const builtInSteps = 'workflow/internal/builtins'; - - const resolvedBuiltInSteps = await enhancedResolve( - dirname(outfile), - 'workflow/internal/builtins' - ).catch((err) => { - throw new Error( - [ - chalk.red('Failed to resolve built-in steps sources.'), - `${chalk.yellow.bold('hint:')} run \`${chalk.cyan.italic('npm install workflow')}\` to resolve this issue.`, - '', - `Caused by: ${chalk.red(String(err))}`, - ].join('\n') - ); - }); - // Helper to create import statement from file path // For workspace/node_modules packages, uses the package name so esbuild // will resolve through package.json exports with the appropriate conditions diff --git a/packages/builders/src/discover-entries-esbuild-plugin.ts b/packages/builders/src/discover-entries-esbuild-plugin.ts index fc33b03491..6ed7d6bc29 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.ts @@ -79,7 +79,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 imports like + // `@workflow/core/runtime` or `workflow/runtime`. + build.onResolve({ filter: /.*/ }, async (args) => { try { const resolved = await enhancedResolve(args.resolveDir, args.path); diff --git a/packages/builders/src/swc-esbuild-plugin.ts b/packages/builders/src/swc-esbuild-plugin.ts index fb0d5824a2..2a228c2730 100644 --- a/packages/builders/src/swc-esbuild-plugin.ts +++ b/packages/builders/src/swc-esbuild-plugin.ts @@ -210,6 +210,16 @@ export function createSwcPlugin(options: SwcPluginOptions): Plugin { let externalPath: string; if (shouldMakeRelative) { + // When the resolved file lives inside node_modules, let + // esbuild bundle it rather than externalizing with a deeply + // nested relative path. Downstream bundlers (Rollup/Vite) + // can't rewrite opaque `__require()` calls in CJS shims, so + // relative paths computed for `outdir` break once the output + // is rebundled to a different directory. + if (normalizedResolvedPath.includes('/node_modules/')) { + return null; // let esbuild bundle it + } + externalPath = relative( options.outdir || process.cwd(), resolvedPath From aebee64f8a00a7097d6cf0f34f77afcdd13d05a7 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 9 Apr 2026 12:02:22 -0700 Subject: [PATCH 2/2] fix(builders): address review feedback - Preserve inputFiles array reference for discoverEntries() WeakMap caching when no extra entries are needed - Add test verifying importParents/parentHasChild works through bare specifier imports (entry -> bare-pkg -> serde file) --- packages/builders/src/base-builder.ts | 11 ++-- .../discover-entries-esbuild-plugin.test.ts | 56 +++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/packages/builders/src/base-builder.ts b/packages/builders/src/base-builder.ts index 22b9ff6e54..25762fa47a 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -424,11 +424,12 @@ export abstract class BaseBuilder { ).catch(() => undefined); // These need to handle watching for dev to scan for - // new entries and changes to existing ones - const discoveryInputs = [...inputFiles]; - if (resolvedWorkflowRuntime) { - discoveryInputs.push(resolvedWorkflowRuntime); - } + // new entries and changes to existing ones. + // Pass inputFiles directly when no extra entries are needed to + // preserve the array reference for discoverEntries() WeakMap caching. + const discoveryInputs = resolvedWorkflowRuntime + ? [...inputFiles, resolvedWorkflowRuntime] + : inputFiles; const discovered = discoveredEntries ?? (await this.discoverEntries( diff --git a/packages/builders/src/discover-entries-esbuild-plugin.test.ts b/packages/builders/src/discover-entries-esbuild-plugin.test.ts index bea33c917f..4467123a12 100644 --- a/packages/builders/src/discover-entries-esbuild-plugin.test.ts +++ b/packages/builders/src/discover-entries-esbuild-plugin.test.ts @@ -21,6 +21,7 @@ vi.mock('./apply-swc-transform.js', () => ({ import { createDiscoverEntriesPlugin, importParents, + parentHasChild, } from './discover-entries-esbuild-plugin.js'; const realTmpdir = realpathSync(tmpdir()); @@ -161,4 +162,59 @@ describe('createDiscoverEntriesPlugin projectRoot', () => { fixture.packageRoot ); }); + + it('tracks importParents through bare specifier imports', async () => { + // Simulate: entry.ts -> bare-pkg -> ./serde-file.ts + // The bare specifier "bare-pkg" should not break the parent-child chain. + const entryFile = join(testRoot, 'entry.ts'); + const pkgDir = join(testRoot, 'node_modules', 'bare-pkg'); + const pkgIndex = join(pkgDir, 'index.js'); + const serdeFile = join(pkgDir, 'serde.js'); + + writeFile( + join(pkgDir, 'package.json'), + JSON.stringify({ name: 'bare-pkg', main: 'index.js' }) + ); + writeFile(pkgIndex, `export { Foo } from './serde.js';`); + writeFile(serdeFile, `export class Foo {}\n`); + writeFile( + entryFile, + `import { Foo } from 'bare-pkg';\nconsole.log(Foo);\n` + ); + + const state = { + discoveredSteps: new Set(), + discoveredWorkflows: new Set(), + discoveredSerdeFiles: new Set(), + }; + + const result = await esbuild.build({ + entryPoints: [entryFile], + absWorkingDir: testRoot, + bundle: true, + format: 'esm', + platform: 'node', + write: false, + plugins: [createDiscoverEntriesPlugin(state)], + }); + + expect(result.errors).toHaveLength(0); + + const normalizedEntry = normalizeSlashes(entryFile); + const normalizedPkgIndex = normalizeSlashes(pkgIndex); + const normalizedSerde = normalizeSlashes(serdeFile); + + // entry.ts -> bare-pkg/index.js should be tracked + const entryChildren = importParents.get(normalizedEntry); + expect(entryChildren).toBeDefined(); + expect(entryChildren!.has(normalizedPkgIndex)).toBe(true); + + // bare-pkg/index.js -> bare-pkg/serde.js should be tracked + const pkgChildren = importParents.get(normalizedPkgIndex); + expect(pkgChildren).toBeDefined(); + expect(pkgChildren!.has(normalizedSerde)).toBe(true); + + // parentHasChild should transitively find serde.js from entry.ts + expect(parentHasChild(normalizedEntry, normalizedSerde)).toBe(true); + }); });