Skip to content

Worlds: move World-scoped state onto the World instance so bundling can't duplicate it #3729

Description

@pranaygp

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

  1. Move per-World state into createWorld()'s closure / the returned World object in world-vercel and world-local.
  2. Keep globalSingleton() for the process-wide remainder.
  3. 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).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions