@langchain/langgraph-sdk 1.9.22 → 1.9.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/dist/stream/channel-registry.cjs +20 -7
  2. package/dist/stream/channel-registry.cjs.map +1 -1
  3. package/dist/stream/channel-registry.d.cts +4 -3
  4. package/dist/stream/channel-registry.d.cts.map +1 -1
  5. package/dist/stream/channel-registry.d.ts +4 -3
  6. package/dist/stream/channel-registry.d.ts.map +1 -1
  7. package/dist/stream/channel-registry.js +20 -7
  8. package/dist/stream/channel-registry.js.map +1 -1
  9. package/dist/stream/controller.cjs +29 -3
  10. package/dist/stream/controller.cjs.map +1 -1
  11. package/dist/stream/controller.d.cts.map +1 -1
  12. package/dist/stream/controller.d.ts.map +1 -1
  13. package/dist/stream/controller.js +30 -4
  14. package/dist/stream/controller.js.map +1 -1
  15. package/dist/stream/optimistic-input.cjs +32 -0
  16. package/dist/stream/optimistic-input.cjs.map +1 -1
  17. package/dist/stream/optimistic-input.js +32 -1
  18. package/dist/stream/optimistic-input.js.map +1 -1
  19. package/dist/stream/root-message-projection.cjs +14 -2
  20. package/dist/stream/root-message-projection.cjs.map +1 -1
  21. package/dist/stream/root-message-projection.js +14 -2
  22. package/dist/stream/root-message-projection.js.map +1 -1
  23. package/dist/stream/submit-coordinator.cjs +14 -1
  24. package/dist/stream/submit-coordinator.cjs.map +1 -1
  25. package/dist/stream/submit-coordinator.js +14 -1
  26. package/dist/stream/submit-coordinator.js.map +1 -1
  27. package/dist/stream/types.d.cts +27 -1
  28. package/dist/stream/types.d.cts.map +1 -1
  29. package/dist/stream/types.d.ts +27 -1
  30. package/dist/stream/types.d.ts.map +1 -1
  31. package/dist/ui/types.d.cts +13 -1
  32. package/dist/ui/types.d.cts.map +1 -1
  33. package/dist/ui/types.d.ts +13 -1
  34. package/dist/ui/types.d.ts.map +1 -1
  35. package/package.json +6 -6
@@ -151,7 +151,8 @@ var ChannelRegistry = class {
151
151
  initial: spec.initial,
152
152
  open: spec.open,
153
153
  refCount: 0,
154
- runtime: void 0
154
+ runtime: void 0,
155
+ pendingDispose: void 0
155
156
  };
156
157
  if (this.#thread != null) newEntry.runtime = spec.open({
157
158
  thread: this.#thread,
@@ -161,6 +162,7 @@ var ChannelRegistry = class {
161
162
  this.#entries.set(spec.key, newEntry);
162
163
  entry = newEntry;
163
164
  }
165
+ entry.pendingDispose = void 0;
164
166
  entry.refCount += 1;
165
167
  let released = false;
166
168
  return {
@@ -172,8 +174,16 @@ var ChannelRegistry = class {
172
174
  if (current == null) return;
173
175
  current.refCount -= 1;
174
176
  if (current.refCount <= 0) {
175
- this.#entries.delete(spec.key);
176
- if (current.runtime != null) tryDispose(current.runtime);
177
+ current.refCount = 0;
178
+ const token = {};
179
+ current.pendingDispose = token;
180
+ queueMicrotask(() => {
181
+ const latest = this.#entries.get(spec.key);
182
+ if (latest == null || latest !== current || latest.pendingDispose !== token || latest.refCount > 0) return;
183
+ this.#entries.delete(spec.key);
184
+ latest.pendingDispose = void 0;
185
+ if (latest.runtime != null) tryDispose(latest.runtime);
186
+ });
177
187
  }
178
188
  }
179
189
  };
@@ -195,12 +205,15 @@ var ChannelRegistry = class {
195
205
  }));
196
206
  }
197
207
  /**
198
- * Number of live entries. Diagnostic-only — callers should not
199
- * branch on this value at runtime; it exists for tests asserting
200
- * that consumers properly release their projections.
208
+ * Number of actively-held entries. Diagnostic-only — callers should
209
+ * not branch on this value at runtime; it exists for tests asserting
210
+ * that consumers properly release their projections. Entries waiting
211
+ * on cancellable microtask disposal do not count as active.
201
212
  */
202
213
  get size() {
203
- return this.#entries.size;
214
+ let count = 0;
215
+ for (const entry of this.#entries.values()) if (entry.refCount > 0) count += 1;
216
+ return count;
204
217
  }
205
218
  };
