Skip to content

Commit 72911f7

Browse files
[core] [nextjs] Fix world.ts being tree-shaken out of the bundle and unavailable at runtime (#1951)
1 parent f20c706 commit 72911f7

10 files changed

Lines changed: 253 additions & 15 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@workflow/core": patch
3+
"workflow": patch
4+
---
5+
6+
Fix `world.ts` being tree-shaken out of the bundle and unavailable at runtime

docs/content/docs/v5/changelog/eager-processing.mdx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,28 @@ Additional changes for Turbopack compatibility:
532532
- Lazy-loaded `getPort` via `createRequire` with opaque specifier to prevent `@workflow/utils/get-port` filesystem operations from being traced
533533
- `getRuntimeRequire()` uses `process.cwd()` as primary resolution base (for custom world packages like `@workflow/world-postgres` that are app-level deps, not `@workflow/core` deps), with `import.meta.url` fallback
534534

535+
### Cold-Start `MODULE_NOT_FOUND: './world.js'` From `getWorldLazy` Fallback
536+
537+
The `getWorldLazy()` design assumed one of two paths would always succeed: either `globalThis[GetWorldFnKey]` is populated (because some prior code reached `world.ts`'s module body), or the dynamic `import('./world.js')` fallback resolves at runtime.
538+
539+
Both assumptions break for routes that consume `start` (or any other `getWorldLazy` consumer) without going through the queue-driven flow handler first:
540+
541+
1. Webpack and Turbopack tree-shake the named import `{ getWorld } from './runtime/world.js'` out of `runtime.ts` once a consumer only uses `start`. `world.ts` is dropped from the bundle entirely, so its module-load `globalThis[GetWorldFnKey] ??= getWorld` registration never fires.
542+
2. The dynamic-import fallback inside `get-world-lazy.ts` builds the specifier `./world.js` at runtime to evade bundler tracing — but webpack inlines `get-world-lazy.js` into the route bundle, 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`.
543+
544+
The symptom: the very first request that goes through `start()` on a cold serverless invocation fails. Once any other code path (typically the queue-driven `/.well-known/workflow/v1/flow` route, which uses `getWorld` directly via `workflowEntrypoint`) has loaded `world.ts`, subsequent `start()` calls succeed for the rest of the process lifetime — making the failure flake-shaped: hard to reproduce in dev where everything tends to be warmed, but reliable on first user traffic into a fresh function instance.
545+
546+
**Fix**: Added `@workflow/core/runtime/world-init`, a server-only side-effect module that imports `./world.js` purely for its module-load side effect (the globalThis registration). It's exported via package conditions:
547+
548+
- `default``./dist/runtime/world-init.js` (real, loads `world.ts`)
549+
- `workflow``./dist/workflow/world-init-stub.js` (empty, used by VM/step bundles)
550+
551+
`packages/workflow/src/api.ts` (the host file behind `workflow/api`'s `default` condition) imports it for its side effect. The matching VM/step entry `api-workflow.ts` does not, so `world.ts` and its server-only deps (`@workflow/world-local`, `@workflow/world-vercel`, `cbor-x`, …) stay out of the workflow sandbox bundle.
552+
553+
Reverification: built bundles for `vade-review` (Next.js webpack) show `createLocalWorld`/`createVercelWorld`/`GetWorldFnKey` present in the route's vendor chunk for `@workflow/core` (zero before the fix), and the workflow VM bundle's `flow/route.js` and `__step_registrations.js` continue to have zero references to either the world-init module or `world.ts`. Cold-start `POST /api/review/submit` succeeds on the first request after a fresh server boot — the regression case.
554+
555+
The dynamic-import fallback in `get-world-lazy.ts` 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`).
556+
535557
### Run#returnValue Worker Deadlock in V2 Inline Execution
536558

537559
When a workflow calls `start()` to spawn child workflows (e.g., `fibonacciWorkflow`), the parent's `Run#returnValue` step polls the child's completion status in a blocking loop (`while (true) { ... sleep(1000) ... }`). In V2, this step is executed inline by the step executor, holding a worker thread slot. If the child workflow's queue message is waiting for the same worker pool, the parent blocks the child from starting — a classic deadlock.

packages/core/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,11 @@
4848
"types": "./dist/runtime/resume-hook.d.ts",
4949
"default": "./dist/runtime/resume-hook.js"
5050
},
51+
"./runtime/world-init": {
52+
"types": "./dist/runtime/world-init.d.ts",
53+
"workflow": "./dist/workflow/world-init-stub.js",
54+
"default": "./dist/runtime/world-init.js"
55+
},
5156
"./class-serialization": {
5257
"types": "./dist/class-serialization.d.ts",
5358
"default": "./dist/class-serialization.js"

packages/core/src/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -680,7 +680,7 @@ export function workflowEntrypoint(
680680
// when there are events, so this signals a bug in
681681
// the World. Fall back to a full reload to avoid
682682
// stale data.
683-
runtimeLogger.error(
683+
runtimeLogger.warn(
684684
'Event cursor missing after initial load — falling back to full reload. ' +
685685
'This indicates a bug in the World implementation.',
686686
{ workflowRunId: runId }

packages/core/src/runtime/get-world-lazy.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,25 @@
77
* world-vercel, process.cwd(), etc.) into the step registrations bundle,
88
* which triggers Turbopack NFT tracing errors in the V2 combined flow route.
99
*
10-
* When the world is not yet cached, falls back to a dynamic import() of
11-
* ./world.js to initialize the world. The dynamic import is fine here
12-
* because get-world-lazy.ts is NOT in the step registrations bundle — it's
13-
* only used by modules that are already importing from this directory.
10+
* Resolution order, in priority:
11+
*
12+
* 1. `globalThis[WorldCacheKey]` — populated by a successful prior
13+
* `getWorld()` call. This is the steady-state hot path.
14+
* 2. `globalThis[GetWorldFnKey]` — populated by the module-load side
15+
* effect at the bottom of `./world.ts`. Fires on every server bundle
16+
* that reaches this file via `workflow/api` (which imports
17+
* `./world-init.ts` for its side effect; see that file for the full
18+
* rationale). This is the cold-start path for routes that consume
19+
* `start` without any prior workflow run.
20+
* 3. Dynamic `import('./world.js')` — last-resort fallback for
21+
* environments where neither (1) nor (2) is available (CJS test
22+
* runners, scripts that import deeply into `@workflow/core` without
23+
* going through `workflow/api`, future bundlers we haven't validated).
24+
* The specifier is built at runtime so esbuild can't trace it into
25+
* step bundles. Note: this branch is unreliable in webpack-bundled
26+
* routes because webpack inlines this module into the route file and
27+
* the relative path resolves against the bundle location — paths (1)
28+
* and (2) cover those cases instead.
1429
*/
1530

1631
import type { World } from '@workflow/world';
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
2+
3+
// Re-declare the symbols here (mirrors world.ts and get-world-lazy.ts)
4+
// instead of importing them, so the test fails loudly if either file
5+
// changes its symbol identity in a way that would silently break the
6+
// cross-module global handshake.
7+
const WorldCacheKey = Symbol.for('@workflow/world//cache');
8+
const WorldCachePromiseKey = Symbol.for('@workflow/world//cachePromise');
9+
const GetWorldFnKey = Symbol.for('@workflow/world//getWorldFn');
10+
11+
type GlobalsShape = {
12+
[WorldCacheKey]?: unknown;
13+
[WorldCachePromiseKey]?: unknown;
14+
[GetWorldFnKey]?: () => Promise<unknown>;
15+
};
16+
17+
const g = globalThis as GlobalsShape;
18+
19+
// Importing world-init for its side effect at the top of this test file is
20+
// itself the regression test: if `import '@workflow/core/runtime/world-init'`
21+
// stops loading `world.ts`, the `GetWorldFnKey` registration won't run and
22+
// the first assertion below fails. This mirrors what `workflow/api` (the
23+
// host file) does in production.
24+
import './world-init.js';
25+
26+
describe('world-init', () => {
27+
let priorFn: GlobalsShape[typeof GetWorldFnKey];
28+
29+
beforeEach(() => {
30+
// Snapshot and clear the world cache. Each test wants to drive
31+
// getWorldLazy down a deterministic path; leaving a previously-cached
32+
// World instance around would short-circuit the test.
33+
priorFn = g[GetWorldFnKey];
34+
delete g[WorldCacheKey];
35+
delete g[WorldCachePromiseKey];
36+
});
37+
38+
afterEach(() => {
39+
g[GetWorldFnKey] = priorFn;
40+
delete g[WorldCacheKey];
41+
delete g[WorldCachePromiseKey];
42+
});
43+
44+
it('registers getWorld on globalThis at module-load time', () => {
45+
expect(typeof g[GetWorldFnKey]).toBe('function');
46+
});
47+
48+
it(
49+
'getWorldLazy resolves via the registered global instead of falling ' +
50+
'through to the dynamic-import branch',
51+
async () => {
52+
const { getWorldLazy } = await import('./get-world-lazy.js');
53+
54+
// Replace the registration with a sentinel-returning function so we can
55+
// prove which branch getWorldLazy used. Using a Symbol means a real
56+
// World instance from `world.ts`'s `getWorld()` (returned by the
57+
// production registration) can't accidentally satisfy this assertion.
58+
const sentinel = Symbol('sentinel-world');
59+
g[GetWorldFnKey] = async () => sentinel as unknown;
60+
61+
const result = await getWorldLazy();
62+
expect(result).toBe(sentinel);
63+
}
64+
);
65+
66+
it('uses ??= to register, so a prior registration is preserved', async () => {
67+
// Simulate the case where some earlier code already set the registration
68+
// (e.g., a setWorld() bypass in tests, or a future module that registers
69+
// before world.ts). The world-init side effect must not clobber it.
70+
const sentinel = async () => 'preserved' as unknown;
71+
g[GetWorldFnKey] = sentinel;
72+
73+
// Re-evaluating world.ts is what would clobber. Importing world-init
74+
// again returns the cached module, so the assignment doesn't re-run —
75+
// this assertion is really documenting the contract.
76+
await import('./world-init.js');
77+
78+
expect(g[GetWorldFnKey]).toBe(sentinel);
79+
});
80+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
/**
2+
* Server-only side-effect module that ensures `world.ts` is loaded so its
3+
* module-load side effect — `globalThis[GetWorldFnKey] ??= getWorld` —
4+
* fires in host bundles.
5+
*
6+
* # Why this exists
7+
*
8+
* `getWorldLazy()` in `./get-world-lazy.ts` checks the globalThis cache
9+
* (populated by `world.ts`'s module-load side effect) before falling back
10+
* to a runtime-built dynamic `import('./world.js')`. When a server route
11+
* only consumes a single helper that goes through `getWorldLazy` — most
12+
* commonly `start` from `workflow/api` — webpack/turbopack tree-shake the
13+
* named import `{ getWorld } from './runtime/world.js'` out of
14+
* `runtime.ts`, taking `world.ts`'s module evaluation with it. The
15+
* globalThis registration never fires.
16+
*
17+
* `getWorldLazy` then falls through to its dynamic-import fallback, which
18+
* itself fails: webpack inlines `get-world-lazy.js` into the bundled route
19+
* file, so the relative specifier `./world.js` resolves against
20+
* `/var/task/<app>/.next/server/app/<...>/route.js` — where no sibling
21+
* `world.js` exists — and Node throws `MODULE_NOT_FOUND`. The symptom is a
22+
* cold-start regression: the very first user request that goes through
23+
* `start()` fails until some other code path (typically the queue-driven
24+
* `/.well-known/workflow/v1/flow` route, which uses `getWorld` directly
25+
* via `workflowEntrypoint`) has loaded `world.ts` and populated the
26+
* cache.
27+
*
28+
* Importing this module for its side effect — exactly once, from the
29+
* host-side `workflow/api` (`packages/workflow/src/api.ts`) — guarantees
30+
* `world.ts` enters the bundle, the global is registered at module load,
31+
* and `getWorldLazy()` short-circuits to the registered function on the
32+
* first call.
33+
*
34+
* # Why a separate module instead of importing `./world.js` directly
35+
*
36+
* `world.ts` is internal to `@workflow/core` and not part of the public
37+
* exports surface. Adding a dedicated public init entry (this file)
38+
* keeps the side-effect intent obvious to anyone reading
39+
* `packages/workflow/src/api.ts`, and lets the `workflow` export
40+
* condition route to a stub for VM/step bundles (see below).
41+
*
42+
* # Why this doesn't break VM/step bundles
43+
*
44+
* The `@workflow/core/runtime/world-init` export resolves via the
45+
* `workflow` condition to `./dist/workflow/world-init-stub.js`, an empty
46+
* module. Esbuild runs the workflow VM and step bundlers with the
47+
* `workflow` condition active, so they pick up the stub and never reach
48+
* `world.ts`. Host bundlers (webpack, turbopack, Node.js) use the
49+
* `default` (or `node`) condition and pick up this file, loading
50+
* `world.ts` as intended. The split keeps `@workflow/world-vercel`,
51+
* `@workflow/world-local`, `cbor-x`, and other server-only deps out of
52+
* the workflow sandbox bundle.
53+
*
54+
* # Why we keep the `getWorldLazy` dynamic-import fallback
55+
*
56+
* The fallback remains in `./get-world-lazy.ts` as a defense-in-depth for
57+
* environments we haven't accounted for (CJS test runners, scripts that
58+
* import `start` without going through `workflow/api`, future bundlers
59+
* with stricter tree-shaking). With this init module in place, the
60+
* fallback should never fire in normal use — but if it does, the
61+
* dynamic-import branch still resolves correctly when `world.js` is
62+
* physically adjacent on disk (e.g., direct Node.js execution of
63+
* unbundled source).
64+
*
65+
* # Maintenance notes
66+
*
67+
* If you add another `getWorldLazy()` consumer that's reachable from a
68+
* host route without going through `workflow/api` (e.g., a new helper in
69+
* `workflow/runtime`), make sure that entry also imports this module —
70+
* or that it transitively reaches `world.ts` via a non-tree-shakeable
71+
* path. Adding a regression test in `world-init.test.ts` is preferred to
72+
* relying on careful manual tracing.
73+
*/
74+
import './world.js';

packages/core/src/runtime/world.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,10 +191,20 @@ export const setWorld = (world: World | undefined): void => {
191191

192192
// Register getWorld on globalThis so getWorldLazy can call it directly when
193193
// world.ts is statically present in the bundle. This avoids the relative
194-
// dynamic import('./world.js') fallback, which fails after Next.js inlines
195-
// get-world-lazy.js into a route bundle (no sibling world.js exists at the
196-
// bundled location). Step bundles never reach this branch because they
197-
// don't statically import world.ts.
194+
// dynamic import('./world.js') fallback in get-world-lazy.ts, which fails
195+
// after Next.js inlines get-world-lazy.js into a route bundle (no sibling
196+
// world.js exists at the bundled location).
197+
//
198+
// For server routes that only consume `start` (or another helper that goes
199+
// through getWorldLazy without statically using getWorld), webpack/turbopack
200+
// would otherwise tree-shake world.ts out of the bundle entirely. The
201+
// host-only `./world-init.ts` module imports world.ts for its side effect
202+
// and is itself imported by `packages/workflow/src/api.ts` so this
203+
// registration runs in every server bundle that touches `workflow/api`.
204+
//
205+
// Step/VM bundles never reach this branch: they don't statically import
206+
// world.ts, and `world-init` resolves to an empty stub via the `workflow`
207+
// export condition.
198208
const GetWorldFnKey = Symbol.for('@workflow/world//getWorldFn');
199209
(globalThis as { [GetWorldFnKey]?: () => Promise<World> })[GetWorldFnKey] ??=
200210
getWorld;
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* VM/step-bundle stub for `@workflow/core/runtime/world-init`.
3+
*
4+
* Resolved via the `workflow` export condition. Empty by design — the host
5+
* is responsible for loading `world.ts` and populating the globalThis
6+
* world cache before any VM or step code executes. Including this module
7+
* (rather than letting the resolver fall through to the host file) keeps
8+
* `world.ts` and its server-only deps (`@workflow/world-vercel`,
9+
* `@workflow/world-local`, `cbor-x`, …) out of the workflow sandbox.
10+
*
11+
* See `../runtime/world-init.ts` for the host-side implementation and the
12+
* full rationale.
13+
*/
14+
15+
export {};

packages/workflow/src/api.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
// Side-effect import: ensure `world.ts` is loaded so its module-load
2+
// `globalThis[GetWorldFnKey] ??= getWorld` registration fires before any
3+
// host route reaches `getWorldLazy()`. Without this, webpack/turbopack
4+
// tree-shake `world.ts` out of routes that only use `start` (the most
5+
// common host-side entry point) and `getWorldLazy()`'s dynamic-import
6+
// fallback then fails because the bundler inlined `get-world-lazy.js`
7+
// into the route bundle. Resolved to an empty stub via the `workflow`
8+
// export condition in VM/step bundles, so this stays host-only.
9+
// See `@workflow/core/src/runtime/world-init.ts` for the full rationale.
10+
import '@workflow/core/runtime/world-init';
11+
12+
export type {
13+
Event,
14+
StopSleepOptions,
15+
StopSleepResult,
16+
WorkflowRun,
17+
} from '@workflow/core/runtime';
118
export {
219
getHookByToken,
320
resumeHook,
@@ -13,9 +30,3 @@ export {
1330
type StartOptions,
1431
start,
1532
} from '@workflow/core/runtime/start';
16-
export type {
17-
Event,
18-
StopSleepOptions,
19-
StopSleepResult,
20-
WorkflowRun,
21-
} from '@workflow/core/runtime';

0 commit comments

Comments
 (0)