@tanstack/ai-client 0.22.1 → 0.23.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.
Files changed (72) hide show
  1. package/README.md +15 -1
  2. package/dist/esm/audio-recorder.js +190 -213
  3. package/dist/esm/audio-recorder.js.map +1 -1
  4. package/dist/esm/chat-client.d.ts +172 -3
  5. package/dist/esm/chat-client.js +1656 -1386
  6. package/dist/esm/chat-client.js.map +1 -1
  7. package/dist/esm/cleared-stream-tracker.d.ts +23 -0
  8. package/dist/esm/cleared-stream-tracker.js +97 -0
  9. package/dist/esm/cleared-stream-tracker.js.map +1 -0
  10. package/dist/esm/client-persistor.d.ts +25 -12
  11. package/dist/esm/client-persistor.js +260 -235
  12. package/dist/esm/client-persistor.js.map +1 -1
  13. package/dist/esm/connection-adapters.d.ts +231 -10
  14. package/dist/esm/connection-adapters.js +989 -574
  15. package/dist/esm/connection-adapters.js.map +1 -1
  16. package/dist/esm/devtools-noop.d.ts +1 -0
  17. package/dist/esm/devtools-noop.js +79 -139
  18. package/dist/esm/devtools-noop.js.map +1 -1
  19. package/dist/esm/devtools.d.ts +31 -1
  20. package/dist/esm/devtools.js +977 -1127
  21. package/dist/esm/devtools.js.map +1 -1
  22. package/dist/esm/events.js +224 -226
  23. package/dist/esm/events.js.map +1 -1
  24. package/dist/esm/generation-client.d.ts +145 -2
  25. package/dist/esm/generation-client.js +659 -321
  26. package/dist/esm/generation-client.js.map +1 -1
  27. package/dist/esm/generation-reconstruct.d.ts +21 -0
  28. package/dist/esm/generation-reconstruct.js +85 -0
  29. package/dist/esm/generation-reconstruct.js.map +1 -0
  30. package/dist/esm/generation-types.d.ts +289 -3
  31. package/dist/esm/generation-types.js +356 -13
  32. package/dist/esm/generation-types.js.map +1 -1
  33. package/dist/esm/index.d.ts +9 -4
  34. package/dist/esm/index.js +7 -39
  35. package/dist/esm/interrupt-manager.d.ts +77 -0
  36. package/dist/esm/interrupt-manager.js +787 -0
  37. package/dist/esm/interrupt-manager.js.map +1 -0
  38. package/dist/esm/mcp-app-bridge.js +56 -64
  39. package/dist/esm/mcp-app-bridge.js.map +1 -1
  40. package/dist/esm/realtime-client.js +366 -440
  41. package/dist/esm/realtime-client.js.map +1 -1
  42. package/dist/esm/response-stream.js +19 -26
  43. package/dist/esm/response-stream.js.map +1 -1
  44. package/dist/esm/sse-parser.js +44 -47
  45. package/dist/esm/sse-parser.js.map +1 -1
  46. package/dist/esm/sse-utils.js +8 -9
  47. package/dist/esm/sse-utils.js.map +1 -1
  48. package/dist/esm/storage-adapters.d.ts +62 -0
  49. package/dist/esm/storage-adapters.js +174 -0
  50. package/dist/esm/storage-adapters.js.map +1 -0
  51. package/dist/esm/types.d.ts +212 -10
  52. package/dist/esm/types.js +38 -7
  53. package/dist/esm/types.js.map +1 -1
  54. package/dist/esm/video-generation-client.d.ts +113 -2
  55. package/dist/esm/video-generation-client.js +665 -379
  56. package/dist/esm/video-generation-client.js.map +1 -1
  57. package/package.json +7 -7
  58. package/src/chat-client.ts +1079 -61
  59. package/src/cleared-stream-tracker.ts +151 -0
  60. package/src/client-persistor.ts +102 -33
  61. package/src/connection-adapters.ts +1185 -142
  62. package/src/devtools-noop.ts +4 -3
  63. package/src/devtools.ts +121 -3
  64. package/src/generation-client.ts +563 -13
  65. package/src/generation-reconstruct.ts +121 -0
  66. package/src/generation-types.ts +727 -3
  67. package/src/index.ts +56 -1
  68. package/src/interrupt-manager.ts +1440 -0
  69. package/src/storage-adapters.ts +242 -0
  70. package/src/types.ts +301 -9
  71. package/src/video-generation-client.ts +479 -13
  72. package/dist/esm/index.js.map +0 -1
@@ -1,611 +1,1026 @@
1
- import { EventType, uiMessagesToWire } from "@tanstack/ai/client";
2
- import { getResponseStreamReader, createResponseStreamTextDecoder } from "./response-stream.js";
1
+ import { createResponseStreamTextDecoder, getResponseStreamReader } from "./response-stream.js";
3
2
  import { parseSseDataLine } from "./sse-utils.js";
4
- const chunkRunIds = /* @__PURE__ */ new WeakMap();
3
+ import { EventType, uiMessagesToWire } from "@tanstack/ai/client";
4
+ //#region src/connection-adapters.ts
5
+ /**
6
+ * Associates connect-wrapped chunks with the run they were produced under.
7
+ * Content events (TEXT_MESSAGE_CONTENT, TOOL_CALL_*, …) carry no `runId` of
8
+ * their own, so the connect wrapper stamps the caller's run id here. Lets
9
+ * run-scoped consumers (e.g. clear-during-stream suppression) attribute those
10
+ * otherwise-runless chunks to their originating request.
11
+ */
12
+ var chunkRunIds = /* @__PURE__ */ new WeakMap();
13
+ /**
14
+ * Resolve a chunk's run id, preferring the value on the chunk itself
15
+ * (RUN_STARTED / RUN_FINISHED / RUN_ERROR carry one) and falling back to the
16
+ * run the connect wrapper stamped it with.
17
+ */
5
18
  function getChunkRunId(chunk) {
6
- return "runId" in chunk && typeof chunk.runId === "string" ? chunk.runId : chunkRunIds.get(chunk);
19
+ return chunkRunIds.get(chunk) ?? ("runId" in chunk && typeof chunk.runId === "string" ? chunk.runId : void 0);
20
+ }
21
+ /**
22
+ * Thrown when an SSE/HTTP stream ends with a non-empty unterminated buffer.
23
+ * Indicates the connection was cut mid-line (server crash, dropped TCP, proxy
24
+ * timeout) so the partial content cannot be safely parsed.
25
+ */
26
+ var StreamTruncatedError = class extends Error {
27
+ constructor() {
28
+ super("Stream ended with unterminated trailing data — connection was likely cut short.");
29
+ this.name = "StreamTruncatedError";
30
+ }
31
+ };
32
+ var StreamReadError = class extends Error {
33
+ constructor(cause) {
34
+ super("Stream response body read failed", { cause });
35
+ this.name = "StreamReadError";
36
+ }
37
+ };
38
+ /**
39
+ * Thrown when a durable (id-tagged) run's stream ends with no terminal event
40
+ * and a reconnect makes no forward progress — the run cannot complete, so the
41
+ * consumer must not be left silently hanging on a stream that just stops.
42
+ */
43
+ var DurableStreamIncompleteError = class extends Error {
44
+ constructor() {
45
+ super("Durable run ended without a terminal event and could not resume — the run did not complete.");
46
+ this.name = "DurableStreamIncompleteError";
47
+ }
48
+ };
49
+ /**
50
+ * Thrown when a durable run exceeds its reconnect ceiling. Bounds the
51
+ * otherwise-unbounded reconnect loop so a flapping producer (or a proxy that
52
+ * rolls the socket after every event) surfaces a failure instead of
53
+ * reconnecting without end.
54
+ */
55
+ var StreamReconnectLimitError = class extends Error {
56
+ constructor(attempts) {
57
+ super(`Durable run exceeded its reconnect ceiling of ${attempts} attempts — giving up.`);
58
+ this.name = "StreamReconnectLimitError";
59
+ }
60
+ };
61
+ function resolveReconnectOptions(options) {
62
+ const maxAttempts = options?.maxAttempts ?? 5;
63
+ const delayMs = options?.delayMs ?? 250;
64
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 0) throw new Error(`Invalid reconnect.maxAttempts: ${maxAttempts}. Must be a non-negative integer.`);
65
+ if (!Number.isFinite(delayMs) || delayMs < 0) throw new Error(`Invalid reconnect.delayMs: ${delayMs}. Must be a non-negative finite number.`);
66
+ return {
67
+ maxAttempts,
68
+ delayMs
69
+ };
7
70
  }