206
219
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"channel-registry.cjs","names":["#rootBus","#entries","#thread","StreamStore"],"sources":["../../src/stream/channel-registry.ts"],"sourcesContent":["/**\n * Framework-agnostic ref-counted subscription cache.\n *\n * # What this module is\n *\n * Every framework binding (React, Vue, Svelte, Angular) owns one\n * {@link ChannelRegistry} per {@link StreamController}. The registry\n * is the single layer that:\n *\n * 1. Deduplicates server-side subscriptions across components — N\n * hooks reading the same projection share one\n * `thread.subscribe(...)` call and one {@link StreamStore}.\n * 2. Lazily opens / tears down subscriptions in step with mounting\n * and unmounting consumers (ref counting on `spec.key`).\n * 3. Survives thread swaps — `controller.hydrate(newThreadId)`\n * rebinds every live entry against the new thread without\n * changing store identity, so React's\n * `useSyncExternalStore` (and equivalents in other frameworks)\n * keep working.\n *\n * # Why ref counting matters\n *\n * Most projections back at least one server subscription. Without\n * deduplication, every additional consumer of e.g. `useMessages(sub)`\n * would open its own SSE/WebSocket subscription, paying the same\n * payload N times. The registry guarantees we only ever pay once per\n * `spec.key`, regardless of how many consumers attach.\n *\n * # Why store identity is preserved on rebind\n *\n * Framework reactivity primitives subscribe to a store *instance* and\n * memoise their last seen snapshot. If we minted a new store on every\n * thread swap, every bound component would silently lose its\n * subscription. Instead, the registry keeps the same {@link StreamStore}\n * but resets its value to `spec.initial` and re-runs `spec.open()` —\n * consumers observe a clean slate without re-subscribing.\n *\n * @see ProjectionSpec - The contract every projection implements.\n * @see StreamStore - The observable store handed to consumers.\n */\nimport { StreamStore } from \"./store.js\";\nimport type {\n AcquiredProjection,\n ProjectionRuntime,\n ProjectionSpec,\n RootEventBus,\n ThreadStream,\n} from \"./types.js\";\n\n/**\n * Internal record kept for each unique `spec.key` actively held by at\n * least one consumer.\n *\n * We intentionally store `initial` and `open` separately from `spec`\n * so the registry never depends on the spec object's identity — two\n * specs sharing the same `key` but produced from different factory\n * calls (e.g. fresh objects on each render) still collapse onto the\n * same entry.\n */\ninterface Entry {\n /** Stable identity used for deduplication. */\n readonly key: string;\n /** Observable store handed back to every consumer of this key. */\n readonly store: StreamStore<unknown>;\n /** Initial snapshot reapplied on dispose / thread rebind. */\n readonly initial: unknown;\n /** Factory that opens the underlying subscription against a thread. */\n readonly open: ProjectionSpec<unknown>[\"open\"];\n /** Live consumers of this entry. Drops to 0 → entry is torn down. */\n refCount: number;\n /**\n * Active runtime returned by `open()`. Undefined while detached\n * (no thread bound yet, or a rebind is in progress).\n */\n runtime: ProjectionRuntime | undefined;\n}\n\n/**\n * Ref-counted, thread-aware projection registry.\n *\n * Owns the `spec.key → (store, runtime)` mapping for one\n * {@link StreamController}. Lifecycle:\n *\n * - `acquire(spec)` → +1 ref, returns `{ store, release }`. The\n * first acquire opens the projection's runtime; subsequent\n * acquires for the same key share both the store and the\n * runtime.\n * - `release()` → -1 ref. When the last consumer releases,\n * the entry is removed and its runtime disposed.\n * - `bind(thread)` → swap or detach the underlying thread; every\n * live entry's runtime is recreated against the new thread,\n * keeping the same store identity.\n * - `dispose()` → tear everything down (idempotent). Safe to\n * call multiple times.\n *\n * The registry is intentionally not generic over a state shape —\n * different consumers can hold projections producing different\n * snapshot types, so the registry keys everything as `unknown` and\n * lets {@link acquire} reapply the caller's `T` at the boundary.\n */\nexport class ChannelRegistry {\n /** Currently bound thread, or `undefined` while detached. */\n #thread: ThreadStream | undefined;\n\n /** Read-only fan-out of the controller's root subscription. */\n readonly #rootBus: RootEventBus;\n\n /** All live entries, keyed by `spec.key`. */\n readonly #entries = new Map<string, Entry>();\n\n /**\n * Construct a registry bound to the controller's root event bus.\n *\n * The bus is forwarded to every projection's `open()` so root-scoped\n * projections can avoid opening a second server subscription when\n * their channel set is already covered by the root pump.\n *\n * @param rootBus - Read-only fan-out of the root subscription.\n */\n constructor(rootBus: RootEventBus) {\n this.#rootBus = rootBus;\n }\n\n /**\n * Rebind every live entry to a new {@link ThreadStream} (or detach\n * when `thread == null`).\n *\n * Each live entry has its current runtime disposed (best-effort)\n * and its store reset to `entry.initial` so consumers see a clean\n * slate during the swap. When `thread != null`, a fresh runtime is\n * opened against the new thread.\n *\n * Critically the {@link StreamStore} *instance* is preserved across\n * the rebind: framework subscribers (e.g. React's\n * `useSyncExternalStore`) keep observing the same store reference,\n * so their subscriptions survive the swap.\n *\n * No-op when called with the currently bound thread.\n *\n * @param thread - The thread stream to bind, or `undefined` to detach.\n */\n bind(thread: ThreadStream | undefined): void {\n if (this.#thread === thread) return;\n const previous = this.#thread;\n this.#thread = thread;\n for (const entry of this.#entries.values()) {\n // Tear down any active runtime from the previous thread.\n if (entry.runtime != null && previous != null) {\n void tryDispose(entry.runtime);\n }\n entry.runtime = undefined;\n entry.store.setValue(entry.initial);\n if (thread != null) {\n entry.runtime = entry.open({\n thread,\n store: entry.store,\n rootBus: this.#rootBus,\n });\n }\n }\n }\n\n /** Currently bound thread (may be `undefined` pre-mount). */\n get thread(): ThreadStream | undefined {\n return this.#thread;\n }\n\n /**\n * Acquire a ref-counted projection.\n *\n * If no entry exists for `spec.key`, one is created (allocating a\n * {@link StreamStore} seeded with `spec.initial`) and — when a\n * thread is currently bound — its runtime is opened immediately.\n * If an entry already exists, its ref count is incremented and the\n * existing store is returned.\n *\n * The returned `release()` is idempotent: calling it more than once\n * is a no-op. When the ref count drops to zero, the entry is removed\n * and its runtime disposed (best-effort, never throws into callers).\n *\n * Safe to call from any framework lifecycle hook. Subsequent calls\n * for the same `spec.key` always return the same `store` reference\n * for the lifetime of the controller, so consumers can rely on store\n * identity.\n *\n * @typeParam T - Snapshot type produced by this projection.\n * @param spec - Projection contract; the registry keys off `spec.key`.\n * @returns A `{ store, release }` handle.\n */\n acquire<T>(spec: ProjectionSpec<T>): AcquiredProjection<T> {\n let entry = this.#entries.get(spec.key);\n if (entry == null) {\n const store = new StreamStore<T>(spec.initial);\n const newEntry: Entry = {\n key: spec.key,\n store: store as StreamStore<unknown>,\n initial: spec.initial as unknown,\n open: spec.open as ProjectionSpec<unknown>[\"open\"],\n refCount: 0,\n runtime: undefined,\n };\n // Open the runtime immediately when a thread is already bound.\n // Otherwise it will be opened lazily by the next `bind()` call.\n if (this.#thread != null) {\n newEntry.runtime = spec.open({\n thread: this.#thread,\n store,\n rootBus: this.#rootBus,\n });\n }\n this.#entries.set(spec.key, newEntry);\n entry = newEntry;\n }\n entry.refCount += 1;\n\n let released = false;\n return {\n store: entry.store as StreamStore<T>,\n release: () => {\n if (released) return;\n released = true;\n const current = this.#entries.get(spec.key);\n if (current == null) return;\n current.refCount -= 1;\n if (current.refCount <= 0) {\n this.#entries.delete(spec.key);\n if (current.runtime != null) void tryDispose(current.runtime);\n }\n },\n };\n }\n\n /**\n * Tear everything down.\n *\n * Detaches the bound thread (so no further `bind()` calls reopen\n * runtimes) and disposes every live runtime in parallel. Safe to\n * call multiple times — subsequent calls find an empty registry\n * and resolve immediately.\n */\n async dispose(): Promise<void> {\n this.#thread = undefined;\n const entries = [...this.#entries.values()];\n this.#entries.clear();\n await Promise.all(\n entries.map(async (entry) => {\n if (entry.runtime != null) await tryDispose(entry.runtime);\n })\n );\n }\n\n /**\n * Number of live entries. Diagnostic-only — callers should not\n * branch on this value at runtime; it exists for tests asserting\n * that consumers properly release their projections.\n */\n get size(): number {\n return this.#entries.size;\n }\n}\n\n/**\n * Best-effort runtime disposal.\n *\n * `dispose()` should never throw, but a misbehaving projection should\n * not be able to wedge the entire registry. We swallow disposal\n * errors so the surrounding `bind()` / `release()` / `dispose()`\n * paths always make progress.\n *\n * @param runtime - Runtime returned by {@link ProjectionSpec.open}.\n */\nasync function tryDispose(runtime: ProjectionRuntime): Promise<void> {\n try {\n await runtime.dispose();\n } catch {\n // Best-effort — dispose should never throw, but we don't want a\n // bad projection to wedge the registry.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoGA,IAAa,kBAAb,MAA6B;;CAE3B;;CAGA;;CAGA,2BAAoB,IAAI,KAAoB;;;;;;;;;;CAW5C,YAAY,SAAuB;AACjC,QAAA,UAAgB;;;;;;;;;;;;;;;;;;;;CAqBlB,KAAK,QAAwC;AAC3C,MAAI,MAAA,WAAiB,OAAQ;EAC7B,MAAM,WAAW,MAAA;AACjB,QAAA,SAAe;AACf,OAAK,MAAM,SAAS,MAAA,QAAc,QAAQ,EAAE;AAE1C,OAAI,MAAM,WAAW,QAAQ,YAAY,KAClC,YAAW,MAAM,QAAQ;AAEhC,SAAM,UAAU,KAAA;AAChB,SAAM,MAAM,SAAS,MAAM,QAAQ;AACnC,OAAI,UAAU,KACZ,OAAM,UAAU,MAAM,KAAK;IACzB;IACA,OAAO,MAAM;IACb,SAAS,MAAA;IACV,CAAC;;;;CAMR,IAAI,SAAmC;AACrC,SAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;CAyBT,QAAW,MAAgD;EACzD,IAAI,QAAQ,MAAA,QAAc,IAAI,KAAK,IAAI;AACvC,MAAI,SAAS,MAAM;GACjB,MAAM,QAAQ,IAAIG,cAAAA,YAAe,KAAK,QAAQ;GAC9C,MAAM,WAAkB;IACtB,KAAK,KAAK;IACH;IACP,SAAS,KAAK;IACd,MAAM,KAAK;IACX,UAAU;IACV,SAAS,KAAA;IACV;AAGD,OAAI,MAAA,UAAgB,KAClB,UAAS,UAAU,KAAK,KAAK;IAC3B,QAAQ,MAAA;IACR;IACA,SAAS,MAAA;IACV,CAAC;AAEJ,SAAA,QAAc,IAAI,KAAK,KAAK,SAAS;AACrC,WAAQ;;AAEV,QAAM,YAAY;EAElB,IAAI,WAAW;AACf,SAAO;GACL,OAAO,MAAM;GACb,eAAe;AACb,QAAI,SAAU;AACd,eAAW;IACX,MAAM,UAAU,MAAA,QAAc,IAAI,KAAK,IAAI;AAC3C,QAAI,WAAW,KAAM;AACrB,YAAQ,YAAY;AACpB,QAAI,QAAQ,YAAY,GAAG;AACzB,WAAA,QAAc,OAAO,KAAK,IAAI;AAC9B,SAAI,QAAQ,WAAW,KAAW,YAAW,QAAQ,QAAQ;;;GAGlE;;;;;;;;;;CAWH,MAAM,UAAyB;AAC7B,QAAA,SAAe,KAAA;EACf,MAAM,UAAU,CAAC,GAAG,MAAA,QAAc,QAAQ,CAAC;AAC3C,QAAA,QAAc,OAAO;AACrB,QAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,UAAU;AAC3B,OAAI,MAAM,WAAW,KAAM,OAAM,WAAW,MAAM,QAAQ;IAC1D,CACH;;;;;;;CAQH,IAAI,OAAe;AACjB,SAAO,MAAA,QAAc;;;;;;;;;;;;;AAczB,eAAe,WAAW,SAA2C;AACnE,KAAI;AACF,QAAM,QAAQ,SAAS;SACjB"}
1
+ {"version":3,"file":"channel-registry.cjs","names":["#rootBus","#entries","#thread","StreamStore"],"sources":["../../src/stream/channel-registry.ts"],"sourcesContent":["/**\n * Framework-agnostic ref-counted subscription cache.\n *\n * # What this module is\n *\n * Every framework binding (React, Vue, Svelte, Angular) owns one\n * {@link ChannelRegistry} per {@link StreamController}. The registry\n * is the single layer that:\n *\n * 1. Deduplicates server-side subscriptions across components — N\n * hooks reading the same projection share one\n * `thread.subscribe(...)` call and one {@link StreamStore}.\n * 2. Lazily opens / tears down subscriptions in step with mounting\n * and unmounting consumers (ref counting on `spec.key`).\n * 3. Survives thread swaps — `controller.hydrate(newThreadId)`\n * rebinds every live entry against the new thread without\n * changing store identity, so React's\n * `useSyncExternalStore` (and equivalents in other frameworks)\n * keep working.\n *\n * # Why ref counting matters\n *\n * Most projections back at least one server subscription. Without\n * deduplication, every additional consumer of e.g. `useMessages(sub)`\n * would open its own SSE/WebSocket subscription, paying the same\n * payload N times. The registry guarantees we only ever pay once per\n * `spec.key`, regardless of how many consumers attach.\n *\n * # Why store identity is preserved on rebind\n *\n * Framework reactivity primitives subscribe to a store *instance* and\n * memoise their last seen snapshot. If we minted a new store on every\n * thread swap, every bound component would silently lose its\n * subscription. Instead, the registry keeps the same {@link StreamStore}\n * but resets its value to `spec.initial` and re-runs `spec.open()` —\n * consumers observe a clean slate without re-subscribing.\n *\n * @see ProjectionSpec - The contract every projection implements.\n * @see StreamStore - The observable store handed to consumers.\n */\nimport { StreamStore } from \"./store.js\";\nimport type {\n AcquiredProjection,\n ProjectionRuntime,\n ProjectionSpec,\n RootEventBus,\n ThreadStream,\n} from \"./types.js\";\n\n/**\n * Internal record kept for each unique `spec.key` actively held by at\n * least one consumer.\n *\n * We intentionally store `initial` and `open` separately from `spec`\n * so the registry never depends on the spec object's identity — two\n * specs sharing the same `key` but produced from different factory\n * calls (e.g. fresh objects on each render) still collapse onto the\n * same entry.\n */\ninterface Entry {\n /** Stable identity used for deduplication. */\n readonly key: string;\n /** Observable store handed back to every consumer of this key. */\n readonly store: StreamStore<unknown>;\n /** Initial snapshot reapplied on dispose / thread rebind. */\n readonly initial: unknown;\n /** Factory that opens the underlying subscription against a thread. */\n readonly open: ProjectionSpec<unknown>[\"open\"];\n /** Live consumers of this entry. Drops to 0 → entry is torn down. */\n refCount: number;\n /**\n * Active runtime returned by `open()`. Undefined while detached\n * (no thread bound yet, or a rebind is in progress).\n */\n runtime: ProjectionRuntime | undefined;\n /**\n * Token for a deferred last-release disposal. Re-acquiring the same\n * projection before the microtask runs clears the token and keeps the\n * runtime alive.\n */\n pendingDispose: object | undefined;\n}\n\n/**\n * Ref-counted, thread-aware projection registry.\n *\n * Owns the `spec.key → (store, runtime)` mapping for one\n * {@link StreamController}. Lifecycle:\n *\n * - `acquire(spec)` → +1 ref, returns `{ store, release }`. The\n * first acquire opens the projection's runtime; subsequent\n * acquires for the same key share both the store and the\n * runtime.\n * - `release()` → -1 ref. When the last consumer releases,\n * the entry is removed and its runtime disposed.\n * - `bind(thread)` → swap or detach the underlying thread; every\n * live entry's runtime is recreated against the new thread,\n * keeping the same store identity.\n * - `dispose()` → tear everything down (idempotent). Safe to\n * call multiple times.\n *\n * The registry is intentionally not generic over a state shape —\n * different consumers can hold projections producing different\n * snapshot types, so the registry keys everything as `unknown` and\n * lets {@link acquire} reapply the caller's `T` at the boundary.\n */\nexport class ChannelRegistry {\n /** Currently bound thread, or `undefined` while detached. */\n #thread: ThreadStream | undefined;\n\n /** Read-only fan-out of the controller's root subscription. */\n readonly #rootBus: RootEventBus;\n\n /** All live entries, keyed by `spec.key`. */\n readonly #entries = new Map<string, Entry>();\n\n /**\n * Construct a registry bound to the controller's root event bus.\n *\n * The bus is forwarded to every projection's `open()` so root-scoped\n * projections can avoid opening a second server subscription when\n * their channel set is already covered by the root pump.\n *\n * @param rootBus - Read-only fan-out of the root subscription.\n */\n constructor(rootBus: RootEventBus) {\n this.#rootBus = rootBus;\n }\n\n /**\n * Rebind every live entry to a new {@link ThreadStream} (or detach\n * when `thread == null`).\n *\n * Each live entry has its current runtime disposed (best-effort)\n * and its store reset to `entry.initial` so consumers see a clean\n * slate during the swap. When `thread != null`, a fresh runtime is\n * opened against the new thread.\n *\n * Critically the {@link StreamStore} *instance* is preserved across\n * the rebind: framework subscribers (e.g. React's\n * `useSyncExternalStore`) keep observing the same store reference,\n * so their subscriptions survive the swap.\n *\n * No-op when called with the currently bound thread.\n *\n * @param thread - The thread stream to bind, or `undefined` to detach.\n */\n bind(thread: ThreadStream | undefined): void {\n if (this.#thread === thread) return;\n const previous = this.#thread;\n this.#thread = thread;\n for (const entry of this.#entries.values()) {\n // Tear down any active runtime from the previous thread.\n if (entry.runtime != null && previous != null) {\n void tryDispose(entry.runtime);\n }\n entry.runtime = undefined;\n entry.store.setValue(entry.initial);\n if (thread != null) {\n entry.runtime = entry.open({\n thread,\n store: entry.store,\n rootBus: this.#rootBus,\n });\n }\n }\n }\n\n /** Currently bound thread (may be `undefined` pre-mount). */\n get thread(): ThreadStream | undefined {\n return this.#thread;\n }\n\n /**\n * Acquire a ref-counted projection.\n *\n * If no entry exists for `spec.key`, one is created (allocating a\n * {@link StreamStore} seeded with `spec.initial`) and — when a\n * thread is currently bound — its runtime is opened immediately.\n * If an entry already exists, its ref count is incremented and the\n * existing store is returned.\n *\n * The returned `release()` is idempotent: calling it more than once\n * is a no-op. When the ref count drops to zero, the entry is removed\n * and its runtime disposed (best-effort, never throws into callers).\n *\n * Safe to call from any framework lifecycle hook. Subsequent calls\n * for the same `spec.key` always return the same `store` reference\n * for the lifetime of the controller, so consumers can rely on store\n * identity.\n *\n * @typeParam T - Snapshot type produced by this projection.\n * @param spec - Projection contract; the registry keys off `spec.key`.\n * @returns A `{ store, release }` handle.\n */\n acquire<T>(spec: ProjectionSpec<T>): AcquiredProjection<T> {\n let entry = this.#entries.get(spec.key);\n if (entry == null) {\n const store = new StreamStore<T>(spec.initial);\n const newEntry: Entry = {\n key: spec.key,\n store: store as StreamStore<unknown>,\n initial: spec.initial as unknown,\n open: spec.open as ProjectionSpec<unknown>[\"open\"],\n refCount: 0,\n runtime: undefined,\n pendingDispose: undefined,\n };\n // Open the runtime immediately when a thread is already bound.\n // Otherwise it will be opened lazily by the next `bind()` call.\n if (this.#thread != null) {\n newEntry.runtime = spec.open({\n thread: this.#thread,\n store,\n rootBus: this.#rootBus,\n });\n }\n this.#entries.set(spec.key, newEntry);\n entry = newEntry;\n }\n entry.pendingDispose = undefined;\n entry.refCount += 1;\n\n let released = false;\n return {\n store: entry.store as StreamStore<T>,\n release: () => {\n if (released) return;\n released = true;\n const current = this.#entries.get(spec.key);\n if (current == null) return;\n current.refCount -= 1;\n if (current.refCount <= 0) {\n current.refCount = 0;\n const token = {};\n current.pendingDispose = token;\n queueMicrotask(() => {\n const latest = this.#entries.get(spec.key);\n if (\n latest == null ||\n latest !== current ||\n latest.pendingDispose !== token ||\n latest.refCount > 0\n ) {\n return;\n }\n this.#entries.delete(spec.key);\n latest.pendingDispose = undefined;\n if (latest.runtime != null) void tryDispose(latest.runtime);\n });\n }\n },\n };\n }\n\n /**\n * Tear everything down.\n *\n * Detaches the bound thread (so no further `bind()` calls reopen\n * runtimes) and disposes every live runtime in parallel. Safe to\n * call multiple times — subsequent calls find an empty registry\n * and resolve immediately.\n */\n async dispose(): Promise<void> {\n this.#thread = undefined;\n const entries = [...this.#entries.values()];\n this.#entries.clear();\n await Promise.all(\n entries.map(async (entry) => {\n if (entry.runtime != null) await tryDispose(entry.runtime);\n })\n );\n }\n\n /**\n * Number of actively-held entries. Diagnostic-only — callers should\n * not branch on this value at runtime; it exists for tests asserting\n * that consumers properly release their projections. Entries waiting\n * on cancellable microtask disposal do not count as active.\n */\n get size(): number {\n let count = 0;\n for (const entry of this.#entries.values()) {\n if (entry.refCount > 0) count += 1;\n }\n return count;\n }\n}\n\n/**\n * Best-effort runtime disposal.\n *\n * `dispose()` should never throw, but a misbehaving projection should\n * not be able to wedge the entire registry. We swallow disposal\n * errors so the surrounding `bind()` / `release()` / `dispose()`\n * paths always make progress.\n *\n * @param runtime - Runtime returned by {@link ProjectionSpec.open}.\n */\nasync function tryDispose(runtime: ProjectionRuntime): Promise<void> {\n try {\n await runtime.dispose();\n } catch {\n // Best-effort — dispose should never throw, but we don't want a\n // bad projection to wedge the registry.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0GA,IAAa,kBAAb,MAA6B;;CAE3B;;CAGA;;CAGA,2BAAoB,IAAI,KAAoB;;;;;;;;;;CAW5C,YAAY,SAAuB;AACjC,QAAA,UAAgB;;;;;;;;;;;;;;;;;;;;CAqBlB,KAAK,QAAwC;AAC3C,MAAI,MAAA,WAAiB,OAAQ;EAC7B,MAAM,WAAW,MAAA;AACjB,QAAA,SAAe;AACf,OAAK,MAAM,SAAS,MAAA,QAAc,QAAQ,EAAE;AAE1C,OAAI,MAAM,WAAW,QAAQ,YAAY,KAClC,YAAW,MAAM,QAAQ;AAEhC,SAAM,UAAU,KAAA;AAChB,SAAM,MAAM,SAAS,MAAM,QAAQ;AACnC,OAAI,UAAU,KACZ,OAAM,UAAU,MAAM,KAAK;IACzB;IACA,OAAO,MAAM;IACb,SAAS,MAAA;IACV,CAAC;;;;CAMR,IAAI,SAAmC;AACrC,SAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;CAyBT,QAAW,MAAgD;EACzD,IAAI,QAAQ,MAAA,QAAc,IAAI,KAAK,IAAI;AACvC,MAAI,SAAS,MAAM;GACjB,MAAM,QAAQ,IAAIG,cAAAA,YAAe,KAAK,QAAQ;GAC9C,MAAM,WAAkB;IACtB,KAAK,KAAK;IACH;IACP,SAAS,KAAK;IACd,MAAM,KAAK;IACX,UAAU;IACV,SAAS,KAAA;IACT,gBAAgB,KAAA;IACjB;AAGD,OAAI,MAAA,UAAgB,KAClB,UAAS,UAAU,KAAK,KAAK;IAC3B,QAAQ,MAAA;IACR;IACA,SAAS,MAAA;IACV,CAAC;AAEJ,SAAA,QAAc,IAAI,KAAK,KAAK,SAAS;AACrC,WAAQ;;AAEV,QAAM,iBAAiB,KAAA;AACvB,QAAM,YAAY;EAElB,IAAI,WAAW;AACf,SAAO;GACL,OAAO,MAAM;GACb,eAAe;AACb,QAAI,SAAU;AACd,eAAW;IACX,MAAM,UAAU,MAAA,QAAc,IAAI,KAAK,IAAI;AAC3C,QAAI,WAAW,KAAM;AACrB,YAAQ,YAAY;AACpB,QAAI,QAAQ,YAAY,GAAG;AACzB,aAAQ,WAAW;KACnB,MAAM,QAAQ,EAAE;AAChB,aAAQ,iBAAiB;AACzB,0BAAqB;MACnB,MAAM,SAAS,MAAA,QAAc,IAAI,KAAK,IAAI;AAC1C,UACE,UAAU,QACV,WAAW,WACX,OAAO,mBAAmB,SAC1B,OAAO,WAAW,EAElB;AAEF,YAAA,QAAc,OAAO,KAAK,IAAI;AAC9B,aAAO,iBAAiB,KAAA;AACxB,UAAI,OAAO,WAAW,KAAW,YAAW,OAAO,QAAQ;OAC3D;;;GAGP;;;;;;;;;;CAWH,MAAM,UAAyB;AAC7B,QAAA,SAAe,KAAA;EACf,MAAM,UAAU,CAAC,GAAG,MAAA,QAAc,QAAQ,CAAC;AAC3C,QAAA,QAAc,OAAO;AACrB,QAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,UAAU;AAC3B,OAAI,MAAM,WAAW,KAAM,OAAM,WAAW,MAAM,QAAQ;IAC1D,CACH;;;;;;;;CASH,IAAI,OAAe;EACjB,IAAI,QAAQ;AACZ,OAAK,MAAM,SAAS,MAAA,QAAc,QAAQ,CACxC,KAAI,MAAM,WAAW,EAAG,UAAS;AAEnC,SAAO;;;;;;;;;;;;;AAcX,eAAe,WAAW,SAA2C;AACnE,KAAI;AACF,QAAM,QAAQ,SAAS;SACjB"}
@@ -91,9 +91,10 @@ declare class ChannelRegistry {
91
91
  */
92
92
  dispose(): Promise<void>;
93
93
  /**
94
- * Number of live entries. Diagnostic-only — callers should not
95
- * branch on this value at runtime; it exists for tests asserting
96
- * that consumers properly release their projections.
94
+ * Number of actively-held entries. Diagnostic-only — callers should
95
+ * not branch on this value at runtime; it exists for tests asserting
96
+ * that consumers properly release their projections. Entries waiting
97
+ * on cancellable microtask disposal do not count as active.
97
98
  */
98
99
  get size(): number;
99
100
  }
@@ -1 +1 @@
1
- {"version":3,"file":"channel-registry.d.cts","names":[],"sources":["../../src/stream/channel-registry.ts"],"mappings":";;;;;;;AAoGA;;;;;;;;;;;;;;;;;;;;cAAa,eAAA;EAAA;EAyFX;;;;;;;;;EAtEA,WAAA,CAAY,OAAA,EAAS,YAAA;EAyIb;;;;;;;;;;;;;;;;;;EAnHR,IAAA,CAAK,MAAA,EAAQ,YAAA;;MAsBT,MAAA,CAAA,GAAU,YAAA;;;;;;;;;;;;;;;;;;;;;;;EA0Bd,OAAA,GAAA,CAAW,IAAA,EAAM,cAAA,CAAe,CAAA,IAAK,kBAAA,CAAmB,CAAA;;;;;;;;;EAmDlD,OAAA,CAAA,GAAW,OAAA;;;;;;MAgBb,IAAA,CAAA;AAAA"}
1
+ {"version":3,"file":"channel-registry.d.cts","names":[],"sources":["../../src/stream/channel-registry.ts"],"mappings":";;;;;;;AA0GA;;;;;;;;;;;;;;;;;;;;cAAa,eAAA;EAAA;EAyFX;;;;;;;;;EAtEA,WAAA,CAAY,OAAA,EAAS,YAAA;EA2Jb;;;;;;;;;;;;;;;;;;EArIR,IAAA,CAAK,MAAA,EAAQ,YAAA;;MAsBT,MAAA,CAAA,GAAU,YAAA;;;;;;;;;;;;;;;;;;;;;;;EA0Bd,OAAA,GAAA,CAAW,IAAA,EAAM,cAAA,CAAe,CAAA,IAAK,kBAAA,CAAmB,CAAA;;;;;;;;;EAoElD,OAAA,CAAA,GAAW,OAAA;;;;;;;MAiBb,IAAA,CAAA;AAAA"}
@@ -91,9 +91,10 @@ declare class ChannelRegistry {
91
91
  */
92
92
  dispose(): Promise<void>;
93
93
  /**
94
- * Number of live entries. Diagnostic-only — callers should not
95
- * branch on this value at runtime; it exists for tests asserting
96
- * that consumers properly release their projections.
94
+ * Number of actively-held entries. Diagnostic-only — callers should
95
+ * not branch on this value at runtime; it exists for tests asserting
96
+ * that consumers properly release their projections. Entries waiting
97
+ * on cancellable microtask disposal do not count as active.
97
98
  */
98
99
  get size(): number;
99
100
  }
@@ -1 +1 @@
1
- {"version":3,"file":"channel-registry.d.ts","names":[],"sources":["../../src/stream/channel-registry.ts"],"mappings":";;;;;;;AAoGA;;;;;;;;;;;;;;;;;;;;cAAa,eAAA;EAAA;EAyFX;;;;;;;;;EAtEA,WAAA,CAAY,OAAA,EAAS,YAAA;EAyIb;;;;;;;;;;;;;;;;;;EAnHR,IAAA,CAAK,MAAA,EAAQ,YAAA;;MAsBT,MAAA,CAAA,GAAU,YAAA;;;;;;;;;;;;;;;;;;;;;;;EA0Bd,OAAA,GAAA,CAAW,IAAA,EAAM,cAAA,CAAe,CAAA,IAAK,kBAAA,CAAmB,CAAA;;;;;;;;;EAmDlD,OAAA,CAAA,GAAW,OAAA;;;;;;MAgBb,IAAA,CAAA;AAAA"}
1
+ {"version":3,"file":"channel-registry.d.ts","names":[],"sources":["../../src/stream/channel-registry.ts"],"mappings":";;;;;;;AA0GA;;;;;;;;;;;;;;;;;;;;cAAa,eAAA;EAAA;EAyFX;;;;;;;;;EAtEA,WAAA,CAAY,OAAA,EAAS,YAAA;EA2Jb;;;;;;;;;;;;;;;;;;EArIR,IAAA,CAAK,MAAA,EAAQ,YAAA;;MAsBT,MAAA,CAAA,GAAU,YAAA;;;;;;;;;;;;;;;;;;;;;;;EA0Bd,OAAA,GAAA,CAAW,IAAA,EAAM,cAAA,CAAe,CAAA,IAAK,kBAAA,CAAmB,CAAA;;;;;;;;;EAoElD,OAAA,CAAA,GAAW,OAAA;;;;;;;MAiBb,IAAA,CAAA;AAAA"}
@@ -151,7 +151,8 @@ var ChannelRegistry = class {
151
151
  initial: spec.initial,
152
152
  open: spec.open,
153
153
  refCount: 0,
154
- runtime: void 0
154
+ runtime: void 0,
155
+ pendingDispose: void 0
155
156
  };
156
157
  if (this.#thread != null) newEntry.runtime = spec.open({
157
158
  thread: this.#thread,
@@ -161,6 +162,7 @@ var ChannelRegistry = class {
161
162
  this.#entries.set(spec.key, newEntry);
162
163
  entry = newEntry;
163
164
  }
165
+ entry.pendingDispose = void 0;
164
166
  entry.refCount += 1;
165
167
  let released = false;
166
168
  return {
@@ -172,8 +174,16 @@ var ChannelRegistry = class {
172
174
  if (current == null) return;
173
175
  current.refCount -= 1;
174
176
  if (current.refCount <= 0) {
175
- this.#entries.delete(spec.key);
176
- if (current.runtime != null) tryDispose(current.runtime);
177
+ current.refCount = 0;
178
+ const token = {};
179
+ current.pendingDispose = token;
180
+ queueMicrotask(() => {
181
+ const latest = this.#entries.get(spec.key);
182
+ if (latest == null || latest !== current || latest.pendingDispose !== token || latest.refCount > 0) return;
183
+ this.#entries.delete(spec.key);
184
+ latest.pendingDispose = void 0;
185
+ if (latest.runtime != null) tryDispose(latest.runtime);
186
+ });
177
187
  }
178
188
  }
179
189
  };
@@ -195,12 +205,15 @@ var ChannelRegistry = class {
195
205
  }));
196
206
  }
197
207
  /**
198
- * Number of live entries. Diagnostic-only — callers should not
199
- * branch on this value at runtime; it exists for tests asserting
200
- * that consumers properly release their projections.
208
+ * Number of actively-held entries. Diagnostic-only — callers should
209
+ * not branch on this value at runtime; it exists for tests asserting
210
+ * that consumers properly release their projections. Entries waiting
211
+ * on cancellable microtask disposal do not count as active.
201
212
  */
202
213
  get size() {
203
- return this.#entries.size;
214
+ let count = 0;
215
+ for (const entry of this.#entries.values()) if (entry.refCount > 0) count += 1;
216
+ return count;
204
217
  }
205
218
  };
206
219
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"channel-registry.js","names":["#rootBus","#entries","#thread"],"sources":["../../src/stream/channel-registry.ts"],"sourcesContent":["/**\n * Framework-agnostic ref-counted subscription cache.\n *\n * # What this module is\n *\n * Every framework binding (React, Vue, Svelte, Angular) owns one\n * {@link ChannelRegistry} per {@link StreamController}. The registry\n * is the single layer that:\n *\n * 1. Deduplicates server-side subscriptions across components — N\n * hooks reading the same projection share one\n * `thread.subscribe(...)` call and one {@link StreamStore}.\n * 2. Lazily opens / tears down subscriptions in step with mounting\n * and unmounting consumers (ref counting on `spec.key`).\n * 3. Survives thread swaps — `controller.hydrate(newThreadId)`\n * rebinds every live entry against the new thread without\n * changing store identity, so React's\n * `useSyncExternalStore` (and equivalents in other frameworks)\n * keep working.\n *\n * # Why ref counting matters\n *\n * Most projections back at least one server subscription. Without\n * deduplication, every additional consumer of e.g. `useMessages(sub)`\n * would open its own SSE/WebSocket subscription, paying the same\n * payload N times. The registry guarantees we only ever pay once per\n * `spec.key`, regardless of how many consumers attach.\n *\n * # Why store identity is preserved on rebind\n *\n * Framework reactivity primitives subscribe to a store *instance* and\n * memoise their last seen snapshot. If we minted a new store on every\n * thread swap, every bound component would silently lose its\n * subscription. Instead, the registry keeps the same {@link StreamStore}\n * but resets its value to `spec.initial` and re-runs `spec.open()` —\n * consumers observe a clean slate without re-subscribing.\n *\n * @see ProjectionSpec - The contract every projection implements.\n * @see StreamStore - The observable store handed to consumers.\n */\nimport { StreamStore } from \"./store.js\";\nimport type {\n AcquiredProjection,\n ProjectionRuntime,\n ProjectionSpec,\n RootEventBus,\n ThreadStream,\n} from \"./types.js\";\n\n/**\n * Internal record kept for each unique `spec.key` actively held by at\n * least one consumer.\n *\n * We intentionally store `initial` and `open` separately from `spec`\n * so the registry never depends on the spec object's identity — two\n * specs sharing the same `key` but produced from different factory\n * calls (e.g. fresh objects on each render) still collapse onto the\n * same entry.\n */\ninterface Entry {\n /** Stable identity used for deduplication. */\n readonly key: string;\n /** Observable store handed back to every consumer of this key. */\n readonly store: StreamStore<unknown>;\n /** Initial snapshot reapplied on dispose / thread rebind. */\n readonly initial: unknown;\n /** Factory that opens the underlying subscription against a thread. */\n readonly open: ProjectionSpec<unknown>[\"open\"];\n /** Live consumers of this entry. Drops to 0 → entry is torn down. */\n refCount: number;\n /**\n * Active runtime returned by `open()`. Undefined while detached\n * (no thread bound yet, or a rebind is in progress).\n */\n runtime: ProjectionRuntime | undefined;\n}\n\n/**\n * Ref-counted, thread-aware projection registry.\n *\n * Owns the `spec.key → (store, runtime)` mapping for one\n * {@link StreamController}. Lifecycle:\n *\n * - `acquire(spec)` → +1 ref, returns `{ store, release }`. The\n * first acquire opens the projection's runtime; subsequent\n * acquires for the same key share both the store and the\n * runtime.\n * - `release()` → -1 ref. When the last consumer releases,\n * the entry is removed and its runtime disposed.\n * - `bind(thread)` → swap or detach the underlying thread; every\n * live entry's runtime is recreated against the new thread,\n * keeping the same store identity.\n * - `dispose()` → tear everything down (idempotent). Safe to\n * call multiple times.\n *\n * The registry is intentionally not generic over a state shape —\n * different consumers can hold projections producing different\n * snapshot types, so the registry keys everything as `unknown` and\n * lets {@link acquire} reapply the caller's `T` at the boundary.\n */\nexport class ChannelRegistry {\n /** Currently bound thread, or `undefined` while detached. */\n #thread: ThreadStream | undefined;\n\n /** Read-only fan-out of the controller's root subscription. */\n readonly #rootBus: RootEventBus;\n\n /** All live entries, keyed by `spec.key`. */\n readonly #entries = new Map<string, Entry>();\n\n /**\n * Construct a registry bound to the controller's root event bus.\n *\n * The bus is forwarded to every projection's `open()` so root-scoped\n * projections can avoid opening a second server subscription when\n * their channel set is already covered by the root pump.\n *\n * @param rootBus - Read-only fan-out of the root subscription.\n */\n constructor(rootBus: RootEventBus) {\n this.#rootBus = rootBus;\n }\n\n /**\n * Rebind every live entry to a new {@link ThreadStream} (or detach\n * when `thread == null`).\n *\n * Each live entry has its current runtime disposed (best-effort)\n * and its store reset to `entry.initial` so consumers see a clean\n * slate during the swap. When `thread != null`, a fresh runtime is\n * opened against the new thread.\n *\n * Critically the {@link StreamStore} *instance* is preserved across\n * the rebind: framework subscribers (e.g. React's\n * `useSyncExternalStore`) keep observing the same store reference,\n * so their subscriptions survive the swap.\n *\n * No-op when called with the currently bound thread.\n *\n * @param thread - The thread stream to bind, or `undefined` to detach.\n */\n bind(thread: ThreadStream | undefined): void {\n if (this.#thread === thread) return;\n const previous = this.#thread;\n this.#thread = thread;\n for (const entry of this.#entries.values()) {\n // Tear down any active runtime from the previous thread.\n if (entry.runtime != null && previous != null) {\n void tryDispose(entry.runtime);\n }\n entry.runtime = undefined;\n entry.store.setValue(entry.initial);\n if (thread != null) {\n entry.runtime = entry.open({\n thread,\n store: entry.store,\n rootBus: this.#rootBus,\n });\n }\n }\n }\n\n /** Currently bound thread (may be `undefined` pre-mount). */\n get thread(): ThreadStream | undefined {\n return this.#thread;\n }\n\n /**\n * Acquire a ref-counted projection.\n *\n * If no entry exists for `spec.key`, one is created (allocating a\n * {@link StreamStore} seeded with `spec.initial`) and — when a\n * thread is currently bound — its runtime is opened immediately.\n * If an entry already exists, its ref count is incremented and the\n * existing store is returned.\n *\n * The returned `release()` is idempotent: calling it more than once\n * is a no-op. When the ref count drops to zero, the entry is removed\n * and its runtime disposed (best-effort, never throws into callers).\n *\n * Safe to call from any framework lifecycle hook. Subsequent calls\n * for the same `spec.key` always return the same `store` reference\n * for the lifetime of the controller, so consumers can rely on store\n * identity.\n *\n * @typeParam T - Snapshot type produced by this projection.\n * @param spec - Projection contract; the registry keys off `spec.key`.\n * @returns A `{ store, release }` handle.\n */\n acquire<T>(spec: ProjectionSpec<T>): AcquiredProjection<T> {\n let entry = this.#entries.get(spec.key);\n if (entry == null) {\n const store = new StreamStore<T>(spec.initial);\n const newEntry: Entry = {\n key: spec.key,\n store: store as StreamStore<unknown>,\n initial: spec.initial as unknown,\n open: spec.open as ProjectionSpec<unknown>[\"open\"],\n refCount: 0,\n runtime: undefined,\n };\n // Open the runtime immediately when a thread is already bound.\n // Otherwise it will be opened lazily by the next `bind()` call.\n if (this.#thread != null) {\n newEntry.runtime = spec.open({\n thread: this.#thread,\n store,\n rootBus: this.#rootBus,\n });\n }\n this.#entries.set(spec.key, newEntry);\n entry = newEntry;\n }\n entry.refCount += 1;\n\n let released = false;\n return {\n store: entry.store as StreamStore<T>,\n release: () => {\n if (released) return;\n released = true;\n const current = this.#entries.get(spec.key);\n if (current == null) return;\n current.refCount -= 1;\n if (current.refCount <= 0) {\n this.#entries.delete(spec.key);\n if (current.runtime != null) void tryDispose(current.runtime);\n }\n },\n };\n }\n\n /**\n * Tear everything down.\n *\n * Detaches the bound thread (so no further `bind()` calls reopen\n * runtimes) and disposes every live runtime in parallel. Safe to\n * call multiple times — subsequent calls find an empty registry\n * and resolve immediately.\n */\n async dispose(): Promise<void> {\n this.#thread = undefined;\n const entries = [...this.#entries.values()];\n this.#entries.clear();\n await Promise.all(\n entries.map(async (entry) => {\n if (entry.runtime != null) await tryDispose(entry.runtime);\n })\n );\n }\n\n /**\n * Number of live entries. Diagnostic-only — callers should not\n * branch on this value at runtime; it exists for tests asserting\n * that consumers properly release their projections.\n */\n get size(): number {\n return this.#entries.size;\n }\n}\n\n/**\n * Best-effort runtime disposal.\n *\n * `dispose()` should never throw, but a misbehaving projection should\n * not be able to wedge the entire registry. We swallow disposal\n * errors so the surrounding `bind()` / `release()` / `dispose()`\n * paths always make progress.\n *\n * @param runtime - Runtime returned by {@link ProjectionSpec.open}.\n */\nasync function tryDispose(runtime: ProjectionRuntime): Promise<void> {\n try {\n await runtime.dispose();\n } catch {\n // Best-effort — dispose should never throw, but we don't want a\n // bad projection to wedge the registry.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoGA,IAAa,kBAAb,MAA6B;;CAE3B;;CAGA;;CAGA,2BAAoB,IAAI,KAAoB;;;;;;;;;;CAW5C,YAAY,SAAuB;AACjC,QAAA,UAAgB;;;;;;;;;;;;;;;;;;;;CAqBlB,KAAK,QAAwC;AAC3C,MAAI,MAAA,WAAiB,OAAQ;EAC7B,MAAM,WAAW,MAAA;AACjB,QAAA,SAAe;AACf,OAAK,MAAM,SAAS,MAAA,QAAc,QAAQ,EAAE;AAE1C,OAAI,MAAM,WAAW,QAAQ,YAAY,KAClC,YAAW,MAAM,QAAQ;AAEhC,SAAM,UAAU,KAAA;AAChB,SAAM,MAAM,SAAS,MAAM,QAAQ;AACnC,OAAI,UAAU,KACZ,OAAM,UAAU,MAAM,KAAK;IACzB;IACA,OAAO,MAAM;IACb,SAAS,MAAA;IACV,CAAC;;;;CAMR,IAAI,SAAmC;AACrC,SAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;CAyBT,QAAW,MAAgD;EACzD,IAAI,QAAQ,MAAA,QAAc,IAAI,KAAK,IAAI;AACvC,MAAI,SAAS,MAAM;GACjB,MAAM,QAAQ,IAAI,YAAe,KAAK,QAAQ;GAC9C,MAAM,WAAkB;IACtB,KAAK,KAAK;IACH;IACP,SAAS,KAAK;IACd,MAAM,KAAK;IACX,UAAU;IACV,SAAS,KAAA;IACV;AAGD,OAAI,MAAA,UAAgB,KAClB,UAAS,UAAU,KAAK,KAAK;IAC3B,QAAQ,MAAA;IACR;IACA,SAAS,MAAA;IACV,CAAC;AAEJ,SAAA,QAAc,IAAI,KAAK,KAAK,SAAS;AACrC,WAAQ;;AAEV,QAAM,YAAY;EAElB,IAAI,WAAW;AACf,SAAO;GACL,OAAO,MAAM;GACb,eAAe;AACb,QAAI,SAAU;AACd,eAAW;IACX,MAAM,UAAU,MAAA,QAAc,IAAI,KAAK,IAAI;AAC3C,QAAI,WAAW,KAAM;AACrB,YAAQ,YAAY;AACpB,QAAI,QAAQ,YAAY,GAAG;AACzB,WAAA,QAAc,OAAO,KAAK,IAAI;AAC9B,SAAI,QAAQ,WAAW,KAAW,YAAW,QAAQ,QAAQ;;;GAGlE;;;;;;;;;;CAWH,MAAM,UAAyB;AAC7B,QAAA,SAAe,KAAA;EACf,MAAM,UAAU,CAAC,GAAG,MAAA,QAAc,QAAQ,CAAC;AAC3C,QAAA,QAAc,OAAO;AACrB,QAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,UAAU;AAC3B,OAAI,MAAM,WAAW,KAAM,OAAM,WAAW,MAAM,QAAQ;IAC1D,CACH;;;;;;;CAQH,IAAI,OAAe;AACjB,SAAO,MAAA,QAAc;;;;;;;;;;;;;AAczB,eAAe,WAAW,SAA2C;AACnE,KAAI;AACF,QAAM,QAAQ,SAAS;SACjB"}
1
+ {"version":3,"file":"channel-registry.js","names":["#rootBus","#entries","#thread"],"sources":["../../src/stream/channel-registry.ts"],"sourcesContent":["/**\n * Framework-agnostic ref-counted subscription cache.\n *\n * # What this module is\n *\n * Every framework binding (React, Vue, Svelte, Angular) owns one\n * {@link ChannelRegistry} per {@link StreamController}. The registry\n * is the single layer that:\n *\n * 1. Deduplicates server-side subscriptions across components — N\n * hooks reading the same projection share one\n * `thread.subscribe(...)` call and one {@link StreamStore}.\n * 2. Lazily opens / tears down subscriptions in step with mounting\n * and unmounting consumers (ref counting on `spec.key`).\n * 3. Survives thread swaps — `controller.hydrate(newThreadId)`\n * rebinds every live entry against the new thread without\n * changing store identity, so React's\n * `useSyncExternalStore` (and equivalents in other frameworks)\n * keep working.\n *\n * # Why ref counting matters\n *\n * Most projections back at least one server subscription. Without\n * deduplication, every additional consumer of e.g. `useMessages(sub)`\n * would open its own SSE/WebSocket subscription, paying the same\n * payload N times. The registry guarantees we only ever pay once per\n * `spec.key`, regardless of how many consumers attach.\n *\n * # Why store identity is preserved on rebind\n *\n * Framework reactivity primitives subscribe to a store *instance* and\n * memoise their last seen snapshot. If we minted a new store on every\n * thread swap, every bound component would silently lose its\n * subscription. Instead, the registry keeps the same {@link StreamStore}\n * but resets its value to `spec.initial` and re-runs `spec.open()` —\n * consumers observe a clean slate without re-subscribing.\n *\n * @see ProjectionSpec - The contract every projection implements.\n * @see StreamStore - The observable store handed to consumers.\n */\nimport { StreamStore } from \"./store.js\";\nimport type {\n AcquiredProjection,\n ProjectionRuntime,\n ProjectionSpec,\n RootEventBus,\n ThreadStream,\n} from \"./types.js\";\n\n/**\n * Internal record kept for each unique `spec.key` actively held by at\n * least one consumer.\n *\n * We intentionally store `initial` and `open` separately from `spec`\n * so the registry never depends on the spec object's identity — two\n * specs sharing the same `key` but produced from different factory\n * calls (e.g. fresh objects on each render) still collapse onto the\n * same entry.\n */\ninterface Entry {\n /** Stable identity used for deduplication. */\n readonly key: string;\n /** Observable store handed back to every consumer of this key. */\n readonly store: StreamStore<unknown>;\n /** Initial snapshot reapplied on dispose / thread rebind. */\n readonly initial: unknown;\n /** Factory that opens the underlying subscription against a thread. */\n readonly open: ProjectionSpec<unknown>[\"open\"];\n /** Live consumers of this entry. Drops to 0 → entry is torn down. */\n refCount: number;\n /**\n * Active runtime returned by `open()`. Undefined while detached\n * (no thread bound yet, or a rebind is in progress).\n */\n runtime: ProjectionRuntime | undefined;\n /**\n * Token for a deferred last-release disposal. Re-acquiring the same\n * projection before the microtask runs clears the token and keeps the\n * runtime alive.\n */\n pendingDispose: object | undefined;\n}\n\n/**\n * Ref-counted, thread-aware projection registry.\n *\n * Owns the `spec.key → (store, runtime)` mapping for one\n * {@link StreamController}. Lifecycle:\n *\n * - `acquire(spec)` → +1 ref, returns `{ store, release }`. The\n * first acquire opens the projection's runtime; subsequent\n * acquires for the same key share both the store and the\n * runtime.\n * - `release()` → -1 ref. When the last consumer releases,\n * the entry is removed and its runtime disposed.\n * - `bind(thread)` → swap or detach the underlying thread; every\n * live entry's runtime is recreated against the new thread,\n * keeping the same store identity.\n * - `dispose()` → tear everything down (idempotent). Safe to\n * call multiple times.\n *\n * The registry is intentionally not generic over a state shape —\n * different consumers can hold projections producing different\n * snapshot types, so the registry keys everything as `unknown` and\n * lets {@link acquire} reapply the caller's `T` at the boundary.\n */\nexport class ChannelRegistry {\n /** Currently bound thread, or `undefined` while detached. */\n #thread: ThreadStream | undefined;\n\n /** Read-only fan-out of the controller's root subscription. */\n readonly #rootBus: RootEventBus;\n\n /** All live entries, keyed by `spec.key`. */\n readonly #entries = new Map<string, Entry>();\n\n /**\n * Construct a registry bound to the controller's root event bus.\n *\n * The bus is forwarded to every projection's `open()` so root-scoped\n * projections can avoid opening a second server subscription when\n * their channel set is already covered by the root pump.\n *\n * @param rootBus - Read-only fan-out of the root subscription.\n */\n constructor(rootBus: RootEventBus) {\n this.#rootBus = rootBus;\n }\n\n /**\n * Rebind every live entry to a new {@link ThreadStream} (or detach\n * when `thread == null`).\n *\n * Each live entry has its current runtime disposed (best-effort)\n * and its store reset to `entry.initial` so consumers see a clean\n * slate during the swap. When `thread != null`, a fresh runtime is\n * opened against the new thread.\n *\n * Critically the {@link StreamStore} *instance* is preserved across\n * the rebind: framework subscribers (e.g. React's\n * `useSyncExternalStore`) keep observing the same store reference,\n * so their subscriptions survive the swap.\n *\n * No-op when called with the currently bound thread.\n *\n * @param thread - The thread stream to bind, or `undefined` to detach.\n */\n bind(thread: ThreadStream | undefined): void {\n if (this.#thread === thread) return;\n const previous = this.#thread;\n this.#thread = thread;\n for (const entry of this.#entries.values()) {\n // Tear down any active runtime from the previous thread.\n if (entry.runtime != null && previous != null) {\n void tryDispose(entry.runtime);\n }\n entry.runtime = undefined;\n entry.store.setValue(entry.initial);\n if (thread != null) {\n entry.runtime = entry.open({\n thread,\n store: entry.store,\n rootBus: this.#rootBus,\n });\n }\n }\n }\n\n /** Currently bound thread (may be `undefined` pre-mount). */\n get thread(): ThreadStream | undefined {\n return this.#thread;\n }\n\n /**\n * Acquire a ref-counted projection.\n *\n * If no entry exists for `spec.key`, one is created (allocating a\n * {@link StreamStore} seeded with `spec.initial`) and — when a\n * thread is currently bound — its runtime is opened immediately.\n * If an entry already exists, its ref count is incremented and the\n * existing store is returned.\n *\n * The returned `release()` is idempotent: calling it more than once\n * is a no-op. When the ref count drops to zero, the entry is removed\n * and its runtime disposed (best-effort, never throws into callers).\n *\n * Safe to call from any framework lifecycle hook. Subsequent calls\n * for the same `spec.key` always return the same `store` reference\n * for the lifetime of the controller, so consumers can rely on store\n * identity.\n *\n * @typeParam T - Snapshot type produced by this projection.\n * @param spec - Projection contract; the registry keys off `spec.key`.\n * @returns A `{ store, release }` handle.\n */\n acquire<T>(spec: ProjectionSpec<T>): AcquiredProjection<T> {\n let entry = this.#entries.get(spec.key);\n if (entry == null) {\n const store = new StreamStore<T>(spec.initial);\n const newEntry: Entry = {\n key: spec.key,\n store: store as StreamStore<unknown>,\n initial: spec.initial as unknown,\n open: spec.open as ProjectionSpec<unknown>[\"open\"],\n refCount: 0,\n runtime: undefined,\n pendingDispose: undefined,\n };\n // Open the runtime immediately when a thread is already bound.\n // Otherwise it will be opened lazily by the next `bind()` call.\n if (this.#thread != null) {\n newEntry.runtime = spec.open({\n thread: this.#thread,\n store,\n rootBus: this.#rootBus,\n });\n }\n this.#entries.set(spec.key, newEntry);\n entry = newEntry;\n }\n entry.pendingDispose = undefined;\n entry.refCount += 1;\n\n let released = false;\n return {\n store: entry.store as StreamStore<T>,\n release: () => {\n if (released) return;\n released = true;\n const current = this.#entries.get(spec.key);\n if (current == null) return;\n current.refCount -= 1;\n if (current.refCount <= 0) {\n current.refCount = 0;\n const token = {};\n current.pendingDispose = token;\n queueMicrotask(() => {\n const latest = this.#entries.get(spec.key);\n if (\n latest == null ||\n latest !== current ||\n latest.pendingDispose !== token ||\n latest.refCount > 0\n ) {\n return;\n }\n this.#entries.delete(spec.key);\n latest.pendingDispose = undefined;\n if (latest.runtime != null) void tryDispose(latest.runtime);\n });\n }\n },\n };\n }\n\n /**\n * Tear everything down.\n *\n * Detaches the bound thread (so no further `bind()` calls reopen\n * runtimes) and disposes every live runtime in parallel. Safe to\n * call multiple times — subsequent calls find an empty registry\n * and resolve immediately.\n */\n async dispose(): Promise<void> {\n this.#thread = undefined;\n const entries = [...this.#entries.values()];\n this.#entries.clear();\n await Promise.all(\n entries.map(async (entry) => {\n if (entry.runtime != null) await tryDispose(entry.runtime);\n })\n );\n }\n\n /**\n * Number of actively-held entries. Diagnostic-only — callers should\n * not branch on this value at runtime; it exists for tests asserting\n * that consumers properly release their projections. Entries waiting\n * on cancellable microtask disposal do not count as active.\n */\n get size(): number {\n let count = 0;\n for (const entry of this.#entries.values()) {\n if (entry.refCount > 0) count += 1;\n }\n return count;\n }\n}\n\n/**\n * Best-effort runtime disposal.\n *\n * `dispose()` should never throw, but a misbehaving projection should\n * not be able to wedge the entire registry. We swallow disposal\n * errors so the surrounding `bind()` / `release()` / `dispose()`\n * paths always make progress.\n *\n * @param runtime - Runtime returned by {@link ProjectionSpec.open}.\n */\nasync function tryDispose(runtime: ProjectionRuntime): Promise<void> {\n try {\n await runtime.dispose();\n } catch {\n // Best-effort — dispose should never throw, but we don't want a\n // bad projection to wedge the registry.\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0GA,IAAa,kBAAb,MAA6B;;CAE3B;;CAGA;;CAGA,2BAAoB,IAAI,KAAoB;;;;;;;;;;CAW5C,YAAY,SAAuB;AACjC,QAAA,UAAgB;;;;;;;;;;;;;;;;;;;;CAqBlB,KAAK,QAAwC;AAC3C,MAAI,MAAA,WAAiB,OAAQ;EAC7B,MAAM,WAAW,MAAA;AACjB,QAAA,SAAe;AACf,OAAK,MAAM,SAAS,MAAA,QAAc,QAAQ,EAAE;AAE1C,OAAI,MAAM,WAAW,QAAQ,YAAY,KAClC,YAAW,MAAM,QAAQ;AAEhC,SAAM,UAAU,KAAA;AAChB,SAAM,MAAM,SAAS,MAAM,QAAQ;AACnC,OAAI,UAAU,KACZ,OAAM,UAAU,MAAM,KAAK;IACzB;IACA,OAAO,MAAM;IACb,SAAS,MAAA;IACV,CAAC;;;;CAMR,IAAI,SAAmC;AACrC,SAAO,MAAA;;;;;;;;;;;;;;;;;;;;;;;;CAyBT,QAAW,MAAgD;EACzD,IAAI,QAAQ,MAAA,QAAc,IAAI,KAAK,IAAI;AACvC,MAAI,SAAS,MAAM;GACjB,MAAM,QAAQ,IAAI,YAAe,KAAK,QAAQ;GAC9C,MAAM,WAAkB;IACtB,KAAK,KAAK;IACH;IACP,SAAS,KAAK;IACd,MAAM,KAAK;IACX,UAAU;IACV,SAAS,KAAA;IACT,gBAAgB,KAAA;IACjB;AAGD,OAAI,MAAA,UAAgB,KAClB,UAAS,UAAU,KAAK,KAAK;IAC3B,QAAQ,MAAA;IACR;IACA,SAAS,MAAA;IACV,CAAC;AAEJ,SAAA,QAAc,IAAI,KAAK,KAAK,SAAS;AACrC,WAAQ;;AAEV,QAAM,iBAAiB,KAAA;AACvB,QAAM,YAAY;EAElB,IAAI,WAAW;AACf,SAAO;GACL,OAAO,MAAM;GACb,eAAe;AACb,QAAI,SAAU;AACd,eAAW;IACX,MAAM,UAAU,MAAA,QAAc,IAAI,KAAK,IAAI;AAC3C,QAAI,WAAW,KAAM;AACrB,YAAQ,YAAY;AACpB,QAAI,QAAQ,YAAY,GAAG;AACzB,aAAQ,WAAW;KACnB,MAAM,QAAQ,EAAE;AAChB,aAAQ,iBAAiB;AACzB,0BAAqB;MACnB,MAAM,SAAS,MAAA,QAAc,IAAI,KAAK,IAAI;AAC1C,UACE,UAAU,QACV,WAAW,WACX,OAAO,mBAAmB,SAC1B,OAAO,WAAW,EAElB;AAEF,YAAA,QAAc,OAAO,KAAK,IAAI;AAC9B,aAAO,iBAAiB,KAAA;AACxB,UAAI,OAAO,WAAW,KAAW,YAAW,OAAO,QAAQ;OAC3D;;;GAGP;;;;;;;;;;CAWH,MAAM,UAAyB;AAC7B,QAAA,SAAe,KAAA;EACf,MAAM,UAAU,CAAC,GAAG,MAAA,QAAc,QAAQ,CAAC;AAC3C,QAAA,QAAc,OAAO;AACrB,QAAM,QAAQ,IACZ,QAAQ,IAAI,OAAO,UAAU;AAC3B,OAAI,MAAM,WAAW,KAAM,OAAM,WAAW,MAAM,QAAQ;IAC1D,CACH;;;;;;;;CASH,IAAI,OAAe;EACjB,IAAI,QAAQ;AACZ,OAAK,MAAM,SAAS,MAAA,QAAc,QAAQ,CACxC,KAAI,MAAM,WAAW,EAAG,UAAS;AAEnC,SAAO;;;;;;;;;;;;;AAcX,eAAe,WAAW,SAA2C;AACnE,KAAI;AACF,QAAM,QAAQ,SAAS;SACjB"}
@@ -962,17 +962,21 @@ var StreamController = class {
962
962
  } : this.#resolveInterruptForResume();
963
963
  if (resolved == null) throw new Error("No pending interrupt to respond to.");
964
964
  const thread = this.#thread;
965
+ const prepared = options?.update != null ? this.#beginOptimistic(options.update) : void 0;
966
+ const dispatchUpdate = this.#resolveDispatchUpdate(options?.update, prepared);
965
967
  try {
966
968
  await this.#submitter.dispatchResume(async () => {
967
969
  await thread.respondInput({
968
970
  namespace: resolved.namespace,
969
971
  interrupt_id: resolved.interruptId,
970
972
  response: require_hitl_interrupt_payload.normalizeHitlResponseForServer(response),
973
+ ...dispatchUpdate != null ? { update: dispatchUpdate } : {},
974
+ ...options?.goto != null ? { goto: options.goto } : {},
971
975
  config: options?.config,
972
976
  metadata: options?.metadata
973
977
  });
974
978
  this.#markInterruptResolvedInRootStore(resolved.interruptId);
975
- });
979
+ }, prepared?.handle);
976
980
  } catch (error) {
977
981
  if (this.#disposed && isAbortLikeError(error)) return;
978
982
  throw error;
@@ -1030,15 +1034,19 @@ var StreamController = class {
1030
1034
  response: require_hitl_interrupt_payload.normalizeHitlResponseForServer(response),
1031
1035
  namespace: pending.find((entry) => entry.interruptId === interruptId)?.namespace ?? [...ROOT_NAMESPACE]
1032
1036
  }));
