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..16573f712d 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, @@ -63,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(); @@ -74,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(); @@ -1267,6 +1315,33 @@ 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) { + 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; } @@ -1300,6 +1375,41 @@ 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 { + 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 = this.esmSyncResolver(context, specifier); + if (resolved) return resolved; + } catch { + // ESM resolution failed, try CJS + } + try { + const resolved = this.cjsSyncResolver(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