@tanstack/ai-client 0.23.3 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,6 +49,32 @@ export interface ReconnectOptions {
49
49
  /** Delay between reconnect attempts, in ms, to avoid hammering. Default 250. */
50
50
  delayMs?: number;
51
51
  }
52
+ /**
53
+ * Reconnect bookkeeping shared by every resumable-stream driver: de-dupes
54
+ * offsets, tracks the last acknowledged offset, honors the SSE empty-id reset
55
+ * convention, and bounds consecutive no-progress reconnects behind a
56
+ * throttling delay. Extracted out of {@link resumableStream} so a WebSocket
57
+ * reconnect driver can reuse the exact same semantics.
58
+ */
59
+ export interface ReconnectTracker {
60
+ /** The most recently accepted (non-duplicate, non-empty) offset, if any. */
61
+ readonly lastEventId: string | undefined;
62
+ /**
63
+ * Record an incoming offset. Returns `'reset'` for an empty id (SSE's
64
+ * resume-cursor reset — clears the de-dupe set and `lastEventId`),
65
+ * `'duplicate'` for an already-seen id, and `'new'` otherwise (including
66
+ * `undefined`, which is untracked — no offset to remember).
67
+ */
68
+ note: (id: string | undefined) => 'new' | 'duplicate' | 'reset';
69
+ /**
70
+ * Throttle before a reconnect attempt. Resets the no-progress counter when
71
+ * `madeProgress` is true; otherwise increments it and throws
72
+ * {@link StreamReconnectLimitError} once it exceeds the configured ceiling.
73
+ */
74
+ waitBeforeReconnect: (madeProgress: boolean, signal?: AbortSignal) => Promise<void>;
75
+ }
76
+ /** Create a {@link ReconnectTracker} bound to the given reconnect bounds. */
77
+ export declare function createReconnectTracker(options?: ReconnectOptions): ReconnectTracker;
52
78
  /**
53
79
  * Per-send context provided by the chat client to the connection adapter.
54
80
  * The adapter combines this with serialized messages to build a full
@@ -327,6 +353,29 @@ export declare function xhrServerSentEvents(url: string | (() => string), option
327
353
  * (bare-line) response is a single plain request, exactly as before.
328
354
  */
329
355
  export declare function xhrHttpStream(url: string | (() => string), options?: XhrConnectionOptionsResolver): ResumableConnectConnectionAdapter;
356
+ export interface WebSocketConnectionOptions {
357
+ protocols?: string | Array<string>;
358
+ body?: Record<string, unknown>;
359
+ reconnect?: ReconnectOptions;
360
+ /** Override the WebSocket implementation (tests / non-browser runtimes). */
361
+ WebSocketImpl?: typeof WebSocket;
362
+ }
363
+ /**
364
+ * Full-duplex, conversation-scoped WebSocket connection adapter. Pairs with the
365
+ * server `toWebSocketResponse` / `toWebSocketStream`. `send()` writes a
366
+ * RunAgentInput frame; `subscribe()` yields inbound chunks.
367
+ *
368
+ * Resumable: `send()` establishes a run session backed by a
369
+ * {@link createReconnectTracker}. If the socket closes before a terminal
370
+ * (`RUN_FINISHED`/`RUN_ERROR`) chunk is seen and the run is durable
371
+ * (offset-tagged `{ id, chunk }` envelopes), the socket is reopened at
372
+ * `?runId=&offset=<lastEventId>`, de-duping the replayed boundary. A drop with
373
+ * no offset ever observed (non-durable) surfaces {@link StreamReadError}
374
+ * instead of reconnecting — there is nothing to resume from.
375
+ */
376
+ export declare function webSocket(url: string | (() => string), options?: WebSocketConnectionOptions): SubscribeConnectionAdapter & {
377
+ joinRun: (runId: string, abortSignal?: AbortSignal) => AsyncIterable<StreamChunk>;
378
+ };
330
379
  /**
331
380
  * Optional persistence handlers for the lightweight adapters (`stream()`,
332
381
  * `rpcStream()`). These are one-shot, request-scoped calls with no built-in
@@ -68,6 +68,38 @@ function resolveReconnectOptions(options) {
68
68
  delayMs
69
69
  };
70
70
  }
71
+ /** Create a {@link ReconnectTracker} bound to the given reconnect bounds. */
72
+ function createReconnectTracker(options) {
73
+ const reconnect = resolveReconnectOptions(options);
74
+ const seen = /* @__PURE__ */ new Set();
75
+ let lastEventId;
76
+ let reconnectAttempts = 0;
77
+ return {
78
+ get lastEventId() {
79
+ return lastEventId;
80
+ },
81
+ note(id) {
82
+ if (id === void 0) return "new";
83
+ if (id === "") {
84
+ lastEventId = void 0;
85
+ seen.clear();
86
+ return "reset";
87
+ }
88
+ if (seen.has(id)) return "duplicate";
89
+ seen.add(id);
90
+ lastEventId = id;
91
+ return "new";
92
+ },
93
+ async waitBeforeReconnect(madeProgress, signal) {
94
+ if (madeProgress) reconnectAttempts = 0;
95
+ else {
96
+ reconnectAttempts += 1;
97
+ if (reconnectAttempts > reconnect.maxAttempts) throw new StreamReconnectLimitError(reconnect.maxAttempts);
98
+ }
99
+ await abortableDelay(reconnect.delayMs, signal);
100
+ }
101
+ };
102
+ }
71
103
  /** Resolve after `ms`, or immediately once `signal` aborts. Never rejects. */
