@langchain/langgraph-sdk
Version:
Client library for interacting with the LangGraph API
237 lines (236 loc) • 8.49 kB
JavaScript
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