@boy-offi9-inc/reqkit 0.2.0 → 0.5.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,111 @@
1
+ const { abortableSleep } = require("./_sleep");
2
+ const { getRateLimitInfo } = require("./_http");
3
+
4
+ function abortable(promise, signal) {
5
+ if (!signal) return promise;
6
+ return new Promise((resolve, reject) => {
7
+ if (signal.aborted) {
8
+ reject(signal.reason);
9
+ return;
10
+ }
11
+ const onAbort = () => reject(signal.reason);
12
+ signal.addEventListener("abort", onAbort, { once: true });
13
+ promise.then(
14
+ (v) => (signal.removeEventListener("abort", onAbort), resolve(v)),
15
+ (e) => (signal.removeEventListener("abort", onAbort), reject(e))
16
+ );
17
+ });
18
+ }
19
+
20
+ /**
21
+ * A *proactive* rate limiter: slows you down before the server has to say
22
+ * 429. Two things feed it:
23
+ * 1. your own budget — `requests` per `perMs` (sliding window), and
24
+ * 2. what the server tells you — `update(response)` reads X-RateLimit-* /
25
+ * RateLimit-* / Retry-After and pauses everything once `remaining` hits 0
26
+ * (or the server sent Retry-After), until the reset time.
27
+ *
28
+ * Callers are served FIFO.
29
+ *
30
+ * ```js
31
+ * const rl = createRateLimiter({ requests: 10, perMs: 1000 });
32
+ * const res = await rl.run(() => fetch(url)); // acquire → call → learn from headers
33
+ * ```
34
+ *
35
+ * @param {object} [opts]
36
+ * @param {number} [opts.requests=Infinity] - max requests per window
37
+ * @param {number} [opts.perMs=1000] - window length
38
+ */
39
+ function createRateLimiter(opts = {}) {
40
+ const { requests = Infinity, perMs = 1000 } = opts;
41
+ if (requests !== Infinity && !(Number.isInteger(requests) && requests >= 1)) {
42
+ throw new TypeError("createRateLimiter: `requests` must be an integer >= 1 (or Infinity)");
43
+ }
44
+ if (!(typeof perMs === "number" && perMs > 0 && Number.isFinite(perMs))) {
45
+ throw new TypeError("createRateLimiter: `perMs` must be a positive number");
46
+ }
47
+
48
+ const stamps = [];
49
+ let blockedUntil = 0;
50
+ let tail = Promise.resolve();
51
+
52
+ async function take(signal) {
53
+ for (;;) {
54
+ signal?.throwIfAborted?.();
55
+ const now = Date.now();
56
+ while (stamps.length > 0 && stamps[0] <= now - perMs) stamps.shift();
57
+
58
+ let waitMs = 0;
59
+ if (blockedUntil > now) waitMs = blockedUntil - now;
60
+ else if (stamps.length >= requests) waitMs = stamps[0] + perMs - now;
61
+
62
+ if (waitMs <= 0) {
63
+ stamps.push(now);
64
+ return;
65
+ }
66
+ await abortableSleep(waitMs, signal);
67
+ }
68
+ }
69
+
70
+ /** Resolves when it's your turn. Rejects with the signal's reason if aborted while waiting. */
71
+ function acquire({ signal } = {}) {
72
+ const turn = tail.then(() => take(signal));
73
+ tail = turn.catch(() => {});
74
+ return abortable(turn, signal);
75
+ }
76
+
77
+ /** Feed a response (anything with `.headers`/`.status`) so the limiter learns the server's limits. */
78
+ function update(response) {
79
+ if (!response || !response.headers) return;
80
+ const { remaining, resetMs, retryAfterMs } = getRateLimitInfo(response.headers);
81
+ let blockMs = null;
82
+ if (retryAfterMs !== null && (response.status === 429 || response.status === 503)) blockMs = retryAfterMs;
83
+ else if (remaining === 0 && resetMs !== null) blockMs = resetMs;
84
+ if (blockMs !== null) blockedUntil = Math.max(blockedUntil, Date.now() + blockMs);
85
+ }
86
+
87
+ /** acquire → fn() → update(result). Errors thrown by fn propagate (the slot stays spent). */
88
+ async function run(fn, { signal } = {}) {
89
+ await acquire({ signal });
90
+ const result = await fn();
91
+ update(result);
92
+ return result;
93
+ }
94
+
95
+ return {
96
+ acquire,
97
+ update,
98
+ run,
99
+ /** Ms until the server-imposed pause ends (0 if none). */
100
+ get blockedForMs() {
101
+ return Math.max(0, blockedUntil - Date.now());
102
+ },
103
+ /** Forget all history and server hints. */
104
+ reset() {
105
+ stamps.length = 0;
106
+ blockedUntil = 0;
107
+ },
108
+ };
109
+ }
110
+
111
+ module.exports = { createRateLimiter };
@@ -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
@@ -1,4 +1,4 @@
1
- const { withRetry, RetryAbortedError } = require("./withRetry");
1
+ const { withRetry, RetryAbortedError, StopRetry } = require("./withRetry");
2
2
  const { withTimeout, TimeoutError } = require("./withTimeout");
