Follow-up to #3728, which fixed the immediate regression but left a discipline where an invariant belongs.
Background
@workflow/world-local and @workflow/world-vercel are bundled into the host application's server build. A bundler keys module identity on (resource, layer), and Next.js alone compiles instrument, app-route, ssr and edge as separate module graphs — so one process holds one copy of every module in these packages per layer. Any mutable module-scope binding is therefore per-copy state rather than the process singleton it reads as. #3493 introduced this exposure for world-vercel by removing it from serverExternalPackages; the visible casualty was the events WebSocket transport falling back to HTTP.
#3728 routes that state through globalSingleton() (globalThis + Symbol.for) and enforces it with scripts/lint/module-scope-state.mjs, run over every published packages/world-*.
The problem with stopping there
Most of the state that was converted is conceptually per-World, not per-process:
ws-transport.ts — the channel registry
http-client.ts — the undici keep-alive pools and the events dispatcher recycler
runs.ts — the long-poll-unsupported negative cache
On the World instance, none of it can be duplicated by a bundler at all — it rides the World's existing singleton for free, and no rule is needed to keep it correct. globalThis would then be left holding only the genuinely process-wide things: ID generators whose sequence must not fork (create-run-id.ts, and world-local's evnt_/chnk_ ULID factories) and log-once latches. That is a surface small enough to reason about instead of police.
Direction
- Move per-World state into
createWorld()'s closure / the returned World object in world-vercel and world-local.
- Keep
globalSingleton() for the process-wide remainder.
- Leave the lint rule in place as the backstop — it should get quieter, not disappear.
Sequencing note: this pairs with #3665. Once request-time handlers and runtime execution share one World, instance state is automatically shared across every layer that reaches it — the two changes together turn "don't rely on module scope" from a rule into a property of the design.
Interaction to watch: setWorld() and the CLI/test paths construct Worlds directly, so anything moved onto the instance must be safe to have more than one of (pools already are; ID generators are not, which is why they stay global).
Follow-up to #3728, which fixed the immediate regression but left a discipline where an invariant belongs.
Background
@workflow/world-localand@workflow/world-vercelare bundled into the host application's server build. A bundler keys module identity on(resource, layer), and Next.js alone compilesinstrument, app-route,ssrandedgeas separate module graphs — so one process holds one copy of every module in these packages per layer. Any mutable module-scope binding is therefore per-copy state rather than the process singleton it reads as. #3493 introduced this exposure for world-vercel by removing it fromserverExternalPackages; the visible casualty was the events WebSocket transport falling back to HTTP.#3728 routes that state through
globalSingleton()(globalThis+Symbol.for) and enforces it withscripts/lint/module-scope-state.mjs, run over every publishedpackages/world-*.The problem with stopping there
Most of the state that was converted is conceptually per-World, not per-process:
ws-transport.ts— the channel registryhttp-client.ts— the undici keep-alive pools and the events dispatcher recyclerruns.ts— the long-poll-unsupported negative cacheOn the World instance, none of it can be duplicated by a bundler at all — it rides the World's existing singleton for free, and no rule is needed to keep it correct.
globalThiswould then be left holding only the genuinely process-wide things: ID generators whose sequence must not fork (create-run-id.ts, and world-local'sevnt_/chnk_ULID factories) and log-once latches. That is a surface small enough to reason about instead of police.Direction
createWorld()'s closure / the returned World object in world-vercel and world-local.globalSingleton()for the process-wide remainder.Sequencing note: this pairs with #3665. Once request-time handlers and runtime execution share one World, instance state is automatically shared across every layer that reaches it — the two changes together turn "don't rely on module scope" from a rule into a property of the design.
Interaction to watch:
setWorld()and the CLI/test paths construct Worlds directly, so anything moved onto the instance must be safe to have more than one of (pools already are; ID generators are not, which is why they stay global).