fix(next): resolve bare specifiers in copied step files for lazy discovery#1670
Conversation
…overy 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 detectedLatest commit: 94cddab The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests🌍 Community Worlds (65 failed)mongodb (4 failed):
redis (3 failed):
turso (58 failed):
Details by Category✅ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
❌ 🌍 Community Worlds
✅ 📋 Other
|
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express | Nitro Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) | Nitro workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Next.js (Turbopack) | Express workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Nitro | Next.js (Turbopack) stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Nitro | Express | Next.js (Turbopack) SummaryFastest Framework by WorldWinner determined by most benchmark wins
Fastest World by FrameworkWinner determined by most benchmark wins
Column Definitions
Worlds:
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
This PR fixes lazy discovery step-file copying in @workflow/next by resolving and rewriting certain bare import specifiers (notably transitive SDK deps) based on the original source file’s location, and standardizes enhanced-resolve usage via the pnpm catalog.
Changes:
- Add
enhanced-resolveto the pnpm catalog and consume it viacatalog:in@workflow/buildersand@workflow/next. - In the deferred Next builder, resolve bare specifiers with
enhanced-resolve(ESM-first, CJS fallback) and rewrite only when they’re not resolvable from the copied file’s app location. - Add a changeset for patch releases of
@workflow/nextand@workflow/builders.
Reviewed changes
Copilot reviewed 5 out of 6 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-workspace.yaml | Adds enhanced-resolve to the shared version catalog. |
| pnpm-lock.yaml | Updates lockfile to reflect catalog usage and dependency graph changes. |
| packages/next/src/builder-deferred.ts | Implements bare-specifier resolution and conditional rewriting for copied step files. |
| packages/next/package.json | Adds enhanced-resolve dependency via catalog:. |
| packages/builders/package.json | Switches enhanced-resolve dependency to catalog:. |
| .changeset/lazy-discovery-bare-specifiers.md | Declares patch releases for the affected packages. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| 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'], | ||
| }); |
There was a problem hiding this comment.
resolveBareCopiedStepSpecifier() constructs new enhanced-resolve ESM/CJS resolvers on every call. This is likely on a hot path while rewriting many imports, and create.sync() allocation can be expensive. Consider creating the resolvers once (e.g., lazily as private fields) and reusing them across calls.
There was a problem hiding this comment.
Fixed. ESM and CJS resolvers are now cached as private class fields (esmSyncResolver, cjsSyncResolver), lazily initialized on first use.
| 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; | ||
| } |
There was a problem hiding this comment.
The try/catch around resolveBareCopiedStepSpecifier() is redundant because resolveBareCopiedStepSpecifier() already swallows resolver errors and returns undefined on failure. Removing the outer try/catch would simplify control flow without changing behavior.
| 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; |
There was a problem hiding this comment.
Fixed. Removed the redundant outer try/catch since resolveBareCopiedStepSpecifier() already handles errors internally and returns undefined on failure.
| const resolveOptions = { | ||
| extensions: [ | ||
| '.ts', | ||
| '.tsx', | ||
| '.mts', | ||
| '.cts', | ||
| '.cjs', | ||
| '.mjs', | ||
| '.js', | ||
| '.jsx', | ||
| '.json', | ||
| ], | ||
| mainFields: ['main'], | ||
| mainFiles: ['index'], | ||
| symlinks: true, | ||
| }; |
There was a problem hiding this comment.
The enhanced-resolve option set here is inconsistent with the resolver configuration used elsewhere in the repo (e.g. builders’ resolver includes dependencyType, exportsFields, importsFields, fullySpecified, etc.). Since this logic is meant to mirror bundler/module resolution semantics, consider reusing the same shared option set (or matching it field-for-field) to avoid subtle resolution differences across the toolchain.
There was a problem hiding this comment.
Fixed. Now uses shared NODE_RESOLVE_OPTIONS and NODE_ESM_RESOLVE_OPTIONS constants that match the configuration in swc-esbuild-plugin.ts field-for-field (dependencyType, exportsFields, importsFields, fullySpecified, enforceExtensions, etc.).
- 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
VaguelySerious
left a comment
There was a problem hiding this comment.
AI review: no blocking issues
| // 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 = { |
There was a problem hiding this comment.
AI Review: Nit
These NODE_RESOLVE_OPTIONS / NODE_ESM_RESOLVE_OPTIONS blocks intentionally mirror packages/builders/src/swc-esbuild-plugin.ts. That is good for consistency today, but it can drift over time. A follow-up could centralize the object (for example exporting a helper from @workflow/builders) so the two call sites cannot diverge silently.
| // 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( |
There was a problem hiding this comment.
AI Review: Nit
The comment refers to resolving from the app directory; resolveBareCopiedStepSpecifier uses dirname(copiedFilePath) as the resolver context (under __workflow_step_files__). Consider wording that matches that precisely so readers do not assume project-root or cwd semantics.
Summary
enhanced-resolvewith ESM conditions (node,import) to resolve bare specifiers from the original source file's location when copying step files for lazy discovery@workflow/serde), leaving direct deps (likeworkflow) as bare specifiersenhanced-resolveto the pnpm catalog and usecatalog:in both@workflow/buildersand@workflow/next