@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.
- package/dist/stream/channel-registry.cjs +20 -7
- package/dist/stream/channel-registry.cjs.map +1 -1
- package/dist/stream/channel-registry.d.cts +4 -3
- package/dist/stream/channel-registry.d.cts.map +1 -1
- package/dist/stream/channel-registry.d.ts +4 -3
- package/dist/stream/channel-registry.d.ts.map +1 -1
- package/dist/stream/channel-registry.js +20 -7
- package/dist/stream/channel-registry.js.map +1 -1
- package/dist/stream/controller.cjs +29 -3
- package/dist/stream/controller.cjs.map +1 -1
- package/dist/stream/controller.d.cts.map +1 -1
- package/dist/stream/controller.d.ts.map +1 -1
- package/dist/stream/controller.js +30 -4
- package/dist/stream/controller.js.map +1 -1
- package/dist/stream/optimistic-input.cjs +32 -0
- package/dist/stream/optimistic-input.cjs.map +1 -1
- package/dist/stream/optimistic-input.js +32 -1
- package/dist/stream/optimistic-input.js.map +1 -1
- package/dist/stream/root-message-projection.cjs +14 -2
- package/dist/stream/root-message-projection.cjs.map +1 -1
- package/dist/stream/root-message-projection.js +14 -2
- package/dist/stream/root-message-projection.js.map +1 -1
- package/dist/stream/submit-coordinator.cjs +14 -1
- package/dist/stream/submit-coordinator.cjs.map +1 -1
- package/dist/stream/submit-coordinator.js +14 -1
- package/dist/stream/submit-coordinator.js.map +1 -1
- package/dist/stream/types.d.cts +27 -1
- package/dist/stream/types.d.cts.map +1 -1
- package/dist/stream/types.d.ts +27 -1
- package/dist/stream/types.d.ts.map +1 -1
- package/dist/ui/types.d.cts +13 -1
- package/dist/ui/types.d.cts.map +1 -1
- package/dist/ui/types.d.ts +13 -1
- package/dist/ui/types.d.ts.map +1 -1
- 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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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":";;;;;;;
|
|
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
|
|
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":";;;;;;;
|
|
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
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
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
|