fix(core): add world-init side-effect module to fix cold-start MODULE_NOT_FOUND#1951
fix(core): add world-init side-effect module to fix cold-start MODULE_NOT_FOUND#1951VaguelySerious wants to merge 3 commits intomainfrom
Conversation
…_NOT_FOUND
Webpack/Turbopack tree-shake `runtime/world.ts` out of server routes that
only consume `start` (or other `getWorldLazy`-using helpers) from
`workflow/api`. The module-load side effect that registers `getWorld` on
`globalThis[GetWorldFnKey]` never fires, and `getWorldLazy()`'s dynamic
`import('./world.js')` fallback can't recover: the bundler inlined
`get-world-lazy.js` into the route file, so the relative specifier
resolves against `/var/task/<app>/.next/server/app/<route>/route.js`
where no sibling `world.js` exists. Node throws `MODULE_NOT_FOUND`.
The symptom is a cold-start regression: the very first user request
through `start()` on a fresh function instance fails. Once any other
code path (queue-driven `/flow`, an internal admin route that hits
`getWorld` directly, …) has loaded `world.ts`, the rest of the process
lifetime works fine — making this hard to catch in dev but reliable on
first prod traffic.
Reproduced in vade-review against the latest workflow tarballs:
POST /api/review/submit -> 500
{"status":"error","message":"Failed to start PR workflow",
"error":"Cannot find module './world.js'"}
at .next/server/app/api/review/submit/route.js:25:11
at async getWorldLazy (.../runtime/get-world-lazy.js:42:26)
# Fix
A new server-only side-effect module `@workflow/core/runtime/world-init`
imports `./world.js` purely to fire its globalThis registration. It is
imported by `packages/workflow/src/api.ts` (the host file behind
`workflow/api`'s `default` condition) so every server bundle that
touches `workflow/api` loads `world.ts` regardless of which named
exports the consumer actually uses.
VM and step bundles continue to skip `world.ts`: the
`@workflow/core/runtime/world-init` export resolves via the `workflow`
condition to `dist/workflow/world-init-stub.js`, an empty module. The
matching `api-workflow.ts` (workflow/api's `workflow` condition entry)
does not import it.
Reverified end-to-end against vade-review with locally-packed tarballs:
- The `@workflow/core` vendor chunk in the host route bundle now
includes `createLocalWorld`, `createVercelWorld`, and `GetWorldFnKey`
(all 0 occurrences before this fix).
- The workflow VM bundle (`flow/route.js`) and step registrations
bundle (`__step_registrations.js`) have 0 references to `world-init`,
`world.ts`, `createLocalWorld`, or `createVercelWorld` — i.e. no
regression on the sandbox-bundle hygiene side.
- Cold-start `POST /api/review/submit` succeeds on the first request
after a fresh server boot (`runId: wrun_…` returned, workflow
executes inline through the step executor as expected). Same request
on `main` returns 500 with the MODULE_NOT_FOUND trace above.
# Tests
`world-init.test.ts` covers three contracts:
1. Importing the module registers `getWorld` on `globalThis`.
2. `getWorldLazy()` resolves through the registered global rather than
falling through to the dynamic-import branch (sentinel-based proof).
3. The `??=` assignment is idempotent — re-importing does not clobber
a prior registration.
The `getWorldLazy` dynamic-import fallback is preserved as
defense-in-depth for environments outside the documented configurations
(CJS test runners, scripts that import deeply into `@workflow/core`
without going through `workflow/api`).
Documented in `docs/content/docs/changelog/eager-processing.mdx` under
the "Cold-Start `MODULE_NOT_FOUND: './world.js'`" section, with a
maintenance note in `world-init.ts` for anyone adding new
`getWorldLazy` consumers reachable from a host route.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: 8a033c7 The changes in this PR will be included in the next version bump. This PR includes changesets to release 18 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 |
📊 Benchmark Results
workflow with no steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 1 step💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 sequential steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.all with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Next.js (Turbopack) | Express Promise.race with 10 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 25 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Promise.race with 50 concurrent steps💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 sequential data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 10 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 25 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) workflow with 50 concurrent data payload steps (10KB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) Stream Benchmarks (includes TTFB metrics)workflow with stream💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) stream pipeline with 5 transform steps (1MB)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) 10 parallel streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: Express | Next.js (Turbopack) fan-out fan-in 10 streams (1MB each)💻 Local Development
▲ Production (Vercel)
🔍 Observability: 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:
❌ Some benchmark jobs failed:
Check the workflow run for details. |
🧪 E2E Test Results❌ Some tests failed Summary
❌ Failed Tests▲ Vercel Production (1 failed)sveltekit (1 failed):
Details by Category❌ ▲ Vercel Production
✅ 💻 Local Development
✅ 📦 Local Production
✅ 🐘 Local Postgres
✅ 🪟 Windows
✅ 📋 Other
❌ Some E2E test jobs failed:
Check the workflow run for details. |
Signed-off-by: Peter Wielander <mittgfu@gmail.com>
Summary
Fixes a v2-flow cold-start regression where the first server request through
start()on a fresh function instance throws:Reproduced in vade-review against the latest workflow tarballs.
Why it happens
getWorldLazy()was designed around two paths: the globalThis cache (populated byworld.ts's module-load side effect) or a runtime-built dynamicimport('./world.js')as a last resort.Both fail on a cold serverless invocation when the consumer only uses
start:Tree-shaking drops
world.ts. Webpack/Turbopack walkworkflow/api → @workflow/core/runtime → start.ts. The named import{ getWorld } from './runtime/world.js'inruntime.tsis unused after tree-shaking the re-export, so the bundler removes the entire import — takingworld.ts's module evaluation with it. TheglobalThis[GetWorldFnKey] ??= getWorldregistration never fires.The dynamic-import fallback can't survive bundling. The specifier
./world.jsis built at runtime to evade bundler tracing, but webpack inlinesget-world-lazy.jsinto the bundled route file. At runtime the relative specifier resolves against/var/task/<app>/.next/server/app/<route>/route.js, where no siblingworld.jsexists. Node throwsMODULE_NOT_FOUND.The symptom is flake-shaped: once any other code path (queue-driven
/flow, an admin route that usesgetWorlddirectly, …) has loadedworld.ts, subsequent calls succeed for the rest of the process lifetime — so dev environments where everything tends to be warmed don't reproduce, but first prod traffic into a fresh function does.Fix
A new server-only side-effect module
@workflow/core/runtime/world-init:Imported once from
packages/workflow/src/api.ts(the host file behindworkflow/api'sdefaultcondition):VM and step bundles continue to skip
world.ts: the export is resolved via theworkflowcondition todist/workflow/world-init-stub.js, an empty module. The matchingapi-workflow.ts(theworkflowcondition entry forworkflow/api) does not import world-init at all, so it stays out of the sandbox bundle entirely.Why a separate module instead of importing
./world.jsdirectlyworld.tsis internal to@workflow/coreand not part of the public exports surface. A dedicated public init entry:workflow/api.workflowexport condition route to a stub for VM/step bundles.world-init.test.ts) against the cross-module global handshake.Reverification (end-to-end against vade-review)
Built the change into local tarballs via
tarballs/scripts/pack.ts, served them locally, switched vade-review to point at them, then:createLocalWorldcreateVercelWorldGetWorldFnKey@workflow/corevendor chunk in route bundle (server)flow/route.js(workflow VM)__step_registrations.js(step bundle)And the runtime check:
(Same request before the fix returns 500 with
Cannot find module './world.js'.)Tests
packages/core/src/runtime/world-init.test.tscovers:getWorldonglobalThis.getWorldLazy()resolves through the registered global (sentinel-based, so a realgetWorld()fromworld.tscan't accidentally satisfy the assertion).??=assignment is idempotent — re-importing does not clobber a prior registration.pnpm --filter @workflow/core testpasses 944/944 unit tests.The dynamic-import fallback in
get-world-lazy.tsis preserved as defense-in-depth for environments outside the documented configurations (CJS test runners, scripts that import deeply into@workflow/corewithout going throughworkflow/api, future bundlers with stricter tree-shaking). The docstring onget-world-lazy.tsnow spells out the resolution-priority order so future readers understand why all three branches exist.Maintenance considerations
The fix relies on a precise contract: any host-side entry point that ends up using
getWorldLazy()needs to either import@workflow/core/runtime/world-inititself or transitively reachworld.tsthrough a non-tree-shakeable path. The host-sideworkflow/apicovers the common case (every consumer that usesstart,getRun,resumeHook, …). A maintenance note inworld-init.tscalls this out, andworld-init.test.tsis the natural place to add a regression test if a new entry point is added.Documented in
docs/content/docs/changelog/eager-processing.mdxunder the new "Cold-StartMODULE_NOT_FOUND: './world.js'" section.Test plan
pnpm --filter @workflow/core test(944/944 pass)pnpm --filter @workflow/core --filter workflow buildcleanpnpm --filter @workflow/core --filter workflow typecheckcleanpnpm biome check(lint clean on changed files)POST /api/review/submitreturns 200, workflow runs through inline step executor as expectedworld-init, noworld.tsreferences)🤖 Generated with Claude Code