@boy-offi9-inc/reqkit 0.3.0 → 0.6.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.
@@ -0,0 +1,193 @@
1
+ const { HTTPError } = require("./HTTPError");
2
+ const { BodyTooLargeError } = require("./withProgress");
3
+ const { normalizeError } = require("./normalizeError");
4
+ const { getWaitMsFromHeaders } = require("./_http");
5
+ const { abortableSleep } = require("./_sleep");
6
+
7
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
8
+
9
+ function parseContentRange(header) {
10
+ // "bytes 100-199/1000" (total may be "*")
11
+ const m = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(String(header || "").trim());
12
+ if (!m) return null;
13
+ return { start: Number(m[1]), end: Number(m[2]), total: m[3] === "*" ? null : Number(m[3]) };
14
+ }
15
+
16
+ function isRetryable(err) {
17
+ if (err instanceof BodyTooLargeError) return false;
18
+ if (err instanceof HTTPError) return RETRYABLE_STATUS.has(err.status);
19
+ const n = normalizeError(err);
20
+ if (n.isAborted) return false;
21
+ return n.isNetworkError || n.isTimeout;
22
+ }
23
+
24
+ /**
25
+ * Downloads a URL into memory and, if the connection drops mid-body, resumes
26
+ * from where it stopped with an HTTP `Range` request instead of starting
27
+ * over. Uses `If-Range` with the first response's strong ETag (or
28
+ * Last-Modified) so a file that changed on the server is restarted rather
29
+ * than stitched together wrongly. If the server ignores Range, it simply
30
+ * restarts from zero.
31
+ *
32
+ * `retries` counts consecutive attempts *without progress* — a flaky link
33
+ * that keeps making headway keeps going. Honors Retry-After on 429/503.
34
+ *
35
+ * ```js
36
+ * const bytes = await downloadResumable(url, {
37
+ * onProgress: ({ percent }) => console.log(percent),
38
+ * maxBytes: 200 * 1024 * 1024,
39
+ * });
40
+ * ```
41
+ *
42
+ * @param {string} url
43
+ * @param {object} [opts]
44
+ * @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. `createFetch(...)` or a mock
45
+ * @param {HeadersInit} [opts.headers]
46
+ * @param {number} [opts.retries=5]
47
+ * @param {number} [opts.baseDelayMs=500]
48
+ * @param {number} [opts.maxDelayMs=10000]
49
+ * @param {(p: {loaded: number, total: number | null, percent: number | null}) => void} [opts.onProgress]
50
+ * @param {number} [opts.maxBytes] - throws BodyTooLargeError past this
51
+ * @param {AbortSignal} [opts.signal]
52
+ * @param {(err: any, attempt: number, delayMs: number, info: {resumeFrom: number}) => void} [opts.onRetry]
53
+ * @returns {Promise<Uint8Array>}
54
+ */
55
+ async function downloadResumable(url, opts = {}) {
56
+ const {
57
+ fetch: fetchFn = globalThis.fetch,
58
+ headers,
59
+ retries = 5,
60
+ baseDelayMs = 500,
61
+ maxDelayMs = 10000,
62
+ onProgress,
63
+ maxBytes,
64
+ signal,
65
+ onRetry,
66
+ } = opts;
67
+
68
+ const chunks = [];
69
+ let loaded = 0;
70
+ let total = null;
71
+ let validator = null;
72
+ let attempt = 0; // consecutive attempts without progress
73
+
74
+ const report = () => {
75
+ if (onProgress) {
76
+ onProgress({ loaded, total, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null });
77
+ }
78
+ };
79
+
80
+ for (;;) {
81
+ signal?.throwIfAborted?.();
82
+ const loadedBefore = loaded;
83
+
84
+ try {
85
+ const reqHeaders = new Headers(headers || undefined);
86
+ // Range offsets count encoded bytes, so ask for an uncompressed body.
87
+ if (!reqHeaders.has("accept-encoding")) reqHeaders.set("accept-encoding", "identity");
88
+ if (loaded > 0) {
89
+ reqHeaders.set("range", `bytes=${loaded}-`);
90
+ if (validator) reqHeaders.set("if-range", validator);
91
+ }
92
+
93
+ const res = await fetchFn(url, { headers: reqHeaders, signal });
94
+
95
+ if (res.status === 416 && loaded > 0) {
96
+ // Our offset no longer fits the file — start over, without burning a retry.
97
+ await res.body?.cancel?.().catch(() => {});
98
+ chunks.length = 0;
99
+ loaded = 0;
100
+ continue;
101
+ }
102
+ if (!res.ok) {
103
+ await res.body?.cancel?.().catch(() => {});
104
+ throw new HTTPError(res, { method: "GET", url });
105
+ }
106
+
107
+ const encoding = res.headers.get("content-encoding");
108
+ const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
109
+
110
+ if (loaded > 0) {
111
+ const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
112
+ if (!range || range.start !== loaded || encoded) {
113
+ // Server ignored Range, the file changed (If-Range failed), or the
114
+ // bytes don't line up: discard and restart from the beginning.
115
+ chunks.length = 0;
116
+ loaded = 0;
117
+ }
118
+ }
119
+
120
+ if (loaded === 0) {
121
+ const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
122
+ const length = Number(res.headers.get("content-length"));
123
+ total = encoded ? null : range ? range.total : Number.isFinite(length) && res.headers.has("content-length") ? length : null;
124
+ const etag = res.headers.get("etag");
125
+ validator = etag && !etag.startsWith("W/") ? etag : res.headers.get("last-modified");
126
+ }
127
+
128
+ if (maxBytes !== undefined && total !== null && total > maxBytes) {
129
+ await res.body?.cancel?.().catch(() => {});
130
+ throw new BodyTooLargeError(maxBytes);
131
+ }
132
+
133
+ if (!res.body || typeof res.body.getReader !== "function") {
134
+ const buf = new Uint8Array(await res.arrayBuffer());
135
+ if (maxBytes !== undefined && buf.byteLength > maxBytes) throw new BodyTooLargeError(maxBytes);
136
+ chunks.push(buf);
137
+ loaded += buf.byteLength;
138
+ total = total ?? loaded;
139
+ report();
140
+ } else {
141
+ const reader = res.body.getReader();
142
+ const onAbort = () => reader.cancel(signal.reason).catch(() => {});
143
+ signal?.addEventListener("abort", onAbort, { once: true });
144
+ let finished = false;
145
+ try {
146
+ for (;;) {
147
+ const { done, value } = await reader.read();
148
+ signal?.throwIfAborted?.();
149
+ if (done) break;
150
+ chunks.push(value);
151
+ loaded += value.byteLength;
152
+ if (maxBytes !== undefined && loaded > maxBytes) throw new BodyTooLargeError(maxBytes);
153
+ report();
154
+ }
155
+ finished = true;
156
+ } finally {
157
+ signal?.removeEventListener("abort", onAbort);
158
+ if (!finished) reader.cancel().catch(() => {});
159
+ }
160
+ }
161
+
162
+ if (total !== null && loaded < total) {
163
+ // Stream ended early without an error (some servers just close the socket).
164
+ throw Object.assign(new Error(`Incomplete download: got ${loaded} of ${total} bytes`), { code: "ECONNRESET" });
165
+ }
166
+
167
+ const out = new Uint8Array(loaded);
168
+ let offset = 0;
169
+ for (const chunk of chunks) {
170
+ out.set(chunk, offset);
171
+ offset += chunk.byteLength;
172
+ }
173
+ return out;
174
+ } catch (err) {
175
+ if (signal?.aborted) throw signal.reason ?? err;
176
+ if (!isRetryable(err)) throw err;
177
+
178
+ if (loaded > loadedBefore) attempt = 0; // it made progress — doesn't count against the budget
179
+ if (attempt >= retries) throw err;
180
+
181
+ let delayMs = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
182
+ if (err instanceof HTTPError) {
183
+ const headerMs = getWaitMsFromHeaders(err.response && err.response.headers);
184
+ if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
185
+ }
186
+ attempt++;
187
+ if (onRetry) onRetry(err, attempt, delayMs, { resumeFrom: loaded });
188
+ await abortableSleep(delayMs, signal);
189
+ }
190
+ }
191
+ }
192
+
193
+ module.exports = { downloadResumable };
package/src/index.js CHANGED
@@ -8,6 +8,13 @@ const { withProgress, BodyTooLargeError } = require("./withProgress");
8
8
  const { withDedupe } = require("./withDedupe");