8
- class StreamTruncatedError extends Error {
9
- constructor() {
10
- super(
11
- "Stream ended with unterminated trailing data — connection was likely cut short."
12
- );
13
- this.name = "StreamTruncatedError";
14
- }
71
+ /** Resolve after `ms`, or immediately once `signal` aborts. Never rejects. */
72
+ function abortableDelay(ms, signal) {
73
+ if (ms <= 0 || signal?.aborted) return Promise.resolve();
74
+ return new Promise((resolve) => {
75
+ const onAbort = () => {
76
+ clearTimeout(timer);
77
+ resolve();
78
+ };
79
+ const timer = setTimeout(() => {
80
+ signal?.removeEventListener("abort", onAbort);
81
+ resolve();
82
+ }, ms);
83
+ signal?.addEventListener("abort", onAbort, { once: true });
84
+ });
15
85
  }
16
86
  function generateRunId(prefix) {
17
- return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
87
+ return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
18
88
  }
89
+ /**
90
+ * Asserts an id is present when synthesizing a terminal event. The chat
91
+ * client always supplies `runContext.threadId` / `runContext.runId`, so an
92
+ * absent id at this layer indicates the adapter was wired up by a caller
93
+ * that bypassed that contract — surface it rather than fabricating one.
94
+ */
19
95
  function requireSyntheticId(value, field) {
20
- if (!value) {
21
- throw new Error(
22
- `Cannot synthesize terminal event: ${field} not supplied via runContext and not observed in the upstream stream.`
23
- );
24
- }
25
- return value;
96
+ if (!value) throw new Error(`Cannot synthesize terminal event: ${field} not supplied via runContext and not observed in the upstream stream.`);
97
+ return value;
26
98
  }
99
+ /**
100
+ * Merge custom headers into request headers
101
+ */
27
102
  function mergeHeaders(customHeaders) {
28
- if (!customHeaders) {
29
- return {};
30
- }
31
- if (customHeaders instanceof Headers) {
32
- const result = {};
33
- customHeaders.forEach((value, key) => {
34
- result[key] = value;
35
- });
36
- return result;
37
- }
38
- return customHeaders;
103
+ if (!customHeaders) return {};
104
+ if (customHeaders instanceof Headers) {
105
+ const result = {};
106
+ customHeaders.forEach((value, key) => {
107
+ result[key] = value;
108
+ });
109
+ return result;
110
+ }
111
+ return customHeaders;
39
112
  }
113
+ /**
114
+ * Request header carrying the client-chosen run id to a delivery-durability
115
+ * sink. The durable log is then keyed by the SAME id the client already holds,
116
+ * so a later join/resume can address the run without first reading back a
117
+ * server-generated id. Sent as a header — NOT a query param — so the POST URL
118
+ * stays byte-identical to a plain, non-durable request; a server that isn't
119
+ * durable simply ignores the header. (The GET join path keeps `?runId` in the
120
+ * query, since a GET has no body/handler contract to disturb.)
121
+ */
122
+ var RUN_ID_HEADER = "X-Run-Id";
123
+ function runIdHeader(runId) {
124
+ return runId === void 0 ? {} : { [RUN_ID_HEADER]: runId };
125
+ }
126
+ function withSearchParams(url, values) {
127
+ const hashIndex = url.indexOf("#");
128
+ const hash = hashIndex === -1 ? "" : url.slice(hashIndex);
129
+ const withoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex);
130
+ const queryIndex = withoutHash.indexOf("?");
131
+ const base = queryIndex === -1 ? withoutHash : withoutHash.slice(0, queryIndex);
132
+ const search = new URLSearchParams(queryIndex === -1 ? "" : withoutHash.slice(queryIndex + 1));
133
+ for (const [key, value] of Object.entries(values)) search.set(key, value);
134
+ const query = search.toString();
135
+ return `${base}${query.length === 0 ? "" : `?${query}`}${hash}`;
136
+ }
137
+ /**
138
+ * Read lines from a stream (newline-delimited)
139
+ */
40
140
  async function* readStreamLines(reader, abortSignal) {
41
- try {
42
- const decoder = createResponseStreamTextDecoder();
43
- let buffer = "";
44
- while (!abortSignal?.aborted) {
45
- const { done, value } = await reader.read();
46
- if (done) break;
47
- buffer += decoder.decode(value, { stream: true });
48
- const lines = buffer.split("\n");
49
- buffer = lines.pop() || "";
50
- for (const line of lines) {
51
- if (line.trim()) {
52
- yield line;
53
- }
54
- }
55
- }
56
- if (buffer.trim() && !abortSignal?.aborted) {
57
- throw new StreamTruncatedError();
58
- }
59
- } finally {
60
- reader.releaseLock();
61
- }
141
+ try {
142
+ const decoder = createResponseStreamTextDecoder();
143
+ let buffer = "";
144
+ while (!abortSignal?.aborted) {
145
+ let result;
146
+ try {
147
+ result = await reader.read();
148
+ } catch (error) {
149
+ if (abortSignal?.aborted) return;
150
+ throw new StreamReadError(error);
151
+ }
152
+ const { done, value } = result;
153
+ if (done) break;
154
+ buffer += decoder.decode(value, { stream: true });
155
+ const lines = buffer.split("\n");
156
+ buffer = lines.pop() || "";
157
+ for (const line of lines) {
158
+ const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
159
+ if (normalized.trim()) yield normalized;
160
+ }
161
+ }
162
+ buffer += decoder.decode();
163
+ if (buffer.trim() && !abortSignal?.aborted) throw new StreamTruncatedError();
164
+ } finally {
165
+ reader.releaseLock();
166
+ }
167
+ }
168
+ /**
169
+ * Type guard for a durable NDJSON envelope `{ id, chunk }`. NDJSON has no
170
+ * native event-id field, so durability rides the offset inside the payload.
171
+ * A bare `StreamChunk` always has a top-level `type`, and the envelope never
172
+ * does, so the two forms are unambiguous — a non-durable line stays bare.
173
+ */
174
+ function isNdjsonEnvelope(value) {
175
+ return typeof value === "object" && value !== null && "chunk" in value && "id" in value && typeof value.id === "string" && !("type" in value);
176
+ }
177
+ /**
178
+ * Parse SSE-format lines into stream events, pairing each chunk with the `id:`
179
+ * offset of the event it arrived on. Shared by the fetch- and XHR-backed SSE
180
+ * adapters so both track delivery offsets identically.
181
+ *
182
+ * Accepts either `data: {...}` lines or bare JSON lines. Skips comments
183
+ * starting with `:` (proxies and CDNs inject these as keepalives) and the
184
+ * `event:` / `retry:` SSE control fields. A `[DONE]` sentinel is treated as a
185
+ * terminal event: a synthesized RUN_FINISHED is yielded using the most recent
186
+ * upstream `threadId` / `runId` (falling back to `fallbackIds`), so the
187
+ * consumer sees a clean terminal event with real correlation ids.
188
+ *
189
+ * A JSON parse failure throws — the consumer surfaces it as an error.
190
+ */
191
+ async function* linesToSSEEvents(lines, fallbackIds) {
192
+ let lastThreadId;
193
+ let lastRunId;
194
+ let lastModel;
195
+ let pendingId;
196
+ for await (const line of lines) {
197
+ if (line === "id" || line.startsWith("id:")) {
198
+ const rawId = line === "id" ? "" : line.slice(3);
199
+ pendingId = rawId.startsWith(" ") ? rawId.slice(1) : rawId;
200
+ continue;
201
+ }
202
+ if (line.startsWith(":") || line.startsWith("event:") || line.startsWith("retry:")) continue;
203
+ const data = parseSseDataLine(line);
204
+ if (data === "[DONE]") {
205
+ yield { chunk: {
206
+ type: EventType.RUN_FINISHED,
207
+ threadId: lastThreadId ?? fallbackIds?.threadId ?? "",
208
+ runId: lastRunId ?? fallbackIds?.runId ?? "",
209
+ model: lastModel ?? "",
210
+ timestamp: Date.now(),
211
+ finishReason: "stop"
212
+ } };
213
+ return;
214
+ }
215
+ const chunk = JSON.parse(data);
216
+ if ("threadId" in chunk && typeof chunk.threadId === "string") lastThreadId = chunk.threadId;
217
+ if ("runId" in chunk && typeof chunk.runId === "string") lastRunId = chunk.runId;
218
+ if ("model" in chunk && typeof chunk.model === "string") lastModel = chunk.model;
219
+ const id = pendingId;
220
+ pendingId = void 0;
221
+ yield {
222
+ chunk,
223
+ ...id !== void 0 ? { id } : {}
224
+ };
225
+ }
226
+ }
227
+ /**
228
+ * Parse NDJSON-format lines into stream events. Durable streams emit each line
229
+ * as an `{ id, chunk }` envelope carrying the delivery offset; non-durable
230
+ * streams emit bare chunks. Both are auto-detected (see {@link isNdjsonEnvelope}),
231
+ * so an untagged stream behaves exactly as a plain single fetch used to.
232
+ */
233
+ async function* linesToNdjsonEvents(lines) {
234
+ for await (const line of lines) {
235
+ const parsed = JSON.parse(line);
236
+ if (isNdjsonEnvelope(parsed)) yield {
237
+ chunk: parsed.chunk,
238
+ id: parsed.id
239
+ };
240
+ else yield { chunk: parsed };
241
+ }
242
+ }
243
+ function assertResponseOk(response) {
244
+ if (!response.ok) throw new Error(`HTTP error! status: ${response.status} ${response.statusText}`);
245
+ }
246
+ /**
247
+ * GET the hydration endpoint for a thread and parse its JSON `{ messages,
248
+ * activeRun }` body. This is the transport-agnostic reconnect probe: keyed on
249
+ * the STABLE thread id, it returns the stored transcript and — if a run is still
250
+ * generating — a cursor the caller tails via `joinRun`. Shared by every fetch/
251
+ * XHR adapter so the client never has to know which transport is in use.
252
+ */
253
+ async function fetchThreadHydration(fetchClient, url, headers, credentials, threadId) {
254
+ const response = await fetchClient(withSearchParams(url, { threadId }), {
255
+ method: "GET",
256
+ headers: {
257
+ Accept: "application/json",
258
+ ...headers
259
+ },
260
+ credentials
261
+ });
262
+ assertResponseOk(response);
263
+ const data = await response.json();
264
+ const activeRun = data.activeRun && typeof data.activeRun.runId === "string" ? { runId: data.activeRun.runId } : null;
265
+ const interrupts = data.interrupts && typeof data.interrupts.runId === "string" && Array.isArray(data.interrupts.pending) && data.interrupts.pending.length > 0 ? {
266
+ runId: data.interrupts.runId,
267
+ pending: data.interrupts.pending
268
+ } : null;
269
+ return {
270
+ messages: Array.isArray(data.messages) ? data.messages : [],
271
+ activeRun,
272
+ interrupts
273
+ };
274
+ }
275
+ /**
276
+ * GET the hydration endpoint for a generation thread and parse its JSON
277
+ * `{ resumeSnapshot, activeRun }` body. Mirrors {@link fetchThreadHydration} for
278
+ * the generation clients: keyed on the stable thread id, it returns the last
279
+ * generation's resume snapshot (re-validated client-side before adoption) and —
280
+ * if a run is still generating — a cursor. Shared by every fetch/XHR adapter.
281
+ */
282
+ async function fetchGenerationHydration(fetchClient, url, headers, credentials, threadId) {
283
+ const response = await fetchClient(withSearchParams(url, { threadId }), {
284
+ method: "GET",
285
+ headers: {
286
+ Accept: "application/json",
287
+ ...headers
288
+ },
289
+ credentials
290
+ });
291
+ assertResponseOk(response);
292
+ const raw = await response.json();
293
+ if (raw === null) return {
294
+ resumeSnapshot: null,
295
+ activeRun: null
296
+ };
297
+ if (typeof raw !== "object" || Array.isArray(raw)) throw new Error(`Generation hydration expected a JSON object from ${url}, received ${Array.isArray(raw) ? "an array" : typeof raw}.`);
298
+ const data = raw;
299
+ const activeRun = data.activeRun && typeof data.activeRun.runId === "string" ? { runId: data.activeRun.runId } : null;
300
+ return {
301
+ resumeSnapshot: data.resumeSnapshot ?? null,
302
+ activeRun
303
+ };
304
+ }
305
+ /** Yield SSE stream events (chunk + offset) from a fetch Response body. */
306
+ async function* responseToSSEEvents(response, abortSignal, fallbackIds) {
307
+ assertResponseOk(response);
308
+ yield* linesToSSEEvents(readStreamLines(getResponseStreamReader(response), abortSignal), fallbackIds);
309
+ }
310
+ /** Yield NDJSON stream events (chunk + offset) from a fetch Response body. */
311
+ async function* responseToNdjsonEvents(response, abortSignal) {
312
+ assertResponseOk(response);
313
+ yield* linesToNdjsonEvents(readStreamLines(getResponseStreamReader(response), abortSignal));
62
314
  }
