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..25762fa47a 100644 --- a/packages/builders/src/base-builder.ts +++ b/packages/builders/src/base-builder.ts @@ -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( + 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(); @@ -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 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); + }); }); 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