UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

237 lines (236 loc) • 8.49 kB
const require_store = require("./store.cjs"); //#region src/stream/channel-registry.ts /** * Framework-agnostic ref-counted subscription cache. * * # What this module is * * Every framework binding (React, Vue, Svelte, Angular) owns one * {@link ChannelRegistry} per {@link StreamController}. The registry * is the single layer that: * * 1. Deduplicates server-side subscriptions across components — N * hooks reading the same projection share one * `thread.subscribe(...)` call and one {@link StreamStore}. * 2. Lazily opens / tears down subscriptions in step with mounting * and unmounting consumers (ref counting on `spec.key`). * 3. Survives thread swaps — `controller.hydrate(newThreadId)` * rebinds every live entry against the new thread without * changing store identity, so React's * `useSyncExternalStore` (and equivalents in other frameworks) * keep working. * * # Why ref counting matters * * Most projections back at least one server subscription. Without * deduplication, every additional consumer of e.g. `useMessages(sub)` * would open its own SSE/WebSocket subscription, paying the same * payload N times. The registry guarantees we only ever pay once per * `spec.key`, regardless of how many consumers attach. * * # Why store identity is preserved on rebind * * Framework reactivity primitives subscribe to a store *instance* and * memoise their last seen snapshot. If we minted a new store on every * thread swap, every bound component would silently lose its * subscription. Instead, the registry keeps the same {@link StreamStore} * but resets its value to `spec.initial` and re-runs `spec.open()` — * consumers observe a clean slate without re-subscribing. * * @see ProjectionSpec - The contract every projection implements. * @see StreamStore - The observable store handed to consumers. */ /** * Ref-counted, thread-aware projection registry. * * Owns the `spec.key → (store, runtime)` mapping for one * {@link StreamController}. Lifecycle: * * - `acquire(spec)` → +1 ref, returns `{ store, release }`. The * first acquire opens the projection's runtime; subsequent * acquires for the same key share both the store and the * runtime. * - `release()` → -1 ref. When the last consumer releases, * the entry is removed and its runtime disposed. * - `bind(thread)` → swap or detach the underlying thread; every * live entry's runtime is recreated against the new thread, * keeping the same store identity. * - `dispose()` → tear everything down (idempotent). Safe to * call multiple times. * * The registry is intentionally not generic over a state shape — * different consumers can hold projections producing different * snapshot types, so the registry keys everything as `unknown` and * lets {@link acquire} reapply the caller's `T` at the boundary. */ var ChannelRegistry = class { /** Currently bound thread, or `undefined` while detached. */ #thread; /** Read-only fan-out of the controller's root subscription. */ #rootBus; /** All live entries, keyed by `spec.key`. */ #entries = /* @__PURE__ */ new Map(); /** * Construct a registry bound to the controller's root event bus. * * The bus is forwarded to every projection's `open()` so root-scoped * projections can avoid opening a second server subscription when * their channel set is already covered by the root pump. * * @param rootBus - Read-only fan-out of the root subscription. */ constructor(rootBus) { this.#rootBus = rootBus; } /** * Rebind every live entry to a new {@link ThreadStream} (or detach * when `thread == null`). * * Each live entry has its current runtime disposed (best-effort) * and its store reset to `entry.initial` so consumers see a clean * slate during the swap. When `thread != null`, a fresh runtime is * opened against the new thread. * * Critically the {@link StreamStore} *instance* is preserved across * the rebind: framework subscribers (e.g. React's * `useSyncExternalStore`) keep observing the same store reference, * so their subscriptions survive the swap. * * No-op when called with the currently bound thread. * * @param thread - The thread stream to bind, or `undefined` to detach. */ bind(thread) { if (this.#thread === thread) return; const previous = this.#thread; this.#thread = thread; for (const entry of this.#entries.values()) { if (entry.runtime != null && previous != null) tryDispose(entry.runtime); entry.runtime = void 0; entry.store.setValue(entry.initial); if (thread != null) entry.runtime = entry.open({ thread, store: entry.store, rootBus: this.#rootBus }); } } /** Currently bound thread (may be `undefined` pre-mount). */ get thread() { return this.#thread; } /** * Acquire a ref-counted projection. * * If no entry exists for `spec.key`, one is created (allocating a * {@link StreamStore} seeded with `spec.initial`) and — when a * thread is currently bound — its runtime is opened immediately. * If an entry already exists, its ref count is incremented and the * existing store is returned. * * The returned `release()` is idempotent: calling it more than once * is a no-op. When the ref count drops to zero, the entry is removed * and its runtime disposed (best-effort, never throws into callers). * * Safe to call from any framework lifecycle hook. Subsequent calls * for the same `spec.key` always return the same `store` reference * for the lifetime of the controller, so consumers can rely on store * identity. * * @typeParam T - Snapshot type produced by this projection. * @param spec - Projection contract; the registry keys off `spec.key`. * @returns A `{ store, release }` handle. */ acquire(spec) { let entry = this.#entries.get(spec.key); if (entry == null) { const store = new require_store.StreamStore(spec.initial); const newEntry = { key: spec.key, store, initial: spec.initial, open: spec.open, refCount: 0, runtime: void 0, pendingDispose: void 0 }; if (this.#thread != null) newEntry.runtime = spec.open({ thread: this.#thread, store, rootBus: this.#rootBus }); this.#entries.set(spec.key, newEntry); entry = newEntry; } entry.pendingDispose = void 0; entry.refCount += 1; let released = false; return { store: entry.store, release: () => { if (released) return; released = true; const current = this.#entries.get(spec.key); if (current == null) return; current.refCount -= 1; if (current.refCount <= 0) { current.refCount = 0; const token = {}; current.pendingDispose = token; queueMicrotask(() => { const latest = this.#entries.get(spec.key); if (latest == null || latest !== current || latest.pendingDispose !== token || latest.refCount > 0) return; this.#entries.delete(spec.key); latest.pendingDispose = void 0; if (latest.runtime != null) tryDispose(latest.runtime); }); } } }; } /** * Tear everything down. * * Detaches the bound thread (so no further `bind()` calls reopen * runtimes) and disposes every live runtime in parallel. Safe to * call multiple times — subsequent calls find an empty registry * and resolve immediately. */ async dispose() { this.#thread = void 0; const entries = [...this.#entries.values()]; this.#entries.clear(); await Promise.all(entries.map(async (entry) => { if (entry.runtime != null) await tryDispose(entry.runtime); })); } /** * Number of actively-held entries. Diagnostic-only — callers should * not branch on this value at runtime; it exists for tests asserting * that consumers properly release their projections. Entries waiting * on cancellable microtask disposal do not count as active. */ get size() { let count = 0; for (const entry of this.#entries.values()) if (entry.refCount > 0) count += 1; return count; } }; /** * Best-effort runtime disposal. * * `dispose()` should never throw, but a misbehaving projection should * not be able to wedge the entire registry. We swallow disposal * errors so the surrounding `bind()` / `release()` / `dispose()` * paths always make progress. * * @param runtime - Runtime returned by {@link ProjectionSpec.open}. */ async function tryDispose(runtime) { try { await runtime.dispose(); } catch {} } //#endregion exports.ChannelRegistry = ChannelRegistry; //# sourceMappingURL=channel-registry.cjs.map