63
315
  async function* responseToSSEChunks(response, abortSignal) {
64
- if (!response.ok) {
65
- throw new Error(
66
- `HTTP error! status: ${response.status} ${response.statusText}`
67
- );
68
- }
69
- const reader = getResponseStreamReader(response);
70
- let lastThreadId;
71
- let lastRunId;
72
- let lastModel;
73
- for await (const line of readStreamLines(reader, abortSignal)) {
74
- if (line.startsWith(":") || line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
75
- continue;
76
- }
77
- const data = parseSseDataLine(line);
78
- if (data === "[DONE]") {
79
- const synthetic = {
80
- type: EventType.RUN_FINISHED,
81
- threadId: lastThreadId ?? "",
82
- runId: lastRunId ?? "",
83
- model: lastModel ?? "",
84
- timestamp: Date.now(),
85
- finishReason: "stop"
86
- };
87
- yield synthetic;
88
- return;
89
- }
90
- const chunk = JSON.parse(data);
91
- if ("threadId" in chunk && typeof chunk.threadId === "string") {
92
- lastThreadId = chunk.threadId;
93
- }
94
- if ("runId" in chunk && typeof chunk.runId === "string") {
95
- lastRunId = chunk.runId;
96
- }
97
- if ("model" in chunk && typeof chunk.model === "string") {
98
- lastModel = chunk.model;
99
- }
100
- yield chunk;
101
- }
316
+ for await (const { chunk } of responseToSSEEvents(response, abortSignal)) yield chunk;
102
317
  }
