UNPKG

@langchain/langgraph-sdk

Version:

Client library for interacting with the LangGraph API

282 lines (281 loc) • 9.63 kB
import { BytesLineDecoder, SSEDecoder } from "../../../utils/sse.js"; import { reconnectDelayMs } from "../../../utils/reconnect.js"; import { IterableReadableStream, idleReconnectStream } from "../../../utils/stream.js"; import { encodePathSegment } from "../../base.js"; import { AsyncQueue } from "./queue.js"; import { isNonRetryableHttpError, isProtocolResponse, isRecord, mergeHeaders, resolveProtocolPath, toAbsoluteUrl, toError } from "./utils.js"; //#region src/client/stream/transport/http.ts /** * Transport adapter that speaks the thread-centric protocol over HTTP * commands plus SSE event streams. Bound to a `threadId` at construction * or later via {@link setThreadId}; request URLs derive from the * currently-bound thread. Each {@link openEventStream} call opens an * independent filtered SSE connection via * `POST /threads/:thread_id/stream/events`. */ var ProtocolSseTransportAdapter = class { threadId; apiUrl; queue = new AsyncQueue(); fetchImpl; defaultHeaders; onRequest; fetchFactory; asyncCaller; maxReconnectAttempts; idleReconnect; onReconnect; onConnected; reconnectDelayMs; paths; sessionAbortController = new AbortController(); eventStreams = /* @__PURE__ */ new Set(); closed = false; constructor(options) { this.fetchImpl = options.fetch ?? fetch; this.apiUrl = options.apiUrl; this.defaultHeaders = options.defaultHeaders ?? {}; this.onRequest = options.onRequest; this.fetchFactory = options.fetchFactory; this.asyncCaller = options.asyncCaller; this.maxReconnectAttempts = options.maxReconnectAttempts ?? 5; this.idleReconnect = options.idleReconnect ?? "auto"; this.onReconnect = options.onReconnect; this.onConnected = options.onConnected; this.reconnectDelayMs = options.reconnectDelayMs ?? reconnectDelayMs; this.threadId = options.threadId ?? ""; this.paths = options.paths; } /** {@inheritDoc TransportAdapter.setThreadId} */ setThreadId(threadId) { this.threadId = threadId; } /** * Command/stream/state URLs derive from the currently-bound thread so a * single adapter can follow {@link setThreadId} re-binds. A fixed * `paths.*` string overrides the default and is used as-is. */ get commandsUrl() { return resolveProtocolPath(this.paths?.commands, this.threadId, (id) => `/threads/${encodePathSegment(id)}/commands`); } get streamUrl() { return resolveProtocolPath(this.paths?.stream, this.threadId, (id) => `/threads/${encodePathSegment(id)}/stream/events`); } get stateUrl() { return resolveProtocolPath(this.paths?.state, this.threadId, (id) => `/threads/${encodePathSegment(id)}/state`); } /** * Fetch checkpointed thread state for hydration. * * Uses `GET`, matching `client.threads.getState()` and both LangGraph * Platform and Agent Protocol custom backends (`POST` is reserved for * `updateState`). */ async getState() { const url = toAbsoluteUrl(this.apiUrl, this.stateUrl); let requestInit = { method: "GET", headers: mergeHeaders(this.defaultHeaders, {}) }; if (this.onRequest) requestInit = await this.onRequest(url, requestInit); const response = await (await this.resolveFetch())(url.toString(), requestInit); if (response.status === 404) return null; if (!response.ok) { const error = toError(/* @__PURE__ */ new Error(`Thread state request failed: ${response.status} ${response.statusText}`)); error.status = response.status; throw error; } return await response.json(); } async resolveFetch() { if (this.fetchFactory) return await this.fetchFactory(); return this.fetchImpl; } /** * HTTP/SSE transports have no handshake — connections are made * per-command and per-subscription. */ async open() {} async send(command) { const response = await this.request(this.commandsUrl, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(command), signal: this.sessionAbortController.signal }); if (response.status === 202 || response.status === 204) return; const payload = await response.json(); if (!isProtocolResponse(payload)) throw new Error("Protocol command did not return a valid response."); return payload; } /** * WebSocket-style single event stream. * For the SSE transport this returns a dummy iterable; real event * delivery happens via {@link openEventStream}. */ events() { const queue = this.queue; return { [Symbol.asyncIterator]: () => ({ next: async () => await queue.shift(), return: async () => { queue.close(); return { done: true, value: void 0 }; } }) }; } openEventStream(params) { if (this.closed) throw new Error("Protocol transport is closed."); const ac = new AbortController(); this.eventStreams.add(ac); const streamQueue = new AsyncQueue(); const streamUrl = this.streamUrl; let resolveReady; let rejectReady; const ready = new Promise((resolve, reject) => { resolveReady = resolve; rejectReady = reject; }); const initialSince = typeof params.since === "number" ? params.since : void 0; let readySettled = false; const startStream = async () => { let attempt = 0; while (!ac.signal.aborted && !this.closed) try { const response = await this.request(streamUrl, { method: "POST", headers: { "content-type": "application/json", accept: "text/event-stream" }, body: JSON.stringify({ channels: params.channels, ...params.namespaces ? { namespaces: params.namespaces } : {}, ...params.depth != null ? { depth: params.depth } : {}, ...!readySettled && initialSince != null ? { since: initialSince } : {} }), signal: ac.signal }, { stream: true }); const contentType = response.headers.get("content-type")?.split(";")[0]; if (contentType && !contentType.includes("text/event-stream")) throw new Error(`Expected response header Content-Type to contain 'text/event-stream', got '${contentType}'`); await this.onConnected?.({ kind: attempt === 0 ? "initial" : "reconnected", attempt }); attempt = 0; if (!readySettled) { readySettled = true; resolveReady(); } const readable = response.body ?? new ReadableStream({ start(controller) { controller.close(); } }); const enableIdle = this.idleReconnect === "auto" || typeof this.idleReconnect === "number" && this.idleReconnect > 0; const lines = readable.pipeThrough(BytesLineDecoder()); const stream = (enableIdle ? lines.pipeThrough(idleReconnectStream({ mode: this.idleReconnect })) : lines).pipeThrough(SSEDecoder()); const iterable = IterableReadableStream.fromReadableStream(stream); for await (const event of iterable) { if (ac.signal.aborted || this.closed) break; if (isRecord(event.data)) streamQueue.push(event.data); } if (ac.signal.aborted || this.closed || this.maxReconnectAttempts <= 0) { streamQueue.close(); return; } throw new Error("Event stream closed by the server"); } catch (error) { if (ac.signal.aborted || this.closed) { if (!readySettled) rejectReady(error); streamQueue.close(); return; } if (this.maxReconnectAttempts <= 0 || isNonRetryableHttpError(error)) { if (!readySettled) rejectReady(error); streamQueue.close(toError(error)); return; } attempt += 1; if (attempt > this.maxReconnectAttempts) { if (!readySettled) rejectReady(error); streamQueue.close(toError(error)); return; } const delay = this.reconnectDelayMs(attempt); this.onReconnect?.({ attempt, cause: error, delayMs: delay }); if (delay > 0) await new Promise((resolve) => { setTimeout(resolve, delay); }); } }; startStream(); const cleanup = () => { this.eventStreams.delete(ac); ac.abort(); streamQueue.close(); }; return { events: { [Symbol.asyncIterator]: () => ({ next: async () => await streamQueue.shift(), return: async () => { cleanup(); return { done: true, value: void 0 }; } }) }, ready, close: cleanup }; } async close() { if (this.closed) return; this.closed = true; this.sessionAbortController.abort(); for (const ac of this.eventStreams) ac.abort(); this.eventStreams.clear(); this.queue.close(); } async request(path, init, options) { const url = toAbsoluteUrl(this.apiUrl, path); let requestInit = { ...init, headers: mergeHeaders(this.defaultHeaders, init.headers) }; if (this.onRequest) requestInit = await this.onRequest(url, requestInit); const useAsyncCaller = this.asyncCaller != null && !options?.stream; const execute = async () => { const response = await (await this.resolveFetch())(url.toString(), requestInit); if (!response.ok) { if (useAsyncCaller) throw response; let body = ""; let detail = ""; try { body = await response.text(); const parsed = JSON.parse(body); if (typeof parsed === "object" && parsed != null) detail = parsed.message ?? parsed.error ?? ""; if (!detail) detail = body; } catch {} const message = detail ? `Protocol request failed: ${response.status} ${response.statusText} — ${detail}` : `Protocol request failed: ${response.status} ${response.statusText}`; throw Object.assign(new Error(message), { status: response.status, text: body }); } return response; }; try { return useAsyncCaller ? await this.asyncCaller.call(execute) : await execute(); } catch (error) { throw toError(error); } } }; //#endregion export { ProtocolSseTransportAdapter }; //# sourceMappingURL=http.js.map