UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

295 lines (294 loc) • 10.5 kB
const require_reconnect = require("../../../utils/reconnect.cjs"); const require_base = require("../../base.cjs"); const require_error = require("../error.cjs"); const require_queue = require("./queue.cjs"); const require_utils = require("./utils.cjs"); //#region src/client/stream/transport/websocket.ts const WEB_SOCKET_CONNECTING = 0; const WEB_SOCKET_OPEN = 1; const WEB_SOCKET_CLOSED = 3; /** * A server that rejects a connection after accepting it closes with 4000 * plus the HTTP status the SSE endpoint would return (4403, 4404, 4422). * Keeping that `status` on the error lets a 4xx end the stream the same * way it does on the SSE transport. */ function closeError(event) { const { code, reason } = event; if (typeof code !== "number" || code < 4e3 || code >= 5e3) return /* @__PURE__ */ new Error("Protocol WebSocket closed unexpectedly."); return Object.assign(/* @__PURE__ */ new Error(`Protocol WebSocket closed by the server: ${code}${reason ? ` ${reason}` : ""}`), { status: code - 4e3 }); } /** * Transport adapter that speaks the thread-centric protocol over a * bidirectional WebSocket. Bound to a `threadId` at construction or later * via {@link setThreadId} — the socket connects to * `ws://.../threads/:thread_id/stream/events`. * * On unexpected disconnect the adapter reconnects with exponential * backoff (see {@link ProtocolWebSocketTransportOptions.maxReconnectAttempts}), * except when the server closes with a code for a 4xx status other than * 408 or 429 (4403, 4404, 4422), which ends the event stream with an error * carrying that `status`. * The server replays buffered events on the new socket; the SDK * deduplicates by `event_id`. {@link ProtocolWebSocketTransportOptions.onReconnected} * runs after each successful reconnect so `ThreadStream` can re-issue * `subscription.subscribe` commands. */ var ProtocolWebSocketTransportAdapter = class { threadId; queue = new require_queue.AsyncQueue(); apiUrl; defaultHeaders; onRequest; webSocketFactory; paths; maxReconnectAttempts; onReconnect; onConnected; reconnectDelayMs; onReconnected; pending = /* @__PURE__ */ new Map(); socket = null; closed = false; intentionalClose = false; reconnectInFlight = null; constructor(options) { this.apiUrl = options.apiUrl; this.threadId = options.threadId ?? ""; this.defaultHeaders = options.defaultHeaders; this.onRequest = options.onRequest; this.webSocketFactory = options.webSocketFactory ?? ((url) => new WebSocket(url)); this.paths = options.paths; this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5; this.onReconnect = options.onReconnect; this.onConnected = options.onConnected; this.onReconnected = options.onReconnected; this.reconnectDelayMs = options.reconnectDelayMs ?? require_reconnect.reconnectDelayMs; } /** {@inheritDoc TransportAdapter.setThreadId} */ setThreadId(threadId) { if (threadId === this.threadId) return; if (this.reconnectInFlight != null || this.socket != null && this.socket.readyState !== WEB_SOCKET_CLOSED) throw new Error("Protocol WebSocket transport cannot be rebound to a different thread while the socket is open. Close the current stream and create a new WebSocket transport for the new thread."); this.threadId = threadId; } /** * Socket URL derives from the currently-bound thread so a single adapter * can follow {@link setThreadId} re-binds; the next {@link open} connects * to the new thread. A fixed `paths.stream` string overrides the default. */ get streamUrl() { return require_utils.resolveProtocolPath(this.paths?.stream, this.threadId, (id) => `/threads/${require_base.encodePathSegment(id)}/stream/events`); } /** * Register a callback invoked after each successful reconnect. Used * by {@link ThreadStream} to re-send active `subscription.subscribe` * commands. */ setOnReconnected(handler) { this.onReconnected = handler; } async open() { const reconnecting = this.reconnectInFlight != null; if (this.closed) throw new Error("Protocol WebSocket transport is closed."); if (this.socket?.readyState === WEB_SOCKET_OPEN) return; if (this.socket != null) { this.#detachSocket(this.socket); this.socket = null; } this.assertBrowserSafeTransportConfig(); const wsUrl = require_utils.toWebSocketUrl(require_utils.toAbsoluteUrl(this.apiUrl, this.streamUrl).toString()); const socket = this.webSocketFactory(wsUrl); this.socket = socket; this.intentionalClose = false; this.#attachSocket(socket); await new Promise((resolve, reject) => { const onOpen = () => { cleanup(); resolve(); }; const onError = () => { cleanup(); reject(/* @__PURE__ */ new Error("Failed to open protocol WebSocket.")); }; const cleanup = () => { socket.removeEventListener("open", onOpen); socket.removeEventListener("error", onError); }; socket.addEventListener("open", onOpen, { once: true }); socket.addEventListener("error", onError, { once: true }); }); if (!reconnecting) await this.onConnected?.({ kind: "initial", attempt: 0 }); } async send(command) { return await this.sendCommand(command); } events() { const queue = this.queue; return { [Symbol.asyncIterator]: () => ({ next: async () => await queue.shift(), return: async () => { queue.close(); return { done: true, value: void 0 }; } }) }; } async close() { if (this.closed) return; this.closed = true; this.intentionalClose = true; for (const { reject } of this.pending.values()) reject(/* @__PURE__ */ new Error("Protocol WebSocket connection closed.")); this.pending.clear(); this.queue.close(); const socket = this.socket; this.socket = null; if (!socket) return; this.#detachSocket(socket); await new Promise((resolve) => { if (socket.readyState === WEB_SOCKET_CLOSED) { resolve(); return; } const onClose = () => { socket.removeEventListener("close", onClose); resolve(); }; socket.addEventListener("close", onClose, { once: true }); if (socket.readyState === WEB_SOCKET_OPEN || socket.readyState === WEB_SOCKET_CONNECTING) socket.close(); else resolve(); }); } assertBrowserSafeTransportConfig() { if (require_utils.hasHeaders(this.defaultHeaders) || this.onRequest != null) throw new Error("Browser WebSocket protocol transport does not support defaultHeaders or onRequest hooks. Supply a custom protocolWebSocketFactory if you need custom WebSocket setup."); } async sendCommand(command) { if (this.closed) throw new Error("Protocol WebSocket transport is closed."); let socket = this.socket; if (this.reconnectInFlight != null && (socket == null || socket.readyState !== WEB_SOCKET_OPEN)) { await this.reconnectInFlight.catch(() => void 0); socket = this.socket; } if (socket == null || socket.readyState !== WEB_SOCKET_OPEN) throw new Error("Protocol WebSocket is not open."); return await new Promise((resolve, reject) => { this.pending.set(command.id, { resolve, reject }); try { socket.send(JSON.stringify(command)); } catch (error) { this.pending.delete(command.id); reject(require_utils.toError(error)); } }); } #attachSocket(socket) { socket.addEventListener("message", this.handleMessage); socket.addEventListener("close", this.handleClose); socket.addEventListener("error", this.handleSocketError); } #detachSocket(socket) { socket.removeEventListener("message", this.handleMessage); socket.removeEventListener("close", this.handleClose); socket.removeEventListener("error", this.handleSocketError); } handleMessage = (event) => { let payload; try { payload = JSON.parse(String(event.data)); } catch { return; } if (require_utils.isRecord(payload) && typeof payload.id === "number" && (payload.type === "success" || payload.type === "error")) { const pending = this.pending.get(payload.id); if (pending) { this.pending.delete(payload.id); pending.resolve(payload); } return; } if (require_utils.isRecord(payload) && payload.type === "event") this.queue.push(payload); }; handleClose = (event) => { const socket = this.socket; if (socket != null) this.#detachSocket(socket); this.socket = null; if (this.intentionalClose || this.closed) { this.queue.close(); return; } const error = closeError(event); if (require_utils.isNonRetryableHttpError(error)) { this.#closeWithError(error); return; } this.#handleUnexpectedDisconnect(error); }; handleSocketError = () => { if (this.closed || this.intentionalClose) return; this.#handleUnexpectedDisconnect(/* @__PURE__ */ new Error("Protocol WebSocket encountered an error.")); }; /** * End the transport for good: a reconnect would be rejected the same way. * Marking it closed also stops a reconnect loop that is in flight. */ #closeWithError(error) { this.closed = true; for (const { reject } of this.pending.values()) reject(error); this.pending.clear(); this.queue.close(error); } #handleUnexpectedDisconnect(cause) { const error = require_utils.toError(cause); for (const { reject } of this.pending.values()) reject(error); this.pending.clear(); if (this.maxReconnectAttempts <= 0) { this.queue.close(error); return; } this.#scheduleReconnect(cause); } #scheduleReconnect(cause) { if (this.closed || this.intentionalClose) return; if (this.reconnectInFlight != null) return; this.reconnectInFlight = Promise.resolve().then(() => this.#runReconnectLoop(cause)).finally(() => { this.reconnectInFlight = null; }); } async #runReconnectLoop(initialCause) { let lastError = initialCause; for (let attempt = 1; attempt <= this.maxReconnectAttempts; attempt += 1) { if (this.closed || this.intentionalClose) return; const delay = this.reconnectDelayMs(attempt); this.onReconnect?.({ attempt, cause: lastError, delayMs: delay }); if (delay > 0) await new Promise((resolve) => { setTimeout(resolve, delay); }); if (this.closed || this.intentionalClose) return; try { await this.open(); if (this.onReconnected) await this.onReconnected(); await this.onConnected?.({ kind: "reconnected", attempt }); return; } catch (error) { lastError = error; } } this.queue.close(new require_error.MaxWebSocketReconnectAttemptsError(this.maxReconnectAttempts, lastError)); } }; //#endregion exports.ProtocolWebSocketTransportAdapter = ProtocolWebSocketTransportAdapter; //# sourceMappingURL=websocket.cjs.map