318
+ /**
319
+ * Build a fetch-backed {@link StreamEventSource}. `parseResponse` decodes the
320
+ * body into events (SSE or NDJSON) — the reconnect engine is identical for both.
321
+ */
322
+ function fetchEventSource(fetchClient, url, requestInit, parseResponse) {
323
+ return async function* (extraHeaders, abortSignal) {
324
+ let response;
325
+ try {
326
+ response = await fetchClient(url, {
327
+ ...requestInit,
328
+ headers: {
329
+ ...requestInit.headers,
330
+ ...extraHeaders
331
+ },
332
+ ...abortSignal ? { signal: abortSignal } : {}
333
+ });
334
+ } catch (error) {
335
+ throw new StreamReadError(error);
336
+ }
337
+ yield* parseResponse(response, abortSignal);
338
+ };
339
+ }
340
+ /**
341
+ * Drive a {@link StreamEventSource} with native-style resumability. Each event's
342
+ * adapter-owned delivery offset (its `id`) is remembered; if the connection
343
+ * drops or ends before a terminal event, the source is re-opened with a
344
+ * `Last-Event-ID` header so the server replays strictly after the last offset.
345
+ * Already-seen offsets are de-duped, so an overlapping replay is safe.
346
+ *
347
+ * When the server does NOT tag events (no durability), no offset is ever seen,
348
+ * so no reconnect happens — behaviour is identical to a plain single request.
349
+ * This engine is transport-agnostic: fetch/XHR × SSE/NDJSON all share it, the
350
+ * only difference being the {@link StreamEventSource} they pass in.
351
+ */
352
+ 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
+ }
365
+ for (;;) {
366
+ if (abortSignal?.aborted) return;
367
+ const extraHeaders = lastEventId !== void 0 ? { "Last-Event-ID": lastEventId } : {};
368
+ let sawTerminal = false;
369
+ let progressed = false;
370
+ try {
371
+ 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
+ }
380
+ progressed = true;
381
+ if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") sawTerminal = true;
382
+ yield chunk;
383
+ }
384
+ } catch (error) {
385
+ if (abortSignal?.aborted) return;
386
+ if ((error instanceof StreamTruncatedError || error instanceof StreamReadError) && lastEventId !== void 0) {
387
+ await waitBeforeReconnect(progressed);
388
+ continue;
389
+ }
390
+ throw error;
391
+ }
392
+ if (abortSignal?.aborted) return;
393
+ if (sawTerminal) return;
394
+ if (lastEventId !== void 0) {
395
+ if (progressed) {
396
+ await waitBeforeReconnect(true);
397
+ continue;
398
+ }
399
+ throw new DurableStreamIncompleteError();
400
+ }
401
+ return;
402
+ }
403
+ }
404
+ /**
405
+ * Normalize a ConnectionAdapter to subscribe/send operations.
406
+ *
407
+ * If a connection provides native subscribe/send, that mode is used.
408
+ * Otherwise, connect() is wrapped using an async queue.
409
+ */
103
410
  function normalizeConnectionAdapter(connection) {
104
- if (!connection) {
105
- throw new Error("Connection adapter is required");
106
- }
107
- const hasConnect = "connect" in connection;
108
- const hasSubscribe = "subscribe" in connection;
109
- const hasSend = "send" in connection;
110
- if (hasConnect && (hasSubscribe || hasSend)) {
111
- throw new Error(
112
- "Connection adapter must provide either connect or both subscribe and send, not both modes"
113
- );
114
- }
115
- if (hasSubscribe && hasSend) {
116
- return {
117
- subscribe: connection.subscribe.bind(connection),
118
- send: connection.send.bind(connection)
119
- };
120
- }
121
- if (!hasConnect) {
122
- throw new Error(
123
- "Connection adapter must provide either connect or both subscribe and send"
124
- );
125
- }
126
- let activeBuffer = [];
127
- let activeWaiters = [];
128
- function push(chunk, runId) {
129
- if (runId) {
130
- chunkRunIds.set(chunk, runId);
131
- }
132
- const waiter = activeWaiters.shift();
133
- if (waiter) {
134
- waiter(chunk);
135
- } else {
136
- activeBuffer.push(chunk);
137
- }
138
- }
139
- return {
140
- subscribe(abortSignal) {
141
- const myBuffer = activeBuffer.splice(0);
142
- const myWaiters = [];
143
- activeBuffer = myBuffer;
144
- activeWaiters = myWaiters;
145
- return (async function* () {
146
- while (!abortSignal?.aborted) {
147
- let chunk;
148
- const buffered = myBuffer.shift();
149
- if (buffered !== void 0) {
150
- chunk = buffered;
151
- } else {
152
- chunk = await new Promise((resolve) => {
153
- const onAbort = () => resolve(null);
154
- myWaiters.push((c) => {
155
- abortSignal?.removeEventListener("abort", onAbort);
156
- resolve(c);
157
- });
158
- abortSignal?.addEventListener("abort", onAbort, { once: true });
159
- });
160
- }
161
- if (chunk !== null) yield chunk;
162
- }
163
- })();
164
- },
165
- async send(messages, data, abortSignal, runContext) {
166
- let hasTerminalEvent = false;
167
- let upstreamThreadId;
168
- let upstreamRunId;
169
- try {
170
- const stream2 = connection.connect(
171
- messages,
172
- data,
173
- abortSignal,
174
- runContext
175
- );
176
- for await (const chunk of stream2) {
177
- if ("threadId" in chunk && typeof chunk.threadId === "string") {
178
- upstreamThreadId = chunk.threadId;
179
- }
180
- if ("runId" in chunk && typeof chunk.runId === "string") {
181
- upstreamRunId = chunk.runId;
182
- }
183
- if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") {
184
- hasTerminalEvent = true;
185
- }
186
- push(chunk, runContext?.runId);
187
- }
188
- if (!abortSignal?.aborted && !hasTerminalEvent) {
189
- const synthetic = {
190
- type: EventType.RUN_FINISHED,
191
- threadId: requireSyntheticId(
192
- upstreamThreadId ?? runContext?.threadId,
193
- "threadId"
194
- ),
195
- runId: requireSyntheticId(
196
- upstreamRunId ?? runContext?.runId,
197
- "runId"
198
- ),
199
- model: "connect-wrapper",
200
- timestamp: Date.now(),
201
- finishReason: "stop"
202
- };
203
- push(synthetic);
204
- }
205
- } catch (err) {
206
- if (!abortSignal?.aborted && !hasTerminalEvent) {
207
- const message = err instanceof Error ? err.message : "Unknown error in connect()";
208
- const synthetic = {
209
- type: EventType.RUN_ERROR,
210
- threadId: requireSyntheticId(
211
- upstreamThreadId ?? runContext?.threadId,
212
- "threadId"
213
- ),
214
- runId: requireSyntheticId(
215
- upstreamRunId ?? runContext?.runId,
216
- "runId"
217
- ),
218
- timestamp: Date.now(),
219
- message
220
- };
221
- push(synthetic);
222
- }
223
- throw err;
224
- }
225
- }
226
- };
411
+ if (!connection) throw new Error("Connection adapter is required");
412
+ const hasConnect = "connect" in connection;
413
+ const hasSubscribe = "subscribe" in connection;
414
+ const hasSend = "send" in connection;
415
+ if (hasConnect && (hasSubscribe || hasSend)) throw new Error("Connection adapter must provide either connect or both subscribe and send, not both modes");
416
+ if (hasSubscribe && hasSend) {
417
+ const joinRun = connection.joinRun?.bind(connection);
418
+ const hydrate = connection.hydrate?.bind(connection);
419
+ return {
420
+ subscribe: connection.subscribe.bind(connection),
421
+ send: connection.send.bind(connection),
422
+ ...joinRun ? { joinRun } : {},
423
+ ...hydrate ? { hydrate } : {}
424
+ };
425
+ }
426
+ if (!hasConnect) throw new Error("Connection adapter must provide either connect or both subscribe and send");
427
+ let activeBuffer = [];
428
+ let activeWaiters = [];
429
+ function push(chunk, runId) {
430
+ if (runId) chunkRunIds.set(chunk, runId);
431
+ const waiter = activeWaiters.shift();
432
+ if (waiter) waiter(chunk);
433
+ else activeBuffer.push(chunk);
434
+ }
435
+ return {
436
+ subscribe(abortSignal) {
437
+ const myBuffer = activeBuffer.splice(0);
438
+ const myWaiters = [];
439
+ activeBuffer = myBuffer;
440
+ activeWaiters = myWaiters;
441
+ return (async function* () {
442
+ while (!abortSignal?.aborted) {
443
+ let chunk;
444
+ const buffered = myBuffer.shift();
445
+ if (buffered !== void 0) chunk = buffered;
446
+ else chunk = await new Promise((resolve) => {
447
+ const onAbort = () => resolve(null);
448
+ myWaiters.push((c) => {
449
+ abortSignal?.removeEventListener("abort", onAbort);
450
+ resolve(c);
451
+ });
452
+ abortSignal?.addEventListener("abort", onAbort, { once: true });
453
+ });
454
+ if (chunk !== null) yield chunk;
455
+ }
456
+ })();
457
+ },
458
+ async send(messages, data, abortSignal, runContext) {
459
+ let hasTerminalEvent = false;
460
+ let upstreamThreadId;
461
+ let upstreamRunId;
462
+ try {
463
+ const stream = connection.connect(messages, data, abortSignal, runContext);
464
+ for await (const chunk of stream) {
465
+ if ("threadId" in chunk && typeof chunk.threadId === "string") upstreamThreadId = chunk.threadId;
466
+ if ("runId" in chunk && typeof chunk.runId === "string") upstreamRunId = chunk.runId;
467
+ if (chunk.type === "RUN_FINISHED" || chunk.type === "RUN_ERROR") hasTerminalEvent = true;
468
+ push(chunk, runContext?.runId);
469
+ }
470
+ if (!abortSignal?.aborted && !hasTerminalEvent) push({
471
+ type: EventType.RUN_FINISHED,
472
+ threadId: requireSyntheticId(upstreamThreadId ?? runContext?.threadId, "threadId"),
473
+ runId: requireSyntheticId(upstreamRunId ?? runContext?.runId, "runId"),
474
+ model: "connect-wrapper",
475
+ timestamp: Date.now(),
476
+ finishReason: "stop"
477
+ });
478
+ } catch (err) {
479
+ if (!abortSignal?.aborted && !hasTerminalEvent) try {
480
+ const message = err instanceof Error ? err.message : "Unknown error in connect()";
481
+ push({
482
+ type: EventType.RUN_ERROR,
483
+ threadId: requireSyntheticId(upstreamThreadId ?? runContext?.threadId, "threadId"),
484
+ runId: requireSyntheticId(upstreamRunId ?? runContext?.runId, "runId"),
485
+ timestamp: Date.now(),
486
+ message
487
+ });
488
+ } catch {}
489
+ throw err;
490
+ }
491
+ },
492
+ ...typeof connection.joinRun === "function" ? { joinRun: (runId, abortSignal) => connection.joinRun(runId, abortSignal) } : {},
493
+ ...(() => {
494
+ const hydrate = connection.hydrate;
495
+ return typeof hydrate === "function" ? { hydrate: (threadId) => hydrate(threadId) } : {};
496
+ })()
497
+ };
227
498
  }
228
499
  function buildRunAgentInputBody(messages, data, runContext, options) {
229
- const wireMessages = uiMessagesToWire(messages);
230
- const forwardedProps = {
231
- ...options.body,
232
- ...runContext?.forwardedProps ?? {},
233
- ...data
234
- };
235
- return {
236
- threadId: runContext?.threadId ?? generateRunId("thread"),
237
- runId: runContext?.runId ?? generateRunId("run"),
238
- ...runContext?.parentRunId !== void 0 && {
239
- parentRunId: runContext.parentRunId
240
- },
241
- state: {},
242
- messages: wireMessages,
243
- tools: runContext?.clientTools ?? [],
244
- context: [],
245
- forwardedProps,
246
- // Backward-compat mirror of `forwardedProps` under the legacy field name.
247
- data: { ...forwardedProps }
248
- };
500
+ const wireMessages = uiMessagesToWire(messages);
501
+ const forwardedProps = {
502
+ ...options.body,
503
+ ...runContext?.forwardedProps ?? {},
504
+ ...data
505
+ };
506
+ return {
507
+ threadId: runContext?.threadId ?? generateRunId("thread"),
508
+ runId: runContext?.runId ?? generateRunId("run"),
509
+ ...runContext?.parentRunId !== void 0 && { parentRunId: runContext.parentRunId },
510
+ ...runContext?.resume !== void 0 && { resume: runContext.resume },
511
+ state: {},
512
+ messages: wireMessages,
513
+ tools: runContext?.clientTools ?? [],
514
+ context: [],
515
+ forwardedProps,
516
+ data: { ...forwardedProps }
517
+ };
249
518
  }
