@tanstack/ai-client 0.23.2 → 0.24.0

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,50 +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) if (id === "") {
373
- lastEventId = void 0;
374
- seen.clear();
375
- } else {
376
- if (seen.has(id)) continue;
377
- seen.add(id);
378
- lastEventId = id;
379
- }
393
+ if (tracker.note(id) === "duplicate") continue;
380
394
  progressed = true;
381
395
  if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") sawTerminal = true;
382
396
  yield chunk;
383
397
  }
384
398
  } catch (error) {
385
399
  if (abortSignal?.aborted) return;
386
- if ((error instanceof StreamTruncatedError || error instanceof StreamReadError) && lastEventId !== void 0) {
387
- await waitBeforeReconnect(progressed);
400
+ if ((error instanceof StreamTruncatedError || error instanceof StreamReadError) && tracker.lastEventId !== void 0) {
401
+ await tracker.waitBeforeReconnect(progressed, abortSignal);
388
402
  continue;
389
403
  }
390
404
  throw error;
391
405
  }
392
406
  if (abortSignal?.aborted) return;
393
407
  if (sawTerminal) return;
394
- if (lastEventId !== void 0) {
408
+ if (tracker.lastEventId !== void 0) {
395
409
  if (progressed) {
396
- await waitBeforeReconnect(true);
410
+ await tracker.waitBeforeReconnect(true, abortSignal);
397
411
  continue;
398
412
  }
399
413
  throw new DurableStreamIncompleteError();
@@ -900,6 +914,232 @@ function xhrHttpStream(url, options = {}) {
900
914
  }
901
915
  };
902
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
+ }
903
1143
  /**
904
1144
  * Create a direct stream connection adapter (for server functions or direct streams)
905
1145
  *
@@ -1021,6 +1261,6 @@ function rpcStream(rpcCall, handlers) {
1021
1261
  };
1022
1262
  }
1023
1263
  //#endregion
1024
- export { DurableStreamIncompleteError, StreamReconnectLimitError, StreamTruncatedError, fetchHttpStream, fetchServerSentEvents, fetcherToConnectionAdapter, getChunkRunId, normalizeConnectionAdapter, rpcStream, stream, xhrHttpStream, xhrServerSentEvents };
1264
+ export { DurableStreamIncompleteError, StreamReconnectLimitError, StreamTruncatedError, createReconnectTracker, fetchHttpStream, fetchServerSentEvents, fetcherToConnectionAdapter, getChunkRunId, normalizeConnectionAdapter, rpcStream, stream, webSocket, xhrHttpStream, xhrServerSentEvents };
1025
1265
 
1026
1266
  //# sourceMappingURL=connection-adapters.js.map