Skip to content

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

Open

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).

Activity

  1. pranaygp commented on Aug 21, 2026

    @pranaygp
    ContributorAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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