519
+ /**
520
+ * Create a Server-Sent Events connection adapter
521
+ *
522
+ * @param url - The API endpoint URL (or a function that returns the URL)
523
+ * @param options - Fetch options (headers, credentials, body, etc.) or a function that returns options (can be async)
524
+ * @returns A connection adapter for SSE streams
525
+ *
526
+ * @example
527
+ * ```typescript
528
+ * // Static URL
529
+ * const connection = fetchServerSentEvents('/api/chat');
530
+ *
531
+ * // Dynamic URL
532
+ * const connection = fetchServerSentEvents(() => `/api/chat?user=${userId}`);
533
+ *
534
+ * // With options
535
+ * const connection = fetchServerSentEvents('/api/chat', {
536
+ * headers: { 'Authorization': 'Bearer token' }
537
+ * });
538
+ *
539
+ * // With dynamic options
540
+ * const connection = fetchServerSentEvents('/api/chat', () => ({
541
+ * headers: { 'Authorization': `Bearer ${getToken()}` }
542
+ * }));
543
+ *
544
+ * // With additional body data
545
+ * const connection = fetchServerSentEvents('/api/chat', async () => ({
546
+ * body: {
547
+ * provider: 'openai',
548
+ * model: 'gpt-5.5',
549
+ * }
550
+ * }));
551
+ * ```
552
+ */
250
553
  function fetchServerSentEvents(url, options = {}) {
251
- return {
252
- async *connect(messages, data, abortSignal, runContext) {
253
- const resolvedUrl = typeof url === "function" ? url() : url;
254
- const resolvedOptions = typeof options === "function" ? await options() : options;
255
- const requestHeaders = {
256
- "Content-Type": "application/json",
257
- ...mergeHeaders(resolvedOptions.headers)
258
- };
259
- const requestBody = buildRunAgentInputBody(
260
- messages,
261
- data,
262
- runContext,
263
- resolvedOptions
264
- );
265
- const fetchClient = resolvedOptions.fetchClient ?? fetch;
266
- const signal = abortSignal || resolvedOptions.signal;
267
- const response = await fetchClient(resolvedUrl, {
268
- method: "POST",
269
- headers: requestHeaders,
270
- body: JSON.stringify(requestBody),
271
- credentials: resolvedOptions.credentials || "same-origin",
272
- ...signal ? { signal } : {}
273
- });
274
- yield* responseToSSEChunks(response, abortSignal);
275
- }
276
- };
554
+ return {
555
+ async *connect(messages, data, abortSignal, runContext) {
556
+ const resolvedUrl = typeof url === "function" ? url() : url;
557
+ const resolvedOptions = typeof options === "function" ? await options() : options;
558
+ const requestHeaders = {
559
+ "Content-Type": "application/json",
560
+ ...mergeHeaders(resolvedOptions.headers),
561
+ ...runIdHeader(runContext?.runId)
562
+ };
563
+ const requestBody = buildRunAgentInputBody(messages, data, runContext, resolvedOptions);
564
+ const fetchClient = resolvedOptions.fetchClient ?? fetch;
565
+ const signal = abortSignal || resolvedOptions.signal;
566
+ yield* resumableStream(fetchEventSource(fetchClient, resolvedUrl, {
567
+ method: "POST",
568
+ headers: requestHeaders,
569
+ body: JSON.stringify(requestBody),
570
+ credentials: resolvedOptions.credentials || "same-origin"
571
+ }, (response, sseSignal) => responseToSSEEvents(response, sseSignal, {
572
+ ...runContext?.threadId !== void 0 ? { threadId: runContext.threadId } : {},
573
+ ...runContext?.runId !== void 0 ? { runId: runContext.runId } : {}
574
+ })), signal, resolvedOptions.reconnect);
575
+ },
576
+ async *joinRun(runId, abortSignal) {
577
+ const resolvedUrl = typeof url === "function" ? url() : url;
578
+ const resolvedOptions = typeof options === "function" ? await options() : options;
579
+ const joinUrl = withSearchParams(resolvedUrl, {
580
+ offset: "-1",
581
+ runId
582
+ });
583
+ const requestHeaders = { ...mergeHeaders(resolvedOptions.headers) };
584
+ const fetchClient = resolvedOptions.fetchClient ?? fetch;
585
+ const signal = abortSignal || resolvedOptions.signal;
586
+ yield* resumableStream(fetchEventSource(fetchClient, joinUrl, {
587
+ method: "GET",
588
+ headers: requestHeaders,
589
+ credentials: resolvedOptions.credentials || "same-origin"
590
+ }, (response, sseSignal) => responseToSSEEvents(response, sseSignal, { runId })), signal, resolvedOptions.reconnect);
591
+ },
592
+ async hydrate(threadId) {
593
+ const resolvedUrl = typeof url === "function" ? url() : url;
594
+ const resolvedOptions = typeof options === "function" ? await options() : options;
595
+ return fetchThreadHydration(resolvedOptions.fetchClient ?? fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.credentials || "same-origin", threadId);
596
+ },
597
+ async hydrateGeneration(threadId) {
598
+ const resolvedUrl = typeof url === "function" ? url() : url;
599
+ const resolvedOptions = typeof options === "function" ? await options() : options;
600
+ return fetchGenerationHydration(resolvedOptions.fetchClient ?? fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.credentials || "same-origin", threadId);
601
+ }
602
+ };
277
603
  }
604
+ /**
605
+ * Create an HTTP streaming connection adapter (for raw streaming without SSE format)
606
+ *
607
+ * @param url - The API endpoint URL (or a function that returns the URL)
608
+ * @param options - Fetch options (headers, credentials, body, etc.) or a function that returns options (can be async)
609
+ * @returns A connection adapter for HTTP streams
610
+ *
611
+ * @example
612
+ * ```typescript
613
+ * // Static URL
614
+ * const connection = fetchHttpStream('/api/chat');
615
+ *
616
+ * // Dynamic URL
617
+ * const connection = fetchHttpStream(() => `/api/chat?user=${userId}`);
618
+ *
619
+ * // With options
620
+ * const connection = fetchHttpStream('/api/chat', {
621
+ * headers: { 'Authorization': 'Bearer token' }
622
+ * });
623
+ *
624
+ * // With dynamic options
625
+ * const connection = fetchHttpStream('/api/chat', () => ({
626
+ * headers: { 'Authorization': `Bearer ${getToken()}` }
627
+ * }));
628
+ *
629
+ * // With additional body data
630
+ * const connection = fetchHttpStream('/api/chat', async () => ({
631
+ * body: {
632
+ * provider: 'openai',
633
+ * model: 'gpt-5.5',
634
+ * }
635
+ * }));
636
+ * ```
637
+ */
278
638
  function fetchHttpStream(url, options = {}) {
279
- return {
280
- async *connect(messages, data, abortSignal, runContext) {
281
- const resolvedUrl = typeof url === "function" ? url() : url;
282
- const resolvedOptions = typeof options === "function" ? await options() : options;
283
- const requestHeaders = {
284
- "Content-Type": "application/json",
285
- ...mergeHeaders(resolvedOptions.headers)
286
- };
287
- const requestBody = buildRunAgentInputBody(
288
- messages,
289
- data,
290
- runContext,
291
- resolvedOptions
292
- );
293
- const fetchClient = resolvedOptions.fetchClient ?? fetch;
294
- const signal = abortSignal || resolvedOptions.signal;
295
- const response = await fetchClient(resolvedUrl, {
296
- method: "POST",
297
- headers: requestHeaders,
298
- body: JSON.stringify(requestBody),
299
- credentials: resolvedOptions.credentials || "same-origin",
300
- ...signal ? { signal } : {}
301
- });
302
- if (!response.ok) {
303
- throw new Error(
304
- `HTTP error! status: ${response.status} ${response.statusText}`
305
- );
306
- }
307
- const reader = getResponseStreamReader(response);
308
- for await (const line of readStreamLines(reader, abortSignal)) {
309
- yield JSON.parse(line);
310
- }
311
- }
312
- };
639
+ return {
640
+ async *connect(messages, data, abortSignal, runContext) {
641
+ const resolvedUrl = typeof url === "function" ? url() : url;
642
+ const resolvedOptions = typeof options === "function" ? await options() : options;
643
+ const requestHeaders = {
644
+ "Content-Type": "application/json",
645
+ ...mergeHeaders(resolvedOptions.headers),
646
+ ...runIdHeader(runContext?.runId)
647
+ };
648
+ const requestBody = buildRunAgentInputBody(messages, data, runContext, resolvedOptions);
649
+ const fetchClient = resolvedOptions.fetchClient ?? fetch;
650
+ const signal = abortSignal || resolvedOptions.signal;
651
+ yield* resumableStream(fetchEventSource(fetchClient, resolvedUrl, {
652
+ method: "POST",
653
+ headers: requestHeaders,
654
+ body: JSON.stringify(requestBody),
655
+ credentials: resolvedOptions.credentials || "same-origin"
656
+ }, responseToNdjsonEvents), signal, resolvedOptions.reconnect);
657
+ },
658
+ async *joinRun(runId, abortSignal) {
659
+ const resolvedUrl = typeof url === "function" ? url() : url;
660
+ const resolvedOptions = typeof options === "function" ? await options() : options;
661
+ const joinUrl = withSearchParams(resolvedUrl, {
662
+ offset: "-1",
663
+ runId
664
+ });
665
+ const requestHeaders = { ...mergeHeaders(resolvedOptions.headers) };
666
+ const fetchClient = resolvedOptions.fetchClient ?? fetch;
667
+ const signal = abortSignal || resolvedOptions.signal;
668
+ yield* resumableStream(fetchEventSource(fetchClient, joinUrl, {
669
+ method: "GET",
670
+ headers: requestHeaders,
671
+ credentials: resolvedOptions.credentials || "same-origin"
672
+ }, responseToNdjsonEvents), signal, resolvedOptions.reconnect);
673
+ },
674
+ async hydrate(threadId) {
675
+ const resolvedUrl = typeof url === "function" ? url() : url;
676
+ const resolvedOptions = typeof options === "function" ? await options() : options;
677
+ return fetchThreadHydration(resolvedOptions.fetchClient ?? fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.credentials || "same-origin", threadId);
678
+ },
679
+ async hydrateGeneration(threadId) {
680
+ const resolvedUrl = typeof url === "function" ? url() : url;
681
+ const resolvedOptions = typeof options === "function" ? await options() : options;
682
+ return fetchGenerationHydration(resolvedOptions.fetchClient ?? fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.credentials || "same-origin", threadId);
683
+ }
684
+ };
313
685
  }
