@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.
- package/README.md +305 -21
- package/RECIPES.md +243 -0
- package/dist/reqkit.mjs +2304 -0
- package/package.json +31 -4
- package/src/HTTPError.js +14 -0
- package/src/_cache.js +55 -0
- package/src/_http.js +90 -0
- package/src/_sleep.js +20 -1
- package/src/createCircuitBreaker.js +124 -0
- package/src/createFetch.js +398 -0
- package/src/createLimiter.js +55 -0
- 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 +22 -1
- package/src/index.mjs +13 -0
- package/src/retryAfterAware.js +26 -88
- package/src/streams.js +149 -0
- package/src/withRetry.js +112 -23
- package/src/withTimeout.js +43 -15
- package/types/index.d.ts +308 -2
package/dist/reqkit.mjs
ADDED
|
@@ -0,0 +1,2304 @@
|
|
|
1
|
+
/*! @boy-offi9-inc/reqkit v0.5.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
|
|
2
|
+
const __defs = {
|
|
3
|
+
"./HTTPError": function (module, exports, require) {
|
|
4
|
+
class HTTPError extends Error {
|
|
5
|
+
constructor(response, request) {
|
|
6
|
+
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
7
|
+
super(`Request failed with status ${response.status}${statusText}: ${request.method} ${request.url}`);
|
|
8
|
+
this.name = "HTTPError";
|
|
9
|
+
this.status = response.status;
|
|
10
|
+
this.statusText = response.statusText || "";
|
|
11
|
+
this.response = response;
|
|
12
|
+
this.url = request.url;
|
|
13
|
+
this.method = request.method;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { HTTPError };
|
|
18
|
+
|
|
19
|
+
},
|
|
20
|
+
"./_cache": function (module, exports, require) {
|
|
21
|
+
// Helpers for createFetch's response cache.
|
|
22
|
+
|
|
23
|
+
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
24
|
+
|
|
25
|
+
function cacheKey(method, url, headers) {
|
|
26
|
+
// Every request header is part of the key, so responses fetched with one
|
|
27
|
+
// user's Authorization header can never be served to another.
|
|
28
|
+
return `${method} ${url} ${JSON.stringify([...headers.entries()].sort())}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Reads a response (via a clone — the original stays usable) into a plain,
|
|
33
|
+
* storable entry. Returns null when the response must not be cached.
|
|
34
|
+
*/
|
|
35
|
+
async function snapshotResponse(response, maxBodyBytes = DEFAULT_MAX_BODY_BYTES) {
|
|
36
|
+
if (response.status !== 200) return null;
|
|
37
|
+
|
|
38
|
+
const cacheControl = (response.headers.get("cache-control") || "").toLowerCase();
|
|
39
|
+
if (/\bno-store\b/.test(cacheControl)) return null;
|
|
40
|
+
if ((response.headers.get("vary") || "").trim() === "*") return null;
|
|
41
|
+
|
|
42
|
+
const declared = Number(response.headers.get("content-length"));
|
|
43
|
+
if (response.headers.has("content-length") && declared > maxBodyBytes) return null;
|
|
44
|
+
|
|
45
|
+
const copy = response.clone();
|
|
46
|
+
const buffer = await copy.arrayBuffer();
|
|
47
|
+
if (buffer.byteLength > maxBodyBytes) return null;
|
|
48
|
+
|
|
49
|
+
const headers = [];
|
|
50
|
+
response.headers.forEach((value, key) => {
|
|
51
|
+
// The body we stored is already decoded; replaying these would corrupt it.
|
|
52
|
+
if (key === "content-encoding" || key === "content-length" || key === "transfer-encoding") return;
|
|
53
|
+
headers.push([key, value]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
status: response.status,
|
|
58
|
+
statusText: response.statusText,
|
|
59
|
+
headers,
|
|
60
|
+
body: new Uint8Array(buffer),
|
|
61
|
+
storedAt: Date.now(),
|
|
62
|
+
etag: response.headers.get("etag"),
|
|
63
|
+
lastModified: response.headers.get("last-modified"),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function entryToResponse(entry) {
|
|
68
|
+
return new Response(entry.body.slice(), {
|
|
69
|
+
status: entry.status,
|
|
70
|
+
statusText: entry.statusText,
|
|
71
|
+
headers: entry.headers,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { cacheKey, snapshotResponse, entryToResponse, DEFAULT_MAX_BODY_BYTES };
|
|
76
|
+
|
|
77
|
+
},
|
|
78
|
+
"./_http": function (module, exports, require) {
|
|
79
|
+
/**
|
|
80
|
+
* Parses a Retry-After header value into milliseconds. Per HTTP spec it's
|
|
81
|
+
* either an integer number of seconds, or an HTTP-date.
|
|
82
|
+
*/
|
|
83
|
+
function parseRetryAfter(headerValue) {
|
|
84
|
+
if (headerValue === null || headerValue === undefined) return null;
|
|
85
|
+
const value = String(headerValue).trim();
|
|
86
|
+
if (!value) return null;
|
|
87
|
+
|
|
88
|
+
// delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
|
|
89
|
+
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
90
|
+
return Number(value) * 1000;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const dateMs = Date.parse(value);
|
|
94
|
+
if (!Number.isNaN(dateMs)) {
|
|
95
|
+
return Math.max(0, dateMs - Date.now());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reset headers come in two flavors: a delta in seconds ("30", the IETF
|
|
102
|
+
// RateLimit-Reset draft, Discord's *-Reset-After) or an absolute epoch
|
|
103
|
+
// (GitHub/Twitter style, seconds or ms). Anything under ~31 years in seconds
|
|
104
|
+
// can't be a real epoch, so treat it as a delta.
|
|
105
|
+
function parseResetMs(value) {
|
|
106
|
+
if (value === null || value === undefined || String(value).trim() === "") return null;
|
|
107
|
+
const n = Number(value);
|
|
108
|
+
if (Number.isNaN(n) || n < 0) return null;
|
|
109
|
+
if (n < 1e9) return n * 1000;
|
|
110
|
+
const epochMs = n > 4102444800 ? n : n * 1000;
|
|
111
|
+
return Math.max(0, epochMs - Date.now());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function readHeader(headers, ...names) {
|
|
115
|
+
for (const name of names) {
|
|
116
|
+
const v = headers.get(name);
|
|
117
|
+
if (v !== null && v !== undefined && v !== "") return v;
|
|
118
|
+
}
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Reads a wait time from common rate-limit headers, trying Retry-After first,
|
|
124
|
+
* then falling back to X-RateLimit-Reset(-After) if present.
|
|
125
|
+
*/
|
|
126
|
+
function getWaitMsFromHeaders(headers) {
|
|
127
|
+
if (!headers || typeof headers.get !== "function") return null;
|
|
128
|
+
|
|
129
|
+
const retryAfter = parseRetryAfter(headers.get("retry-after"));
|
|
130
|
+
if (retryAfter !== null) return retryAfter;
|
|
131
|
+
|
|
132
|
+
return parseResetMs(
|
|
133
|
+
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Everything useful a response says about rate limits:
|
|
139
|
+
* { remaining: number|null, resetMs: number|null, retryAfterMs: number|null }
|
|
140
|
+
* Understands X-RateLimit-*, X-Rate-Limit-*, RateLimit-* and Retry-After.
|
|
141
|
+
*/
|
|
142
|
+
function getRateLimitInfo(headers) {
|
|
143
|
+
const info = { remaining: null, resetMs: null, retryAfterMs: null };
|
|
144
|
+
if (!headers || typeof headers.get !== "function") return info;
|
|
145
|
+
|
|
146
|
+
const remaining = readHeader(headers, "x-ratelimit-remaining", "x-rate-limit-remaining", "ratelimit-remaining");
|
|
147
|
+
if (remaining !== null && !Number.isNaN(Number(remaining))) info.remaining = Number(remaining);
|
|
148
|
+
|
|
149
|
+
info.resetMs = parseResetMs(
|
|
150
|
+
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
151
|
+
);
|
|
152
|
+
info.retryAfterMs = parseRetryAfter(headers.get("retry-after"));
|
|
153
|
+
return info;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function discardBody(response) {
|
|
157
|
+
// An unread body keeps the underlying connection busy; cancel it before
|
|
158
|
+
// we sit through a backoff and fire the next request.
|
|
159
|
+
try {
|
|
160
|
+
if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
|
|
161
|
+
await response.body.cancel();
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
// locked / already consumed / not a web stream — safe to ignore
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody };
|
|
169
|
+
|
|
170
|
+
},
|
|
171
|
+
"./_sleep": function (module, exports, require) {
|
|
172
|
+
class RetryAbortedError extends Error {
|
|
173
|
+
constructor(cause) {
|
|
174
|
+
super("Retry loop was aborted");
|
|
175
|
+
this.name = "RetryAbortedError";
|
|
176
|
+
if (cause !== undefined) this.cause = cause;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Abortable sleep shared by withRetry and retryAfterAware. Rejects with
|
|
182
|
+
* RetryAbortedError (carrying `cause`, usually the last failure) if the
|
|
183
|
+
* signal aborts before or during the wait.
|
|
184
|
+
*/
|
|
185
|
+
function sleep(ms, signal, cause) {
|
|
186
|
+
return new Promise((resolve, reject) => {
|
|
187
|
+
if (signal?.aborted) {
|
|
188
|
+
reject(new RetryAbortedError(cause));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const onAbort = () => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
reject(new RetryAbortedError(cause));
|
|
194
|
+
};
|
|
195
|
+
const timer = setTimeout(() => {
|
|
196
|
+
signal?.removeEventListener("abort", onAbort);
|
|
197
|
+
resolve();
|
|
198
|
+
}, ms);
|
|
199
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Like sleep(), but rejects with the signal's own reason (an AbortError by default). */
|
|
204
|
+
function abortableSleep(ms, signal) {
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
if (signal?.aborted) {
|
|
207
|
+
reject(signal.reason);
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const onAbort = () => {
|
|
211
|
+
clearTimeout(timer);
|
|
212
|
+
reject(signal.reason);
|
|
213
|
+
};
|
|
214
|
+
const timer = setTimeout(() => {
|
|
215
|
+
signal?.removeEventListener("abort", onAbort);
|
|
216
|
+
resolve();
|
|
217
|
+
}, ms);
|
|
218
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
module.exports = { RetryAbortedError, sleep, abortableSleep };
|
|
223
|
+
|
|
224
|
+
},
|
|
225
|
+
"./buildQuery": function (module, exports, require) {
|
|
226
|
+
function toParam(key, value) {
|
|
227
|
+
if (value instanceof Date) {
|
|
228
|
+
if (Number.isNaN(value.getTime())) {
|
|
229
|
+
throw new TypeError(`buildQuery: value for "${key}" is an invalid Date`);
|
|
230
|
+
}
|
|
231
|
+
return value.toISOString();
|
|
232
|
+
}
|
|
233
|
+
if (typeof value === "object" || typeof value === "function" || typeof value === "symbol") {
|
|
234
|
+
throw new TypeError(
|
|
235
|
+
`buildQuery: value for "${key}" is not a primitive, Date or array of those — flatten it or JSON.stringify it first`
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return String(value);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Builds a query string from a params object. Skips null/undefined values,
|
|
243
|
+
* repeats the key for arrays (`?tag=a&tag=b`), serializes Dates as ISO 8601,
|
|
244
|
+
* and encodes everything. Nested objects throw a TypeError instead of
|
|
245
|
+
* silently becoming "[object Object]".
|
|
246
|
+
*
|
|
247
|
+
* Deliberately minimal — for full query-string parsing/nested-object support,
|
|
248
|
+
* reach for `query-string` (sindresorhus) instead. This just covers the
|
|
249
|
+
* common "build a query string from a flat params object" case with zero deps.
|
|
250
|
+
*
|
|
251
|
+
* @param {Record<string, unknown>} params
|
|
252
|
+
*/
|
|
253
|
+
function buildQuery(params) {
|
|
254
|
+
if (!params) return "";
|
|
255
|
+
|
|
256
|
+
const pairs = [];
|
|
257
|
+
for (const [key, value] of Object.entries(params)) {
|
|
258
|
+
if (value === null || value === undefined) continue;
|
|
259
|
+
|
|
260
|
+
const values = Array.isArray(value) ? value : [value];
|
|
261
|
+
for (const v of values) {
|
|
262
|
+
if (v === null || v === undefined) continue;
|
|
263
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(toParam(key, v))}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return pairs.join("&");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
module.exports = { buildQuery };
|
|
271
|
+
|
|
272
|
+
},
|
|
273
|
+
"./createCircuitBreaker": function (module, exports, require) {
|
|
274
|
+
class CircuitOpenError extends Error {
|
|
275
|
+
constructor(retryAfterMs) {
|
|
276
|
+
super("Circuit is open — failing fast instead of calling a service that keeps failing");
|
|
277
|
+
this.name = "CircuitOpenError";
|
|
278
|
+
// Deliberately not an E-code: this is *not* a network error and must not be retried as one.
|
|
279
|
+
this.code = "CIRCUIT_OPEN";
|
|
280
|
+
this.retryAfterMs = retryAfterMs;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function defaultIsFailure(valueOrError, isError) {
|
|
285
|
+
if (isError) return !(valueOrError && valueOrError.name === "AbortError");
|
|
286
|
+
return Boolean(valueOrError) && typeof valueOrError.status === "number" && valueOrError.status >= 500;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Circuit breaker: after `failureThreshold` consecutive failures it "opens"
|
|
291
|
+
* and fails fast with CircuitOpenError for `resetTimeoutMs`, so a dying
|
|
292
|
+
* service isn't hammered (and your callers don't wait on it). Then it goes
|
|
293
|
+
* "half-open" and lets `halfOpenMax` probe through: success closes the
|
|
294
|
+
* circuit, failure re-opens it.
|
|
295
|
+
*
|
|
296
|
+
* By default a thrown error (except AbortError) or a result with status >= 500
|
|
297
|
+
* counts as a failure; override with `isFailure(valueOrError, isError)`.
|
|
298
|
+
*
|
|
299
|
+
* @param {object} [opts]
|
|
300
|
+
* @param {number} [opts.failureThreshold=5]
|
|
301
|
+
* @param {number} [opts.resetTimeoutMs=30000]
|
|
302
|
+
* @param {number} [opts.halfOpenMax=1]
|
|
303
|
+
* @param {(valueOrError: any, isError: boolean) => boolean} [opts.isFailure]
|
|
304
|
+
* @param {(state: string, prev: string) => void} [opts.onStateChange]
|
|
305
|
+
*/
|
|
306
|
+
function createCircuitBreaker(opts = {}) {
|
|
307
|
+
const {
|
|
308
|
+
failureThreshold = 5,
|
|
309
|
+
resetTimeoutMs = 30000,
|
|
310
|
+
halfOpenMax = 1,
|
|
311
|
+
isFailure = defaultIsFailure,
|
|
312
|
+
onStateChange,
|
|
313
|
+
} = opts;
|
|
314
|
+
|
|
315
|
+
let state = "closed";
|
|
316
|
+
let failures = 0;
|
|
317
|
+
let openedAt = 0;
|
|
318
|
+
let probes = 0;
|
|
319
|
+
|
|
320
|
+
function setState(next) {
|
|
321
|
+
if (next === state) return;
|
|
322
|
+
const prev = state;
|
|
323
|
+
state = next;
|
|
324
|
+
if (onStateChange) onStateChange(next, prev);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function open() {
|
|
328
|
+
openedAt = Date.now();
|
|
329
|
+
setState("open");
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function current() {
|
|
333
|
+
if (state === "open" && Date.now() - openedAt >= resetTimeoutMs) {
|
|
334
|
+
probes = 0;
|
|
335
|
+
setState("half-open");
|
|
336
|
+
}
|
|
337
|
+
return state;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function recordFailure() {
|
|
341
|
+
if (state === "half-open") {
|
|
342
|
+
open();
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
failures++;
|
|
346
|
+
if (failures >= failureThreshold) open();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function recordSuccess() {
|
|
350
|
+
failures = 0;
|
|
351
|
+
if (state === "half-open") setState("closed");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function run(fn) {
|
|
355
|
+
const s = current();
|
|
356
|
+
if (s === "open") {
|
|
357
|
+
throw new CircuitOpenError(Math.max(0, resetTimeoutMs - (Date.now() - openedAt)));
|
|
358
|
+
}
|
|
359
|
+
let probing = false;
|
|
360
|
+
if (s === "half-open") {
|
|
361
|
+
if (probes >= halfOpenMax) throw new CircuitOpenError(0);
|
|
362
|
+
probes++;
|
|
363
|
+
probing = true;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
let result;
|
|
367
|
+
try {
|
|
368
|
+
result = await fn();
|
|
369
|
+
} catch (err) {
|
|
370
|
+
if (isFailure(err, true)) recordFailure();
|
|
371
|
+
else if (probing) probes--; // e.g. an abort — free the probe slot
|
|
372
|
+
throw err;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (isFailure(result, false)) recordFailure();
|
|
376
|
+
else recordSuccess();
|
|
377
|
+
return result;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
run,
|
|
382
|
+
get state() {
|
|
383
|
+
return current();
|
|
384
|
+
},
|
|
385
|
+
get failures() {
|
|
386
|
+
return failures;
|
|
387
|
+
},
|
|
388
|
+
/** Force the circuit closed and forget the failure count. */
|
|
389
|
+
reset() {
|
|
390
|
+
failures = 0;
|
|
391
|
+
probes = 0;
|
|
392
|
+
setState("closed");
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
module.exports = { createCircuitBreaker, CircuitOpenError };
|
|
398
|
+
|
|
399
|
+
},
|
|
400
|
+
"./createFetch": function (module, exports, require) {
|
|
401
|
+
const { withRetry, RetryAbortedError } = require("./withRetry");
|
|
402
|
+
const { withTimeout } = require("./withTimeout");
|
|
403
|
+
const { withDedupe } = require("./withDedupe");
|
|
404
|
+
const { buildQuery } = require("./buildQuery");
|
|
405
|
+
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
406
|
+
const { normalizeError } = require("./normalizeError");
|
|
407
|
+
const { HTTPError } = require("./HTTPError");
|
|
408
|
+
const { createRateLimiter } = require("./createRateLimiter");
|
|
409
|
+
const { createCircuitBreaker } = require("./createCircuitBreaker");
|
|
410
|
+
const { createMemoryCache } = require("./createMemoryCache");
|
|
411
|
+
const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
|
|
412
|
+
const { discardBody } = require("./_http");
|
|
413
|
+
|
|
414
|
+
const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
|
|
415
|
+
// Safe to repeat without side effects. POST/PATCH are deliberately absent.
|
|
416
|
+
const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
417
|
+
|
|
418
|
+
function isAbsoluteUrl(url) {
|
|
419
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function joinUrl(baseUrl, url, query) {
|
|
423
|
+
let full = String(url);
|
|
424
|
+
if (baseUrl && !isAbsoluteUrl(full)) {
|
|
425
|
+
full = `${String(baseUrl).replace(/\/+$/, "")}/${full.replace(/^\/+/, "")}`;
|
|
426
|
+
}
|
|
427
|
+
const qs = query ? buildQuery(query) : "";
|
|
428
|
+
if (qs) full += (full.includes("?") ? "&" : "?") + qs;
|
|
429
|
+
return full;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function resolveHeaders(source) {
|
|
433
|
+
const value = typeof source === "function" ? await source() : source;
|
|
434
|
+
return new Headers(value || undefined);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function mergeHeaders(...headerSets) {
|
|
438
|
+
const out = new Headers();
|
|
439
|
+
for (const set of headerSets) {
|
|
440
|
+
set.forEach((value, key) => out.set(key, value));
|
|
441
|
+
}
|
|
442
|
+
return out;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function isStreamBody(body) {
|
|
446
|
+
return Boolean(body) && typeof body === "object" && typeof body.getReader === "function";
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function originOf(url) {
|
|
450
|
+
try {
|
|
451
|
+
return new URL(url).origin;
|
|
452
|
+
} catch {
|
|
453
|
+
return url;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function mergeDefaults(base, extra) {
|
|
458
|
+
const merged = { ...base, ...extra };
|
|
459
|
+
|
|
460
|
+
if (base.headers || extra.headers) {
|
|
461
|
+
merged.headers = async () => {
|
|
462
|
+
const a = await resolveHeaders(base.headers);
|
|
463
|
+
const b = await resolveHeaders(extra.headers);
|
|
464
|
+
return mergeHeaders(a, b);
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
|
|
468
|
+
merged.retry = { ...base.retry, ...extra.retry };
|
|
469
|
+
}
|
|
470
|
+
return merged;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Builds a fetch-like function from the other reqkit pieces — only if you
|
|
475
|
+
* want that. It's still plain `fetch` underneath: you get a standard
|
|
476
|
+
* `Response` back, and `fetch` itself stays swappable.
|
|
477
|
+
*
|
|
478
|
+
* What you get on top of fetch:
|
|
479
|
+
* - `baseUrl`, `query` (via buildQuery), `json` request bodies
|
|
480
|
+
* - default headers (an object, or a function for tokens that change)
|
|
481
|
+
* - per-attempt `timeout` (withTimeout) and external `signal`
|
|
482
|
+
* - retries (withRetry) on network errors/timeouts and retryable statuses,
|
|
483
|
+
* honoring Retry-After; only for idempotent methods unless you opt in
|
|
484
|
+
* - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
|
|
485
|
+
* *before* the server has to say 429
|
|
486
|
+
* - `circuitBreaker`: fail fast against a service that keeps failing
|
|
487
|
+
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
|
|
488
|
+
* ETag / Last-Modified revalidation (304s)
|
|
489
|
+
* - concurrent GET de-duplication (withDedupe)
|
|
490
|
+
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
491
|
+
* - `onRequest` / `onResponse` hooks
|
|
492
|
+
*
|
|
493
|
+
* @param {object} [defaults]
|
|
494
|
+
* @param {string} [defaults.baseUrl]
|
|
495
|
+
* @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [defaults.headers]
|
|
496
|
+
* @param {number | false} [defaults.timeout=30000] - per attempt, ms
|
|
497
|
+
* @param {false | object} [defaults.retry] - false disables. Options:
|
|
498
|
+
* `retries`(2), `statusCodes`, `methods`, `baseDelayMs`, `maxDelayMs`,
|
|
499
|
+
* `factor`, `jitter`, `maxRetryTime`, `onRetry`, `calculateDelay`
|
|
500
|
+
* @param {boolean | object} [defaults.rateLimit] - `true` just learns from
|
|
501
|
+
* response headers; `{ requests, perMs }` also enforces a budget; or pass a
|
|
502
|
+
* createRateLimiter() instance to share one budget
|
|
503
|
+
* @param {boolean | object | Function} [defaults.circuitBreaker] - options for
|
|
504
|
+
* a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
|
|
505
|
+
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
|
|
506
|
+
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
|
|
507
|
+
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
508
|
+
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
509
|
+
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
510
|
+
* @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
|
|
511
|
+
* runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
|
|
512
|
+
* @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
|
|
513
|
+
*/
|
|
514
|
+
function createFetch(defaults = {}) {
|
|
515
|
+
const {
|
|
516
|
+
baseUrl,
|
|
517
|
+
headers: defaultHeaders,
|
|
518
|
+
timeout: defaultTimeout = 30000,
|
|
519
|
+
retry: defaultRetry = {},
|
|
520
|
+
rateLimit,
|
|
521
|
+
circuitBreaker,
|
|
522
|
+
cache,
|
|
523
|
+
dedupe = false,
|
|
524
|
+
throwHttpErrors: defaultThrow = true,
|
|
525
|
+
fetch: customFetch,
|
|
526
|
+
onRequest,
|
|
527
|
+
onResponse,
|
|
528
|
+
} = defaults;
|
|
529
|
+
|
|
530
|
+
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
531
|
+
|
|
532
|
+
// --- stateful pieces, built once so `extend()` children share them -------
|
|
533
|
+
const limiter = !rateLimit
|
|
534
|
+
? null
|
|
535
|
+
: typeof rateLimit.acquire === "function"
|
|
536
|
+
? rateLimit
|
|
537
|
+
: createRateLimiter(rateLimit === true ? {} : rateLimit);
|
|
538
|
+
|
|
539
|
+
const breakerRegistry = new Map();
|
|
540
|
+
const breakerFor = !circuitBreaker
|
|
541
|
+
? null
|
|
542
|
+
: typeof circuitBreaker === "function"
|
|
543
|
+
? circuitBreaker
|
|
544
|
+
: typeof circuitBreaker.run === "function"
|
|
545
|
+
? () => circuitBreaker
|
|
546
|
+
: (url) => {
|
|
547
|
+
const origin = originOf(url);
|
|
548
|
+
if (!breakerRegistry.has(origin)) {
|
|
549
|
+
breakerRegistry.set(origin, createCircuitBreaker(circuitBreaker === true ? {} : circuitBreaker));
|
|
550
|
+
}
|
|
551
|
+
return breakerRegistry.get(origin);
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
const cacheCfg = !cache
|
|
555
|
+
? null
|
|
556
|
+
: {
|
|
557
|
+
ttl: 60000,
|
|
558
|
+
staleWhileRevalidate: 0,
|
|
559
|
+
maxEntries: 100,
|
|
560
|
+
maxBodyBytes: undefined,
|
|
561
|
+
...(cache === true ? {} : cache),
|
|
562
|
+
};
|
|
563
|
+
if (cacheCfg && !cacheCfg.store) cacheCfg.store = createMemoryCache({ maxEntries: cacheCfg.maxEntries });
|
|
564
|
+
// In-flight bookkeeping lives on the (shared) cache config so `extend()`
|
|
565
|
+
// children see the parent's pending writes and revalidations.
|
|
566
|
+
if (cacheCfg) {
|
|
567
|
+
cacheCfg.pending = cacheCfg.pending || new Map();
|
|
568
|
+
cacheCfg.revalidating = cacheCfg.revalidating || new Set();
|
|
569
|
+
}
|
|
570
|
+
const pendingStores = cacheCfg ? cacheCfg.pending : null;
|
|
571
|
+
const revalidating = cacheCfg ? cacheCfg.revalidating : null;
|
|
572
|
+
const emit = (type, url) => cacheCfg && cacheCfg.onEvent && cacheCfg.onEvent({ type, url });
|
|
573
|
+
|
|
574
|
+
// Shared in-flight GETs. Every caller gets its own clone of the response so
|
|
575
|
+
// body reads don't fight each other.
|
|
576
|
+
const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
|
|
577
|
+
|
|
578
|
+
function scheduleStore(key, response, url) {
|
|
579
|
+
const p = snapshotResponse(response, cacheCfg.maxBodyBytes)
|
|
580
|
+
.then(async (entry) => {
|
|
581
|
+
if (entry) {
|
|
582
|
+
await cacheCfg.store.set(key, entry);
|
|
583
|
+
emit("store", url);
|
|
584
|
+
}
|
|
585
|
+
})
|
|
586
|
+
.catch(() => {})
|
|
587
|
+
.finally(() => {
|
|
588
|
+
if (pendingStores.get(key) === p) pendingStores.delete(key);
|
|
589
|
+
});
|
|
590
|
+
pendingStores.set(key, p);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function request(input, init = {}) {
|
|
594
|
+
const {
|
|
595
|
+
query,
|
|
596
|
+
json,
|
|
597
|
+
timeout = defaultTimeout,
|
|
598
|
+
retry: retryOpt,
|
|
599
|
+
throwHttpErrors = defaultThrow,
|
|
600
|
+
signal,
|
|
601
|
+
headers: initHeaders,
|
|
602
|
+
cache: cacheMode,
|
|
603
|
+
...fetchInit
|
|
604
|
+
} = init;
|
|
605
|
+
|
|
606
|
+
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
607
|
+
const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
|
|
608
|
+
|
|
609
|
+
let body = fetchInit.body;
|
|
610
|
+
if (json !== undefined) {
|
|
611
|
+
body = JSON.stringify(json);
|
|
612
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
|
|
616
|
+
if (onRequest) await onRequest(ctx);
|
|
617
|
+
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
618
|
+
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
619
|
+
|
|
620
|
+
// --- retry policy ------------------------------------------------------
|
|
621
|
+
const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
|
|
622
|
+
const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
|
|
623
|
+
const hasIdempotencyKey = finalHeaders.has("idempotency-key");
|
|
624
|
+
const canRetry =
|
|
625
|
+
retryCfg !== false &&
|
|
626
|
+
!isStreamBody(ctx.init.body) && // a consumed stream can't be replayed
|
|
627
|
+
(methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
|
|
628
|
+
const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
|
|
629
|
+
|
|
630
|
+
// One trip to the network: rate limit → circuit breaker → per-attempt
|
|
631
|
+
// timeout → fetch, retried per the policy above. `netHeaders` and
|
|
632
|
+
// `netSignal` are parameters so background revalidation can reuse this.
|
|
633
|
+
function network(netHeaders, netSignal) {
|
|
634
|
+
const attemptOnce = () => {
|
|
635
|
+
const attempt = async () => {
|
|
636
|
+
if (limiter) await limiter.acquire({ signal: netSignal });
|
|
637
|
+
const res = await withTimeout(
|
|
638
|
+
(attemptSignal) => {
|
|
639
|
+
const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
|
|
640
|
+
const useDedupe =
|
|
641
|
+
dedupe && !netSignal && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
|
|
642
|
+
if (!useDedupe) return doFetch(ctx.url, attemptInit);
|
|
643
|
+
// No per-attempt signal on a shared request: aborting one caller
|
|
644
|
+
// must never cancel everyone else's. It still gets its own
|
|
645
|
+
// timeout so a hung server can't leak the request.
|
|
646
|
+
const sharedInit = {
|
|
647
|
+
...attemptInit,
|
|
648
|
+
signal: timeout === false ? undefined : AbortSignal.timeout(timeout),
|
|
649
|
+
};
|
|
650
|
+
const key = `${finalMethod} ${ctx.url} ${JSON.stringify([...netHeaders.entries()].sort())}`;
|
|
651
|
+
return sharedFetch(key, ctx.url, sharedInit).then((res) => res.clone());
|
|
652
|
+
},
|
|
653
|
+
timeout === false ? Infinity : timeout,
|
|
654
|
+
{ signal: netSignal }
|
|
655
|
+
);
|
|
656
|
+
if (limiter) limiter.update(res);
|
|
657
|
+
return res;
|
|
658
|
+
};
|
|
659
|
+
const breaker = breakerFor && breakerFor(ctx.url);
|
|
660
|
+
return breaker ? breaker.run(attempt) : attempt();
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
return canRetry
|
|
664
|
+
? withRetry(attemptOnce, {
|
|
665
|
+
...retryRest,
|
|
666
|
+
retries,
|
|
667
|
+
retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
|
|
668
|
+
shouldRetry:
|
|
669
|
+
customShouldRetry ||
|
|
670
|
+
((err) => {
|
|
671
|
+
const n = normalizeError(err);
|
|
672
|
+
return n.isNetworkError || n.isTimeout;
|
|
673
|
+
}),
|
|
674
|
+
signal: netSignal,
|
|
675
|
+
})
|
|
676
|
+
: attemptOnce();
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function conditionalHeaders(entry) {
|
|
680
|
+
const h = new Headers(finalHeaders);
|
|
681
|
+
if (entry) {
|
|
682
|
+
if (entry.etag && !h.has("if-none-match")) h.set("if-none-match", entry.etag);
|
|
683
|
+
if (entry.lastModified && !h.has("if-modified-since")) h.set("if-modified-since", entry.lastModified);
|
|
684
|
+
}
|
|
685
|
+
return h;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// --- cache lookup --------------------------------------------------------
|
|
689
|
+
const useCache =
|
|
690
|
+
Boolean(cacheCfg) &&
|
|
691
|
+
finalMethod === "GET" &&
|
|
692
|
+
!ctx.init.body &&
|
|
693
|
+
cacheMode !== false &&
|
|
694
|
+
cacheMode !== "no-store";
|
|
695
|
+
const key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
|
|
696
|
+
let entry = null;
|
|
697
|
+
|
|
698
|
+
const finish = async (response) => {
|
|
699
|
+
if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
|
|
700
|
+
if (throwHttpErrors && !response.ok) {
|
|
701
|
+
throw new HTTPError(response, { method: finalMethod, url: ctx.url });
|
|
702
|
+
}
|
|
703
|
+
return response;
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
if (useCache && cacheMode !== "reload") {
|
|
707
|
+
if (pendingStores.has(key)) await pendingStores.get(key);
|
|
708
|
+
entry = (await cacheCfg.store.get(key)) || null;
|
|
709
|
+
if (entry) {
|
|
710
|
+
const age = Date.now() - entry.storedAt;
|
|
711
|
+
if (age < cacheCfg.ttl) {
|
|
712
|
+
emit("hit", ctx.url);
|
|
713
|
+
return finish(entryToResponse(entry));
|
|
714
|
+
}
|
|
715
|
+
if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
|
|
716
|
+
emit("stale", ctx.url);
|
|
717
|
+
if (!revalidating.has(key)) {
|
|
718
|
+
revalidating.add(key);
|
|
719
|
+
const staleEntry = entry;
|
|
720
|
+
(async () => {
|
|
721
|
+
try {
|
|
722
|
+
const res = await network(conditionalHeaders(staleEntry), undefined);
|
|
723
|
+
if (res.status === 304) {
|
|
724
|
+
staleEntry.storedAt = Date.now();
|
|
725
|
+
await cacheCfg.store.set(key, staleEntry);
|
|
726
|
+
emit("revalidated", ctx.url);
|
|
727
|
+
await discardBody(res);
|
|
728
|
+
} else if (res.status === 200) {
|
|
729
|
+
scheduleStore(key, res, ctx.url);
|
|
730
|
+
await pendingStores.get(key);
|
|
731
|
+
} else {
|
|
732
|
+
await discardBody(res);
|
|
733
|
+
}
|
|
734
|
+
} catch {
|
|
735
|
+
// keep serving the stale copy; try again next time
|
|
736
|
+
} finally {
|
|
737
|
+
revalidating.delete(key);
|
|
738
|
+
}
|
|
739
|
+
})();
|
|
740
|
+
}
|
|
741
|
+
return finish(entryToResponse(entry));
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
// --- network -------------------------------------------------------------
|
|
747
|
+
let response;
|
|
748
|
+
try {
|
|
749
|
+
response = await network(useCache ? conditionalHeaders(entry) : finalHeaders, signal);
|
|
750
|
+
} catch (err) {
|
|
751
|
+
// Present a user abort as the plain AbortError they caused, not as retry internals.
|
|
752
|
+
if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
|
|
753
|
+
throw err;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (useCache) {
|
|
757
|
+
if (response.status === 304 && entry) {
|
|
758
|
+
entry.storedAt = Date.now();
|
|
759
|
+
await cacheCfg.store.set(key, entry);
|
|
760
|
+
emit("revalidated", ctx.url);
|
|
761
|
+
await discardBody(response);
|
|
762
|
+
response = entryToResponse(entry);
|
|
763
|
+
} else {
|
|
764
|
+
emit("miss", ctx.url);
|
|
765
|
+
if (response.status === 200) scheduleStore(key, response, ctx.url);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
return finish(response);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
|
|
773
|
+
request.json = async (input, init) => {
|
|
774
|
+
const response = await request(input, init);
|
|
775
|
+
const { data, error } = await parseJsonSafe(response);
|
|
776
|
+
if (error) throw error;
|
|
777
|
+
return data;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
/** A new fetch with these defaults merged over the current ones (headers and retry are merged, the rest replaced). */
|
|
781
|
+
request.extend = (overrides = {}) =>
|
|
782
|
+
createFetch(
|
|
783
|
+
mergeDefaults(
|
|
784
|
+
{
|
|
785
|
+
...defaults,
|
|
786
|
+
// children share the parent's budget, breakers and cache unless they bring their own
|
|
787
|
+
rateLimit: limiter || undefined,
|
|
788
|
+
circuitBreaker: breakerFor || undefined,
|
|
789
|
+
cache: cacheCfg || undefined,
|
|
790
|
+
},
|
|
791
|
+
overrides
|
|
792
|
+
)
|
|
793
|
+
);
|
|
794
|
+
|
|
795
|
+
return request;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
module.exports = { createFetch, HTTPError };
|
|
799
|
+
|
|
800
|
+
},
|
|
801
|
+
"./createLimiter": function (module, exports, require) {
|
|
802
|
+
/**
|
|
803
|
+
* Creates a concurrency limiter: at most `concurrency` calls run at once,
|
|
804
|
+
* the rest wait in a FIFO queue. Handy for "download these 200 files, 4 at a
|
|
805
|
+
* time" without pulling in a queue library. (Same idea as p-limit.)
|
|
806
|
+
*
|
|
807
|
+
* ```js
|
|
808
|
+
* const limit = createLimiter(4);
|
|
809
|
+
* const bytes = await Promise.all(urls.map((u) => limit(() => download(u))));
|
|
810
|
+
* // or: await limit.map(urls, (u) => download(u));
|
|
811
|
+
* ```
|
|
812
|
+
*
|
|
813
|
+
* @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
|
|
814
|
+
*/
|
|
815
|
+
function createLimiter(concurrency = Infinity) {
|
|
816
|
+
if (concurrency !== Infinity && !(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
817
|
+
throw new TypeError("createLimiter: `concurrency` must be an integer >= 1 (or Infinity)");
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const queue = [];
|
|
821
|
+
let active = 0;
|
|
822
|
+
|
|
823
|
+
function next() {
|
|
824
|
+
while (active < concurrency && queue.length > 0) {
|
|
825
|
+
active++;
|
|
826
|
+
queue.shift()();
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function limit(fn, ...args) {
|
|
831
|
+
return new Promise((resolve, reject) => {
|
|
832
|
+
queue.push(() => {
|
|
833
|
+
Promise.resolve()
|
|
834
|
+
.then(() => fn(...args))
|
|
835
|
+
.then(resolve, reject)
|
|
836
|
+
.finally(() => {
|
|
837
|
+
active--;
|
|
838
|
+
next();
|
|
839
|
+
});
|
|
840
|
+
});
|
|
841
|
+
next();
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
Object.defineProperties(limit, {
|
|
846
|
+
activeCount: { get: () => active },
|
|
847
|
+
pendingCount: { get: () => queue.length },
|
|
848
|
+
});
|
|
849
|
+
|
|
850
|
+
/** Runs `fn(item, index)` for every item through the limiter. Results keep input order. */
|
|
851
|
+
limit.map = (items, fn) => Promise.all(Array.from(items, (item, i) => limit(fn, item, i)));
|
|
852
|
+
|
|
853
|
+
return limit;
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
module.exports = { createLimiter };
|
|
857
|
+
|
|
858
|
+
},
|
|
859
|
+
"./createMemoryCache": function (module, exports, require) {
|
|
860
|
+
/**
|
|
861
|
+
* A small in-memory LRU store for createFetch's `cache` option. Anything with
|
|
862
|
+
* async-or-sync `get(key)`, `set(key, value)` and `delete(key)` can be used
|
|
863
|
+
* instead (Redis, IndexedDB, a Map...).
|
|
864
|
+
*
|
|
865
|
+
* @param {{ maxEntries?: number }} [opts]
|
|
866
|
+
*/
|
|
867
|
+
function createMemoryCache(opts = {}) {
|
|
868
|
+
const { maxEntries = 100 } = opts;
|
|
869
|
+
if (!(Number.isInteger(maxEntries) && maxEntries >= 1)) {
|
|
870
|
+
throw new TypeError("createMemoryCache: `maxEntries` must be an integer >= 1");
|
|
871
|
+
}
|
|
872
|
+
const map = new Map();
|
|
873
|
+
|
|
874
|
+
return {
|
|
875
|
+
get(key) {
|
|
876
|
+
if (!map.has(key)) return undefined;
|
|
877
|
+
const value = map.get(key);
|
|
878
|
+
map.delete(key); // re-insert so it counts as most recently used
|
|
879
|
+
map.set(key, value);
|
|
880
|
+
return value;
|
|
881
|
+
},
|
|
882
|
+
set(key, value) {
|
|
883
|
+
map.delete(key);
|
|
884
|
+
map.set(key, value);
|
|
885
|
+
while (map.size > maxEntries) map.delete(map.keys().next().value);
|
|
886
|
+
},
|
|
887
|
+
delete(key) {
|
|
888
|
+
return map.delete(key);
|
|
889
|
+
},
|
|
890
|
+
clear() {
|
|
891
|
+
map.clear();
|
|
892
|
+
},
|
|
893
|
+
get size() {
|
|
894
|
+
return map.size;
|
|
895
|
+
},
|
|
896
|
+
};
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
module.exports = { createMemoryCache };
|
|
900
|
+
|
|
901
|
+
},
|
|
902
|
+
"./createMockFetch": function (module, exports, require) {
|
|
903
|
+
const { abortableSleep } = require("./_sleep");
|
|
904
|
+
|
|
905
|
+
const BODYLESS = new Set([101, 204, 205, 304]);
|
|
906
|
+
|
|
907
|
+
function toBody(body, headers) {
|
|
908
|
+
if (body === null || body === undefined) return null;
|
|
909
|
+
if (typeof body === "string") return body;
|
|
910
|
+
if (
|
|
911
|
+
body instanceof ArrayBuffer ||
|
|
912
|
+
ArrayBuffer.isView(body) ||
|
|
913
|
+
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
914
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream) ||
|
|
915
|
+
body instanceof URLSearchParams
|
|
916
|
+
) {
|
|
917
|
+
return body;
|
|
918
|
+
}
|
|
919
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
920
|
+
return JSON.stringify(body);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
function buildResponse(status, body, extraHeaders) {
|
|
924
|
+
const headers = new Headers(extraHeaders || undefined);
|
|
925
|
+
const payload = BODYLESS.has(status) ? null : toBody(body, headers);
|
|
926
|
+
return new Response(payload, { status, headers });
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
function matchesUrl(matcher, url) {
|
|
930
|
+
if (typeof matcher === "function") return Boolean(matcher(url));
|
|
931
|
+
if (matcher instanceof RegExp) return matcher.test(url);
|
|
932
|
+
if (matcher === "*" || matcher === undefined) return true;
|
|
933
|
+
if (url === matcher) return true;
|
|
934
|
+
try {
|
|
935
|
+
const u = new URL(url);
|
|
936
|
+
if (matcher === u.pathname + u.search) return true;
|
|
937
|
+
if (!String(matcher).includes("?") && matcher === u.pathname) return true;
|
|
938
|
+
} catch {
|
|
939
|
+
// relative/odd URL — exact match only
|
|
940
|
+
}
|
|
941
|
+
return false;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* A scriptable, fetch-compatible test double — for testing code that uses
|
|
946
|
+
* retries, timeouts, rate limits and so on without a network.
|
|
947
|
+
*
|
|
948
|
+
* ```js
|
|
949
|
+
* const mock = createMockFetch();
|
|
950
|
+
* mock.on("GET", "/users/1")
|
|
951
|
+
* .replyOnce(503, "busy", { headers: { "retry-after": "0" } })
|
|
952
|
+
* .reply(200, { id: 1 });
|
|
953
|
+
* mock.on("POST", /\/orders/).fail(mock.networkError());
|
|
954
|
+
*
|
|
955
|
+
* const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test" });
|
|
956
|
+
* await api.json("/users/1"); // → { id: 1 } after one retry
|
|
957
|
+
* mock.calls.length; // 2
|
|
958
|
+
* ```
|
|
959
|
+
*
|
|
960
|
+
* String matchers match the full URL, `pathname + search`, or the bare
|
|
961
|
+
* `pathname`; you can also pass a RegExp or a `(url) => boolean`. `"*"` as the
|
|
962
|
+
* method matches any method. Unmatched requests reject with a clear error.
|
|
963
|
+
*/
|
|
964
|
+
function createMockFetch() {
|
|
965
|
+
let routes = [];
|
|
966
|
+
const calls = [];
|
|
967
|
+
|
|
968
|
+
function on(method, matcher) {
|
|
969
|
+
const route = { method: String(method).toUpperCase(), matcher, once: [], fallback: null };
|
|
970
|
+
routes.push(route);
|
|
971
|
+
|
|
972
|
+
const chain = {
|
|
973
|
+
/** Always answer like this (once any `…Once` replies are used up). */
|
|
974
|
+
reply(status = 200, body, opts) {
|
|
975
|
+
route.fallback = replyHandler(status, body, opts);
|
|
976
|
+
return chain;
|
|
977
|
+
},
|
|
978
|
+
replyOnce(status = 200, body, opts) {
|
|
979
|
+
route.once.push(replyHandler(status, body, opts));
|
|
980
|
+
return chain;
|
|
981
|
+
},
|
|
982
|
+
/** `fn(call)` returns a Response, `{ status, body, headers }`, or throws/rejects. */
|
|
983
|
+
replyWith(fn) {
|
|
984
|
+
route.fallback = customHandler(fn);
|
|
985
|
+
return chain;
|
|
986
|
+
},
|
|
987
|
+
replyWithOnce(fn) {
|
|
988
|
+
route.once.push(customHandler(fn));
|
|
989
|
+
return chain;
|
|
990
|
+
},
|
|
991
|
+
/** Reject like fetch does on a network failure. */
|
|
992
|
+
fail(err = networkError()) {
|
|
993
|
+
route.fallback = async () => {
|
|
994
|
+
throw err;
|
|
995
|
+
};
|
|
996
|
+
return chain;
|
|
997
|
+
},
|
|
998
|
+
failOnce(err = networkError()) {
|
|
999
|
+
route.once.push(async () => {
|
|
1000
|
+
throw err;
|
|
1001
|
+
});
|
|
1002
|
+
return chain;
|
|
1003
|
+
},
|
|
1004
|
+
};
|
|
1005
|
+
return chain;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function replyHandler(status, body, opts = {}) {
|
|
1009
|
+
return async (call) => {
|
|
1010
|
+
if (opts.delayMs) await abortableSleep(opts.delayMs, call.signal);
|
|
1011
|
+
return buildResponse(status, body, opts.headers);
|
|
1012
|
+
};
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
function customHandler(fn) {
|
|
1016
|
+
return async (call) => {
|
|
1017
|
+
const out = await fn(call);
|
|
1018
|
+
if (out instanceof Response) return out;
|
|
1019
|
+
return buildResponse(out.status ?? 200, out.body, out.headers);
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function networkError(code = "ECONNREFUSED") {
|
|
1024
|
+
const err = new TypeError("fetch failed");
|
|
1025
|
+
err.cause = Object.assign(new Error(`connect ${code}`), { code });
|
|
1026
|
+
return err;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
async function mockFetch(input, init = {}) {
|
|
1030
|
+
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
|
1031
|
+
const method = String(init.method || (typeof input === "object" && input.method) || "GET").toUpperCase();
|
|
1032
|
+
const signal = init.signal || (typeof input === "object" ? input.signal : undefined);
|
|
1033
|
+
signal?.throwIfAborted?.();
|
|
1034
|
+
|
|
1035
|
+
const headers = {};
|
|
1036
|
+
new Headers(init.headers || (typeof input === "object" ? input.headers : undefined)).forEach((v, k) => {
|
|
1037
|
+
headers[k] = v;
|
|
1038
|
+
});
|
|
1039
|
+
|
|
1040
|
+
const call = {
|
|
1041
|
+
url,
|
|
1042
|
+
method,
|
|
1043
|
+
headers,
|
|
1044
|
+
body: init.body,
|
|
1045
|
+
signal,
|
|
1046
|
+
get json() {
|
|
1047
|
+
try {
|
|
1048
|
+
return typeof this.body === "string" ? JSON.parse(this.body) : undefined;
|
|
1049
|
+
} catch {
|
|
1050
|
+
return undefined;
|
|
1051
|
+
}
|
|
1052
|
+
},
|
|
1053
|
+
};
|
|
1054
|
+
calls.push(call);
|
|
1055
|
+
|
|
1056
|
+
const route = routes.find(
|
|
1057
|
+
(r) => (r.method === "*" || r.method === method) && matchesUrl(r.matcher, url) && (r.once.length > 0 || r.fallback)
|
|
1058
|
+
);
|
|
1059
|
+
if (!route) {
|
|
1060
|
+
throw new Error(`createMockFetch: no mock registered for ${method} ${url}`);
|
|
1061
|
+
}
|
|
1062
|
+
const handler = route.once.length > 0 ? route.once.shift() : route.fallback;
|
|
1063
|
+
return handler(call);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
return {
|
|
1067
|
+
fetch: mockFetch,
|
|
1068
|
+
on,
|
|
1069
|
+
get: (m) => on("GET", m),
|
|
1070
|
+
post: (m) => on("POST", m),
|
|
1071
|
+
put: (m) => on("PUT", m),
|
|
1072
|
+
patch: (m) => on("PATCH", m),
|
|
1073
|
+
delete: (m) => on("DELETE", m),
|
|
1074
|
+
/** A TypeError("fetch failed") with `cause.code`, like Node's fetch throws. */
|
|
1075
|
+
networkError,
|
|
1076
|
+
/** Every request made so far, in order. */
|
|
1077
|
+
calls,
|
|
1078
|
+
/** Remove all routes and forget recorded calls. */
|
|
1079
|
+
reset() {
|
|
1080
|
+
routes = [];
|
|
1081
|
+
calls.length = 0;
|
|
1082
|
+
},
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
module.exports = { createMockFetch };
|
|
1087
|
+
|
|
1088
|
+
},
|
|
1089
|
+
"./createRateLimiter": function (module, exports, require) {
|
|
1090
|
+
const { abortableSleep } = require("./_sleep");
|
|
1091
|
+
const { getRateLimitInfo } = require("./_http");
|
|
1092
|
+
|
|
1093
|
+
function abortable(promise, signal) {
|
|
1094
|
+
if (!signal) return promise;
|
|
1095
|
+
return new Promise((resolve, reject) => {
|
|
1096
|
+
if (signal.aborted) {
|
|
1097
|
+
reject(signal.reason);
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const onAbort = () => reject(signal.reason);
|
|
1101
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1102
|
+
promise.then(
|
|
1103
|
+
(v) => (signal.removeEventListener("abort", onAbort), resolve(v)),
|
|
1104
|
+
(e) => (signal.removeEventListener("abort", onAbort), reject(e))
|
|
1105
|
+
);
|
|
1106
|
+
});
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* A *proactive* rate limiter: slows you down before the server has to say
|
|
1111
|
+
* 429. Two things feed it:
|
|
1112
|
+
* 1. your own budget — `requests` per `perMs` (sliding window), and
|
|
1113
|
+
* 2. what the server tells you — `update(response)` reads X-RateLimit-* /
|
|
1114
|
+
* RateLimit-* / Retry-After and pauses everything once `remaining` hits 0
|
|
1115
|
+
* (or the server sent Retry-After), until the reset time.
|
|
1116
|
+
*
|
|
1117
|
+
* Callers are served FIFO.
|
|
1118
|
+
*
|
|
1119
|
+
* ```js
|
|
1120
|
+
* const rl = createRateLimiter({ requests: 10, perMs: 1000 });
|
|
1121
|
+
* const res = await rl.run(() => fetch(url)); // acquire → call → learn from headers
|
|
1122
|
+
* ```
|
|
1123
|
+
*
|
|
1124
|
+
* @param {object} [opts]
|
|
1125
|
+
* @param {number} [opts.requests=Infinity] - max requests per window
|
|
1126
|
+
* @param {number} [opts.perMs=1000] - window length
|
|
1127
|
+
*/
|
|
1128
|
+
function createRateLimiter(opts = {}) {
|
|
1129
|
+
const { requests = Infinity, perMs = 1000 } = opts;
|
|
1130
|
+
if (requests !== Infinity && !(Number.isInteger(requests) && requests >= 1)) {
|
|
1131
|
+
throw new TypeError("createRateLimiter: `requests` must be an integer >= 1 (or Infinity)");
|
|
1132
|
+
}
|
|
1133
|
+
if (!(typeof perMs === "number" && perMs > 0 && Number.isFinite(perMs))) {
|
|
1134
|
+
throw new TypeError("createRateLimiter: `perMs` must be a positive number");
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
const stamps = [];
|
|
1138
|
+
let blockedUntil = 0;
|
|
1139
|
+
let tail = Promise.resolve();
|
|
1140
|
+
|
|
1141
|
+
async function take(signal) {
|
|
1142
|
+
for (;;) {
|
|
1143
|
+
signal?.throwIfAborted?.();
|
|
1144
|
+
const now = Date.now();
|
|
1145
|
+
while (stamps.length > 0 && stamps[0] <= now - perMs) stamps.shift();
|
|
1146
|
+
|
|
1147
|
+
let waitMs = 0;
|
|
1148
|
+
if (blockedUntil > now) waitMs = blockedUntil - now;
|
|
1149
|
+
else if (stamps.length >= requests) waitMs = stamps[0] + perMs - now;
|
|
1150
|
+
|
|
1151
|
+
if (waitMs <= 0) {
|
|
1152
|
+
stamps.push(now);
|
|
1153
|
+
return;
|
|
1154
|
+
}
|
|
1155
|
+
await abortableSleep(waitMs, signal);
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Resolves when it's your turn. Rejects with the signal's reason if aborted while waiting. */
|
|
1160
|
+
function acquire({ signal } = {}) {
|
|
1161
|
+
const turn = tail.then(() => take(signal));
|
|
1162
|
+
tail = turn.catch(() => {});
|
|
1163
|
+
return abortable(turn, signal);
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Feed a response (anything with `.headers`/`.status`) so the limiter learns the server's limits. */
|
|
1167
|
+
function update(response) {
|
|
1168
|
+
if (!response || !response.headers) return;
|
|
1169
|
+
const { remaining, resetMs, retryAfterMs } = getRateLimitInfo(response.headers);
|
|
1170
|
+
let blockMs = null;
|
|
1171
|
+
if (retryAfterMs !== null && (response.status === 429 || response.status === 503)) blockMs = retryAfterMs;
|
|
1172
|
+
else if (remaining === 0 && resetMs !== null) blockMs = resetMs;
|
|
1173
|
+
if (blockMs !== null) blockedUntil = Math.max(blockedUntil, Date.now() + blockMs);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
/** acquire → fn() → update(result). Errors thrown by fn propagate (the slot stays spent). */
|
|
1177
|
+
async function run(fn, { signal } = {}) {
|
|
1178
|
+
await acquire({ signal });
|
|
1179
|
+
const result = await fn();
|
|
1180
|
+
update(result);
|
|
1181
|
+
return result;
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
return {
|
|
1185
|
+
acquire,
|
|
1186
|
+
update,
|
|
1187
|
+
run,
|
|
1188
|
+
/** Ms until the server-imposed pause ends (0 if none). */
|
|
1189
|
+
get blockedForMs() {
|
|
1190
|
+
return Math.max(0, blockedUntil - Date.now());
|
|
1191
|
+
},
|
|
1192
|
+
/** Forget all history and server hints. */
|
|
1193
|
+
reset() {
|
|
1194
|
+
stamps.length = 0;
|
|
1195
|
+
blockedUntil = 0;
|
|
1196
|
+
},
|
|
1197
|
+
};
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
module.exports = { createRateLimiter };
|
|
1201
|
+
|
|
1202
|
+
},
|
|
1203
|
+
"./downloadResumable": function (module, exports, require) {
|
|
1204
|
+
const { HTTPError } = require("./HTTPError");
|
|
1205
|
+
const { BodyTooLargeError } = require("./withProgress");
|
|
1206
|
+
const { normalizeError } = require("./normalizeError");
|
|
1207
|
+
const { getWaitMsFromHeaders } = require("./_http");
|
|
1208
|
+
const { abortableSleep } = require("./_sleep");
|
|
1209
|
+
|
|
1210
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
1211
|
+
|
|
1212
|
+
function parseContentRange(header) {
|
|
1213
|
+
// "bytes 100-199/1000" (total may be "*")
|
|
1214
|
+
const m = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(String(header || "").trim());
|
|
1215
|
+
if (!m) return null;
|
|
1216
|
+
return { start: Number(m[1]), end: Number(m[2]), total: m[3] === "*" ? null : Number(m[3]) };
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function isRetryable(err) {
|
|
1220
|
+
if (err instanceof BodyTooLargeError) return false;
|
|
1221
|
+
if (err instanceof HTTPError) return RETRYABLE_STATUS.has(err.status);
|
|
1222
|
+
const n = normalizeError(err);
|
|
1223
|
+
if (n.isAborted) return false;
|
|
1224
|
+
return n.isNetworkError || n.isTimeout;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
/**
|
|
1228
|
+
* Downloads a URL into memory and, if the connection drops mid-body, resumes
|
|
1229
|
+
* from where it stopped with an HTTP `Range` request instead of starting
|
|
1230
|
+
* over. Uses `If-Range` with the first response's strong ETag (or
|
|
1231
|
+
* Last-Modified) so a file that changed on the server is restarted rather
|
|
1232
|
+
* than stitched together wrongly. If the server ignores Range, it simply
|
|
1233
|
+
* restarts from zero.
|
|
1234
|
+
*
|
|
1235
|
+
* `retries` counts consecutive attempts *without progress* — a flaky link
|
|
1236
|
+
* that keeps making headway keeps going. Honors Retry-After on 429/503.
|
|
1237
|
+
*
|
|
1238
|
+
* ```js
|
|
1239
|
+
* const bytes = await downloadResumable(url, {
|
|
1240
|
+
* onProgress: ({ percent }) => console.log(percent),
|
|
1241
|
+
* maxBytes: 200 * 1024 * 1024,
|
|
1242
|
+
* });
|
|
1243
|
+
* ```
|
|
1244
|
+
*
|
|
1245
|
+
* @param {string} url
|
|
1246
|
+
* @param {object} [opts]
|
|
1247
|
+
* @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. `createFetch(...)` or a mock
|
|
1248
|
+
* @param {HeadersInit} [opts.headers]
|
|
1249
|
+
* @param {number} [opts.retries=5]
|
|
1250
|
+
* @param {number} [opts.baseDelayMs=500]
|
|
1251
|
+
* @param {number} [opts.maxDelayMs=10000]
|
|
1252
|
+
* @param {(p: {loaded: number, total: number | null, percent: number | null}) => void} [opts.onProgress]
|
|
1253
|
+
* @param {number} [opts.maxBytes] - throws BodyTooLargeError past this
|
|
1254
|
+
* @param {AbortSignal} [opts.signal]
|
|
1255
|
+
* @param {(err: any, attempt: number, delayMs: number, info: {resumeFrom: number}) => void} [opts.onRetry]
|
|
1256
|
+
* @returns {Promise<Uint8Array>}
|
|
1257
|
+
*/
|
|
1258
|
+
async function downloadResumable(url, opts = {}) {
|
|
1259
|
+
const {
|
|
1260
|
+
fetch: fetchFn = globalThis.fetch,
|
|
1261
|
+
headers,
|
|
1262
|
+
retries = 5,
|
|
1263
|
+
baseDelayMs = 500,
|
|
1264
|
+
maxDelayMs = 10000,
|
|
1265
|
+
onProgress,
|
|
1266
|
+
maxBytes,
|
|
1267
|
+
signal,
|
|
1268
|
+
onRetry,
|
|
1269
|
+
} = opts;
|
|
1270
|
+
|
|
1271
|
+
const chunks = [];
|
|
1272
|
+
let loaded = 0;
|
|
1273
|
+
let total = null;
|
|
1274
|
+
let validator = null;
|
|
1275
|
+
let attempt = 0; // consecutive attempts without progress
|
|
1276
|
+
|
|
1277
|
+
const report = () => {
|
|
1278
|
+
if (onProgress) {
|
|
1279
|
+
onProgress({ loaded, total, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null });
|
|
1280
|
+
}
|
|
1281
|
+
};
|
|
1282
|
+
|
|
1283
|
+
for (;;) {
|
|
1284
|
+
signal?.throwIfAborted?.();
|
|
1285
|
+
const loadedBefore = loaded;
|
|
1286
|
+
|
|
1287
|
+
try {
|
|
1288
|
+
const reqHeaders = new Headers(headers || undefined);
|
|
1289
|
+
// Range offsets count encoded bytes, so ask for an uncompressed body.
|
|
1290
|
+
if (!reqHeaders.has("accept-encoding")) reqHeaders.set("accept-encoding", "identity");
|
|
1291
|
+
if (loaded > 0) {
|
|
1292
|
+
reqHeaders.set("range", `bytes=${loaded}-`);
|
|
1293
|
+
if (validator) reqHeaders.set("if-range", validator);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
const res = await fetchFn(url, { headers: reqHeaders, signal });
|
|
1297
|
+
|
|
1298
|
+
if (res.status === 416 && loaded > 0) {
|
|
1299
|
+
// Our offset no longer fits the file — start over, without burning a retry.
|
|
1300
|
+
await res.body?.cancel?.().catch(() => {});
|
|
1301
|
+
chunks.length = 0;
|
|
1302
|
+
loaded = 0;
|
|
1303
|
+
continue;
|
|
1304
|
+
}
|
|
1305
|
+
if (!res.ok) {
|
|
1306
|
+
await res.body?.cancel?.().catch(() => {});
|
|
1307
|
+
throw new HTTPError(res, { method: "GET", url });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
const encoding = res.headers.get("content-encoding");
|
|
1311
|
+
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
1312
|
+
|
|
1313
|
+
if (loaded > 0) {
|
|
1314
|
+
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
1315
|
+
if (!range || range.start !== loaded || encoded) {
|
|
1316
|
+
// Server ignored Range, the file changed (If-Range failed), or the
|
|
1317
|
+
// bytes don't line up: discard and restart from the beginning.
|
|
1318
|
+
chunks.length = 0;
|
|
1319
|
+
loaded = 0;
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
if (loaded === 0) {
|
|
1324
|
+
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
1325
|
+
const length = Number(res.headers.get("content-length"));
|
|
1326
|
+
total = encoded ? null : range ? range.total : Number.isFinite(length) && res.headers.has("content-length") ? length : null;
|
|
1327
|
+
const etag = res.headers.get("etag");
|
|
1328
|
+
validator = etag && !etag.startsWith("W/") ? etag : res.headers.get("last-modified");
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
1332
|
+
await res.body?.cancel?.().catch(() => {});
|
|
1333
|
+
throw new BodyTooLargeError(maxBytes);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (!res.body || typeof res.body.getReader !== "function") {
|
|
1337
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
1338
|
+
if (maxBytes !== undefined && buf.byteLength > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
1339
|
+
chunks.push(buf);
|
|
1340
|
+
loaded += buf.byteLength;
|
|
1341
|
+
total = total ?? loaded;
|
|
1342
|
+
report();
|
|
1343
|
+
} else {
|
|
1344
|
+
const reader = res.body.getReader();
|
|
1345
|
+
const onAbort = () => reader.cancel(signal.reason).catch(() => {});
|
|
1346
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1347
|
+
let finished = false;
|
|
1348
|
+
try {
|
|
1349
|
+
for (;;) {
|
|
1350
|
+
const { done, value } = await reader.read();
|
|
1351
|
+
signal?.throwIfAborted?.();
|
|
1352
|
+
if (done) break;
|
|
1353
|
+
chunks.push(value);
|
|
1354
|
+
loaded += value.byteLength;
|
|
1355
|
+
if (maxBytes !== undefined && loaded > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
1356
|
+
report();
|
|
1357
|
+
}
|
|
1358
|
+
finished = true;
|
|
1359
|
+
} finally {
|
|
1360
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1361
|
+
if (!finished) reader.cancel().catch(() => {});
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (total !== null && loaded < total) {
|
|
1366
|
+
// Stream ended early without an error (some servers just close the socket).
|
|
1367
|
+
throw Object.assign(new Error(`Incomplete download: got ${loaded} of ${total} bytes`), { code: "ECONNRESET" });
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
const out = new Uint8Array(loaded);
|
|
1371
|
+
let offset = 0;
|
|
1372
|
+
for (const chunk of chunks) {
|
|
1373
|
+
out.set(chunk, offset);
|
|
1374
|
+
offset += chunk.byteLength;
|
|
1375
|
+
}
|
|
1376
|
+
return out;
|
|
1377
|
+
} catch (err) {
|
|
1378
|
+
if (signal?.aborted) throw signal.reason ?? err;
|
|
1379
|
+
if (!isRetryable(err)) throw err;
|
|
1380
|
+
|
|
1381
|
+
if (loaded > loadedBefore) attempt = 0; // it made progress — doesn't count against the budget
|
|
1382
|
+
if (attempt >= retries) throw err;
|
|
1383
|
+
|
|
1384
|
+
let delayMs = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
1385
|
+
if (err instanceof HTTPError) {
|
|
1386
|
+
const headerMs = getWaitMsFromHeaders(err.response && err.response.headers);
|
|
1387
|
+
if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
|
|
1388
|
+
}
|
|
1389
|
+
attempt++;
|
|
1390
|
+
if (onRetry) onRetry(err, attempt, delayMs, { resumeFrom: loaded });
|
|
1391
|
+
await abortableSleep(delayMs, signal);
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
module.exports = { downloadResumable };
|
|
1397
|
+
|
|
1398
|
+
},
|
|
1399
|
+
"./index": function (module, exports, require) {
|
|
1400
|
+
const { withRetry, RetryAbortedError, StopRetry } = require("./withRetry");
|
|
1401
|
+
const { withTimeout, TimeoutError } = require("./withTimeout");
|
|
1402
|
+
const { normalizeError } = require("./normalizeError");
|
|
1403
|
+
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
1404
|
+
const { buildQuery } = require("./buildQuery");
|
|
1405
|
+
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
|
|
1406
|
+
const { withProgress, BodyTooLargeError } = require("./withProgress");
|
|
1407
|
+
const { withDedupe } = require("./withDedupe");
|
|
1408
|
+
const { createLimiter } = require("./createLimiter");
|
|
1409
|
+
const { createFetch, HTTPError } = require("./createFetch");
|
|
1410
|
+
const { createRateLimiter } = require("./createRateLimiter");
|
|
1411
|
+
const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
|
|
1412
|
+
const { createMemoryCache } = require("./createMemoryCache");
|
|
1413
|
+
const { createMockFetch } = require("./createMockFetch");
|
|
1414
|
+
const { downloadResumable } = require("./downloadResumable");
|
|
1415
|
+
const { readLines, readNDJSON, readSSE } = require("./streams");
|
|
1416
|
+
|
|
1417
|
+
module.exports = {
|
|
1418
|
+
withRetry,
|
|
1419
|
+
RetryAbortedError,
|
|
1420
|
+
StopRetry,
|
|
1421
|
+
withTimeout,
|
|
1422
|
+
TimeoutError,
|
|
1423
|
+
normalizeError,
|
|
1424
|
+
parseJsonSafe,
|
|
1425
|
+
buildQuery,
|
|
1426
|
+
retryAfterAware,
|
|
1427
|
+
parseRetryAfter,
|
|
1428
|
+
getWaitMsFromHeaders,
|
|
1429
|
+
withProgress,
|
|
1430
|
+
BodyTooLargeError,
|
|
1431
|
+
withDedupe,
|
|
1432
|
+
createLimiter,
|
|
1433
|
+
createFetch,
|
|
1434
|
+
HTTPError,
|
|
1435
|
+
createRateLimiter,
|
|
1436
|
+
createCircuitBreaker,
|
|
1437
|
+
CircuitOpenError,
|
|
1438
|
+
createMemoryCache,
|
|
1439
|
+
createMockFetch,
|
|
1440
|
+
downloadResumable,
|
|
1441
|
+
readLines,
|
|
1442
|
+
readNDJSON,
|
|
1443
|
+
readSSE,
|
|
1444
|
+
};
|
|
1445
|
+
|
|
1446
|
+
},
|
|
1447
|
+
"./normalizeError": function (module, exports, require) {
|
|
1448
|
+
/**
|
|
1449
|
+
* Normalizes errors from fetch, axios, node's http/https, undici and generic
|
|
1450
|
+
* Error objects into one consistent shape:
|
|
1451
|
+
* { message, status, code, isNetworkError, isTimeout, isAborted, cause }
|
|
1452
|
+
*
|
|
1453
|
+
* Every field is always present (null/false when not applicable) so callers
|
|
1454
|
+
* can destructure without existence checks.
|
|
1455
|
+
*
|
|
1456
|
+
* - `isTimeout` is true only for real timeouts (reqkit's TimeoutError,
|
|
1457
|
+
* AbortSignal.timeout(), ETIMEDOUT, undici timeouts) — not for a plain
|
|
1458
|
+
* user-initiated abort.
|
|
1459
|
+
* - `isAborted` is true when the request was cancelled on purpose
|
|
1460
|
+
* (AbortController.abort(), axios cancel).
|
|
1461
|
+
*/
|
|
1462
|
+
|
|
1463
|
+
function shape(err, overrides) {
|
|
1464
|
+
return {
|
|
1465
|
+
message: "Unknown error",
|
|
1466
|
+
status: null,
|
|
1467
|
+
code: null,
|
|
1468
|
+
isNetworkError: false,
|
|
1469
|
+
isTimeout: false,
|
|
1470
|
+
isAborted: false,
|
|
1471
|
+
cause: err,
|
|
1472
|
+
...overrides,
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
// Node's fetch (undici) throws `TypeError: fetch failed` and puts the real
|
|
1477
|
+
// reason (ECONNREFUSED, ENOTFOUND, UND_ERR_*...) on `err.cause`, sometimes
|
|
1478
|
+
// inside an AggregateError. Walk the chain to find the first string code.
|
|
1479
|
+
function findCode(err) {
|
|
1480
|
+
const seen = new Set();
|
|
1481
|
+
const queue = [err];
|
|
1482
|
+
while (queue.length && seen.size < 10) {
|
|
1483
|
+
const cur = queue.shift();
|
|
1484
|
+
if (!cur || typeof cur !== "object" || seen.has(cur)) continue;
|
|
1485
|
+
seen.add(cur);
|
|
1486
|
+
if (typeof cur.code === "string") return cur.code;
|
|
1487
|
+
queue.push(cur.cause);
|
|
1488
|
+
if (Array.isArray(cur.errors)) queue.push(...cur.errors);
|
|
1489
|
+
}
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
function isTimeoutCode(code) {
|
|
1494
|
+
return code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT" || /^UND_ERR_.*TIMEOUT$/.test(code || "");
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
function isNetworkCode(code) {
|
|
1498
|
+
if (!code) return false;
|
|
1499
|
+
return /^E[A-Z0-9]+$/.test(code) || code === "EAI_AGAIN" || code === "EAI_FAIL" || code.startsWith("UND_ERR_");
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
// Browsers and Node throw a bare TypeError for network-level fetch failures.
|
|
1503
|
+
const FETCH_NETWORK_MESSAGE =
|
|
1504
|
+
/fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch/i;
|
|
1505
|
+
|
|
1506
|
+
function isFetchNetworkTypeError(err) {
|
|
1507
|
+
return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
function normalizeError(err) {
|
|
1511
|
+
if (!err) return shape(err);
|
|
1512
|
+
|
|
1513
|
+
// axios error shape: err.response.{status,data}, err.request, err.code
|
|
1514
|
+
if (err.isAxiosError || (err.response && typeof err.response.status === "number" && err.config)) {
|
|
1515
|
+
return shape(err, {
|
|
1516
|
+
message: err.response?.data?.message || err.message || "Request failed",
|
|
1517
|
+
status: err.response?.status ?? null,
|
|
1518
|
+
code: err.code ?? null,
|
|
1519
|
+
isNetworkError: !err.response && Boolean(err.request),
|
|
1520
|
+
isTimeout: err.code === "ECONNABORTED" || err.code === "ETIMEDOUT" || /timeout/i.test(err.message || ""),
|
|
1521
|
+
isAborted: err.code === "ERR_CANCELED" || err.name === "CanceledError",
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// fetch Response passed in directly (not thrown, but common enough to handle)
|
|
1526
|
+
if (typeof err.status === "number" && typeof err.ok === "boolean") {
|
|
1527
|
+
return shape(err, {
|
|
1528
|
+
message: err.statusText || `Request failed with status ${err.status}`,
|
|
1529
|
+
status: err.status,
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
const chainCode = findCode(err);
|
|
1534
|
+
|
|
1535
|
+
// reqkit's TimeoutError, AbortSignal.timeout() (DOMException TimeoutError),
|
|
1536
|
+
// node ETIMEDOUT, undici connect/headers/body timeouts
|
|
1537
|
+
if (err.name === "TimeoutError" || isTimeoutCode(chainCode)) {
|
|
1538
|
+
return shape(err, {
|
|
1539
|
+
message: err.message || "Request timed out",
|
|
1540
|
+
code: chainCode || "ETIMEDOUT",
|
|
1541
|
+
isTimeout: true,
|
|
1542
|
+
});
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
// Deliberate cancellation via AbortController — not a timeout.
|
|
1546
|
+
if (err.name === "AbortError") {
|
|
1547
|
+
return shape(err, {
|
|
1548
|
+
message: err.message || "Request aborted",
|
|
1549
|
+
code: "ABORT_ERR",
|
|
1550
|
+
isAborted: true,
|
|
1551
|
+
});
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
// node http/https / undici network-level failures, including
|
|
1555
|
+
// `TypeError: fetch failed` with the real code on err.cause
|
|
1556
|
+
if (isNetworkCode(chainCode) || isFetchNetworkTypeError(err)) {
|
|
1557
|
+
return shape(err, {
|
|
1558
|
+
message: err.message || `Network error (${chainCode})`,
|
|
1559
|
+
code: chainCode,
|
|
1560
|
+
isNetworkError: true,
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
return shape(err, {
|
|
1565
|
+
message: err.message || String(err),
|
|
1566
|
+
status: err.status ?? err.statusCode ?? err.response?.status ?? err.response?.statusCode ?? null,
|
|
1567
|
+
code: err.code ?? null,
|
|
1568
|
+
});
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
module.exports = { normalizeError };
|
|
1572
|
+
|
|
1573
|
+
},
|
|
1574
|
+
"./parseJsonSafe": function (module, exports, require) {
|
|
1575
|
+
/**
|
|
1576
|
+
* Parses a fetch Response (or raw string) as JSON without throwing.
|
|
1577
|
+
* Returns { data, error } — exactly one is non-null.
|
|
1578
|
+
*
|
|
1579
|
+
* @param {Response | string} input
|
|
1580
|
+
*/
|
|
1581
|
+
async function parseJsonSafe(input) {
|
|
1582
|
+
let text;
|
|
1583
|
+
try {
|
|
1584
|
+
text = typeof input === "string" ? input : await input.text();
|
|
1585
|
+
} catch (err) {
|
|
1586
|
+
return { data: null, error: err };
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
if (!text) {
|
|
1590
|
+
return { data: null, error: null };
|
|
1591
|
+
}
|
|
1592
|
+
|
|
1593
|
+
try {
|
|
1594
|
+
return { data: JSON.parse(text), error: null };
|
|
1595
|
+
} catch (err) {
|
|
1596
|
+
return { data: null, error: err };
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
|
|
1600
|
+
module.exports = { parseJsonSafe };
|
|
1601
|
+
|
|
1602
|
+
},
|
|
1603
|
+
"./retryAfterAware": function (module, exports, require) {
|
|
1604
|
+
const { withRetry } = require("./withRetry");
|
|
1605
|
+
const { normalizeError } = require("./normalizeError");
|
|
1606
|
+
const { parseRetryAfter, getWaitMsFromHeaders } = require("./_http");
|
|
1607
|
+
|
|
1608
|
+
function defaultShouldRetryError(err) {
|
|
1609
|
+
const normalized = normalizeError(err);
|
|
1610
|
+
return normalized.isNetworkError || normalized.isTimeout;
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/**
|
|
1614
|
+
* Retries a fetch-like call, honoring Retry-After / X-RateLimit-Reset
|
|
1615
|
+
* headers on 429/503 responses instead of blind exponential backoff — most
|
|
1616
|
+
* generic retry libraries treat rate-limit responses like any other failure
|
|
1617
|
+
* and ignore the header the server is explicitly giving you.
|
|
1618
|
+
*
|
|
1619
|
+
* This is a thin preset over `withRetry` (retryStatusCodes + Retry-After,
|
|
1620
|
+
* no jitter, network errors/timeouts only for thrown errors). Reach for
|
|
1621
|
+
* `withRetry` directly when you need jitter, `maxRetryTime`, `StopRetry`,
|
|
1622
|
+
* `calculateDelay`, ...
|
|
1623
|
+
*
|
|
1624
|
+
* Also retries thrown network errors / timeouts (see `shouldRetryError`),
|
|
1625
|
+
* and the whole loop — including any backoff wait — can be cancelled with
|
|
1626
|
+
* `signal`. The body of each response that gets retried is cancelled so the
|
|
1627
|
+
* connection is released.
|
|
1628
|
+
*
|
|
1629
|
+
* `fn` should return a fetch Response (or anything with `.status` and
|
|
1630
|
+
* `.headers.get()`).
|
|
1631
|
+
*
|
|
1632
|
+
* @param {(attempt: number) => Promise<Response>} fn
|
|
1633
|
+
* @param {object} [opts]
|
|
1634
|
+
* @param {number} [opts.retries=3]
|
|
1635
|
+
* @param {number[]} [opts.retryStatusCodes=[429, 503]]
|
|
1636
|
+
* @param {number} [opts.fallbackDelayMs=1000] - used when no usable header is present
|
|
1637
|
+
* @param {number} [opts.maxDelayMs=60000]
|
|
1638
|
+
* @param {(status: number, waitMs: number, attempt: number) => void} [opts.onRetry]
|
|
1639
|
+
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetryError] -
|
|
1640
|
+
* decides whether a thrown error is retried. Default: network errors and
|
|
1641
|
+
* timeouts only.
|
|
1642
|
+
* @param {(err: any, waitMs: number, attempt: number) => void} [opts.onRetryError]
|
|
1643
|
+
* @param {AbortSignal} [opts.signal] - cancels the retry loop, throwing
|
|
1644
|
+
* RetryAbortedError
|
|
1645
|
+
*/
|
|
1646
|
+
function retryAfterAware(fn, opts = {}) {
|
|
1647
|
+
const {
|
|
1648
|
+
retries = 3,
|
|
1649
|
+
retryStatusCodes = [429, 503],
|
|
1650
|
+
fallbackDelayMs = 1000,
|
|
1651
|
+
maxDelayMs = 60000,
|
|
1652
|
+
onRetry,
|
|
1653
|
+
shouldRetryError = defaultShouldRetryError,
|
|
1654
|
+
onRetryError,
|
|
1655
|
+
signal,
|
|
1656
|
+
} = opts;
|
|
1657
|
+
|
|
1658
|
+
return withRetry(fn, {
|
|
1659
|
+
retries,
|
|
1660
|
+
retryStatusCodes,
|
|
1661
|
+
baseDelayMs: fallbackDelayMs,
|
|
1662
|
+
maxDelayMs,
|
|
1663
|
+
maxRetryAfterMs: maxDelayMs,
|
|
1664
|
+
factor: 2,
|
|
1665
|
+
jitter: 0,
|
|
1666
|
+
shouldRetry: shouldRetryError,
|
|
1667
|
+
onRetry: (err, attempt, waitMs, ctx) => {
|
|
1668
|
+
if (ctx.reason === "result") {
|
|
1669
|
+
if (onRetry) onRetry(ctx.status, waitMs, attempt);
|
|
1670
|
+
} else if (onRetryError) {
|
|
1671
|
+
onRetryError(err, waitMs, attempt);
|
|
1672
|
+
}
|
|
1673
|
+
},
|
|
1674
|
+
signal,
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
|
|
1679
|
+
|
|
1680
|
+
},
|
|
1681
|
+
"./streams": function (module, exports, require) {
|
|
1682
|
+
const encoder = new TextEncoder();
|
|
1683
|
+
|
|
1684
|
+
// Yields Uint8Array chunks from a fetch Response, a web ReadableStream, or any
|
|
1685
|
+
// async iterable (e.g. a Node Readable). Cancels the underlying stream when
|
|
1686
|
+
// the consumer stops early or the signal aborts, so the connection is freed.
|
|
1687
|
+
async function* chunks(source, signal) {
|
|
1688
|
+
let body = source;
|
|
1689
|
+
if (body && typeof body.getReader !== "function" && "body" in body) body = body.body; // a Response
|
|
1690
|
+
if (body === null || body === undefined) return;
|
|
1691
|
+
signal?.throwIfAborted?.();
|
|
1692
|
+
|
|
1693
|
+
if (typeof body.getReader === "function") {
|
|
1694
|
+
const reader = body.getReader();
|
|
1695
|
+
const onAbort = () => reader.cancel(signal.reason).catch(() => {});
|
|
1696
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1697
|
+
try {
|
|
1698
|
+
for (;;) {
|
|
1699
|
+
const { done, value } = await reader.read();
|
|
1700
|
+
signal?.throwIfAborted?.(); // cancel() makes read() resolve as done — don't mistake that for the end
|
|
1701
|
+
if (done) return;
|
|
1702
|
+
yield value;
|
|
1703
|
+
}
|
|
1704
|
+
} finally {
|
|
1705
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1706
|
+
reader.cancel().catch(() => {});
|
|
1707
|
+
}
|
|
1708
|
+
} else if (typeof body[Symbol.asyncIterator] === "function") {
|
|
1709
|
+
for await (const chunk of body) {
|
|
1710
|
+
signal?.throwIfAborted?.();
|
|
1711
|
+
yield typeof chunk === "string" ? encoder.encode(chunk) : chunk;
|
|
1712
|
+
}
|
|
1713
|
+
} else {
|
|
1714
|
+
throw new TypeError("reqkit streams: expected a Response, a ReadableStream or an async iterable");
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/**
|
|
1719
|
+
* Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
|
|
1720
|
+
* multi-byte characters split across chunks, and a leading BOM.
|
|
1721
|
+
*
|
|
1722
|
+
* ```js
|
|
1723
|
+
* for await (const line of readLines(response)) console.log(line);
|
|
1724
|
+
* ```
|
|
1725
|
+
*
|
|
1726
|
+
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
1727
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
1728
|
+
* @returns {AsyncGenerator<string>}
|
|
1729
|
+
*/
|
|
1730
|
+
async function* readLines(source, opts = {}) {
|
|
1731
|
+
const decoder = new TextDecoder("utf-8");
|
|
1732
|
+
let buffer = "";
|
|
1733
|
+
let first = true;
|
|
1734
|
+
|
|
1735
|
+
for await (const chunk of chunks(source, opts.signal)) {
|
|
1736
|
+
buffer += decoder.decode(chunk, { stream: true });
|
|
1737
|
+
if (first && buffer.length > 0) {
|
|
1738
|
+
buffer = buffer.replace(/^\uFEFF/, "");
|
|
1739
|
+
first = false;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
let pos = 0;
|
|
1743
|
+
for (;;) {
|
|
1744
|
+
const rest = buffer.slice(pos);
|
|
1745
|
+
const i = rest.search(/\r\n|\n|\r/);
|
|
1746
|
+
if (i === -1) break;
|
|
1747
|
+
// A trailing "\r" might be the first half of "\r\n" — wait for more data.
|
|
1748
|
+
if (rest[i] === "\r" && i === rest.length - 1) break;
|
|
1749
|
+
yield rest.slice(0, i);
|
|
1750
|
+
pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
|
|
1751
|
+
}
|
|
1752
|
+
buffer = buffer.slice(pos);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
buffer += decoder.decode();
|
|
1756
|
+
if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
|
|
1757
|
+
if (buffer.length > 0) yield buffer;
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
/**
|
|
1761
|
+
* Async-iterates parsed JSON values from a newline-delimited JSON stream
|
|
1762
|
+
* (NDJSON / JSON Lines). Blank lines are skipped. A malformed line throws a
|
|
1763
|
+
* SyntaxError naming the line, unless `skipInvalid` is true.
|
|
1764
|
+
*
|
|
1765
|
+
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
1766
|
+
* @param {{ signal?: AbortSignal, skipInvalid?: boolean }} [opts]
|
|
1767
|
+
*/
|
|
1768
|
+
async function* readNDJSON(source, opts = {}) {
|
|
1769
|
+
let lineNo = 0;
|
|
1770
|
+
for await (const line of readLines(source, opts)) {
|
|
1771
|
+
lineNo++;
|
|
1772
|
+
if (line.trim() === "") continue;
|
|
1773
|
+
try {
|
|
1774
|
+
yield JSON.parse(line);
|
|
1775
|
+
} catch (err) {
|
|
1776
|
+
if (opts.skipInvalid) continue;
|
|
1777
|
+
throw new SyntaxError(`readNDJSON: invalid JSON on line ${lineNo}: ${err.message}`);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
/**
|
|
1783
|
+
* Async-iterates Server-Sent Events (text/event-stream) as
|
|
1784
|
+
* `{ event, data, id, retry }`, following the spec: multi-line `data:` is
|
|
1785
|
+
* joined with "\n", comments (`:`) are ignored, `event` defaults to
|
|
1786
|
+
* "message", `id` carries over between events, and an event cut off by the end
|
|
1787
|
+
* of the stream is discarded. Great for LLM token streams.
|
|
1788
|
+
*
|
|
1789
|
+
* ```js
|
|
1790
|
+
* for await (const { data } of readSSE(response)) {
|
|
1791
|
+
* if (data === "[DONE]") break;
|
|
1792
|
+
* console.log(JSON.parse(data));
|
|
1793
|
+
* }
|
|
1794
|
+
* ```
|
|
1795
|
+
*
|
|
1796
|
+
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
1797
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
1798
|
+
*/
|
|
1799
|
+
async function* readSSE(source, opts = {}) {
|
|
1800
|
+
let event = "";
|
|
1801
|
+
let data = [];
|
|
1802
|
+
let retry;
|
|
1803
|
+
let lastId = "";
|
|
1804
|
+
|
|
1805
|
+
for await (const line of readLines(source, opts)) {
|
|
1806
|
+
if (line === "") {
|
|
1807
|
+
if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
|
|
1808
|
+
event = "";
|
|
1809
|
+
data = [];
|
|
1810
|
+
retry = undefined;
|
|
1811
|
+
continue;
|
|
1812
|
+
}
|
|
1813
|
+
if (line.startsWith(":")) continue;
|
|
1814
|
+
|
|
1815
|
+
const idx = line.indexOf(":");
|
|
1816
|
+
const field = idx === -1 ? line : line.slice(0, idx);
|
|
1817
|
+
let value = idx === -1 ? "" : line.slice(idx + 1);
|
|
1818
|
+
if (value.startsWith(" ")) value = value.slice(1);
|
|
1819
|
+
|
|
1820
|
+
if (field === "event") event = value;
|
|
1821
|
+
else if (field === "data") data.push(value);
|
|
1822
|
+
else if (field === "id") {
|
|
1823
|
+
if (!value.includes("\0")) lastId = value;
|
|
1824
|
+
} else if (field === "retry") {
|
|
1825
|
+
if (/^\d+$/.test(value)) retry = Number(value);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
module.exports = { readLines, readNDJSON, readSSE };
|
|
1831
|
+
|
|
1832
|
+
},
|
|
1833
|
+
"./withDedupe": function (module, exports, require) {
|
|
1834
|
+
// Non-plain objects (AbortSignal, Response, class instances, functions...)
|
|
1835
|
+
// can't be meaningfully JSON-serialized — JSON.stringify turns every
|
|
1836
|
+
// AbortSignal into "{}", which would make unrelated calls share one request.
|
|
1837
|
+
// Give them a stable identity-based id instead.
|
|
1838
|
+
const identityIds = new WeakMap();
|
|
1839
|
+
let nextIdentityId = 1;
|
|
1840
|
+
|
|
1841
|
+
function identityOf(value) {
|
|
1842
|
+
let id = identityIds.get(value);
|
|
1843
|
+
if (id === undefined) {
|
|
1844
|
+
id = nextIdentityId++;
|
|
1845
|
+
identityIds.set(value, id);
|
|
1846
|
+
}
|
|
1847
|
+
return id;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function isPlainObject(value) {
|
|
1851
|
+
const proto = Object.getPrototypeOf(value);
|
|
1852
|
+
return proto === Object.prototype || proto === null;
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function keyReplacer(_key, value) {
|
|
1856
|
+
if (typeof value === "bigint") return { $bigint: String(value) };
|
|
1857
|
+
if (typeof value === "function") return { $ref: identityOf(value) };
|
|
1858
|
+
if (value === null || typeof value !== "object") return value;
|
|
1859
|
+
if (Array.isArray(value)) return value;
|
|
1860
|
+
if (!isPlainObject(value)) return { $ref: identityOf(value) };
|
|
1861
|
+
|
|
1862
|
+
// Sort plain-object keys so {a:1,b:2} and {b:2,a:1} dedupe together.
|
|
1863
|
+
const sorted = {};
|
|
1864
|
+
for (const k of Object.keys(value).sort()) sorted[k] = value[k];
|
|
1865
|
+
return sorted;
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1868
|
+
function defaultKeyFn(...args) {
|
|
1869
|
+
try {
|
|
1870
|
+
return JSON.stringify(args, keyReplacer);
|
|
1871
|
+
} catch {
|
|
1872
|
+
// Circular structures etc. Fall back to identity for objects.
|
|
1873
|
+
return args
|
|
1874
|
+
.map((a) => ((a && typeof a === "object") || typeof a === "function" ? `#${identityOf(a)}` : String(a)))
|
|
1875
|
+
.join("|");
|
|
1876
|
+
}
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
/**
|
|
1880
|
+
* Wraps an async function so that concurrent calls with the same key share
|
|
1881
|
+
* a single in-flight promise instead of firing duplicate requests. Common
|
|
1882
|
+
* use case: several parts of a UI ask for the same resource at once (e.g. a
|
|
1883
|
+
* user profile) — only one network request actually goes out, and every
|
|
1884
|
+
* caller resolves with the same result.
|
|
1885
|
+
*
|
|
1886
|
+
* The in-flight entry is cleared as soon as the call settles (success or
|
|
1887
|
+
* failure), so the next call — even with the same key — always starts a
|
|
1888
|
+
* fresh request. This is deduplication of concurrent calls, not a cache.
|
|
1889
|
+
*
|
|
1890
|
+
* @param {(...args: any[]) => Promise<any>} fn
|
|
1891
|
+
* @param {object} [opts]
|
|
1892
|
+
* @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
|
|
1893
|
+
* from the call arguments. By default plain data is compared by value
|
|
1894
|
+
* (object key order doesn't matter) and anything else — AbortSignals,
|
|
1895
|
+
* Responses, class instances, functions — by identity, so two calls with
|
|
1896
|
+
* different AbortSignals are never merged.
|
|
1897
|
+
* @returns {(...args: any[]) => Promise<any>}
|
|
1898
|
+
*/
|
|
1899
|
+
function withDedupe(fn, opts = {}) {
|
|
1900
|
+
const { keyFn = defaultKeyFn } = opts;
|
|
1901
|
+
const inFlight = new Map();
|
|
1902
|
+
|
|
1903
|
+
return function deduped(...args) {
|
|
1904
|
+
const key = keyFn(...args);
|
|
1905
|
+
|
|
1906
|
+
if (inFlight.has(key)) {
|
|
1907
|
+
return inFlight.get(key);
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const promise = Promise.resolve()
|
|
1911
|
+
.then(() => fn(...args))
|
|
1912
|
+
.finally(() => {
|
|
1913
|
+
inFlight.delete(key);
|
|
1914
|
+
});
|
|
1915
|
+
|
|
1916
|
+
inFlight.set(key, promise);
|
|
1917
|
+
return promise;
|
|
1918
|
+
};
|
|
1919
|
+
}
|
|
1920
|
+
|
|
1921
|
+
module.exports = { withDedupe };
|
|
1922
|
+
|
|
1923
|
+
},
|
|
1924
|
+
"./withProgress": function (module, exports, require) {
|
|
1925
|
+
class BodyTooLargeError extends Error {
|
|
1926
|
+
constructor(maxBytes) {
|
|
1927
|
+
super(`Response body exceeded the ${maxBytes}-byte limit`);
|
|
1928
|
+
this.name = "BodyTooLargeError";
|
|
1929
|
+
this.code = "EBODYTOOLARGE";
|
|
1930
|
+
this.maxBytes = maxBytes;
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
function cancelQuietly(reader) {
|
|
1935
|
+
try {
|
|
1936
|
+
const p = reader.cancel();
|
|
1937
|
+
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
1938
|
+
} catch {
|
|
1939
|
+
// already closed / released — nothing to do
|
|
1940
|
+
}
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Reads a fetch Response body while reporting progress, and returns the
|
|
1945
|
+
* fully collected bytes as a Uint8Array. Works for any streamed response,
|
|
1946
|
+
* not just file downloads — same shape whether it's a media file, a large
|
|
1947
|
+
* JSON payload, or anything else worth showing progress for.
|
|
1948
|
+
*
|
|
1949
|
+
* `total` is null (and `percent` with it) when the size isn't trustworthy:
|
|
1950
|
+
* no Content-Length, or a Content-Encoding is applied (Content-Length is then
|
|
1951
|
+
* the *compressed* size while fetch hands you *decompressed* bytes, so
|
|
1952
|
+
* loaded could exceed total). `percent` is always clamped to 0–100.
|
|
1953
|
+
*
|
|
1954
|
+
* @param {Response} response
|
|
1955
|
+
* @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} [onProgress]
|
|
1956
|
+
* @param {object} [opts]
|
|
1957
|
+
* @param {number} [opts.maxBytes] - abort with BodyTooLargeError once more
|
|
1958
|
+
* than this many bytes have been received
|
|
1959
|
+
* @param {AbortSignal} [opts.signal] - cancels the read; rejects with the
|
|
1960
|
+
* signal's reason (an AbortError by default)
|
|
1961
|
+
* @returns {Promise<Uint8Array>}
|
|
1962
|
+
*/
|
|
1963
|
+
async function withProgress(response, onProgress, opts = {}) {
|
|
1964
|
+
const { maxBytes, signal } = opts;
|
|
1965
|
+
signal?.throwIfAborted?.();
|
|
1966
|
+
|
|
1967
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
1968
|
+
// No streaming support available — fall back to a single-shot read,
|
|
1969
|
+
// still reporting one final progress event so callers don't need a branch.
|
|
1970
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
1971
|
+
signal?.throwIfAborted?.();
|
|
1972
|
+
if (maxBytes !== undefined && buf.byteLength > maxBytes) {
|
|
1973
|
+
throw new BodyTooLargeError(maxBytes);
|
|
1974
|
+
}
|
|
1975
|
+
if (onProgress) {
|
|
1976
|
+
onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
|
|
1977
|
+
}
|
|
1978
|
+
return buf;
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
const encoding = response.headers?.get?.("content-encoding");
|
|
1982
|
+
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
1983
|
+
const contentLength = response.headers?.get?.("content-length");
|
|
1984
|
+
const parsed = contentLength ? Number(contentLength) : NaN;
|
|
1985
|
+
const total = !encoded && Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
1986
|
+
|
|
1987
|
+
const reader = response.body.getReader();
|
|
1988
|
+
|
|
1989
|
+
// Fail fast when the declared size already exceeds the cap.
|
|
1990
|
+
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
1991
|
+
cancelQuietly(reader);
|
|
1992
|
+
throw new BodyTooLargeError(maxBytes);
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
const onAbort = () => cancelQuietly(reader);
|
|
1996
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1997
|
+
|
|
1998
|
+
const chunks = [];
|
|
1999
|
+
let loaded = 0;
|
|
2000
|
+
let finished = false;
|
|
2001
|
+
|
|
2002
|
+
try {
|
|
2003
|
+
for (;;) {
|
|
2004
|
+
const { done, value } = await reader.read();
|
|
2005
|
+
// reader.cancel() on abort makes read() resolve as done — check the
|
|
2006
|
+
// signal first so an aborted read never looks like a complete one.
|
|
2007
|
+
signal?.throwIfAborted?.();
|
|
2008
|
+
if (done) break;
|
|
2009
|
+
|
|
2010
|
+
chunks.push(value);
|
|
2011
|
+
loaded += value.byteLength;
|
|
2012
|
+
|
|
2013
|
+
if (maxBytes !== undefined && loaded > maxBytes) {
|
|
2014
|
+
throw new BodyTooLargeError(maxBytes);
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
if (onProgress) {
|
|
2018
|
+
onProgress({
|
|
2019
|
+
loaded,
|
|
2020
|
+
total,
|
|
2021
|
+
percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null,
|
|
2022
|
+
});
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
finished = true;
|
|
2026
|
+
} finally {
|
|
2027
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2028
|
+
if (!finished) cancelQuietly(reader);
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
const result = new Uint8Array(loaded);
|
|
2032
|
+
let offset = 0;
|
|
2033
|
+
for (const chunk of chunks) {
|
|
2034
|
+
result.set(chunk, offset);
|
|
2035
|
+
offset += chunk.byteLength;
|
|
2036
|
+
}
|
|
2037
|
+
return result;
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
module.exports = { withProgress, BodyTooLargeError };
|
|
2041
|
+
|
|
2042
|
+
},
|
|
2043
|
+
"./withRetry": function (module, exports, require) {
|
|
2044
|
+
const { RetryAbortedError, sleep } = require("./_sleep");
|
|
2045
|
+
const { getWaitMsFromHeaders, discardBody } = require("./_http");
|
|
2046
|
+
|
|
2047
|
+
function defaultShouldRetry(err) {
|
|
2048
|
+
// Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
|
|
2049
|
+
// (e.g. network errors only). HTTP *statuses* aren't errors with fetch, so
|
|
2050
|
+
// opt in with `retryStatusCodes` / `shouldRetryResult`.
|
|
2051
|
+
return Boolean(err) && err.name !== "AbortError";
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
/**
|
|
2055
|
+
* Throw this from inside `fn` to stop retrying immediately. If you pass an
|
|
2056
|
+
* Error, that exact error is what withRetry rethrows; with a string, the
|
|
2057
|
+
* StopRetry itself is thrown. (Same idea as p-retry's AbortError.)
|
|
2058
|
+
*/
|
|
2059
|
+
class StopRetry extends Error {
|
|
2060
|
+
constructor(reasonOrMessage) {
|
|
2061
|
+
const isError = reasonOrMessage instanceof Error;
|
|
2062
|
+
super(isError ? reasonOrMessage.message : String(reasonOrMessage ?? "Retrying was stopped"));
|
|
2063
|
+
this.name = "StopRetry";
|
|
2064
|
+
this.original = isError ? reasonOrMessage : null;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
/**
|
|
2069
|
+
* Retries an async function with exponential backoff + jitter. Retries on
|
|
2070
|
+
* thrown errors, and — opt-in — on *results* too (HTTP status codes, or any
|
|
2071
|
+
* predicate), honoring Retry-After / X-RateLimit-Reset when the result has
|
|
2072
|
+
* headers.
|
|
2073
|
+
*
|
|
2074
|
+
* @param {(attempt: number) => Promise<any>} fn
|
|
2075
|
+
* @param {object} [opts]
|
|
2076
|
+
* @param {number} [opts.retries=3] - max retry attempts after the first try (Infinity is fine with maxRetryTime)
|
|
2077
|
+
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
2078
|
+
* @param {number} [opts.maxDelayMs=10000] - backoff ceiling
|
|
2079
|
+
* @param {number} [opts.factor=2] - backoff multiplier
|
|
2080
|
+
* @param {number|"full"} [opts.jitter=0.2] - fraction of extra random delay
|
|
2081
|
+
* added on top (0 disables); "full" picks uniformly in [0, delay] — the
|
|
2082
|
+
* AWS "full jitter" strategy, best when many clients retry at once
|
|
2083
|
+
* @param {number} [opts.maxRetryTime=Infinity] - total time budget in ms
|
|
2084
|
+
* across all attempts and waits; once the next wait would blow it, stop
|
|
2085
|
+
* and surface the last error/result
|
|
2086
|
+
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetry] - for thrown errors
|
|
2087
|
+
* @param {number[]} [opts.retryStatusCodes=[]] - retry when the result's `.status` is in this list
|
|
2088
|
+
* @param {(result: any, attempt: number) => boolean} [opts.shouldRetryResult] - retry on any result predicate
|
|
2089
|
+
* @param {boolean} [opts.respectRetryAfter=true] - use Retry-After/X-RateLimit-Reset from `result.headers`
|
|
2090
|
+
* @param {number} [opts.maxRetryAfterMs=60000] - cap on a header-provided wait
|
|
2091
|
+
* @param {(info: {attempt: number, error: any, result: any, delayMs: number}) => number|false} [opts.calculateDelay]
|
|
2092
|
+
* - override the wait; return a number of ms, or false to stop retrying
|
|
2093
|
+
* @param {(err: any, attempt: number, delayMs: number, ctx: {reason: "error"|"result", result?: any, status?: number}) => void} [opts.onRetry]
|
|
2094
|
+
* - `err` is undefined when the retry was triggered by a result
|
|
2095
|
+
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
2096
|
+
* including any pending backoff wait, throwing RetryAbortedError (with the
|
|
2097
|
+
* last failure as `cause`). Separate from a per-call signal you pass into
|
|
2098
|
+
* `fn` yourself — this one governs whether *another attempt starts at all*.
|
|
2099
|
+
*/
|
|
2100
|
+
async function withRetry(fn, opts = {}) {
|
|
2101
|
+
const {
|
|
2102
|
+
retries = 3,
|
|
2103
|
+
baseDelayMs = 300,
|
|
2104
|
+
maxDelayMs = 10000,
|
|
2105
|
+
factor = 2,
|
|
2106
|
+
jitter = 0.2,
|
|
2107
|
+
maxRetryTime = Infinity,
|
|
2108
|
+
shouldRetry = defaultShouldRetry,
|
|
2109
|
+
retryStatusCodes = [],
|
|
2110
|
+
shouldRetryResult,
|
|
2111
|
+
respectRetryAfter = true,
|
|
2112
|
+
maxRetryAfterMs = 60000,
|
|
2113
|
+
calculateDelay,
|
|
2114
|
+
onRetry,
|
|
2115
|
+
signal,
|
|
2116
|
+
} = opts;
|
|
2117
|
+
|
|
2118
|
+
const startedAt = Date.now();
|
|
2119
|
+
|
|
2120
|
+
function backoff(attempt) {
|
|
2121
|
+
const exponential = Math.min(baseDelayMs * factor ** attempt, maxDelayMs);
|
|
2122
|
+
if (jitter === "full") return Math.round(Math.random() * exponential);
|
|
2123
|
+
const extra = Math.random() * exponential * (Number(jitter) || 0);
|
|
2124
|
+
return Math.round(exponential + extra);
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// Returns the wait in ms, or null if retrying should stop (custom
|
|
2128
|
+
// calculateDelay said so, or the time budget would be exceeded).
|
|
2129
|
+
function planWait(defaultMs, attempt, error, result) {
|
|
2130
|
+
let waitMs = defaultMs;
|
|
2131
|
+
if (calculateDelay) {
|
|
2132
|
+
const custom = calculateDelay({ attempt, error, result, delayMs: defaultMs });
|
|
2133
|
+
if (custom === false || custom === null || custom === undefined) return null;
|
|
2134
|
+
waitMs = Math.max(0, Number(custom) || 0);
|
|
2135
|
+
}
|
|
2136
|
+
if (Date.now() - startedAt + waitMs > maxRetryTime) return null;
|
|
2137
|
+
return waitMs;
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
function wantsResultRetry(result, attempt) {
|
|
2141
|
+
if (
|
|
2142
|
+
retryStatusCodes.length > 0 &&
|
|
2143
|
+
result &&
|
|
2144
|
+
typeof result.status === "number" &&
|
|
2145
|
+
retryStatusCodes.includes(result.status)
|
|
2146
|
+
) {
|
|
2147
|
+
return true;
|
|
2148
|
+
}
|
|
2149
|
+
return shouldRetryResult ? Boolean(shouldRetryResult(result, attempt)) : false;
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
let lastErr;
|
|
2153
|
+
for (let attempt = 0; ; attempt++) {
|
|
2154
|
+
if (signal?.aborted) {
|
|
2155
|
+
throw new RetryAbortedError(lastErr);
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
let result;
|
|
2159
|
+
try {
|
|
2160
|
+
result = await fn(attempt);
|
|
2161
|
+
} catch (err) {
|
|
2162
|
+
if (err instanceof StopRetry) throw err.original ?? err;
|
|
2163
|
+
lastErr = err;
|
|
2164
|
+
if (attempt >= retries || !shouldRetry(err, attempt)) throw err;
|
|
2165
|
+
|
|
2166
|
+
const waitMs = planWait(backoff(attempt), attempt, err, undefined);
|
|
2167
|
+
if (waitMs === null) throw err;
|
|
2168
|
+
|
|
2169
|
+
if (onRetry) onRetry(err, attempt, waitMs, { reason: "error" });
|
|
2170
|
+
await sleep(waitMs, signal, err);
|
|
2171
|
+
continue;
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
if (attempt >= retries || !wantsResultRetry(result, attempt)) return result;
|
|
2175
|
+
|
|
2176
|
+
let defaultMs = backoff(attempt);
|
|
2177
|
+
if (respectRetryAfter) {
|
|
2178
|
+
const headerMs = getWaitMsFromHeaders(result && result.headers);
|
|
2179
|
+
if (headerMs !== null) defaultMs = Math.min(headerMs, maxRetryAfterMs);
|
|
2180
|
+
}
|
|
2181
|
+
const waitMs = planWait(defaultMs, attempt, undefined, result);
|
|
2182
|
+
if (waitMs === null) return result;
|
|
2183
|
+
|
|
2184
|
+
await discardBody(result);
|
|
2185
|
+
if (onRetry) onRetry(undefined, attempt, waitMs, { reason: "result", result, status: result && result.status });
|
|
2186
|
+
await sleep(waitMs, signal);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
module.exports = { withRetry, RetryAbortedError, StopRetry };
|
|
2191
|
+
|
|
2192
|
+
},
|
|
2193
|
+
"./withTimeout": function (module, exports, require) {
|
|
2194
|
+
class TimeoutError extends Error {
|
|
2195
|
+
constructor(ms) {
|
|
2196
|
+
super(`Timed out after ${ms}ms`);
|
|
2197
|
+
this.name = "TimeoutError";
|
|
2198
|
+
this.code = "ETIMEDOUT";
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Races an async function against a timeout. `fn` is handed an AbortSignal
|
|
2204
|
+
* so the underlying request can actually be cancelled (not just abandoned) —
|
|
2205
|
+
* e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
|
|
2206
|
+
*
|
|
2207
|
+
* Pass your own `signal` to also cancel from the outside (user navigated
|
|
2208
|
+
* away, shutdown...). That signal is linked into the one `fn` receives, and
|
|
2209
|
+
* aborting it rejects with its reason even if `fn` ignores the signal.
|
|
2210
|
+
* `ms: Infinity` disables the timer (handy to only link a signal).
|
|
2211
|
+
*
|
|
2212
|
+
* @param {(signal: AbortSignal) => Promise<any>} fn
|
|
2213
|
+
* @param {number} ms
|
|
2214
|
+
* @param {object} [opts]
|
|
2215
|
+
* @param {AbortSignal} [opts.signal] - external cancellation
|
|
2216
|
+
*/
|
|
2217
|
+
async function withTimeout(fn, ms, opts = {}) {
|
|
2218
|
+
const { signal } = opts;
|
|
2219
|
+
|
|
2220
|
+
if (typeof ms !== "number" || Number.isNaN(ms) || ms < 0) {
|
|
2221
|
+
throw new TypeError("withTimeout: `ms` must be a non-negative number (or Infinity)");
|
|
2222
|
+
}
|
|
2223
|
+
signal?.throwIfAborted?.();
|
|
2224
|
+
|
|
2225
|
+
const controller = new AbortController();
|
|
2226
|
+
let timedOut = false;
|
|
2227
|
+
|
|
2228
|
+
const timer =
|
|
2229
|
+
ms === Infinity
|
|
2230
|
+
? null
|
|
2231
|
+
: setTimeout(() => {
|
|
2232
|
+
timedOut = true;
|
|
2233
|
+
controller.abort(new TimeoutError(ms));
|
|
2234
|
+
}, ms);
|
|
2235
|
+
|
|
2236
|
+
const onExternalAbort = () => controller.abort(signal.reason);
|
|
2237
|
+
signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
2238
|
+
|
|
2239
|
+
const guard = new Promise((_, reject) => {
|
|
2240
|
+
controller.signal.addEventListener(
|
|
2241
|
+
"abort",
|
|
2242
|
+
() => reject(timedOut ? new TimeoutError(ms) : controller.signal.reason),
|
|
2243
|
+
{ once: true }
|
|
2244
|
+
);
|
|
2245
|
+
});
|
|
2246
|
+
|
|
2247
|
+
try {
|
|
2248
|
+
return await Promise.race([fn(controller.signal), guard]);
|
|
2249
|
+
} catch (err) {
|
|
2250
|
+
// If our timeout is what triggered the abort, always surface TimeoutError —
|
|
2251
|
+
// regardless of whether fn's own rejection or our guard "won" the race
|
|
2252
|
+
// (that's a listener-registration-order detail callers shouldn't have to
|
|
2253
|
+
// think about).
|
|
2254
|
+
if (timedOut) throw new TimeoutError(ms);
|
|
2255
|
+
throw err;
|
|
2256
|
+
} finally {
|
|
2257
|
+
if (timer) clearTimeout(timer);
|
|
2258
|
+
signal?.removeEventListener("abort", onExternalAbort);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
module.exports = { withTimeout, TimeoutError };
|
|
2263
|
+
|
|
2264
|
+
},
|
|
2265
|
+
};
|
|
2266
|
+
const __cache = {};
|
|
2267
|
+
function __require(id) {
|
|
2268
|
+
const key = id.replace(/\.js$/, "");
|
|
2269
|
+
if (!(key in __defs)) throw new Error(`reqkit bundle: cannot resolve ${id}`);
|
|
2270
|
+
if (__cache[key]) return __cache[key].exports;
|
|
2271
|
+
const module = (__cache[key] = { exports: {} });
|
|
2272
|
+
__defs[key].call(module.exports, module, module.exports, __require);
|
|
2273
|
+
return module.exports;
|
|
2274
|
+
}
|
|
2275
|
+
const __reqkit = __require("./index");
|
|
2276
|
+
export const {
|
|
2277
|
+
BodyTooLargeError,
|
|
2278
|
+
CircuitOpenError,
|
|
2279
|
+
HTTPError,
|
|
2280
|
+
RetryAbortedError,
|
|
2281
|
+
StopRetry,
|
|
2282
|
+
TimeoutError,
|
|
2283
|
+
buildQuery,
|
|
2284
|
+
createCircuitBreaker,
|
|
2285
|
+
createFetch,
|
|
2286
|
+
createLimiter,
|
|
2287
|
+
createMemoryCache,
|
|
2288
|
+
createMockFetch,
|
|
2289
|
+
createRateLimiter,
|
|
2290
|
+
downloadResumable,
|
|
2291
|
+
getWaitMsFromHeaders,
|
|
2292
|
+
normalizeError,
|
|
2293
|
+
parseJsonSafe,
|
|
2294
|
+
parseRetryAfter,
|
|
2295
|
+
readLines,
|
|
2296
|
+
readNDJSON,
|
|
2297
|
+
readSSE,
|
|
2298
|
+
retryAfterAware,
|
|
2299
|
+
withDedupe,
|
|
2300
|
+
withProgress,
|
|
2301
|
+
withRetry,
|
|
2302
|
+
withTimeout,
|
|
2303
|
+
} = __reqkit;
|
|
2304
|
+
export default __reqkit;
|