9
9
  const { createLimiter } = require("./createLimiter");
10
10
  const { createFetch, HTTPError } = require("./createFetch");
11
+ const { createRateLimiter } = require("./createRateLimiter");
12
+ const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
13
+ const { createMemoryCache } = require("./createMemoryCache");
14
+ const { createMockFetch } = require("./createMockFetch");
15
+ const { downloadResumable } = require("./downloadResumable");
16
+ const { readLines, readNDJSON, readSSE } = require("./streams");
17
+ const { streamSSE } = require("./streamSSE");
11
18
 
12
19
  module.exports = {
13
20
  withRetry,
@@ -27,4 +34,14 @@ module.exports = {
27
34
  createLimiter,
28
35
  createFetch,
29
36
  HTTPError,
37
+ createRateLimiter,
38
+ createCircuitBreaker,
39
+ CircuitOpenError,
40
+ createMemoryCache,
41
+ createMockFetch,
42
+ downloadResumable,
43
+ readLines,
44
+ readNDJSON,
45
+ readSSE,
46
+ streamSSE,
30
47
  };
package/src/index.mjs CHANGED
@@ -18,6 +18,16 @@ export const {
18
18
  createLimiter,
19
19
  createFetch,
20
20
  HTTPError,
21
+ createRateLimiter,
22
+ createCircuitBreaker,
23
+ CircuitOpenError,
24
+ createMemoryCache,
25
+ createMockFetch,
26
+ downloadResumable,
27
+ readLines,
28
+ readNDJSON,
29
+ readSSE,
30
+ streamSSE,
21
31
  } = reqkit;
22
32
 
23
33
  export default reqkit;
@@ -54,7 +54,7 @@ function isNetworkCode(code) {
54
54
 
55
55
  // Browsers and Node throw a bare TypeError for network-level fetch failures.
56
56
  const FETCH_NETWORK_MESSAGE =
57
- /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch/i;
57
+ /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch|^terminated$|^network error$/i;
58
58
 
59
59
  function isFetchNetworkTypeError(err) {
60
60
  return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
@@ -0,0 +1,197 @@
1
+ const { readSSE } = require("./streams");
2
+ const { HTTPError } = require("./HTTPError");
3
+ const { normalizeError } = require("./normalizeError");
4
+ const { getWaitMsFromHeaders, discardBody } = require("./_http");
5
+ const { abortableSleep } = require("./_sleep");
6
+
7
+ function isRetryableStatus(status) {
8
+ return status === 408 || status === 425 || status === 429 || status >= 500;
9
+ }
10
+
11
+ function isRetryableError(err) {
12
+ const n = normalizeError(err);
13
+ return !n.isAborted && (n.isNetworkError || n.isTimeout);
14
+ }
15
+
16
+ async function resolveHeaders(source) {
17
+ const value = typeof source === "function" ? await source() : source;
18
+ return new Headers(value || undefined);
19
+ }
20
+
21
+ /**
22
+ * Connects to a Server-Sent Events endpoint and yields its events — and when
23
+ * the connection drops (or the server closes it), reconnects by itself,
24
+ * sending `Last-Event-ID` so the server can resume where you left off. This is
25
+ * what the browser's `EventSource` does, but with `fetch`: custom headers,
26
+ * POST bodies, AbortSignal, your own retry/backoff rules.
27
+ *
28
+ * ```js
29
+ * for await (const { data } of streamSSE("https://api.example.com/events", { signal })) {
30
+ * console.log(data);
31
+ * }
32
+ * ```
33
+ *
34
+ * Reconnect behavior:
35
+ * - `Last-Event-ID` is sent on every reconnect (the last `id:` seen).
36
+ * - A server `retry: <ms>` field sets the base reconnect delay, like EventSource.
37
+ * - Waits back off exponentially (capped at `maxDelayMs`) while connecting keeps
38
+ * failing; 429/503 `Retry-After` is honored. A connection that delivered at
39
+ * least one event resets the failure count (only no-progress attempts count
40
+ * toward `retries`).
41
+ * - HTTP 204 means "stop reconnecting" (iteration ends). A non-retryable
42
+ * status (e.g. 401/404) or a wrong content-type throws immediately.
43
+ * - Only GET reconnects by default. For other methods (e.g. a POST that starts
44
+ * an LLM stream) re-sending the request could repeat side effects, so
45
+ * nothing is retried unless you pass `reconnect: true`.
46
+ * - Breaking out of the loop, or aborting `signal`, closes the connection.
47
+ *
48
+ * @param {string} url
49
+ * @param {object} [opts]
50
+ * @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. a createFetch() instance
51
+ * @param {string} [opts.method="GET"]
52
+ * @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [opts.headers] -
53
+ * a function is called before every (re)connect — handy for refreshing a token
54
+ * @param {BodyInit} [opts.body]
55
+ * @param {AbortSignal} [opts.signal]
56
+ * @param {string} [opts.lastEventId] - resume from here on the very first connect
57
+ * @param {boolean} [opts.reconnect] - default: true for GET/HEAD, false otherwise
58
+ * @param {number} [opts.retries=10] - consecutive failed connections before giving up (Infinity = never)
59
+ * @param {number} [opts.reconnectDelayMs=1000] - base delay (the server's `retry:` overrides it)
60
+ * @param {number} [opts.maxDelayMs=30000]
61
+ * @param {(event: object) => boolean} [opts.stopWhen] - end the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`
62
+ * @param {(info: {connections: number, lastEventId: string}) => void} [opts.onOpen]
63
+ * @param {(info: {reason: "error"|"end"|"status", error?: any, status?: number, attempt: number, delayMs: number, lastEventId: string}) => void} [opts.onReconnect]
64
+ * @returns {AsyncGenerator<{event: string, data: string, id: string, retry: number|undefined}>}
65
+ */
66
+ async function* streamSSE(url, opts = {}) {
67
+ const {
68
+ fetch: fetchFn = globalThis.fetch,
69
+ method = "GET",
70
+ headers,
71
+ body,
72
+ signal,
73
+ reconnect = method === "GET" || method === "HEAD",
74
+ retries = 10,
75
+ reconnectDelayMs = 1000,
76
+ maxDelayMs = 30000,
77
+ stopWhen,
78
+ onOpen,
79
+ onReconnect,
80
+ } = opts;
81
+
82
+ let lastId = opts.lastEventId || "";
83
+ let baseDelay = reconnectDelayMs;
84
+ let failures = 0; // consecutive connections that delivered nothing
85
+ let connections = 0;
86
+
87
+ for (;;) {
88
+ signal?.throwIfAborted?.();
89
+ let failure = null; // { reason, error?, status?, response? }
90
+ let res = null;
91
+
92
+ // ---- connect -----------------------------------------------------------
93
+ try {
94
+ const h = await resolveHeaders(headers);
95
+ if (!h.has("accept")) h.set("accept", "text/event-stream");
96
+ if (!h.has("cache-control")) h.set("cache-control", "no-cache");
97
+ if (lastId) h.set("last-event-id", lastId);
98
+ res = await fetchFn(url, { method, headers: h, body, signal });
99
+ } catch (err) {
100
+ if (signal?.aborted) throw signal.reason ?? err;
101
+ if (err instanceof HTTPError) {
102
+ if (!isRetryableStatus(err.status)) throw err;
103
+ failure = { reason: "status", status: err.status, response: err.response, error: err };
104
+ } else if (isRetryableError(err)) {
105
+ failure = { reason: "error", error: err };
106
+ } else {
107
+ throw err;
108
+ }
109
+ }
110
+
111
+ // ---- inspect the response ---------------------------------------------
112
+ if (!failure) {
113
+ if (res.status === 204) {
114
+ await discardBody(res);
115
+ return; // the server's way of saying "don't reconnect"
116
+ }
117
+ if (!res.ok) {
118
+ if (!isRetryableStatus(res.status)) {
119
+ await discardBody(res);
120
+ throw new HTTPError(res, { method, url });
121
+ }
122
+ failure = { reason: "status", status: res.status, response: res };
123
+ } else {
124
+ const type = res.headers.get("content-type") || "";
125
+ if (!/text\/event-stream/i.test(type)) {
126
+ await discardBody(res);
127
+ throw Object.assign(new Error(`streamSSE: expected "text/event-stream" but the server sent "${type || "no content-type"}"`), {
128
+ code: "SSE_BAD_CONTENT_TYPE",
129
+ });
130
+ }
131
+ }
132
+ }
133
+
134
+ // ---- stream ------------------------------------------------------------
135
+ let gotEvent = false;
136
+ if (!failure) {
137
+ connections++;
138
+ if (onOpen) onOpen({ connections, lastEventId: lastId });
139
+ try {
140
+ for await (const event of readSSE(res, { signal })) {
141
+ gotEvent = true;
142
+ lastId = event.id;
143
+ if (event.retry !== undefined) baseDelay = event.retry;
144
+ yield event;
145
+ if (stopWhen && stopWhen(event)) return;
146
+ }
147
+ failure = { reason: "end" };
148
+ } catch (err) {
149
+ if (signal?.aborted) throw signal.reason ?? err;
150
+ if (!isRetryableError(err)) throw err;
151
+ failure = { reason: "error", error: err };
152
+ }
153
+ }
154
+
155
+ // ---- decide whether and when to reconnect ------------------------------
156
+ if (!reconnect) {
157
+ if (failure.reason === "end") return;
158
+ if (failure.reason === "status") {
159
+ throw failure.error || new HTTPError(failure.response, { method, url });
160
+ }
161
+ throw failure.error;
162
+ }
163
+
164
+ // A connection that delivered events made progress: reconnect promptly and
165
+ // start the failure count over. Only no-progress attempts count toward `retries`.
166
+ failures = gotEvent ? 0 : failures + 1;
167
+ if (failures > retries) {
168
+ if (failure.reason === "status") throw failure.error || new HTTPError(failure.response, { method, url });
169
+ if (failure.reason === "error") throw failure.error;
170
+ throw Object.assign(new Error(`streamSSE: the server keeps closing the stream without sending events — gave up after ${retries} retries`), {
171
+ code: "SSE_GAVE_UP",
172
+ });
173
+ }
174
+
175
+ let delayMs = Math.min(baseDelay * 2 ** Math.max(failures - 1, 0), maxDelayMs);
176
+ if (failure.reason === "status" && failure.response) {
177
+ const headerMs = getWaitMsFromHeaders(failure.response.headers);
178
+ if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
179
+ await discardBody(failure.response);
180
+ }
181
+ delayMs += Math.random() * delayMs * 0.1; // a little jitter so clients don't reconnect in lockstep
182
+
183
+ if (onReconnect) {
184
+ onReconnect({
185
+ reason: failure.reason,
186
+ error: failure.error,
187
+ status: failure.status,
188
+ attempt: Math.max(failures, 1),
189
+ delayMs,
190
+ lastEventId: lastId,
191
+ });
192
+ }
193
+ await abortableSleep(delayMs, signal);
194
+ }
195
+ }
196
+
197
+ module.exports = { streamSSE };
package/src/streams.js ADDED
@@ -0,0 +1,149 @@
1
+ const encoder = new TextEncoder();
2
+
3
+ // Yields Uint8Array chunks from a fetch Response, a web ReadableStream, or any
4
+ // async iterable (e.g. a Node Readable). Cancels the underlying stream when
5
+ // the consumer stops early or the signal aborts, so the connection is freed.
6
+ async function* chunks(source, signal) {
7
+ let body = source;
8
+ if (body && typeof body.getReader !== "function" && "body" in body) body = body.body; // a Response
9
+ if (body === null || body === undefined) return;
10
+ signal?.throwIfAborted?.();
11
+
12
+ if (typeof body.getReader === "function") {
13
+ const reader = body.getReader();
14
+ const onAbort = () => reader.cancel(signal.reason).catch(() => {});
15
+ signal?.addEventListener("abort", onAbort, { once: true });
16
+ try {
17
+ for (;;) {
18
+ const { done, value } = await reader.read();
19
+ signal?.throwIfAborted?.(); // cancel() makes read() resolve as done — don't mistake that for the end
20
+ if (done) return;
21
+ yield value;
22
+ }
23
+ } finally {
24
+ signal?.removeEventListener("abort", onAbort);
25
+ reader.cancel().catch(() => {});
26
+ }
27
+ } else if (typeof body[Symbol.asyncIterator] === "function") {
28
+ for await (const chunk of body) {
29
+ signal?.throwIfAborted?.();
30
+ yield typeof chunk === "string" ? encoder.encode(chunk) : chunk;
31
+ }
32
+ } else {
33
+ throw new TypeError("reqkit streams: expected a Response, a ReadableStream or an async iterable");
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
39
+ * multi-byte characters split across chunks, and a leading BOM.
40
+ *
41
+ * ```js
42
+ * for await (const line of readLines(response)) console.log(line);
43
+ * ```
44
+ *
45
+ * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
46
+ * @param {{ signal?: AbortSignal }} [opts]
47
+ * @returns {AsyncGenerator<string>}
48
+ */
49
+ async function* readLines(source, opts = {}) {
50
+ const decoder = new TextDecoder("utf-8");
51
+ let buffer = "";
52
+ let first = true;
53
+
54
+ for await (const chunk of chunks(source, opts.signal)) {
55
+ buffer += decoder.decode(chunk, { stream: true });
56
+ if (first && buffer.length > 0) {
57
+ buffer = buffer.replace(/^\uFEFF/, "");
58
+ first = false;
59
+ }
60
+
61
+ let pos = 0;
62
+ for (;;) {
63
+ const rest = buffer.slice(pos);
64
+ const i = rest.search(/\r\n|\n|\r/);
65
+ if (i === -1) break;
66
+ // A trailing "\r" might be the first half of "\r\n" — wait for more data.
67
+ if (rest[i] === "\r" && i === rest.length - 1) break;
68
+ yield rest.slice(0, i);
69
+ pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
70
+ }
71
+ buffer = buffer.slice(pos);
72
+ }
73
+
74
+ buffer += decoder.decode();
75
+ if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
76
+ if (buffer.length > 0) yield buffer;
77
+ }
78
+
79
+ /**
80
+ * Async-iterates parsed JSON values from a newline-delimited JSON stream
81
+ * (NDJSON / JSON Lines). Blank lines are skipped. A malformed line throws a
82
+ * SyntaxError naming the line, unless `skipInvalid` is true.
83
+ *
84
+ * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
85
+ * @param {{ signal?: AbortSignal, skipInvalid?: boolean }} [opts]
86
+ */
87
+ async function* readNDJSON(source, opts = {}) {
88
+ let lineNo = 0;
89
+ for await (const line of readLines(source, opts)) {
90
+ lineNo++;
91
+ if (line.trim() === "") continue;
92
+ try {
93
+ yield JSON.parse(line);
94
+ } catch (err) {
95
+ if (opts.skipInvalid) continue;
96
+ throw new SyntaxError(`readNDJSON: invalid JSON on line ${lineNo}: ${err.message}`);
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Async-iterates Server-Sent Events (text/event-stream) as
103
+ * `{ event, data, id, retry }`, following the spec: multi-line `data:` is
104
+ * joined with "\n", comments (`:`) are ignored, `event` defaults to
105
+ * "message", `id` carries over between events, and an event cut off by the end
106
+ * of the stream is discarded. Great for LLM token streams.
107
+ *
108
+ * ```js
109
+ * for await (const { data } of readSSE(response)) {
110
+ * if (data === "[DONE]") break;
111
+ * console.log(JSON.parse(data));
112
+ * }
113
+ * ```
114
+ *
115
+ * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
116
+ * @param {{ signal?: AbortSignal }} [opts]
117
+ */
118
+ async function* readSSE(source, opts = {}) {
119
+ let event = "";
120
+ let data = [];
121
+ let retry;
122
+ let lastId = "";
123
+
124
+ for await (const line of readLines(source, opts)) {
125
+ if (line === "") {
126
+ if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
127
+ event = "";
128
+ data = [];
129
+ retry = undefined;
130
+ continue;
131
+ }
132
+ if (line.startsWith(":")) continue;
133
+
134
+ const idx = line.indexOf(":");
135
+ const field = idx === -1 ? line : line.slice(0, idx);
136
+ let value = idx === -1 ? "" : line.slice(idx + 1);
137
+ if (value.startsWith(" ")) value = value.slice(1);
138
+
139
+ if (field === "event") event = value;
140
+ else if (field === "data") data.push(value);
141
+ else if (field === "id") {
142
+ if (!value.includes("\0")) lastId = value;
143
+ } else if (field === "retry") {
144
+ if (/^\d+$/.test(value)) retry = Number(value);
145
+ }
146
+ }
147
+ }
148
+
149
+ module.exports = { readLines, readNDJSON, readSSE };