314
686
  function createDefaultXMLHttpRequest() {
315
- if (typeof globalThis.XMLHttpRequest !== "function") {
316
- throw new Error("XMLHttpRequest is not available in this runtime");
317
- }
318
- return new globalThis.XMLHttpRequest();
687
+ if (typeof globalThis.XMLHttpRequest !== "function") throw new Error("XMLHttpRequest is not available in this runtime");
688
+ return new globalThis.XMLHttpRequest();
319
689
  }
320
690
  function cleanupXhr(xhr, abortSignal, onAbort) {
321
- xhr.onprogress = null;
322
- xhr.onload = null;
323
- xhr.onerror = null;
324
- xhr.onabort = null;
325
- xhr.onloadend = null;
326
- if (abortSignal && onAbort) {
327
- abortSignal.removeEventListener("abort", onAbort);
328
- }
691
+ xhr.onprogress = null;
692
+ xhr.onload = null;
693
+ xhr.onerror = null;
694
+ xhr.onabort = null;
695
+ xhr.onloadend = null;
696
+ if (abortSignal && onAbort) abortSignal.removeEventListener("abort", onAbort);
329
697
  }
330
698
  function readXhrLines(xhr, abortSignal) {
331
- let offset = 0;
332
- let buffer = "";
333
- const lines = [];
334
- const waiters = [];
335
- let done = false;
336
- let aborted = false;
337
- let error;
338
- let onAbort;
339
- const wake = () => {
340
- const waiter = waiters.shift();
341
- waiter?.();
342
- };
343
- const enqueueDelta = () => {
344
- if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
345
- error = new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`);
346
- done = true;
347
- return;
348
- }
349
- const responseText = xhr.responseText;
350
- if (responseText.length <= offset) {
351
- return;
352
- }
353
- buffer += responseText.slice(offset);
354
- offset = responseText.length;
355
- const splitLines = buffer.split("\n");
356
- buffer = splitLines.pop() ?? "";
357
- for (const line of splitLines) {
358
- const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
359
- if (normalized.trim()) {
360
- lines.push(normalized);
361
- }
362
- }
363
- };
364
- const finish = () => {
365
- enqueueDelta();
366
- if (xhr.status < 200 || xhr.status >= 300) {
367
- error = new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`);
368
- } else if (buffer.trim() && !aborted) {
369
- error = new StreamTruncatedError();
370
- }
371
- done = true;
372
- wake();
373
- };
374
- xhr.onprogress = () => {
375
- enqueueDelta();
376
- wake();
377
- };
378
- xhr.onload = finish;
379
- xhr.onerror = () => {
380
- error = new Error("XHR request failed");
381
- done = true;
382
- wake();
383
- };
384
- xhr.onabort = () => {
385
- aborted = true;
386
- done = true;
387
- wake();
388
- };
389
- xhr.onloadend = () => {
390
- if (!done) {
391
- finish();
392
- }
393
- };
394
- if (abortSignal) {
395
- onAbort = () => {
396
- aborted = true;
397
- xhr.abort();
398
- };
399
- if (abortSignal.aborted) {
400
- onAbort();
401
- } else {
402
- abortSignal.addEventListener("abort", onAbort, { once: true });
403
- }
404
- }
405
- return (async function* () {
406
- try {
407
- while (true) {
408
- const line = lines.shift();
409
- if (line !== void 0) {
410
- yield line;
411
- continue;
412
- }
413
- if (error) {
414
- throw error;
415
- }
416
- if (done || abortSignal?.aborted) {
417
- return;
418
- }
419
- await new Promise((resolve) => {
420
- waiters.push(resolve);
421
- });
422
- }
423
- } finally {
424
- cleanupXhr(xhr, abortSignal, onAbort);
425
- }
426
- })();
427
- }
428
- function createConfiguredXhrRequest(url, options, messages, data, runContext) {
429
- const xhr = options.xhrFactory?.() ?? createDefaultXMLHttpRequest();
430
- xhr.open("POST", url);
431
- if (options.withCredentials !== void 0) {
432
- xhr.withCredentials = options.withCredentials;
433
- }
434
- const requestHeaders = {
435
- "Content-Type": "application/json",
436
- ...mergeHeaders(options.headers)
437
- };
438
- for (const [name, value] of Object.entries(requestHeaders)) {
439
- xhr.setRequestHeader(name, value);
440
- }
441
- const requestBody = buildRunAgentInputBody(
442
- messages,
443
- data,
444
- runContext,
445
- options
446
- );
447
- return { xhr, body: JSON.stringify(requestBody) };
699
+ let offset = 0;
700
+ let buffer = "";
701
+ const lines = [];
702
+ const waiters = [];
703
+ let done = false;
704
+ let aborted = false;
705
+ let error;
706
+ let onAbort;
707
+ const wake = () => {
708
+ waiters.shift()?.();
709
+ };
710
+ const enqueueDelta = () => {
711
+ if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) {
712
+ error = /* @__PURE__ */ new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`);
713
+ done = true;
714
+ return;
715
+ }
716
+ const responseText = xhr.responseText;
717
+ if (responseText.length <= offset) return;
718
+ buffer += responseText.slice(offset);
719
+ offset = responseText.length;
720
+ const splitLines = buffer.split("\n");
721
+ buffer = splitLines.pop() ?? "";
722
+ for (const line of splitLines) {
723
+ const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
724
+ if (normalized.trim()) lines.push(normalized);
725
+ }
726
+ };
727
+ const finish = () => {
728
+ enqueueDelta();
729
+ if (xhr.status !== 0 && (xhr.status < 200 || xhr.status >= 300)) error = /* @__PURE__ */ new Error(`XHR error! status: ${xhr.status} ${xhr.statusText}`);
730
+ else if (buffer.trim() && !aborted) error = new StreamTruncatedError();
731
+ done = true;
732
+ wake();
733
+ };
734
+ xhr.onprogress = () => {
735
+ enqueueDelta();
736
+ wake();
737
+ };
738
+ xhr.onload = finish;
739
+ xhr.onerror = () => {
740
+ error = new StreamReadError(/* @__PURE__ */ new Error("XHR request failed"));
741
+ done = true;
742
+ wake();
743
+ };
744
+ xhr.onabort = () => {
745
+ aborted = true;
746
+ done = true;
747
+ wake();
748
+ };
749
+ xhr.onloadend = () => {
750
+ if (!done) finish();
751
+ };
752
+ if (abortSignal) {
753
+ onAbort = () => {
754
+ aborted = true;
755
+ xhr.abort();
756
+ };
757
+ if (abortSignal.aborted) onAbort();
758
+ else abortSignal.addEventListener("abort", onAbort, { once: true });
759
+ }
760
+ return (async function* () {
761
+ try {
762
+ while (true) {
763
+ const line = lines.shift();
764
+ if (line !== void 0) {
765
+ yield line;
766
+ continue;
767
+ }
768
+ if (error) throw error;
769
+ if (done || abortSignal?.aborted) return;
770
+ await new Promise((resolve) => {
771
+ waiters.push(resolve);
772
+ });
773
+ }
774
+ } finally {
775
+ cleanupXhr(xhr, abortSignal, onAbort);
776
+ }
777
+ })();
778
+ }
779
+ function createConfiguredXhrRequest(url, options, messages, data, runContext, method = "POST", extraHeaders = {}) {
780
+ const xhr = options.xhrFactory?.() ?? createDefaultXMLHttpRequest();
781
+ xhr.open(method, url);
782
+ if (options.withCredentials !== void 0) xhr.withCredentials = options.withCredentials;
783
+ const requestHeaders = {
784
+ "Content-Type": "application/json",
785
+ ...mergeHeaders(options.headers),
786
+ ...method === "POST" ? runIdHeader(runContext?.runId) : {},
787
+ ...extraHeaders
788
+ };
789
+ for (const [name, value] of Object.entries(requestHeaders)) xhr.setRequestHeader(name, value);
790
+ const requestBody = buildRunAgentInputBody(messages, data, runContext, options);
791
+ return {
792
+ xhr,
793
+ body: JSON.stringify(requestBody)
794
+ };
448
795
  }
449
796
  async function resolveXhrConnectionOptions(options) {
450
- return typeof options === "function" ? await options() : options;
797
+ return typeof options === "function" ? await options() : options;
451
798
  }
799
+ /**
800
+ * Build an XHR-backed {@link StreamEventSource}. `parseLines` decodes the raw
801
+ * newline-delimited body into events (SSE or NDJSON); the reconnect engine is
802
+ * shared with the fetch adapters. A fresh XHR is opened per attempt, so a
803
+ * `Last-Event-ID` reconnect header (via `extraHeaders`) is applied at open time.
804
+ */
805
+ function xhrEventSource(url, options, method, messages, data, runContext, parseLines) {
806
+ return async function* (extraHeaders, abortSignal) {
807
+ const request = createConfiguredXhrRequest(url, options, messages, data, runContext, method, extraHeaders);
808
+ const lines = readXhrLines(request.xhr, abortSignal);
809
+ if (abortSignal?.aborted) {
810
+ await lines.next();
811
+ return;
812
+ }
813
+ request.xhr.send(method === "GET" ? null : request.body);
814
+ try {
815
+ yield* parseLines(lines);
816
+ } finally {
817
+ if (!abortSignal?.aborted) request.xhr.abort();
818
+ }
819
+ };
820
+ }
821
+ /** SSE line parser bound to the run's ids for a `[DONE]` fallback. */
822
+ function xhrSSEParser(runContext) {
823
+ const fallbackIds = {
824
+ ...runContext?.threadId !== void 0 ? { threadId: runContext.threadId } : {},
825
+ ...runContext?.runId !== void 0 ? { runId: runContext.runId } : {}
826
+ };
827
+ return (lines) => linesToSSEEvents(lines, fallbackIds);
828
+ }
829
+ /**
830
+ * Create an XMLHttpRequest-backed Server-Sent Events connection adapter.
831
+ *
832
+ * Resumable: against a durable (`id:`-tagged) server response, a dropped socket
833
+ * auto-reconnects with `Last-Event-ID` and de-dupes the replayed prefix, and
834
+ * `joinRun` attaches to an existing run from the start. A non-durable response
835
+ * is a single plain request, exactly as before.
836
+ */
452
837
  function xhrServerSentEvents(url, options = {}) {
453
- return {
454
- async *connect(messages, data, abortSignal, runContext) {
455
- const resolvedUrl = typeof url === "function" ? url() : url;
456
- const resolvedOptions = await resolveXhrConnectionOptions(options);
457
- const signal = abortSignal || resolvedOptions.signal;
458
- const request = createConfiguredXhrRequest(
459
- resolvedUrl,
460
- resolvedOptions,
461
- messages,
462
- data,
463
- runContext
464
- );
465
- const lines = readXhrLines(request.xhr, signal);
466
- if (signal?.aborted) {
467
- await lines.next();
468
- return;
469
- }
470
- request.xhr.send(request.body);
471
- let lastThreadId;
472
- let lastRunId;
473
- let lastModel;
474
- for await (const line of lines) {
475
- if (line.startsWith(":") || line.startsWith("event:") || line.startsWith("id:") || line.startsWith("retry:")) {
476
- continue;
477
- }
478
- const chunkData = parseSseDataLine(line);
479
- if (chunkData === "[DONE]") {
480
- const synthetic = {
481
- type: EventType.RUN_FINISHED,
482
- threadId: lastThreadId ?? runContext?.threadId ?? "",
483
- runId: lastRunId ?? runContext?.runId ?? "",
484
- model: lastModel ?? "",
485
- timestamp: Date.now(),
486
- finishReason: "stop"
487
- };
488
- request.xhr.abort();
489
- yield synthetic;
490
- return;
491
- }
492
- const chunk = JSON.parse(chunkData);
493
- if ("threadId" in chunk && typeof chunk.threadId === "string") {
494
- lastThreadId = chunk.threadId;
495
- }
496
- if ("runId" in chunk && typeof chunk.runId === "string") {
497
- lastRunId = chunk.runId;
498
- }
499
- if ("model" in chunk && typeof chunk.model === "string") {
500
- lastModel = chunk.model;
501
- }
502
- yield chunk;
503
- }
504
- }
505
- };
838
+ return {
839
+ async *connect(messages, data, abortSignal, runContext) {
840
+ const resolvedUrl = typeof url === "function" ? url() : url;
841
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
842
+ const signal = abortSignal || resolvedOptions.signal;
843
+ yield* resumableStream(xhrEventSource(resolvedUrl, resolvedOptions, "POST", messages, data, runContext, xhrSSEParser(runContext)), signal, resolvedOptions.reconnect);
844
+ },
845
+ async *joinRun(runId, abortSignal) {
846
+ const resolvedUrl = typeof url === "function" ? url() : url;
847
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
848
+ const signal = abortSignal || resolvedOptions.signal;
849
+ yield* resumableStream(xhrEventSource(withSearchParams(resolvedUrl, {
850
+ offset: "-1",
851
+ runId
852
+ }), resolvedOptions, "GET", [], void 0, void 0, (lines) => linesToSSEEvents(lines, { runId })), signal, resolvedOptions.reconnect);
853
+ },
854
+ async hydrate(threadId) {
855
+ const resolvedUrl = typeof url === "function" ? url() : url;
856
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
857
+ return fetchThreadHydration(fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.withCredentials ? "include" : "same-origin", threadId);
858
+ },
859
+ async hydrateGeneration(threadId) {
860
+ const resolvedUrl = typeof url === "function" ? url() : url;
861
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
862
+ return fetchGenerationHydration(fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.withCredentials ? "include" : "same-origin", threadId);
863
+ }
864
+ };
506
865
  }
866
+ /**
867
+ * Create an XMLHttpRequest-backed newline-delimited JSON stream adapter.
868
+ *
869
+ * Resumable: against a durable (envelope-tagged) server response, a dropped
870
+ * socket auto-reconnects with `Last-Event-ID` and de-dupes the replayed prefix,
871
+ * and `joinRun` attaches to an existing run from the start. A non-durable
872
+ * (bare-line) response is a single plain request, exactly as before.
873
+ */
507
874
  function xhrHttpStream(url, options = {}) {
508
- return {
509
- async *connect(messages, data, abortSignal, runContext) {
510
- const resolvedUrl = typeof url === "function" ? url() : url;
511
- const resolvedOptions = await resolveXhrConnectionOptions(options);
512
- const signal = abortSignal || resolvedOptions.signal;
513
- const request = createConfiguredXhrRequest(
514
- resolvedUrl,
515
- resolvedOptions,
516
- messages,
517
- data,
518
- runContext
519
- );
520
- const lines = readXhrLines(request.xhr, signal);
521
- if (signal?.aborted) {
522
- await lines.next();
523
- return;
524
- }
525
- request.xhr.send(request.body);
526
- for await (const line of lines) {
527
- yield JSON.parse(line);
528
- }
529
- }
530
- };
531
- }
532
- function stream(streamFactory) {
533
- return {
534
- async *connect(messages, data, abortSignal) {
535
- yield* streamFactory(messages, data, abortSignal);
536
- }
537
- };
875
+ return {
876
+ async *connect(messages, data, abortSignal, runContext) {
877
+ const resolvedUrl = typeof url === "function" ? url() : url;
878
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
879
+ const signal = abortSignal || resolvedOptions.signal;
880
+ yield* resumableStream(xhrEventSource(resolvedUrl, resolvedOptions, "POST", messages, data, runContext, linesToNdjsonEvents), signal, resolvedOptions.reconnect);
881
+ },
882
+ async *joinRun(runId, abortSignal) {
883
+ const resolvedUrl = typeof url === "function" ? url() : url;
884
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
885
+ const signal = abortSignal || resolvedOptions.signal;
886
+ yield* resumableStream(xhrEventSource(withSearchParams(resolvedUrl, {
887
+ offset: "-1",
888
+ runId
889
+ }), resolvedOptions, "GET", [], void 0, void 0, linesToNdjsonEvents), signal, resolvedOptions.reconnect);
890
+ },
891
+ async hydrate(threadId) {
892
+ const resolvedUrl = typeof url === "function" ? url() : url;
893
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
894
+ return fetchThreadHydration(fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.withCredentials ? "include" : "same-origin", threadId);
895
+ },
896
+ async hydrateGeneration(threadId) {
897
+ const resolvedUrl = typeof url === "function" ? url() : url;
898
+ const resolvedOptions = await resolveXhrConnectionOptions(options);
899
+ return fetchGenerationHydration(fetch, resolvedUrl, mergeHeaders(resolvedOptions.headers), resolvedOptions.withCredentials ? "include" : "same-origin", threadId);
900
+ }
901
+ };
902
+ }
903
+ /**
904
+ * Create a direct stream connection adapter (for server functions or direct streams)
905
+ *
906
+ * @param streamFactory - A function that returns an async iterable of StreamChunks
907
+ * @param handlers - Optional persistence handlers (`hydrate`,
908
+ * `hydrateGeneration`, `joinRun`) that let server-driven persistence work
909
+ * without an HTTP endpoint — each is usually a one-line server-function call
910
+ * @returns A connection adapter for direct streams
911
+ *
912
+ * @example
913
+ * ```typescript
914
+ * // With TanStack Start server function
915
+ * const connection = stream(() => serverFunction({ messages }));
916
+ *
917
+ * const client = new ChatClient({ connection });
918
+ *
919
+ * // With generation persistence over server functions
920
+ * const connection = stream(
921
+ * () => generateImageFn({ data: input }),
922
+ * {
923
+ * hydrateGeneration: (threadId) => getImageHydrationFn({ data: threadId }),
924
+ * joinRun: (runId) => joinImageRunFn({ data: runId }),
925
+ * },
926
+ * );
927
+ * ```
928
+ */
929
+ function stream(streamFactory, handlers) {
930
+ return {
931
+ async *connect(messages, data, abortSignal) {
932
+ yield* streamFactory(messages, data, abortSignal);
933
+ },
934
+ ...handlers?.hydrate ? { hydrate: handlers.hydrate } : {},
935
+ ...handlers?.hydrateGeneration ? { hydrateGeneration: handlers.hydrateGeneration } : {},
936
+ ...handlers?.joinRun ? { joinRun: handlers.joinRun } : {}
937
+ };
538
938
  }
939
+ /**
940
+ * Wrap a `ChatFetcher` as a `ConnectConnectionAdapter` so the chat client can
941
+ * consume it through the same `subscribe`/`send` plumbing used for SSE /
942
+ * HTTP-stream / RPC connections. May return either a `Response` (parsed as
943
+ * SSE) or an `AsyncIterable<StreamChunk>` (yielded directly).
944
+ *
945
+ * @internal
946
+ */
539
947
  function fetcherToConnectionAdapter(fetcher) {
540
- return {
541
- async *connect(messages, data, abortSignal, runContext) {
542
- if (!abortSignal) {
543
- throw new Error(
544
- "fetcherToConnectionAdapter requires an AbortSignal — the chat client always supplies one."
545
- );
546
- }
547
- if (!runContext) {
548
- throw new Error(
549
- "fetcherToConnectionAdapter requires a RunAgentInputContext — the chat client always supplies one."
550
- );
551
- }
552
- const uiMessages = messages;
553
- const result = await fetcher(
554
- {
555
- messages: uiMessages,
556
- data,
557
- threadId: runContext.threadId,
558
- runId: runContext.runId
559
- },
560
- { signal: abortSignal }
561
- );
562
- if (result instanceof Response) {
563
- yield* responseToSSEChunks(result, abortSignal);
564
- } else {
565
- yield* abortableIterable(result, abortSignal);
566
- }
567
- }
568
- };
948
+ return { async *connect(messages, data, abortSignal, runContext) {
949
+ if (!abortSignal) throw new Error("fetcherToConnectionAdapter requires an AbortSignal — the chat client always supplies one.");
950
+ if (!runContext) throw new Error("fetcherToConnectionAdapter requires a RunAgentInputContext — the chat client always supplies one.");
951
+ const result = await fetcher({
952
+ messages,
953
+ data,
954
+ threadId: runContext.threadId,
955
+ runId: runContext.runId
956
+ }, { signal: abortSignal });
957
+ if (result instanceof Response) yield* responseToSSEChunks(result, abortSignal);
958
+ else yield* abortableIterable(result, abortSignal);
959
+ } };
569
960
  }
961
+ /**
962
+ * Wrap an AsyncIterable so iteration aborts when `signal` fires. Without
963
+ * this, a fetcher that returns a generator ignoring its signal would leave
964
+ * the for-await loop hanging until the iterable naturally ends.
965
+ */
570
966
  async function* abortableIterable(iterable, signal) {
571
- if (signal.aborted) return;
572
- const iterator = iterable[Symbol.asyncIterator]();
573
- const abortPromise = new Promise(
574
- (resolve) => {
575
- signal.addEventListener(
576
- "abort",
577
- () => resolve({ done: true, value: void 0 }),
578
- { once: true }
579
- );
580
- }
581
- );
582
- try {
583
- while (true) {
584
- const result = await Promise.race([iterator.next(), abortPromise]);
585
- if (result.done) return;
586
- yield result.value;
587
- }
588
- } finally {
589
- await iterator.return?.();
590
- }
591
- }
592
- function rpcStream(rpcCall) {
593
- return {
594
- async *connect(messages, data, abortSignal) {
595
- yield* rpcCall(messages, data, abortSignal);
596
- }
597
- };
598
- }
599
- export {
600
- StreamTruncatedError,
601
- fetchHttpStream,
602
- fetchServerSentEvents,
603
- fetcherToConnectionAdapter,
604
- getChunkRunId,
605
- normalizeConnectionAdapter,
606
- rpcStream,
607
- stream,
608
- xhrHttpStream,
609
- xhrServerSentEvents
610
- };
611
- //# sourceMappingURL=connection-adapters.js.map
967
+ if (signal.aborted) return;
968
+ const iterator = iterable[Symbol.asyncIterator]();
969
+ const abortPromise = new Promise((resolve) => {
970
+ signal.addEventListener("abort", () => resolve({
971
+ done: true,
972
+ value: void 0
973
+ }), { once: true });
974
+ });
975
+ try {
976
+ while (true) {
977
+ const result = await Promise.race([iterator.next(), abortPromise]);
978
+ if (result.done) return;
979
+ yield result.value;
980
+ }
981
+ } finally {
982
+ await iterator.return?.();
983
+ }
984
+ }
985
+ /**
986
+ * Create an RPC stream connection adapter (for RPC-based streaming like Cap'n Web RPC)
987
+ *
988
+ * @param rpcCall - A function that accepts messages and returns an async iterable of StreamChunks
989
+ * @param handlers - Optional persistence handlers (`hydrate`,
990
+ * `hydrateGeneration`, `joinRun`) that let server-driven persistence work
991
+ * without an HTTP endpoint — each is usually a one-line RPC call
992
+ * @returns A connection adapter for RPC streams
993
+ *
994
+ * @example
995
+ * ```typescript
996
+ * // With Cap'n Web RPC
997
+ * const connection = rpcStream((messages, data) =>
998
+ * api.streamMurfResponse(messages, data)
999
+ * );
1000
+ *
1001
+ * const client = new ChatClient({ connection });
1002
+ *
1003
+ * // With generation persistence over RPC
1004
+ * const connection = rpcStream(
1005
+ * (messages, data) => api.streamMurfResponse(messages, data),
1006
+ * {
1007
+ * hydrateGeneration: (threadId) => api.getGenerationHydration(threadId),
1008
+ * joinRun: (runId) => api.replayRun(runId),
1009
+ * },
1010
+ * );
1011
+ * ```
1012
+ */
1013
+ function rpcStream(rpcCall, handlers) {
1014
+ return {
1015
+ async *connect(messages, data, abortSignal) {
1016
+ yield* rpcCall(messages, data, abortSignal);
1017
+ },
1018
+ ...handlers?.hydrate ? { hydrate: handlers.hydrate } : {},
1019
+ ...handlers?.hydrateGeneration ? { hydrateGeneration: handlers.hydrateGeneration } : {},
1020
+ ...handlers?.joinRun ? { joinRun: handlers.joinRun } : {}
1021
+ };
1022
+ }
1023
+ //#endregion
1024
+ export { DurableStreamIncompleteError, StreamReconnectLimitError, StreamTruncatedError, fetchHttpStream, fetchServerSentEvents, fetcherToConnectionAdapter, getChunkRunId, normalizeConnectionAdapter, rpcStream, stream, xhrHttpStream, xhrServerSentEvents };
1025
+
1026
+ //# sourceMappingURL=connection-adapters.js.map