3
3
  const { normalizeError } = require("./normalizeError");
4
4
  const { parseJsonSafe } = require("./parseJsonSafe");
@@ -6,10 +6,19 @@ const { buildQuery } = require("./buildQuery");
6
6
  const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
7
7
  const { withProgress, BodyTooLargeError } = require("./withProgress");
8
8
  const { withDedupe } = require("./withDedupe");
9
+ const { createLimiter } = require("./createLimiter");
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");
9
17
 
10
18
  module.exports = {
11
19
  withRetry,
12
20
  RetryAbortedError,
21
+ StopRetry,
13
22
  withTimeout,
14
23
  TimeoutError,
15
24
  normalizeError,
@@ -21,4 +30,16 @@ module.exports = {
21
30
  withProgress,
22
31
  BodyTooLargeError,
23
32
  withDedupe,
33
+ createLimiter,
34
+ createFetch,
35
+ HTTPError,
36
+ createRateLimiter,
37
+ createCircuitBreaker,
38
+ CircuitOpenError,
39
+ createMemoryCache,
40
+ createMockFetch,
41
+ downloadResumable,
42
+ readLines,
43
+ readNDJSON,
44
+ readSSE,
24
45
  };
package/src/index.mjs CHANGED
@@ -3,6 +3,7 @@ import reqkit from "./index.js";
3
3
  export const {
4
4
  withRetry,
5
5
  RetryAbortedError,
6
+ StopRetry,
6
7
  withTimeout,
7
8
  TimeoutError,
8
9
  normalizeError,
@@ -14,6 +15,18 @@ export const {
14
15
  withProgress,
15
16
  BodyTooLargeError,
16
17
  withDedupe,
18
+ createLimiter,
19
+ createFetch,
20
+ HTTPError,
21
+ createRateLimiter,
22
+ createCircuitBreaker,
23
+ CircuitOpenError,
24
+ createMemoryCache,
25
+ createMockFetch,
26
+ downloadResumable,
27
+ readLines,
28
+ readNDJSON,
29
+ readSSE,
17
30
  } = reqkit;
18
31
 
19
32
  export default reqkit;
@@ -1,75 +1,23 @@
1
- const { RetryAbortedError, sleep } = require("./_sleep");
1
+ const { withRetry } = require("./withRetry");
2
2
  const { normalizeError } = require("./normalizeError");
3
-
4
- /**
5
- * Parses a Retry-After header value into milliseconds. Per HTTP spec it's
6
- * either an integer number of seconds, or an HTTP-date.
7
- */
8
- function parseRetryAfter(headerValue) {
9
- if (headerValue === null || headerValue === undefined) return null;
10
- const value = String(headerValue).trim();
11
- if (!value) return null;
12
-
13
- // delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
14
- if (/^\d+(\.\d+)?$/.test(value)) {
15
- return Number(value) * 1000;
16
- }
17
-
18
- const dateMs = Date.parse(value);
19
- if (!Number.isNaN(dateMs)) {
20
- return Math.max(0, dateMs - Date.now());
21
- }
22
-
23
- return null;
24
- }
25
-
26
- /**
27
- * Reads a wait time from common rate-limit headers, trying Retry-After first,
28
- * then falling back to X-RateLimit-Reset (epoch seconds or ms) if present.
29
- */
30
- function getWaitMsFromHeaders(headers) {
31
- if (!headers || typeof headers.get !== "function") return null;
32
-
33
- const retryAfter = parseRetryAfter(headers.get("retry-after"));
34
- if (retryAfter !== null) return retryAfter;
35
-
36
- const resetHeader = headers.get("x-ratelimit-reset") || headers.get("x-rate-limit-reset");
37
- if (resetHeader) {
38
- const resetValue = Number(resetHeader);
39
- if (!Number.isNaN(resetValue)) {
40
- // Could be epoch seconds or epoch ms — treat values below a
41
- // year-2100-in-seconds threshold as seconds.
42
- const resetMs = resetValue > 4102444800 ? resetValue : resetValue * 1000;
43
- return Math.max(0, resetMs - Date.now());
44
- }
45
- }
46
-
47
- return null;
48
- }
3
+ const { parseRetryAfter, getWaitMsFromHeaders } = require("./_http");
49
4
 
50
5
  function defaultShouldRetryError(err) {
51
6
  const normalized = normalizeError(err);
52
7
  return normalized.isNetworkError || normalized.isTimeout;
53
8
  }
54
9
 
55
- async function discardBody(response) {
56
- // An unread body keeps the underlying connection busy; cancel it before
57
- // we sit through a backoff and fire the next request.
58
- try {
59
- if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
60
- await response.body.cancel();
61
- }
62
- } catch {
63
- // locked / already consumed / not a web stream — safe to ignore
64
- }
65
- }
66
-
67
10
  /**
68
11
  * Retries a fetch-like call, honoring Retry-After / X-RateLimit-Reset
69
12
  * headers on 429/503 responses instead of blind exponential backoff — most
70
13
  * generic retry libraries treat rate-limit responses like any other failure
71
14
  * and ignore the header the server is explicitly giving you.
72
15
  *
16
+ * This is a thin preset over `withRetry` (retryStatusCodes + Retry-After,
17
+ * no jitter, network errors/timeouts only for thrown errors). Reach for
18
+ * `withRetry` directly when you need jitter, `maxRetryTime`, `StopRetry`,
19
+ * `calculateDelay`, ...
20
+ *
73
21
  * Also retries thrown network errors / timeouts (see `shouldRetryError`),
74
22
  * and the whole loop — including any backoff wait — can be cancelled with
75
23
  * `signal`. The body of each response that gets retried is cancelled so the
@@ -92,7 +40,7 @@ async function discardBody(response) {
92
40
  * @param {AbortSignal} [opts.signal] - cancels the retry loop, throwing
93
41
  * RetryAbortedError
94
42
  */
95
- async function retryAfterAware(fn, opts = {}) {
43
+ function retryAfterAware(fn, opts = {}) {
96
44
  const {
97
45
  retries = 3,
98
46
  retryStatusCodes = [429, 503],
@@ -104,34 +52,24 @@ async function retryAfterAware(fn, opts = {}) {
104
52
  signal,
105
53
  } = opts;
106
54
 
107
- let lastErr;
108
- for (let attempt = 0; attempt <= retries; attempt++) {
109
- if (signal?.aborted) throw new RetryAbortedError(lastErr);
110
-
111
- let response;
112
- try {
113
- response = await fn(attempt);
114
- } catch (err) {
115
- lastErr = err;
116
- if (attempt === retries || !shouldRetryError(err, attempt)) throw err;
117
-
118
- const waitMs = Math.min(fallbackDelayMs * 2 ** attempt, maxDelayMs);
119
- if (onRetryError) onRetryError(err, waitMs, attempt);
120
- await sleep(waitMs, signal, err);
121
- continue;
122
- }
123
-
124
- if (!retryStatusCodes.includes(response.status) || attempt === retries) {
125
- return response;
126
- }
127
-
128
- const headerWaitMs = getWaitMsFromHeaders(response.headers);
129
- const waitMs = Math.min(headerWaitMs ?? fallbackDelayMs * 2 ** attempt, maxDelayMs);
130
-
131
- await discardBody(response);
132
- if (onRetry) onRetry(response.status, waitMs, attempt);
133
- await sleep(waitMs, signal);
134
- }
55
+ return withRetry(fn, {
56
+ retries,
57
+ retryStatusCodes,
58
+ baseDelayMs: fallbackDelayMs,
59
+ maxDelayMs,
60
+ maxRetryAfterMs: maxDelayMs,
61
+ factor: 2,
62
+ jitter: 0,
63
+ shouldRetry: shouldRetryError,
64
+ onRetry: (err, attempt, waitMs, ctx) => {
65
+ if (ctx.reason === "result") {
66
+ if (onRetry) onRetry(ctx.status, waitMs, attempt);
67
+ } else if (onRetryError) {
68
+ onRetryError(err, waitMs, attempt);
69
+ }
70
+ },
71
+ signal,
72
+ });
135
73
  }
136
74
 
137
75
  module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
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 };