@boy-offi9-inc/reqkit 0.3.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.
- package/README.md +146 -1
- package/RECIPES.md +243 -0
- package/dist/reqkit.mjs +2304 -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 +15 -0
- package/src/index.mjs +9 -0
- package/src/streams.js +149 -0
- package/types/index.d.ts +175 -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,12 @@ 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");
|
|
11
17
|
|
|
12
18
|
module.exports = {
|
|
13
19
|
withRetry,
|
|
@@ -27,4 +33,13 @@ module.exports = {
|
|
|
27
33
|
createLimiter,
|
|
28
34
|
createFetch,
|
|
29
35
|
HTTPError,
|
|
36
|
+
createRateLimiter,
|
|
37
|
+
createCircuitBreaker,
|
|
38
|
+
CircuitOpenError,
|
|
39
|
+
createMemoryCache,
|
|
40
|
+
createMockFetch,
|
|
41
|
+
downloadResumable,
|
|
42
|
+
readLines,
|
|
43
|
+
readNDJSON,
|
|
44
|
+
readSSE,
|
|
30
45
|
};
|
package/src/index.mjs
CHANGED
|
@@ -18,6 +18,15 @@ 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,
|
|
21
30
|
} = reqkit;
|
|
22
31
|
|
|
23
32
|
export default reqkit;
|
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 };
|
package/types/index.d.ts
CHANGED
|
@@ -203,6 +203,15 @@ export interface CreateFetchDefaults {
|
|
|
203
203
|
timeout?: number | false;
|
|
204
204
|
/** false disables retrying. */
|
|
205
205
|
retry?: false | CreateFetchRetryOptions;
|
|
206
|
+
/**
|
|
207
|
+
* `true` only learns from X-RateLimit-* / Retry-After headers; `{ requests, perMs }`
|
|
208
|
+
* also enforces a budget; or share a createRateLimiter() instance.
|
|
209
|
+
*/
|
|
210
|
+
rateLimit?: boolean | RateLimiterOptions | RateLimiter;
|
|
211
|
+
/** Options for a per-origin breaker, a shared breaker instance, or `(url) => breaker`. */
|
|
212
|
+
circuitBreaker?: boolean | CircuitBreakerOptions | CircuitBreaker | ((url: string) => CircuitBreaker);
|
|
213
|
+
/** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
|
|
214
|
+
cache?: boolean | CacheOptions;
|
|
206
215
|
/** Merge identical concurrent GET/HEAD calls. Default false. */
|
|
207
216
|
dedupe?: boolean;
|
|
208
217
|
/** Throw HTTPError on non-2xx. Default true. */
|
|
@@ -212,7 +221,7 @@ export interface CreateFetchDefaults {
|
|
|
212
221
|
onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
|
|
213
222
|
}
|
|
214
223
|
|
|
215
|
-
export interface ReqkitRequestInit extends Omit<RequestInit, "headers"> {
|
|
224
|
+
export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"> {
|
|
216
225
|
headers?: HeadersInit;
|
|
217
226
|
query?: Record<string, unknown>;
|
|
218
227
|
/** Serialized as the body with a JSON content-type. */
|
|
@@ -220,6 +229,8 @@ export interface ReqkitRequestInit extends Omit<RequestInit, "headers"> {
|
|
|
220
229
|
timeout?: number | false;
|
|
221
230
|
retry?: false | CreateFetchRetryOptions;
|
|
222
231
|
throwHttpErrors?: boolean;
|
|
232
|
+
/** `false`/"no-store" bypass the cache; "reload" skips the read but refreshes the entry. */
|
|
233
|
+
cache?: false | "no-store" | "reload";
|
|
223
234
|
}
|
|
224
235
|
|
|
225
236
|
export interface ReqkitFetch {
|
|
@@ -231,3 +242,166 @@ export interface ReqkitFetch {
|
|
|
231
242
|
}
|
|
232
243
|
|
|
233
244
|
export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
|
|
245
|
+
|
|
246
|
+
export interface RateLimiterOptions {
|
|
247
|
+
/** Max requests per window. Default Infinity. */
|
|
248
|
+
requests?: number;
|
|
249
|
+
/** Window length in ms. Default 1000. */
|
|
250
|
+
perMs?: number;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export interface RateLimiter {
|
|
254
|
+
/** Resolves when it's your turn (FIFO). Rejects with the signal's reason if aborted while waiting. */
|
|
255
|
+
acquire(opts?: { signal?: AbortSignal }): Promise<void>;
|
|
256
|
+
/** Learn from a response's X-RateLimit-* / RateLimit-* / Retry-After headers. */
|
|
257
|
+
update(response: { status?: number; headers?: { get(name: string): string | null } } | null | undefined): void;
|
|
258
|
+
/** acquire → fn() → update(result). */
|
|
259
|
+
run<T>(fn: () => Promise<T>, opts?: { signal?: AbortSignal }): Promise<T>;
|
|
260
|
+
/** Ms until a server-imposed pause ends (0 if none). */
|
|
261
|
+
readonly blockedForMs: number;
|
|
262
|
+
reset(): void;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function createRateLimiter(opts?: RateLimiterOptions): RateLimiter;
|
|
266
|
+
|
|
267
|
+
export class CircuitOpenError extends Error {
|
|
268
|
+
name: "CircuitOpenError";
|
|
269
|
+
code: "CIRCUIT_OPEN";
|
|
270
|
+
/** Ms until the breaker will let a probe through. */
|
|
271
|
+
retryAfterMs: number;
|
|
272
|
+
constructor(retryAfterMs: number);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export interface CircuitBreakerOptions {
|
|
276
|
+
/** Consecutive failures before opening. Default 5. */
|
|
277
|
+
failureThreshold?: number;
|
|
278
|
+
/** How long to stay open before allowing a probe. Default 30000. */
|
|
279
|
+
resetTimeoutMs?: number;
|
|
280
|
+
/** Concurrent probes allowed while half-open. Default 1. */
|
|
281
|
+
halfOpenMax?: number;
|
|
282
|
+
/** Default: any thrown error except AbortError, or a result with status >= 500. */
|
|
283
|
+
isFailure?: (valueOrError: any, isError: boolean) => boolean;
|
|
284
|
+
onStateChange?: (state: CircuitState, previous: CircuitState) => void;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export type CircuitState = "closed" | "open" | "half-open";
|
|
288
|
+
|
|
289
|
+
export interface CircuitBreaker {
|
|
290
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
291
|
+
readonly state: CircuitState;
|
|
292
|
+
readonly failures: number;
|
|
293
|
+
reset(): void;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function createCircuitBreaker(opts?: CircuitBreakerOptions): CircuitBreaker;
|
|
297
|
+
|
|
298
|
+
export interface CacheStore {
|
|
299
|
+
get(key: string): any | Promise<any>;
|
|
300
|
+
set(key: string, value: any): void | Promise<void>;
|
|
301
|
+
delete(key: string): any;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export interface MemoryCache extends CacheStore {
|
|
305
|
+
clear(): void;
|
|
306
|
+
readonly size: number;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function createMemoryCache(opts?: { maxEntries?: number }): MemoryCache;
|
|
310
|
+
|
|
311
|
+
export interface CacheEvent {
|
|
312
|
+
type: "hit" | "stale" | "revalidated" | "miss" | "store";
|
|
313
|
+
url: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export interface CacheOptions {
|
|
317
|
+
/** How long an entry is served without asking the server, in ms. Default 60000. */
|
|
318
|
+
ttl?: number;
|
|
319
|
+
/** After the TTL, keep serving the stale copy for this long while refreshing in the background. Default 0. */
|
|
320
|
+
staleWhileRevalidate?: number;
|
|
321
|
+
/** For the built-in memory store. Default 100. */
|
|
322
|
+
maxEntries?: number;
|
|
323
|
+
/** Responses larger than this aren't cached. Default 1 MiB. */
|
|
324
|
+
maxBodyBytes?: number;
|
|
325
|
+
/** Bring your own store (Redis, IndexedDB, a Map...). */
|
|
326
|
+
store?: CacheStore;
|
|
327
|
+
onEvent?: (event: CacheEvent) => void;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export interface MockCall {
|
|
331
|
+
url: string;
|
|
332
|
+
method: string;
|
|
333
|
+
headers: Record<string, string>;
|
|
334
|
+
body: BodyInit | null | undefined;
|
|
335
|
+
signal?: AbortSignal;
|
|
336
|
+
/** The body parsed as JSON, if it was a JSON string. */
|
|
337
|
+
readonly json: any;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
export interface MockReplyOptions {
|
|
341
|
+
headers?: HeadersInit;
|
|
342
|
+
/** Wait this long before answering; respects the request's AbortSignal. */
|
|
343
|
+
delayMs?: number;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export interface MockRoute {
|
|
347
|
+
reply(status?: number, body?: unknown, opts?: MockReplyOptions): MockRoute;
|
|
348
|
+
replyOnce(status?: number, body?: unknown, opts?: MockReplyOptions): MockRoute;
|
|
349
|
+
replyWith(fn: (call: MockCall) => Response | { status?: number; body?: unknown; headers?: HeadersInit } | Promise<Response | { status?: number; body?: unknown; headers?: HeadersInit }>): MockRoute;
|
|
350
|
+
replyWithOnce(fn: (call: MockCall) => Response | { status?: number; body?: unknown; headers?: HeadersInit } | Promise<Response | { status?: number; body?: unknown; headers?: HeadersInit }>): MockRoute;
|
|
351
|
+
fail(err?: unknown): MockRoute;
|
|
352
|
+
failOnce(err?: unknown): MockRoute;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export type MockMatcher = string | RegExp | ((url: string) => boolean);
|
|
356
|
+
|
|
357
|
+
export interface MockFetch {
|
|
358
|
+
fetch: typeof fetch;
|
|
359
|
+
on(method: string, matcher: MockMatcher): MockRoute;
|
|
360
|
+
get(matcher: MockMatcher): MockRoute;
|
|
361
|
+
post(matcher: MockMatcher): MockRoute;
|
|
362
|
+
put(matcher: MockMatcher): MockRoute;
|
|
363
|
+
patch(matcher: MockMatcher): MockRoute;
|
|
364
|
+
delete(matcher: MockMatcher): MockRoute;
|
|
365
|
+
/** A TypeError("fetch failed") whose `cause.code` is set, like Node's fetch throws. */
|
|
366
|
+
networkError(code?: string): TypeError;
|
|
367
|
+
readonly calls: MockCall[];
|
|
368
|
+
reset(): void;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function createMockFetch(): MockFetch;
|
|
372
|
+
|
|
373
|
+
export interface DownloadResumableOptions {
|
|
374
|
+
fetch?: typeof fetch;
|
|
375
|
+
headers?: HeadersInit;
|
|
376
|
+
/** Consecutive attempts without progress before giving up. Default 5. */
|
|
377
|
+
retries?: number;
|
|
378
|
+
baseDelayMs?: number;
|
|
379
|
+
maxDelayMs?: number;
|
|
380
|
+
onProgress?: (progress: DownloadProgress) => void;
|
|
381
|
+
/** Throws BodyTooLargeError past this many bytes. */
|
|
382
|
+
maxBytes?: number;
|
|
383
|
+
signal?: AbortSignal;
|
|
384
|
+
onRetry?: (err: unknown, attempt: number, delayMs: number, info: { resumeFrom: number }) => void;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function downloadResumable(url: string, opts?: DownloadResumableOptions): Promise<Uint8Array>;
|
|
388
|
+
|
|
389
|
+
export type StreamSource = Response | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array | string>;
|
|
390
|
+
|
|
391
|
+
export interface StreamOptions {
|
|
392
|
+
signal?: AbortSignal;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export interface SSEEvent {
|
|
396
|
+
event: string;
|
|
397
|
+
data: string;
|
|
398
|
+
id: string;
|
|
399
|
+
retry: number | undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export function readLines(source: StreamSource, opts?: StreamOptions): AsyncGenerator<string, void, undefined>;
|
|
403
|
+
export function readNDJSON<T = unknown>(
|
|
404
|
+
source: StreamSource,
|
|
405
|
+
opts?: StreamOptions & { skipInvalid?: boolean }
|
|
406
|
+
): AsyncGenerator<T, void, undefined>;
|
|
407
|
+
export function readSSE(source: StreamSource, opts?: StreamOptions): AsyncGenerator<SSEEvent, void, undefined>;
|