@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.
- package/README.md +174 -1
- package/RECIPES.md +243 -0
- package/dist/reqkit.mjs +2507 -0
- package/package.json +25 -5
- package/src/HTTPError.js +14 -0
- package/src/_cache.js +55 -0
- package/src/_http.js +44 -13
- package/src/_sleep.js +20 -1
- package/src/createCircuitBreaker.js +124 -0
- package/src/createFetch.js +225 -45
- package/src/createMemoryCache.js +40 -0
- package/src/createMockFetch.js +184 -0
- package/src/createRateLimiter.js +111 -0
- package/src/downloadResumable.js +193 -0
- package/src/index.js +17 -0
- package/src/index.mjs +10 -0
- package/src/normalizeError.js +1 -1
- package/src/streamSSE.js +197 -0
- package/src/streams.js +149 -0
- package/types/index.d.ts +211 -1
|
@@ -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;
|
package/src/normalizeError.js
CHANGED
|
@@ -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
|
|
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 || "");
|
package/src/streamSSE.js
ADDED
|
@@ -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 };
|