72
104
  function abortableDelay(ms, signal) {
73
105
  if (ms <= 0 || signal?.aborted) return Promise.resolve();
@@ -350,52 +382,32 @@ function fetchEventSource(fetchClient, url, requestInit, parseResponse) {
350
382
  * only difference being the {@link StreamEventSource} they pass in.
351
383
  */
352
384
  async function* resumableStream(openEventSource, abortSignal, reconnectOptions) {
353
- const seen = /* @__PURE__ */ new Set();
354
- let lastEventId;
355
- const reconnect = resolveReconnectOptions(reconnectOptions);
356
- let reconnectAttempts = 0;
357
- async function waitBeforeReconnect(madeProgress) {
358
- if (madeProgress) reconnectAttempts = 0;
359
- else {
360
- reconnectAttempts += 1;
361
- if (reconnectAttempts > reconnect.maxAttempts) throw new StreamReconnectLimitError(reconnect.maxAttempts);
362
- }
363
- await abortableDelay(reconnect.delayMs, abortSignal);
364
- }
385
+ const tracker = createReconnectTracker(reconnectOptions);
365
386
  for (;;) {
366
387
  if (abortSignal?.aborted) return;
367
- const extraHeaders = lastEventId !== void 0 ? { "Last-Event-ID": lastEventId } : {};
388
+ const extraHeaders = tracker.lastEventId !== void 0 ? { "Last-Event-ID": tracker.lastEventId } : {};
368
389
  let sawTerminal = false;
369
390
  let progressed = false;
370
391
  try {
371
392
  for await (const { chunk, id } of openEventSource(extraHeaders, abortSignal)) {
372
- if (id !== void 0) {
373
- if (id === "") {
374
- lastEventId = void 0;
375
- seen.clear();
376
- } else {
377
- if (seen.has(id)) continue;
378
- seen.add(id);
379
- lastEventId = id;
380
- }
381
- }
393
+ if (tracker.note(id) === "duplicate") continue;
382
394
  progressed = true;
383
395
  if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") sawTerminal = true;
384
396
  yield chunk;
385
397
  }
386
398
  } catch (error) {
387
399
  if (abortSignal?.aborted) return;
388
- if ((error instanceof StreamTruncatedError || error instanceof StreamReadError) && lastEventId !== void 0) {
389
- await waitBeforeReconnect(progressed);
400
+ if ((error instanceof StreamTruncatedError || error instanceof StreamReadError) && tracker.lastEventId !== void 0) {
401
+ await tracker.waitBeforeReconnect(progressed, abortSignal);
390
402
  continue;
391
403
  }
392
404
  throw error;
393
405
  }
394
406
  if (abortSignal?.aborted) return;
395
407
  if (sawTerminal) return;
396
- if (lastEventId !== void 0) {
408
+ if (tracker.lastEventId !== void 0) {
397
409
  if (progressed) {
398
- await waitBeforeReconnect(true);
410
+ await tracker.waitBeforeReconnect(true, abortSignal);
399
411
  continue;
400
412
  }
401
413
  throw new DurableStreamIncompleteError();
@@ -902,6 +914,232 @@ function xhrHttpStream(url, options = {}) {
902
914
  }
903
915
  };
904
916
  }
917
+ function runIdQuery(url, runId) {
918
+ return runId ? withSearchParams(url, { runId }) : url;
919
+ }
920
+ function isPingFrame(parsed) {
921
+ return typeof parsed === "object" && parsed !== null && parsed.type === "ping";
922
+ }
923
+ /**
924
+ * A push→pull bridge from socket callbacks to an async iterable: chunks queue
925
+ * until the consumer pulls, a recorded failure rejects the iterator, and
926
+ * `end()` (or the abort signal) finishes it cleanly. Shared by `webSocket()`'s
927
+ * `subscribe()` and `joinRun()`.
928
+ */
929
+ function createChunkPipe(abortSignal, onFinally) {
930
+ const queue = [];
931
+ const waiters = [];
932
+ let failure;
933
+ let ended = false;
934
+ const wake = () => waiters.shift()?.(null);
935
+ const push = (chunk) => {
936
+ const w = waiters.shift();
937
+ if (w) w(chunk);
938
+ else queue.push(chunk);
939
+ };
940
+ const fail = (error) => {
941
+ failure = error;
942
+ wake();
943
+ };
944
+ const end = () => {
945
+ ended = true;
946
+ wake();
947
+ };
948
+ const onAbort = () => wake();
949
+ abortSignal?.addEventListener("abort", onAbort);
950
+ return {
951
+ push,
952
+ fail,
953
+ end,
954
+ iterable: (async function* () {
955
+ try {
956
+ while (!abortSignal?.aborted) {
957
+ const buffered = queue.shift();
958
+ if (buffered !== void 0) {
959
+ yield buffered;
960
+ continue;
961
+ }
962
+ if (failure !== void 0) throw failure;
963
+ if (ended) return;
964
+ const chunk = await new Promise((r) => waiters.push(r));
965
+ if (failure !== void 0) throw failure;
966
+ if (chunk === null) return;
967
+ yield chunk;
968
+ }
969
+ } finally {
970
+ abortSignal?.removeEventListener("abort", onAbort);
971
+ onFinally();
972
+ }
973
+ })()
974
+ };
975
+ }
976
+ /**
977
+ * Full-duplex, conversation-scoped WebSocket connection adapter. Pairs with the
978
+ * server `toWebSocketResponse` / `toWebSocketStream`. `send()` writes a
979
+ * RunAgentInput frame; `subscribe()` yields inbound chunks.
980
+ *
981
+ * Resumable: `send()` establishes a run session backed by a
982
+ * {@link createReconnectTracker}. If the socket closes before a terminal
983
+ * (`RUN_FINISHED`/`RUN_ERROR`) chunk is seen and the run is durable
984
+ * (offset-tagged `{ id, chunk }` envelopes), the socket is reopened at
985
+ * `?runId=&offset=<lastEventId>`, de-duping the replayed boundary. A drop with
986
+ * no offset ever observed (non-durable) surfaces {@link StreamReadError}
987
+ * instead of reconnecting — there is nothing to resume from.
988
+ */
989
+ function webSocket(url, options = {}) {
990
+ const Impl = options.WebSocketImpl ?? WebSocket;
991
+ let socket;
992
+ let socketMode;
993
+ let openPromise;
994
+ const listeners = /* @__PURE__ */ new Set();
995
+ let currentSession;
996
+ function failAll(error) {
997
+ for (const l of listeners) l.fail(error);
998
+ }
999
+ function openOnce(target, mode) {
1000
+ if (socket && socket.readyState <= 1 && mode === "run" && socketMode === "run") return socket;
1001
+ const prior = socket;
1002
+ const ws = options.protocols ? new Impl(target, options.protocols) : new Impl(target);
1003
+ socket = ws;
1004
+ socketMode = mode;
1005
+ openPromise = new Promise((resolve, reject) => {
1006
+ ws.onopen = () => resolve();
1007
+ ws.onerror = (e) => reject(new StreamReadError(e));
1008
+ });
1009
+ openPromise.catch(() => {});
1010
+ ws.onmessage = (event) => {
1011
+ if (ws !== socket) return;
1012
+ let parsed;
1013
+ try {
1014
+ parsed = JSON.parse(String(event.data));
1015
+ } catch (error) {
1016
+ failAll(new StreamReadError(error));
1017
+ return;
1018
+ }
1019
+ if (isPingFrame(parsed)) return;
1020
+ const envelopeId = isNdjsonEnvelope(parsed) ? parsed.id : void 0;
1021
+ const chunk = isNdjsonEnvelope(parsed) ? parsed.chunk : parsed;
1022
+ const session = currentSession;
1023
+ if (session) {
1024
+ if (session.tracker.note(envelopeId) === "duplicate") return;
1025
+ session.progressed = true;
1026
+ if (session.runId === void 0) session.runId = getChunkRunId(chunk);
1027
+ if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") session.sawTerminal = true;
1028
+ }
1029
+ for (const l of listeners) l.push(chunk);
1030
+ };
1031
+ ws.onclose = () => {
1032
+ if (ws !== socket) return;
1033
+ const session = currentSession;
1034
+ if (!session) {
1035
+ failAll(new StreamReadError(/* @__PURE__ */ new Error("WebSocket connection closed")));
1036
+ return;
1037
+ }
1038
+ if (session.signal?.aborted || session.sawTerminal) return;
1039
+ const lastEventId = session.tracker.lastEventId;
1040
+ if (lastEventId === void 0) {
1041
+ currentSession = void 0;
1042
+ failAll(new StreamReadError(/* @__PURE__ */ new Error("WebSocket connection closed")));
1043
+ return;
1044
+ }
1045
+ reconnect(session, lastEventId);
1046
+ };
1047
+ if (prior && prior.readyState <= 1) prior.close();
1048
+ return ws;
1049
+ }
1050
+ async function reconnect(session, offset) {
1051
+ try {
1052
+ await session.tracker.waitBeforeReconnect(session.progressed, session.signal);
1053
+ } catch (error) {
1054
+ if (currentSession === session) currentSession = void 0;
1055
+ failAll(error);
1056
+ return;
1057
+ }
1058
+ if (session.signal?.aborted) return;
1059
+ if (currentSession !== session) return;
1060
+ if (socket && socket.readyState <= 1) return;
1061
+ session.progressed = false;
1062
+ openOnce(withSearchParams(typeof url === "function" ? url() : url, {
1063
+ ...session.runId !== void 0 ? { runId: session.runId } : {},
1064
+ offset
1065
+ }), "resume");
1066
+ }
1067
+ function waitOpen(ws) {
1068
+ if (ws.readyState === 1) return Promise.resolve();
1069
+ return openPromise ?? Promise.resolve();
1070
+ }
1071
+ return {
1072
+ subscribe(abortSignal) {
1073
+ const pipe = createChunkPipe(abortSignal, () => listeners.delete(sink));
1074
+ const sink = {
1075
+ push: pipe.push,
1076
+ fail: pipe.fail
1077
+ };
1078
+ listeners.add(sink);
1079
+ return pipe.iterable;
1080
+ },
1081
+ async send(messages, data, abortSignal, runContext) {
1082
+ const ws = openOnce(runIdQuery(typeof url === "function" ? url() : url, runContext?.runId), "run");
1083
+ await waitOpen(ws);
1084
+ if (!currentSession || currentSession.runId !== runContext?.runId) currentSession = {
1085
+ runId: runContext?.runId,
1086
+ tracker: createReconnectTracker(options.reconnect),
1087
+ sawTerminal: false,
1088
+ progressed: false,
1089
+ signal: abortSignal
1090
+ };
1091
+ else {
1092
+ currentSession.signal = abortSignal;
1093
+ currentSession.sawTerminal = false;
1094
+ currentSession.progressed = false;
1095
+ }
1096
+ const session = currentSession;
1097
+ abortSignal?.addEventListener("abort", () => {
1098
+ const abortRunId = session.runId;
1099
+ const live = socket;
1100
+ if (abortRunId === void 0 || session.sawTerminal || socketMode !== "run" || live === void 0 || live.readyState !== 1) return;
1101
+ try {
1102
+ live.send(JSON.stringify({
1103
+ type: "abort",
1104
+ runId: abortRunId
1105
+ }));
1106
+ } catch {}
1107
+ }, { once: true });
1108
+ const body = buildRunAgentInputBody(messages, data, runContext, { body: options.body });
1109
+ ws.send(JSON.stringify(body));
1110
+ },
1111
+ joinRun(runId, abortSignal) {
1112
+ const target = withSearchParams(typeof url === "function" ? url() : url, {
1113
+ offset: "-1",
1114
+ runId
1115
+ });
1116
+ const ws = options.protocols ? new Impl(target, options.protocols) : new Impl(target);
1117
+ const pipe = createChunkPipe(abortSignal, () => {
1118
+ if (ws.readyState <= 1) ws.close();
1119
+ });
1120
+ ws.onmessage = (event) => {
1121
+ let parsed;
1122
+ try {
1123
+ parsed = JSON.parse(String(event.data));
1124
+ } catch (error) {
1125
+ pipe.fail(new StreamReadError(error));
1126
+ return;
1127
+ }
1128
+ if (isPingFrame(parsed)) return;
1129
+ pipe.push(isNdjsonEnvelope(parsed) ? parsed.chunk : parsed);
1130
+ };
1131
+ ws.onclose = (event) => {
1132
+ if (event?.code === 1e3) {
1133
+ pipe.end();
1134
+ return;
1135
+ }
1136
+ const detail = event ? `${event.code}${event.reason ? `: ${event.reason}` : ""}` : "unknown";
1137
+ pipe.fail(new StreamReadError(/* @__PURE__ */ new Error(`WebSocket connection closed (${detail})`)));
1138
+ };
1139
+ return pipe.iterable;
1140
+ }
1141
+ };
1142
+ }
905
1143
  /**
906
1144
  * Create a direct stream connection adapter (for server functions or direct streams)
907
1145
  *
@@ -954,7 +1192,9 @@ function fetcherToConnectionAdapter(fetcher) {
954
1192
  messages,
955
1193
  data,
956
1194
  threadId: runContext.threadId,
957
- runId: runContext.runId
1195
+ runId: runContext.runId,
1196
+ ...runContext.parentRunId !== void 0 ? { parentRunId: runContext.parentRunId } : {},
1197
+ ...runContext.resume !== void 0 ? { resume: runContext.resume } : {}
958
1198
  }, { signal: abortSignal });
959
1199
  if (result instanceof Response) yield* responseToSSEChunks(result, abortSignal);
960
1200
  else yield* abortableIterable(result, abortSignal);
@@ -1023,6 +1263,6 @@ function rpcStream(rpcCall, handlers) {
1023
1263
  };
1024
1264
  }
1025
1265
  //#endregion
1026
- export { DurableStreamIncompleteError, StreamReconnectLimitError, StreamTruncatedError, fetchHttpStream, fetchServerSentEvents, fetcherToConnectionAdapter, getChunkRunId, normalizeConnectionAdapter, rpcStream, stream, xhrHttpStream, xhrServerSentEvents };
1266
+ export { DurableStreamIncompleteError, StreamReconnectLimitError, StreamTruncatedError, createReconnectTracker, fetchHttpStream, fetchServerSentEvents, fetcherToConnectionAdapter, getChunkRunId, normalizeConnectionAdapter, rpcStream, stream, webSocket, xhrHttpStream, xhrServerSentEvents };
1027
1267
 
1028
1268
  //# sourceMappingURL=connection-adapters.js.map