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