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