From c27fcaf72318ab6a971b3f8ca6a47b76b1a7a850 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 9 Apr 2026 10:42:52 -0700 Subject: [PATCH 1/2] fix(next): resolve bare specifiers in copied step files for lazy discovery When the deferred builder copies step files to __workflow_step_files__/, bare specifiers that are transitive SDK deps can't resolve from the app directory. Use enhanced-resolve with ESM conditions (preferring 'import' over 'require') to resolve from the original source location, only when the specifier can't be resolved from the app directory. Also add enhanced-resolve to the pnpm catalog and use catalog: in both @workflow/builders and @workflow/next. --- .changeset/lazy-discovery-bare-specifiers.md | 10 +++ packages/builders/package.json | 2 +- packages/next/package.json | 1 + packages/next/src/builder-deferred.ts | 83 ++++++++++++++++++++ pnpm-lock.yaml | 18 ++--- pnpm-workspace.yaml | 1 + 6 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 .changeset/lazy-discovery-bare-specifiers.md diff --git a/.changeset/lazy-discovery-bare-specifiers.md b/.changeset/lazy-discovery-bare-specifiers.md new file mode 100644 index 0000000000..c7d3216539 --- /dev/null +++ b/.changeset/lazy-discovery-bare-specifiers.md @@ -0,0 +1,10 @@ +--- +"@workflow/next": patch +"@workflow/builders": patch +--- + +Fix lazy discovery bare specifier resolution in copied step files + +- Use `enhanced-resolve` with ESM conditions to resolve bare specifiers from the original source file's location +- Only rewrite specifiers that can't resolve from the app directory (transitive SDK deps) +- Add `enhanced-resolve` to pnpm catalog and use `catalog:` in both packages diff --git a/packages/builders/package.json b/packages/builders/package.json index 1e8cdf5137..94121d2e7d 100644 --- a/packages/builders/package.json +++ b/packages/builders/package.json @@ -46,7 +46,7 @@ "@workflow/swc-plugin": "workspace:*", "builtin-modules": "5.0.0", "chalk": "5.6.2", - "enhanced-resolve": "5.19.0", + "enhanced-resolve": "catalog:", "esbuild": "catalog:", "find-up": "7.0.0", "json5": "2.2.3", diff --git a/packages/next/package.json b/packages/next/package.json index fc1c0721d3..3e85d6135d 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -37,6 +37,7 @@ "@workflow/builders": "workspace:*", "@workflow/core": "workspace:*", "@workflow/swc-plugin": "workspace:*", + "enhanced-resolve": "catalog:", "semver": "catalog:", "watchpack": "2.5.1" }, diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index 813dc6a104..fdaf68b2b3 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -19,6 +19,7 @@ import { relative, resolve, } from 'node:path'; +import enhancedResolveOrig from 'enhanced-resolve'; import { createSocketServer, type SocketIO, @@ -1267,6 +1268,38 @@ export async function getNextBuilderDeferred() { copiedStepFileBySourcePath: Map ): string { if (!specifier.startsWith('.')) { + // Bare specifiers (e.g. '@workflow/serde') that are transitive + // dependencies of SDK packages can't be resolved by the bundler + // from the copied file's location (__workflow_step_files__/ inside + // the app dir) because the app doesn't directly depend on them. + // + // Only rewrite when the specifier can't be resolved from the app + // directory. If the package is a direct dependency of the app, + // the bare specifier will resolve normally and should be left as-is. + const appResolvable = this.resolveBareCopiedStepSpecifier( + specifier, + copiedFilePath + ); + if (!appResolvable) { + try { + const resolved = this.resolveBareCopiedStepSpecifier( + specifier, + sourceFilePath + ); + if (!resolved) return specifier; + let rewrittenPath = relative( + dirname(copiedFilePath), + resolved + ).replace(/\\/g, '/'); + if (!rewrittenPath.startsWith('.')) { + rewrittenPath = `./${rewrittenPath}`; + } + return rewrittenPath; + } catch { + // If resolution fails (e.g. Node.js builtins), keep as-is. + return specifier; + } + } return specifier; } @@ -1300,6 +1333,56 @@ export async function getNextBuilderDeferred() { return `${rewrittenPath}${suffix}`; } + /** + * Resolves a bare specifier (e.g. '@workflow/serde', 'workflow') to an + * absolute file path using ESM-compatible resolution semantics via + * `enhanced-resolve`. Tries ESM conditions first (`node`, `import`), + * falling back to CJS resolution if ESM fails. + */ + private resolveBareCopiedStepSpecifier( + specifier: string, + sourceFilePath: string + ): string | undefined { + const resolveOptions = { + extensions: [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.cjs', + '.mjs', + '.js', + '.jsx', + '.json', + ], + mainFields: ['main'], + mainFiles: ['index'], + symlinks: true, + }; + const esmResolver = enhancedResolveOrig.create.sync({ + ...resolveOptions, + conditionNames: ['node', 'import'], + }); + const cjsResolver = enhancedResolveOrig.create.sync({ + ...resolveOptions, + conditionNames: ['node', 'require'], + }); + const context = dirname(sourceFilePath); + try { + const resolved = esmResolver(context, specifier); + if (resolved) return resolved; + } catch { + // ESM resolution failed, try CJS + } + try { + const resolved = cjsResolver(context, specifier); + if (resolved) return resolved; + } catch { + // CJS resolution also failed + } + return undefined; + } + private resolveCopiedStepImportTargetPath(targetPath: string): string { if (existsSync(targetPath)) { return targetPath; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47678829b6..29c70b0c42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,9 @@ catalogs: ai: specifier: 6.0.116 version: 6.0.116 + enhanced-resolve: + specifier: 5.19.0 + version: 5.19.0 esbuild: specifier: ^0.27.3 version: 0.27.3 @@ -419,7 +422,7 @@ importers: specifier: 5.6.2 version: 5.6.2 enhanced-resolve: - specifier: 5.19.0 + specifier: 'catalog:' version: 5.19.0 esbuild: specifier: 'catalog:' @@ -745,6 +748,9 @@ importers: '@workflow/swc-plugin': specifier: workspace:* version: link:../swc-plugin-workflow + enhanced-resolve: + specifier: 'catalog:' + version: 5.19.0 semver: specifier: 'catalog:' version: 7.7.4 @@ -14086,10 +14092,6 @@ packages: tailwindcss@4.1.18: resolution: {integrity: sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==} - tapable@2.2.2: - resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==} - engines: {node: '>=6'} - tapable@2.3.0: resolution: {integrity: sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==} engines: {node: '>=6'} @@ -25480,7 +25482,7 @@ snapshots: node-abort-controller: 3.1.1 schema-utils: 3.3.0 semver: 7.7.4 - tapable: 2.2.2 + tapable: 2.3.0 typescript: 5.9.3 webpack: 5.104.1(@swc/core@1.15.3)(esbuild@0.27.3) @@ -30866,8 +30868,6 @@ snapshots: tailwindcss@4.1.18: {} - tapable@2.2.2: {} - tapable@2.3.0: {} tar-fs@2.1.4: @@ -31063,7 +31063,7 @@ snapshots: dependencies: chalk: 4.1.2 enhanced-resolve: 5.19.0 - tapable: 2.2.2 + tapable: 2.3.0 tsconfig-paths: 4.2.0 tsconfig-paths@4.2.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 667f4878f3..4bc99bd843 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -14,6 +14,7 @@ catalog: "@vercel/queue": 0.1.4 "@vitest/coverage-v8": ^4.0.18 ai: 6.0.116 + enhanced-resolve: 5.19.0 esbuild: ^0.27.3 nitro: 3.0.1-alpha.1 semver: 7.7.4 From 94cddab6def3fb74b4ab424584a27509e65c7c58 Mon Sep 17 00:00:00 2001 From: Nathan Rajlich Date: Thu, 9 Apr 2026 11:54:16 -0700 Subject: [PATCH 2/2] fix(next): address review feedback on lazy discovery resolver - Cache ESM/CJS resolvers as class fields instead of re-creating per call - Remove redundant try/catch (resolveBareCopiedStepSpecifier already returns undefined on failure) - Use shared NODE_RESOLVE_OPTIONS / NODE_ESM_RESOLVE_OPTIONS matching the configuration in swc-esbuild-plugin.ts for consistent resolution --- packages/next/src/builder-deferred.ts | 113 ++++++++++++++++---------- 1 file changed, 70 insertions(+), 43 deletions(-) diff --git a/packages/next/src/builder-deferred.ts b/packages/next/src/builder-deferred.ts index fdaf68b2b3..16573f712d 100644 --- a/packages/next/src/builder-deferred.ts +++ b/packages/next/src/builder-deferred.ts @@ -64,6 +64,45 @@ export async function getNextBuilderDeferred() { 'import("@workflow/builders")' )) as typeof import('@workflow/builders'); + // Shared resolve options matching the configuration used by the SWC + // esbuild plugin (swc-esbuild-plugin.ts) for consistent resolution + // semantics across the toolchain. + const NODE_RESOLVE_OPTIONS = { + dependencyType: 'commonjs' as const, + modules: ['node_modules'], + exportsFields: ['exports'], + importsFields: ['imports'], + conditionNames: ['node', 'require'], + descriptionFiles: ['package.json'], + extensions: [ + '.ts', + '.tsx', + '.mts', + '.cts', + '.cjs', + '.mjs', + '.js', + '.jsx', + '.json', + '.node', + ], + enforceExtensions: false, + symlinks: true, + mainFields: ['main'], + mainFiles: ['index'], + roots: [], + fullySpecified: false, + preferRelative: false, + preferAbsolute: false, + restrictions: [], + }; + + const NODE_ESM_RESOLVE_OPTIONS = { + ...NODE_RESOLVE_OPTIONS, + dependencyType: 'esm' as const, + conditionNames: ['node', 'import'], + }; + class NextDeferredBuilder extends BaseBuilderClass { private socketIO?: SocketIO; private readonly discoveredWorkflowFiles = new Set(); @@ -75,6 +114,14 @@ export async function getNextBuilderDeferred() { private cacheWriteTimer: NodeJS.Timeout | null = null; private deferredRebuildTimer: NodeJS.Timeout | null = null; private lastDeferredBuildSignature: string | null = null; + // Lazily initialized resolvers for bare specifier rewriting. + // Cached to avoid re-creating on every import rewrite. + private esmSyncResolver?: ReturnType< + typeof enhancedResolveOrig.create.sync + >; + private cjsSyncResolver?: ReturnType< + typeof enhancedResolveOrig.create.sync + >; async build() { const outputDir = await this.findAppDirectory(); @@ -1281,24 +1328,19 @@ export async function getNextBuilderDeferred() { copiedFilePath ); if (!appResolvable) { - try { - const resolved = this.resolveBareCopiedStepSpecifier( - specifier, - sourceFilePath - ); - if (!resolved) return specifier; - let rewrittenPath = relative( - dirname(copiedFilePath), - resolved - ).replace(/\\/g, '/'); - if (!rewrittenPath.startsWith('.')) { - rewrittenPath = `./${rewrittenPath}`; - } - return rewrittenPath; - } catch { - // If resolution fails (e.g. Node.js builtins), keep as-is. - return specifier; + const resolved = this.resolveBareCopiedStepSpecifier( + specifier, + sourceFilePath + ); + if (!resolved) return specifier; + let rewrittenPath = relative( + dirname(copiedFilePath), + resolved + ).replace(/\\/g, '/'); + if (!rewrittenPath.startsWith('.')) { + rewrittenPath = `./${rewrittenPath}`; } + return rewrittenPath; } return specifier; } @@ -1343,39 +1385,24 @@ export async function getNextBuilderDeferred() { specifier: string, sourceFilePath: string ): string | undefined { - const resolveOptions = { - extensions: [ - '.ts', - '.tsx', - '.mts', - '.cts', - '.cjs', - '.mjs', - '.js', - '.jsx', - '.json', - ], - mainFields: ['main'], - mainFiles: ['index'], - symlinks: true, - }; - const esmResolver = enhancedResolveOrig.create.sync({ - ...resolveOptions, - conditionNames: ['node', 'import'], - }); - const cjsResolver = enhancedResolveOrig.create.sync({ - ...resolveOptions, - conditionNames: ['node', 'require'], - }); + if (!this.esmSyncResolver) { + this.esmSyncResolver = enhancedResolveOrig.create.sync( + NODE_ESM_RESOLVE_OPTIONS + ); + } + if (!this.cjsSyncResolver) { + this.cjsSyncResolver = + enhancedResolveOrig.create.sync(NODE_RESOLVE_OPTIONS); + } const context = dirname(sourceFilePath); try { - const resolved = esmResolver(context, specifier); + const resolved = this.esmSyncResolver(context, specifier); if (resolved) return resolved; } catch { // ESM resolution failed, try CJS } try { - const resolved = cjsResolver(context, specifier); + const resolved = this.cjsSyncResolver(context, specifier); if (resolved) return resolved; } catch { // CJS resolution also failed