1037
+ const prepared = options?.update != null ? this.#beginOptimistic(options.update) : void 0;
1038
+ const dispatchUpdate = this.#resolveDispatchUpdate(options?.update, prepared);
1033
1039
  try {
1034
1040
  await this.#submitter.dispatchResume(async () => {
1035
1041
  await thread.respondInput({
1036
1042
  responses,
1043
+ ...dispatchUpdate != null ? { update: dispatchUpdate } : {},
1044
+ ...options?.goto != null ? { goto: options.goto } : {},
1037
1045
  config: options?.config,
1038
1046
  metadata: options?.metadata
1039
1047
  });
1040
1048
  for (const { interrupt_id: interruptId } of responses) this.#markInterruptResolvedInRootStore(interruptId);
1041
- });
1049
+ }, prepared?.handle);
1042
1050
  } catch (error) {
1043
1051
  if (this.#disposed && isAbortLikeError(error)) return;
1044
1052
  throw error;
@@ -1539,7 +1547,7 @@ var StreamController = class {
1539
1547
  prevValue: currentValues[key]
1540
1548
  }));
1541
1549
  this.#sawValuesForRun = false;
1542
- this.#rootMessages.appendOptimistic(prepared.optimisticMessages, prepared.extraValues);
1550
+ this.#rootMessages.appendOptimistic(prepared.optimisticMessages, prepared.extraValues, { sync: true });
1543
1551
  if (prepared.echoedIds.length > 0) this.#messageMetadata.markPending(prepared.echoedIds);
