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
9 changes: 9 additions & 0 deletions .changeset/builders-discovery-fixes.md
Original file line number Diff line number Diff line change
@@ -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
58 changes: 38 additions & 20 deletions packages/builders/src/base-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,11 +396,47 @@ 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(
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

Silently skipping when workflow/runtime cannot be resolved preserves prior behavior, but it also means discovery still misses SDK-only serde files in misconfigured installs. If that scenario is worth surfacing, a debug log (or a more explicit warning mode) might help.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Fair point. The silent catch is intentional to avoid breaking builds when workflow isn't installed yet (e.g. during initial setup), but a debug-level log would be helpful. Will add if this becomes a support issue.

dirname(outfile),
'workflow/runtime'
).catch(() => undefined);

// These need to handle watching for dev to scan for
// new entries and changes to existing ones
// 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(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();
Expand All @@ -418,24 +454,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
Expand Down
56 changes: 56 additions & 0 deletions packages/builders/src/discover-entries-esbuild-plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ vi.mock('./apply-swc-transform.js', () => ({
import {
createDiscoverEntriesPlugin,
importParents,
parentHasChild,
} from './discover-entries-esbuild-plugin.js';

const realTmpdir = realpathSync(tmpdir());
Expand Down Expand Up @@ -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<string>(),
discoveredWorkflows: new Set<string>(),
discoveredSerdeFiles: new Set<string>(),
};

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);
});
});
7 changes: 6 additions & 1 deletion packages/builders/src/discover-entries-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
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

Using onResolve with filter: /.*/ means this hook runs for every resolved request during discovery. That tradeoff is reasonable for correctness, but it may increase discovery CPU on very large graphs. If this shows up in profiles later, consider whether a narrower filter plus a second pass is worth it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Good point. The discovery pass runs once (or once per watch rebuild) and the graph is bounded by the project's actual import tree, so this shouldn't be a bottleneck in practice. But agreed it's worth keeping an eye on for very large monorepos.

try {
const resolved = await enhancedResolve(args.resolveDir, args.path);

Comment on lines +82 to 90
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

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

The new onResolve({ filter: /.*/ }) behavior is intended to support bare specifier chains, but there’s no test asserting that importParents/parentHasChild() works through a bare specifier import (e.g. entry -> workflow/runtime -> serde file). Please add a vitest case that builds a small fixture with a bare specifier dependency and verifies parentHasChild() (or the downstream bundling decision) reflects that relationship.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Added. New test tracks importParents through bare specifier imports sets up a fixture with entry.ts -> 'bare-pkg' -> ./serde.js and verifies:

  • importParents tracks both the bare specifier resolution (entry -> bare-pkg/index.js) and the relative import (index.js -> serde.js)
  • parentHasChild(entry, serde) returns true transitively through the bare specifier

Expand Down
10 changes: 10 additions & 0 deletions packages/builders/src/swc-esbuild-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/')) {
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

Bundling any resolved file under /node_modules/ avoids brittle relative externals for downstream rebundlers. That is likely the right default here; still worth watching step-bundle size regressions after this change, since it is broader than only fixing the broken externals case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed. This is intentionally broad to fix the class of broken relative externals, but we should monitor step bundle sizes. In practice the node_modules files that hit this path are small CJS shims with internal require() calls (like @vercel/functions/headers.js), not large libraries.

return null; // let esbuild bundle it
}

externalPath = relative(
options.outdir || process.cwd(),
resolvedPath
Expand Down
Loading