You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Worlds: move World-scoped state onto the World instance so bundling can't duplicate it #3729
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).
Partly overtaken by #3728, which went wider than this issue assumed.
The sweep now covers every package that lands in the host application's server build, not just the worlds: @workflow/core (22 findings → 0), @workflow/world, @workflow/ai and @workflow/nest, alongside the four world packages. Build-time packages, the CLI, the o11y UI and the test runner stay out — a single module graph makes the hazard impossible there.
Two things learned while doing it, both relevant to whatever picks this up:
One instance of core is not reachable. Layers cannot share a module, and core cannot be made external because core is workflow code — runtime/start.ts:253 and nine methods in runtime/run.ts are 'use step' functions that must go through the SWC loader, which an external package never does. packages/next/src/index.ts:217-221 already strips workflow-bearing packages back out of serverExternalPackages for exactly this reason. Getting to one copy would mean splitting core into a transformed shim plus an external, directive-free engine. That is a real option, but it is an architecture project and it trades against the cold-start win #3493 was chasing.
step-single-flight.ts was the one with teeth. A per-copy map is not single-flight: two invocations arriving through different layers would each believe they were alone in the process and both run the step body, degrading in-process dedup to the cross-process residual its own doc scopes out to the ownership lease.
What remains under this issue is the original idea and still worth doing: move World-scoped state onto the World instance — the ws registry, the undici pools, the long-poll cache — so it rides the World's singleton and cannot be duplicated at all, leaving globalThis holding only the genuinely process-wide things (ID generators whose sequence must not fork, log-once latches). That pairs with #3665, now also fixed in #3728.
Activity
pranaygp commentedon Aug 21, 2026
Partly overtaken by #3728, which went wider than this issue assumed.
The sweep now covers every package that lands in the host application's server build, not just the worlds:
@workflow/core(22 findings → 0),@workflow/world,@workflow/aiand@workflow/nest, alongside the four world packages. Build-time packages, the CLI, the o11y UI and the test runner stay out — a single module graph makes the hazard impossible there.Two things learned while doing it, both relevant to whatever picks this up:
One instance of core is not reachable. Layers cannot share a module, and core cannot be made external because core is workflow code —
runtime/start.ts:253and nine methods inruntime/run.tsare'use step'functions that must go through the SWC loader, which an external package never does.packages/next/src/index.ts:217-221already strips workflow-bearing packages back out ofserverExternalPackagesfor exactly this reason. Getting to one copy would mean splitting core into a transformed shim plus an external, directive-free engine. That is a real option, but it is an architecture project and it trades against the cold-start win #3493 was chasing.step-single-flight.tswas the one with teeth. A per-copy map is not single-flight: two invocations arriving through different layers would each believe they were alone in the process and both run the step body, degrading in-process dedup to the cross-process residual its own doc scopes out to the ownership lease.What remains under this issue is the original idea and still worth doing: move World-scoped state onto the World instance — the ws registry, the undici pools, the long-poll cache — so it rides the World's singleton and cannot be duplicated at all, leaving
globalThisholding only the genuinely process-wide things (ID generators whose sequence must not fork, log-once latches). That pairs with #3665, now also fixed in #3728.