1544
1552
  return {
1545
1553
  dispatchInput: prepared.dispatchInput,
@@ -1550,6 +1558,24 @@ var StreamController = class {
1550
1558
  };
1551
1559
  }
1552
1560
  /**
1561
+ * Pick the `update` payload to dispatch on a resume (`respond` /
1562
+ * `respondAll`).
1563
+ *
1564
+ * When the optimistic path ran ({@link #beginOptimistic} returned a handle),
1565
+ * its `dispatchInput` already carries the minted message ids the server must
1566
+ * echo back, so dispatch that — the echo reconciles the optimistic messages
1567
+ * by id (no duplicate). Otherwise (optimistic UI disabled, or an `update`
1568
+ * with no echoable messages — e.g. the tuple-entry form) fall back to
1569
+ * serializing `BaseMessage` instances to dicts, exactly as before. Returns
1570
+ * `undefined` when there is no `update`, so the server still sees a plain
1571
+ * resume.
1572
+ */
1573
+ #resolveDispatchUpdate(update, prepared) {
1574
+ if (prepared != null) return prepared.dispatchInput;
1575
+ if (update == null) return void 0;
1576
+ return require_optimistic_input.serializeUpdateMessages(update, this.#messagesKey);
1577
+ }
1578
+ /**
1553
1579
  * Reconcile optimistic state when a run terminates.
1554
1580
  *
1555
1581
  * - Messages: any echoed id still `"pending"` (never echoed by the