@boy-offi9-inc/reqkit 0.7.0 → 0.8.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 +95 -55
- package/dist/reqkit.mjs +2143 -1907
- package/package.json +9 -5
- package/src/{createFetch.js → http/createFetch.js} +169 -30
- package/src/index.js +18 -18
- package/src/{_http.js → internal/http.js} +11 -1
- package/src/{paginate.js → pagination/paginate.js} +33 -4
- package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
- package/src/{withRetry.js → resilience/retry.js} +2 -2
- package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
- package/src/{downloadResumable.js → streams/download.js} +5 -5
- package/src/{streams.js → streams/readers.js} +64 -20
- package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
- package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
- package/types/errors.d.ts +24 -0
- package/types/http.d.ts +180 -0
- package/types/index.d.ts +7 -518
- package/types/pagination.d.ts +62 -0
- package/types/resilience.d.ts +171 -0
- package/types/streams.d.ts +105 -0
- package/types/testing.d.ts +44 -0
- /package/{RECIPES.md → docs/recipes.md} +0 -0
- /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
- /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
- /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
- /package/src/{_cache.js → http/cache/entry.js} +0 -0
- /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
- /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
- /package/src/{_sleep.js → internal/sleep.js} +0 -0
- /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
- /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
- /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
- /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
- /package/src/{withProgress.js → streams/progress.js} +0 -0
package/dist/reqkit.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
/*! @boy-offi9-inc/reqkit v0.
|
|
1
|
+
/*! @boy-offi9-inc/reqkit v0.8.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
|
|
2
2
|
const __defs = {
|
|
3
|
-
"
|
|
3
|
+
"errors/HTTPError": function (module, exports, require) {
|
|
4
4
|
class HTTPError extends Error {
|
|
5
5
|
constructor(response, request) {
|
|
6
6
|
const statusText = response.statusText ? ` ${response.statusText}` : "";
|
|
@@ -17,7 +17,182 @@ class HTTPError extends Error {
|
|
|
17
17
|
module.exports = { HTTPError };
|
|
18
18
|
|
|
19
19
|
},
|
|
20
|
-
"
|
|
20
|
+
"errors/normalizeError": function (module, exports, require) {
|
|
21
|
+
/**
|
|
22
|
+
* Normalizes errors from fetch, axios, node's http/https, undici and generic
|
|
23
|
+
* Error objects into one consistent shape:
|
|
24
|
+
* { message, status, code, isNetworkError, isTimeout, isAborted, cause }
|
|
25
|
+
*
|
|
26
|
+
* Every field is always present (null/false when not applicable) so callers
|
|
27
|
+
* can destructure without existence checks.
|
|
28
|
+
*
|
|
29
|
+
* - `isTimeout` is true only for real timeouts (reqkit's TimeoutError,
|
|
30
|
+
* AbortSignal.timeout(), ETIMEDOUT, undici timeouts) — not for a plain
|
|
31
|
+
* user-initiated abort.
|
|
32
|
+
* - `isAborted` is true when the request was cancelled on purpose
|
|
33
|
+
* (AbortController.abort(), axios cancel).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
function shape(err, overrides) {
|
|
37
|
+
return {
|
|
38
|
+
message: "Unknown error",
|
|
39
|
+
status: null,
|
|
40
|
+
code: null,
|
|
41
|
+
isNetworkError: false,
|
|
42
|
+
isTimeout: false,
|
|
43
|
+
isAborted: false,
|
|
44
|
+
cause: err,
|
|
45
|
+
...overrides,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Node's fetch (undici) throws `TypeError: fetch failed` and puts the real
|
|
50
|
+
// reason (ECONNREFUSED, ENOTFOUND, UND_ERR_*...) on `err.cause`, sometimes
|
|
51
|
+
// inside an AggregateError. Walk the chain to find the first string code.
|
|
52
|
+
function findCode(err) {
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
const queue = [err];
|
|
55
|
+
while (queue.length && seen.size < 10) {
|
|
56
|
+
const cur = queue.shift();
|
|
57
|
+
if (!cur || typeof cur !== "object" || seen.has(cur)) continue;
|
|
58
|
+
seen.add(cur);
|
|
59
|
+
if (typeof cur.code === "string") return cur.code;
|
|
60
|
+
queue.push(cur.cause);
|
|
61
|
+
if (Array.isArray(cur.errors)) queue.push(...cur.errors);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isTimeoutCode(code) {
|
|
67
|
+
return code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT" || /^UND_ERR_.*TIMEOUT$/.test(code || "");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function isNetworkCode(code) {
|
|
71
|
+
if (!code) return false;
|
|
72
|
+
return /^E[A-Z0-9]+$/.test(code) || code === "EAI_AGAIN" || code === "EAI_FAIL" || code.startsWith("UND_ERR_");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Browsers and Node throw a bare TypeError for network-level fetch failures.
|
|
76
|
+
const FETCH_NETWORK_MESSAGE =
|
|
77
|
+
/fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch|^terminated$|^network error$/i;
|
|
78
|
+
|
|
79
|
+
function isFetchNetworkTypeError(err) {
|
|
80
|
+
return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeError(err) {
|
|
84
|
+
if (!err) return shape(err);
|
|
85
|
+
|
|
86
|
+
// axios error shape: err.response.{status,data}, err.request, err.code
|
|
87
|
+
if (err.isAxiosError || (err.response && typeof err.response.status === "number" && err.config)) {
|
|
88
|
+
return shape(err, {
|
|
89
|
+
message: err.response?.data?.message || err.message || "Request failed",
|
|
90
|
+
status: err.response?.status ?? null,
|
|
91
|
+
code: err.code ?? null,
|
|
92
|
+
isNetworkError: !err.response && Boolean(err.request),
|
|
93
|
+
isTimeout: err.code === "ECONNABORTED" || err.code === "ETIMEDOUT" || /timeout/i.test(err.message || ""),
|
|
94
|
+
isAborted: err.code === "ERR_CANCELED" || err.name === "CanceledError",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// fetch Response passed in directly (not thrown, but common enough to handle)
|
|
99
|
+
if (typeof err.status === "number" && typeof err.ok === "boolean") {
|
|
100
|
+
return shape(err, {
|
|
101
|
+
message: err.statusText || `Request failed with status ${err.status}`,
|
|
102
|
+
status: err.status,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const chainCode = findCode(err);
|
|
107
|
+
|
|
108
|
+
// reqkit's TimeoutError, AbortSignal.timeout() (DOMException TimeoutError),
|
|
109
|
+
// node ETIMEDOUT, undici connect/headers/body timeouts
|
|
110
|
+
if (err.name === "TimeoutError" || isTimeoutCode(chainCode)) {
|
|
111
|
+
return shape(err, {
|
|
112
|
+
message: err.message || "Request timed out",
|
|
113
|
+
code: chainCode || "ETIMEDOUT",
|
|
114
|
+
isTimeout: true,
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Deliberate cancellation via AbortController — not a timeout.
|
|
119
|
+
if (err.name === "AbortError") {
|
|
120
|
+
return shape(err, {
|
|
121
|
+
message: err.message || "Request aborted",
|
|
122
|
+
code: "ABORT_ERR",
|
|
123
|
+
isAborted: true,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// node http/https / undici network-level failures, including
|
|
128
|
+
// `TypeError: fetch failed` with the real code on err.cause
|
|
129
|
+
if (isNetworkCode(chainCode) || isFetchNetworkTypeError(err)) {
|
|
130
|
+
return shape(err, {
|
|
131
|
+
message: err.message || `Network error (${chainCode})`,
|
|
132
|
+
code: chainCode,
|
|
133
|
+
isNetworkError: true,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return shape(err, {
|
|
138
|
+
message: err.message || String(err),
|
|
139
|
+
status: err.status ?? err.statusCode ?? err.response?.status ?? err.response?.statusCode ?? null,
|
|
140
|
+
code: err.code ?? null,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
module.exports = { normalizeError };
|
|
145
|
+
|
|
146
|
+
},
|
|
147
|
+
"http/buildQuery": function (module, exports, require) {
|
|
148
|
+
function toParam(key, value) {
|
|
149
|
+
if (value instanceof Date) {
|
|
150
|
+
if (Number.isNaN(value.getTime())) {
|
|
151
|
+
throw new TypeError(`buildQuery: value for "${key}" is an invalid Date`);
|
|
152
|
+
}
|
|
153
|
+
return value.toISOString();
|
|
154
|
+
}
|
|
155
|
+
if (typeof value === "object" || typeof value === "function" || typeof value === "symbol") {
|
|
156
|
+
throw new TypeError(
|
|
157
|
+
`buildQuery: value for "${key}" is not a primitive, Date or array of those — flatten it or JSON.stringify it first`
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
return String(value);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Builds a query string from a params object. Skips null/undefined values,
|
|
165
|
+
* repeats the key for arrays (`?tag=a&tag=b`), serializes Dates as ISO 8601,
|
|
166
|
+
* and encodes everything. Nested objects throw a TypeError instead of
|
|
167
|
+
* silently becoming "[object Object]".
|
|
168
|
+
*
|
|
169
|
+
* Deliberately minimal — for full query-string parsing/nested-object support,
|
|
170
|
+
* reach for `query-string` (sindresorhus) instead. This just covers the
|
|
171
|
+
* common "build a query string from a flat params object" case with zero deps.
|
|
172
|
+
*
|
|
173
|
+
* @param {Record<string, unknown>} params
|
|
174
|
+
*/
|
|
175
|
+
function buildQuery(params) {
|
|
176
|
+
if (!params) return "";
|
|
177
|
+
|
|
178
|
+
const pairs = [];
|
|
179
|
+
for (const [key, value] of Object.entries(params)) {
|
|
180
|
+
if (value === null || value === undefined) continue;
|
|
181
|
+
|
|
182
|
+
const values = Array.isArray(value) ? value : [value];
|
|
183
|
+
for (const v of values) {
|
|
184
|
+
if (v === null || v === undefined) continue;
|
|
185
|
+
pairs.push(`${encodeURIComponent(key)}=${encodeURIComponent(toParam(key, v))}`);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return pairs.join("&");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { buildQuery };
|
|
193
|
+
|
|
194
|
+
},
|
|
195
|
+
"http/cache/entry": function (module, exports, require) {
|
|
21
196
|
// Helpers for createFetch's response cache.
|
|
22
197
|
|
|
23
198
|
const DEFAULT_MAX_BODY_BYTES = 1024 * 1024;
|
|
@@ -75,521 +250,288 @@ function entryToResponse(entry) {
|
|
|
75
250
|
module.exports = { cacheKey, snapshotResponse, entryToResponse, DEFAULT_MAX_BODY_BYTES };
|
|
76
251
|
|
|
77
252
|
},
|
|
78
|
-
"
|
|
253
|
+
"http/cache/memoryCache": function (module, exports, require) {
|
|
79
254
|
/**
|
|
80
|
-
*
|
|
81
|
-
*
|
|
255
|
+
* A small in-memory LRU store for createFetch's `cache` option. Anything with
|
|
256
|
+
* async-or-sync `get(key)`, `set(key, value)` and `delete(key)` can be used
|
|
257
|
+
* instead (Redis, IndexedDB, a Map...).
|
|
258
|
+
*
|
|
259
|
+
* @param {{ maxEntries?: number }} [opts]
|
|
82
260
|
*/
|
|
83
|
-
function
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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());
|
|
261
|
+
function createMemoryCache(opts = {}) {
|
|
262
|
+
const { maxEntries = 100 } = opts;
|
|
263
|
+
if (!(Number.isInteger(maxEntries) && maxEntries >= 1)) {
|
|
264
|
+
throw new TypeError("createMemoryCache: `maxEntries` must be an integer >= 1");
|
|
96
265
|
}
|
|
266
|
+
const map = new Map();
|
|
97
267
|
|
|
98
|
-
return
|
|
268
|
+
return {
|
|
269
|
+
get(key) {
|
|
270
|
+
if (!map.has(key)) return undefined;
|
|
271
|
+
const value = map.get(key);
|
|
272
|
+
map.delete(key); // re-insert so it counts as most recently used
|
|
273
|
+
map.set(key, value);
|
|
274
|
+
return value;
|
|
275
|
+
},
|
|
276
|
+
set(key, value) {
|
|
277
|
+
map.delete(key);
|
|
278
|
+
map.set(key, value);
|
|
279
|
+
while (map.size > maxEntries) map.delete(map.keys().next().value);
|
|
280
|
+
},
|
|
281
|
+
delete(key) {
|
|
282
|
+
return map.delete(key);
|
|
283
|
+
},
|
|
284
|
+
clear() {
|
|
285
|
+
map.clear();
|
|
286
|
+
},
|
|
287
|
+
get size() {
|
|
288
|
+
return map.size;
|
|
289
|
+
},
|
|
290
|
+
};
|
|
99
291
|
}
|
|
100
292
|
|
|
101
|
-
|
|
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
|
-
}
|
|
293
|
+
module.exports = { createMemoryCache };
|
|
113
294
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
295
|
+
},
|
|
296
|
+
"http/createFetch": function (module, exports, require) {
|
|
297
|
+
const { withRetry, RetryAbortedError } = require("../resilience/retry");
|
|
298
|
+
const { withTimeout } = require("../resilience/timeout");
|
|
299
|
+
const { withDedupe } = require("../resilience/dedupe");
|
|
300
|
+
const { buildQuery } = require("./buildQuery");
|
|
301
|
+
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
302
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
303
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
304
|
+
const { createRateLimiter } = require("../resilience/rateLimiter");
|
|
305
|
+
const { createCircuitBreaker } = require("../resilience/circuitBreaker");
|
|
306
|
+
const { createMemoryCache } = require("./cache/memoryCache");
|
|
307
|
+
const { cacheKey, snapshotResponse, entryToResponse } = require("./cache/entry");
|
|
308
|
+
const { discardBody, getOrigin } = require("../internal/http");
|
|
121
309
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (!headers || typeof headers.get !== "function") return null;
|
|
310
|
+
const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
|
|
311
|
+
// Safe to repeat without side effects. POST/PATCH are deliberately absent.
|
|
312
|
+
const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
313
|
+
// Default headers that carry credentials. They're only sent to trusted origins.
|
|
314
|
+
const SENSITIVE_HEADERS = ["authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"];
|
|
128
315
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
return parseResetMs(
|
|
133
|
-
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
134
|
-
);
|
|
316
|
+
function isAbsoluteUrl(url) {
|
|
317
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
135
318
|
}
|
|
136
319
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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);
|
|
320
|
+
function joinUrl(baseUrl, url, query) {
|
|
321
|
+
let full = String(url);
|
|
322
|
+
if (baseUrl && !isAbsoluteUrl(full)) {
|
|
323
|
+
full = `${String(baseUrl).replace(/\/+$/, "")}/${full.replace(/^\/+/, "")}`;
|
|
324
|
+
}
|
|
325
|
+
const qs = query ? buildQuery(query) : "";
|
|
326
|
+
if (qs) full += (full.includes("?") ? "&" : "?") + qs;
|
|
327
|
+
return full;
|
|
328
|
+
}
|
|
148
329
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
);
|
|
152
|
-
info.retryAfterMs = parseRetryAfter(headers.get("retry-after"));
|
|
153
|
-
return info;
|
|
330
|
+
async function resolveHeaders(source) {
|
|
331
|
+
const value = typeof source === "function" ? await source() : source;
|
|
332
|
+
return new Headers(value || undefined);
|
|
154
333
|
}
|
|
155
334
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
335
|
+
function mergeHeaders(...headerSets) {
|
|
336
|
+
const out = new Headers();
|
|
337
|
+
for (const set of headerSets) {
|
|
338
|
+
set.forEach((value, key) => out.set(key, value));
|
|
165
339
|
}
|
|
340
|
+
return out;
|
|
166
341
|
}
|
|
167
342
|
|
|
168
|
-
|
|
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
|
-
}
|
|
343
|
+
function isStreamBody(body) {
|
|
344
|
+
return Boolean(body) && typeof body === "object" && typeof body.getReader === "function";
|
|
178
345
|
}
|
|
179
346
|
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
});
|
|
347
|
+
function originOf(url) {
|
|
348
|
+
return getOrigin(url) || url;
|
|
201
349
|
}
|
|
202
350
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
};
|
|
214
|
-
const timer = setTimeout(() => {
|
|
215
|
-
signal?.removeEventListener("abort", onAbort);
|
|
216
|
-
resolve();
|
|
217
|
-
}, ms);
|
|
218
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
-
});
|
|
351
|
+
// One in-flight token refresh per `auth` config object, so concurrent 401s —
|
|
352
|
+
// even across extend() children — trigger exactly one refresh.
|
|
353
|
+
const refreshStates = new WeakMap();
|
|
354
|
+
function refreshStateFor(auth) {
|
|
355
|
+
let state = refreshStates.get(auth);
|
|
356
|
+
if (!state) {
|
|
357
|
+
state = { inflight: null };
|
|
358
|
+
refreshStates.set(auth, state);
|
|
359
|
+
}
|
|
360
|
+
return state;
|
|
220
361
|
}
|
|
221
362
|
|
|
222
|
-
|
|
363
|
+
function mergeDefaults(base, extra) {
|
|
364
|
+
const merged = { ...base, ...extra };
|
|
223
365
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
}
|
|
231
|
-
return value.toISOString();
|
|
366
|
+
if (base.headers || extra.headers) {
|
|
367
|
+
merged.headers = async () => {
|
|
368
|
+
const a = await resolveHeaders(base.headers);
|
|
369
|
+
const b = await resolveHeaders(extra.headers);
|
|
370
|
+
return mergeHeaders(a, b);
|
|
371
|
+
};
|
|
232
372
|
}
|
|
233
|
-
if (
|
|
234
|
-
|
|
235
|
-
`buildQuery: value for "${key}" is not a primitive, Date or array of those — flatten it or JSON.stringify it first`
|
|
236
|
-
);
|
|
373
|
+
if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
|
|
374
|
+
merged.retry = { ...base.retry, ...extra.retry };
|
|
237
375
|
}
|
|
238
|
-
return
|
|
376
|
+
return merged;
|
|
239
377
|
}
|
|
240
378
|
|
|
241
379
|
/**
|
|
242
|
-
* Builds a
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
* silently becoming "[object Object]".
|
|
380
|
+
* Builds a fetch-like function from the other reqkit pieces — only if you
|
|
381
|
+
* want that. It's still plain `fetch` underneath: you get a standard
|
|
382
|
+
* `Response` back, and `fetch` itself stays swappable.
|
|
246
383
|
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
384
|
+
* What you get on top of fetch:
|
|
385
|
+
* - `baseUrl`, `query` (via buildQuery), `json` request bodies
|
|
386
|
+
* - default headers (an object, or a function for tokens that change)
|
|
387
|
+
* - per-attempt `timeout` (withTimeout) and external `signal`
|
|
388
|
+
* - retries (withRetry) on network errors/timeouts and retryable statuses,
|
|
389
|
+
* honoring Retry-After; only for idempotent methods unless you opt in
|
|
390
|
+
* - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
|
|
391
|
+
* *before* the server has to say 429
|
|
392
|
+
* - `circuitBreaker`: fail fast against a service that keeps failing
|
|
393
|
+
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate,
|
|
394
|
+
* stale-if-error and ETag / Last-Modified revalidation (304s)
|
|
395
|
+
* - `onEvent`: one callback for everything that happens (request, attempt,
|
|
396
|
+
* retry, response, error, cache, rate-limit waits, circuit changes, token
|
|
397
|
+
* refreshes) — plug it into your logger or metrics
|
|
398
|
+
* - concurrent GET de-duplication (withDedupe)
|
|
399
|
+
* - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
|
|
400
|
+
* concurrent requests) and replay the request
|
|
401
|
+
* - credential safety: default credentials (`auth`, and default headers like
|
|
402
|
+
* Authorization/Cookie/X-API-Key) are only sent to `baseUrl`'s origin and
|
|
403
|
+
* `trustedOrigins` — pass an absolute URL to another host and they stay home
|
|
404
|
+
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
405
|
+
* - `onRequest` / `onResponse` hooks
|
|
250
406
|
*
|
|
251
|
-
* @param {
|
|
407
|
+
* @param {object} [defaults]
|
|
408
|
+
* @param {string} [defaults.baseUrl]
|
|
409
|
+
* @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [defaults.headers]
|
|
410
|
+
* @param {number | false} [defaults.timeout=30000] - per attempt, ms
|
|
411
|
+
* @param {false | object} [defaults.retry] - false disables. Options:
|
|
412
|
+
* `retries`(2), `statusCodes`, `methods`, `baseDelayMs`, `maxDelayMs`,
|
|
413
|
+
* `factor`, `jitter`, `maxRetryTime`, `onRetry`, `calculateDelay`
|
|
414
|
+
* @param {boolean | object} [defaults.rateLimit] - `true` just learns from
|
|
415
|
+
* response headers; `{ requests, perMs }` also enforces a budget; or pass a
|
|
416
|
+
* createRateLimiter() instance to share one budget
|
|
417
|
+
* @param {boolean | object | Function} [defaults.circuitBreaker] - options for
|
|
418
|
+
* a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
|
|
419
|
+
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate, staleIfError,
|
|
420
|
+
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL. `staleIfError` (ms) lets a
|
|
421
|
+
* cached copy be served for that long past its TTL when the network fails or the server answers 5xx.
|
|
422
|
+
* @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
|
|
423
|
+
* `getToken()` supplies the current token (sync or async); on a 401 (or
|
|
424
|
+
* whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
|
|
425
|
+
* called once for all concurrent callers and the request is replayed with the
|
|
426
|
+
* new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
|
|
427
|
+
* @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
|
|
428
|
+
* receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
|
|
429
|
+
* `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
|
|
430
|
+
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
431
|
+
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
432
|
+
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
433
|
+
* @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
|
|
434
|
+
* runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
|
|
435
|
+
* @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
|
|
436
|
+
* @param {(event: object) => void | Promise<void>} [defaults.onEvent] - receives `{ type, ... }` events:
|
|
437
|
+
* "request", "attempt", "retry", "response", "error", "cache", "ratelimit", "circuit", "auth".
|
|
438
|
+
* Errors thrown by the handler are swallowed — logging must never break a request.
|
|
252
439
|
*/
|
|
253
|
-
function
|
|
254
|
-
|
|
440
|
+
function createFetch(defaults = {}) {
|
|
441
|
+
const {
|
|
442
|
+
baseUrl,
|
|
443
|
+
headers: defaultHeaders,
|
|
444
|
+
timeout: defaultTimeout = 30000,
|
|
445
|
+
retry: defaultRetry = {},
|
|
446
|
+
rateLimit,
|
|
447
|
+
circuitBreaker,
|
|
448
|
+
cache,
|
|
449
|
+
auth,
|
|
450
|
+
trustedOrigins,
|
|
451
|
+
dedupe = false,
|
|
452
|
+
throwHttpErrors: defaultThrow = true,
|
|
453
|
+
fetch: customFetch,
|
|
454
|
+
onRequest,
|
|
455
|
+
onResponse,
|
|
456
|
+
onEvent,
|
|
457
|
+
} = defaults;
|
|
255
458
|
|
|
256
|
-
const
|
|
257
|
-
for (const [key, value] of Object.entries(params)) {
|
|
258
|
-
if (value === null || value === undefined) continue;
|
|
459
|
+
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
259
460
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
461
|
+
// A throwing (or rejecting) handler must never break a request.
|
|
462
|
+
const emitEvent = (event) => {
|
|
463
|
+
if (!onEvent) return;
|
|
464
|
+
try {
|
|
465
|
+
const result = onEvent(event);
|
|
466
|
+
if (result && typeof result.catch === "function") result.catch(() => {});
|
|
467
|
+
} catch {
|
|
468
|
+
// ignore
|
|
264
469
|
}
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
|
|
473
|
+
const trusted = new Set();
|
|
474
|
+
for (const o of [baseUrl, ...(trustedOrigins || [])]) {
|
|
475
|
+
const origin = o && getOrigin(o);
|
|
476
|
+
if (origin) trusted.add(origin);
|
|
265
477
|
}
|
|
478
|
+
const isTrustedTarget = (url) => {
|
|
479
|
+
if (trusted.size === 0 || !isAbsoluteUrl(url)) return true; // relative URLs resolve against our own base
|
|
480
|
+
const origin = getOrigin(url);
|
|
481
|
+
return origin !== null && trusted.has(origin);
|
|
482
|
+
};
|
|
266
483
|
|
|
267
|
-
|
|
268
|
-
|
|
484
|
+
// --- stateful pieces, built once so `extend()` children share them -------
|
|
485
|
+
const limiter = !rateLimit
|
|
486
|
+
? null
|
|
487
|
+
: typeof rateLimit.acquire === "function"
|
|
488
|
+
? rateLimit
|
|
489
|
+
: createRateLimiter(rateLimit === true ? {} : rateLimit);
|
|
269
490
|
|
|
270
|
-
|
|
491
|
+
const breakerRegistry = new Map();
|
|
492
|
+
const breakerFor = !circuitBreaker
|
|
493
|
+
? null
|
|
494
|
+
: typeof circuitBreaker === "function"
|
|
495
|
+
? circuitBreaker
|
|
496
|
+
: typeof circuitBreaker.run === "function"
|
|
497
|
+
? () => circuitBreaker
|
|
498
|
+
: (url) => {
|
|
499
|
+
const origin = originOf(url);
|
|
500
|
+
if (!breakerRegistry.has(origin)) {
|
|
501
|
+
const options = circuitBreaker === true ? {} : { ...circuitBreaker };
|
|
502
|
+
const userOnStateChange = options.onStateChange;
|
|
503
|
+
options.onStateChange = (state, previous) => {
|
|
504
|
+
if (userOnStateChange) userOnStateChange(state, previous);
|
|
505
|
+
emitEvent({ type: "circuit", origin, state, previous });
|
|
506
|
+
};
|
|
507
|
+
breakerRegistry.set(origin, createCircuitBreaker(options));
|
|
508
|
+
}
|
|
509
|
+
return breakerRegistry.get(origin);
|
|
510
|
+
};
|
|
271
511
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
if (
|
|
286
|
-
|
|
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
|
-
// One in-flight token refresh per `auth` config object, so concurrent 401s —
|
|
458
|
-
// even across extend() children — trigger exactly one refresh.
|
|
459
|
-
const refreshStates = new WeakMap();
|
|
460
|
-
function refreshStateFor(auth) {
|
|
461
|
-
let state = refreshStates.get(auth);
|
|
462
|
-
if (!state) {
|
|
463
|
-
state = { inflight: null };
|
|
464
|
-
refreshStates.set(auth, state);
|
|
465
|
-
}
|
|
466
|
-
return state;
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
function mergeDefaults(base, extra) {
|
|
470
|
-
const merged = { ...base, ...extra };
|
|
471
|
-
|
|
472
|
-
if (base.headers || extra.headers) {
|
|
473
|
-
merged.headers = async () => {
|
|
474
|
-
const a = await resolveHeaders(base.headers);
|
|
475
|
-
const b = await resolveHeaders(extra.headers);
|
|
476
|
-
return mergeHeaders(a, b);
|
|
477
|
-
};
|
|
478
|
-
}
|
|
479
|
-
if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
|
|
480
|
-
merged.retry = { ...base.retry, ...extra.retry };
|
|
481
|
-
}
|
|
482
|
-
return merged;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Builds a fetch-like function from the other reqkit pieces — only if you
|
|
487
|
-
* want that. It's still plain `fetch` underneath: you get a standard
|
|
488
|
-
* `Response` back, and `fetch` itself stays swappable.
|
|
489
|
-
*
|
|
490
|
-
* What you get on top of fetch:
|
|
491
|
-
* - `baseUrl`, `query` (via buildQuery), `json` request bodies
|
|
492
|
-
* - default headers (an object, or a function for tokens that change)
|
|
493
|
-
* - per-attempt `timeout` (withTimeout) and external `signal`
|
|
494
|
-
* - retries (withRetry) on network errors/timeouts and retryable statuses,
|
|
495
|
-
* honoring Retry-After; only for idempotent methods unless you opt in
|
|
496
|
-
* - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
|
|
497
|
-
* *before* the server has to say 429
|
|
498
|
-
* - `circuitBreaker`: fail fast against a service that keeps failing
|
|
499
|
-
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
|
|
500
|
-
* ETag / Last-Modified revalidation (304s)
|
|
501
|
-
* - concurrent GET de-duplication (withDedupe)
|
|
502
|
-
* - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
|
|
503
|
-
* concurrent requests) and replay the request
|
|
504
|
-
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
505
|
-
* - `onRequest` / `onResponse` hooks
|
|
506
|
-
*
|
|
507
|
-
* @param {object} [defaults]
|
|
508
|
-
* @param {string} [defaults.baseUrl]
|
|
509
|
-
* @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [defaults.headers]
|
|
510
|
-
* @param {number | false} [defaults.timeout=30000] - per attempt, ms
|
|
511
|
-
* @param {false | object} [defaults.retry] - false disables. Options:
|
|
512
|
-
* `retries`(2), `statusCodes`, `methods`, `baseDelayMs`, `maxDelayMs`,
|
|
513
|
-
* `factor`, `jitter`, `maxRetryTime`, `onRetry`, `calculateDelay`
|
|
514
|
-
* @param {boolean | object} [defaults.rateLimit] - `true` just learns from
|
|
515
|
-
* response headers; `{ requests, perMs }` also enforces a budget; or pass a
|
|
516
|
-
* createRateLimiter() instance to share one budget
|
|
517
|
-
* @param {boolean | object | Function} [defaults.circuitBreaker] - options for
|
|
518
|
-
* a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
|
|
519
|
-
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
|
|
520
|
-
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
|
|
521
|
-
* @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
|
|
522
|
-
* `getToken()` supplies the current token (sync or async); on a 401 (or
|
|
523
|
-
* whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
|
|
524
|
-
* called once for all concurrent callers and the request is replayed with the
|
|
525
|
-
* new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
|
|
526
|
-
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
527
|
-
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
528
|
-
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
529
|
-
* @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
|
|
530
|
-
* runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
|
|
531
|
-
* @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
|
|
532
|
-
*/
|
|
533
|
-
function createFetch(defaults = {}) {
|
|
534
|
-
const {
|
|
535
|
-
baseUrl,
|
|
536
|
-
headers: defaultHeaders,
|
|
537
|
-
timeout: defaultTimeout = 30000,
|
|
538
|
-
retry: defaultRetry = {},
|
|
539
|
-
rateLimit,
|
|
540
|
-
circuitBreaker,
|
|
541
|
-
cache,
|
|
542
|
-
auth,
|
|
543
|
-
dedupe = false,
|
|
544
|
-
throwHttpErrors: defaultThrow = true,
|
|
545
|
-
fetch: customFetch,
|
|
546
|
-
onRequest,
|
|
547
|
-
onResponse,
|
|
548
|
-
} = defaults;
|
|
549
|
-
|
|
550
|
-
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
551
|
-
|
|
552
|
-
// --- stateful pieces, built once so `extend()` children share them -------
|
|
553
|
-
const limiter = !rateLimit
|
|
554
|
-
? null
|
|
555
|
-
: typeof rateLimit.acquire === "function"
|
|
556
|
-
? rateLimit
|
|
557
|
-
: createRateLimiter(rateLimit === true ? {} : rateLimit);
|
|
558
|
-
|
|
559
|
-
const breakerRegistry = new Map();
|
|
560
|
-
const breakerFor = !circuitBreaker
|
|
561
|
-
? null
|
|
562
|
-
: typeof circuitBreaker === "function"
|
|
563
|
-
? circuitBreaker
|
|
564
|
-
: typeof circuitBreaker.run === "function"
|
|
565
|
-
? () => circuitBreaker
|
|
566
|
-
: (url) => {
|
|
567
|
-
const origin = originOf(url);
|
|
568
|
-
if (!breakerRegistry.has(origin)) {
|
|
569
|
-
breakerRegistry.set(origin, createCircuitBreaker(circuitBreaker === true ? {} : circuitBreaker));
|
|
570
|
-
}
|
|
571
|
-
return breakerRegistry.get(origin);
|
|
572
|
-
};
|
|
573
|
-
|
|
574
|
-
const cacheCfg = !cache
|
|
575
|
-
? null
|
|
576
|
-
: {
|
|
577
|
-
ttl: 60000,
|
|
578
|
-
staleWhileRevalidate: 0,
|
|
579
|
-
maxEntries: 100,
|
|
580
|
-
maxBodyBytes: undefined,
|
|
581
|
-
...(cache === true ? {} : cache),
|
|
582
|
-
};
|
|
583
|
-
if (cacheCfg && !cacheCfg.store) cacheCfg.store = createMemoryCache({ maxEntries: cacheCfg.maxEntries });
|
|
584
|
-
// In-flight bookkeeping lives on the (shared) cache config so `extend()`
|
|
585
|
-
// children see the parent's pending writes and revalidations.
|
|
586
|
-
if (cacheCfg) {
|
|
587
|
-
cacheCfg.pending = cacheCfg.pending || new Map();
|
|
588
|
-
cacheCfg.revalidating = cacheCfg.revalidating || new Set();
|
|
512
|
+
const cacheCfg = !cache
|
|
513
|
+
? null
|
|
514
|
+
: {
|
|
515
|
+
ttl: 60000,
|
|
516
|
+
staleWhileRevalidate: 0,
|
|
517
|
+
staleIfError: 0,
|
|
518
|
+
maxEntries: 100,
|
|
519
|
+
maxBodyBytes: undefined,
|
|
520
|
+
...(cache === true ? {} : cache),
|
|
521
|
+
};
|
|
522
|
+
if (cacheCfg && !cacheCfg.store) cacheCfg.store = createMemoryCache({ maxEntries: cacheCfg.maxEntries });
|
|
523
|
+
// In-flight bookkeeping lives on the (shared) cache config so `extend()`
|
|
524
|
+
// children see the parent's pending writes and revalidations.
|
|
525
|
+
if (cacheCfg) {
|
|
526
|
+
cacheCfg.pending = cacheCfg.pending || new Map();
|
|
527
|
+
cacheCfg.revalidating = cacheCfg.revalidating || new Set();
|
|
589
528
|
}
|
|
590
529
|
const pendingStores = cacheCfg ? cacheCfg.pending : null;
|
|
591
530
|
const revalidating = cacheCfg ? cacheCfg.revalidating : null;
|
|
592
|
-
const emit = (type, url) =>
|
|
531
|
+
const emit = (type, url) => {
|
|
532
|
+
if (cacheCfg && cacheCfg.onEvent) cacheCfg.onEvent({ type, url });
|
|
533
|
+
emitEvent({ type: "cache", url, method: "GET", event: type });
|
|
534
|
+
};
|
|
593
535
|
|
|
594
536
|
// Shared in-flight GETs. Every caller gets its own clone of the response so
|
|
595
537
|
// body reads don't fight each other.
|
|
@@ -613,9 +555,12 @@ function createFetch(defaults = {}) {
|
|
|
613
555
|
// Returns a token newer than `usedToken`, refreshing at most once no matter
|
|
614
556
|
// how many requests were rejected at the same time. If someone else already
|
|
615
557
|
// refreshed (the current token isn't the one we used), just use that.
|
|
616
|
-
async function obtainFreshToken(authCfg, tokenUsed, response) {
|
|
558
|
+
async function obtainFreshToken(authCfg, tokenUsed, response, info) {
|
|
617
559
|
const current = await authCfg.getToken();
|
|
618
|
-
if (current && current !== tokenUsed)
|
|
560
|
+
if (current && current !== tokenUsed) {
|
|
561
|
+
emitEvent({ type: "auth", ...info, outcome: "reused" });
|
|
562
|
+
return current;
|
|
563
|
+
}
|
|
619
564
|
|
|
620
565
|
const state = refreshStateFor(authCfg);
|
|
621
566
|
if (!state.inflight) {
|
|
@@ -628,10 +573,21 @@ function createFetch(defaults = {}) {
|
|
|
628
573
|
}
|
|
629
574
|
})();
|
|
630
575
|
}
|
|
631
|
-
|
|
576
|
+
try {
|
|
577
|
+
const token = await state.inflight;
|
|
578
|
+
emitEvent({ type: "auth", ...info, outcome: "refreshed" });
|
|
579
|
+
return token;
|
|
580
|
+
} catch (error) {
|
|
581
|
+
emitEvent({ type: "auth", ...info, outcome: "failed", error });
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
632
584
|
}
|
|
633
585
|
|
|
634
|
-
|
|
586
|
+
// May a cached copy be served because the origin is failing?
|
|
587
|
+
const withinStaleIfError = (entry) =>
|
|
588
|
+
cacheCfg && cacheCfg.staleIfError > 0 && Date.now() - entry.storedAt < cacheCfg.ttl + cacheCfg.staleIfError;
|
|
589
|
+
|
|
590
|
+
async function execute(input, init, state) {
|
|
635
591
|
const {
|
|
636
592
|
query,
|
|
637
593
|
json,
|
|
@@ -645,7 +601,20 @@ function createFetch(defaults = {}) {
|
|
|
645
601
|
} = init;
|
|
646
602
|
|
|
647
603
|
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
648
|
-
const
|
|
604
|
+
const url = joinUrl(baseUrl, input, query);
|
|
605
|
+
state.url = url;
|
|
606
|
+
state.method = method;
|
|
607
|
+
const credentialsAllowed = isTrustedTarget(url);
|
|
608
|
+
|
|
609
|
+
const defaultHeaderSet = await resolveHeaders(defaultHeaders);
|
|
610
|
+
if (!credentialsAllowed) {
|
|
611
|
+
// An absolute URL to some other host: don't hand it our default credentials.
|
|
612
|
+
for (const name of [...SENSITIVE_HEADERS, ...(auth && auth.header ? [String(auth.header).toLowerCase()] : [])]) {
|
|
613
|
+
defaultHeaderSet.delete(name);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// Headers the caller passes for *this* call are deliberate, so they always go through.
|
|
617
|
+
const headers = mergeHeaders(defaultHeaderSet, new Headers(initHeaders || undefined));
|
|
649
618
|
|
|
650
619
|
let body = fetchInit.body;
|
|
651
620
|
if (json !== undefined) {
|
|
@@ -653,16 +622,19 @@ function createFetch(defaults = {}) {
|
|
|
653
622
|
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
654
623
|
}
|
|
655
624
|
|
|
656
|
-
const ctx = { url
|
|
625
|
+
const ctx = { url, init: { ...fetchInit, method, headers, body } };
|
|
657
626
|
if (onRequest) await onRequest(ctx);
|
|
658
627
|
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
659
628
|
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
629
|
+
state.url = ctx.url;
|
|
630
|
+
state.method = finalMethod;
|
|
631
|
+
emitEvent({ type: "request", url: ctx.url, method: finalMethod });
|
|
660
632
|
|
|
661
633
|
// --- auth: attach the current token (unless the caller set the header) ----
|
|
662
634
|
const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
|
|
663
635
|
const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
|
|
664
636
|
let usedToken = null;
|
|
665
|
-
if (auth && !finalHeaders.has(authHeader)) {
|
|
637
|
+
if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
|
|
666
638
|
usedToken = (await auth.getToken()) || null;
|
|
667
639
|
if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
|
|
668
640
|
}
|
|
@@ -682,8 +654,13 @@ function createFetch(defaults = {}) {
|
|
|
682
654
|
// `netSignal` are parameters so background revalidation can reuse this.
|
|
683
655
|
function network(netHeaders, netSignal) {
|
|
684
656
|
const attemptOnce = () => {
|
|
657
|
+
state.attempts++;
|
|
658
|
+
emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
|
|
685
659
|
const attempt = async () => {
|
|
686
|
-
if (limiter)
|
|
660
|
+
if (limiter) {
|
|
661
|
+
const waitedMs = await limiter.acquire({ signal: netSignal });
|
|
662
|
+
if (waitedMs > 0) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
|
|
663
|
+
}
|
|
687
664
|
const res = await withTimeout(
|
|
688
665
|
(attemptSignal) => {
|
|
689
666
|
const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
|
|
@@ -715,6 +692,19 @@ function createFetch(defaults = {}) {
|
|
|
715
692
|
...retryRest,
|
|
716
693
|
retries,
|
|
717
694
|
retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
|
|
695
|
+
onRetry: (err, attempt, delayMs, rctx) => {
|
|
696
|
+
if (retryRest.onRetry) retryRest.onRetry(err, attempt, delayMs, rctx);
|
|
697
|
+
emitEvent({
|
|
698
|
+
type: "retry",
|
|
699
|
+
url: ctx.url,
|
|
700
|
+
method: finalMethod,
|
|
701
|
+
attempt: attempt + 1,
|
|
702
|
+
delayMs,
|
|
703
|
+
reason: rctx.reason === "result" ? "status" : "error",
|
|
704
|
+
status: rctx.status,
|
|
705
|
+
error: err,
|
|
706
|
+
});
|
|
707
|
+
},
|
|
718
708
|
shouldRetry:
|
|
719
709
|
customShouldRetry ||
|
|
720
710
|
((err) => {
|
|
@@ -746,6 +736,17 @@ function createFetch(defaults = {}) {
|
|
|
746
736
|
let entry = null;
|
|
747
737
|
|
|
748
738
|
const finish = async (response) => {
|
|
739
|
+
state.responded = true;
|
|
740
|
+
emitEvent({
|
|
741
|
+
type: "response",
|
|
742
|
+
url: ctx.url,
|
|
743
|
+
method: finalMethod,
|
|
744
|
+
status: response.status,
|
|
745
|
+
ok: response.ok,
|
|
746
|
+
durationMs: Date.now() - state.startedAt,
|
|
747
|
+
attempts: state.attempts,
|
|
748
|
+
cache: state.cache,
|
|
749
|
+
});
|
|
749
750
|
if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
|
|
750
751
|
if (throwHttpErrors && !response.ok) {
|
|
751
752
|
throw new HTTPError(response, { method: finalMethod, url: ctx.url });
|
|
@@ -760,10 +761,12 @@ function createFetch(defaults = {}) {
|
|
|
760
761
|
const age = Date.now() - entry.storedAt;
|
|
761
762
|
if (age < cacheCfg.ttl) {
|
|
762
763
|
emit("hit", ctx.url);
|
|
764
|
+
state.cache = "hit";
|
|
763
765
|
return finish(entryToResponse(entry));
|
|
764
766
|
}
|
|
765
767
|
if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
|
|
766
768
|
emit("stale", ctx.url);
|
|
769
|
+
state.cache = "stale";
|
|
767
770
|
if (!revalidating.has(key)) {
|
|
768
771
|
revalidating.add(key);
|
|
769
772
|
const staleEntry = entry;
|
|
@@ -800,13 +803,19 @@ function createFetch(defaults = {}) {
|
|
|
800
803
|
} catch (err) {
|
|
801
804
|
// Present a user abort as the plain AbortError they caused, not as retry internals.
|
|
802
805
|
if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
|
|
806
|
+
// The network failed but we still hold a usable copy: serve it (never for a deliberate abort).
|
|
807
|
+
if (entry && withinStaleIfError(entry) && !(signal && signal.aborted) && !(err && err.name === "AbortError")) {
|
|
808
|
+
emit("stale-if-error", ctx.url);
|
|
809
|
+
state.cache = "stale-if-error";
|
|
810
|
+
return finish(entryToResponse(entry));
|
|
811
|
+
}
|
|
803
812
|
throw err;
|
|
804
813
|
}
|
|
805
814
|
|
|
806
815
|
// --- auth: a rejected token is refreshed once, then the request replays ----
|
|
807
816
|
const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
|
|
808
817
|
if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
|
|
809
|
-
const fresh = await obtainFreshToken(auth, usedToken, response);
|
|
818
|
+
const fresh = await obtainFreshToken(auth, usedToken, response, { url: ctx.url, method: finalMethod });
|
|
810
819
|
if (fresh) {
|
|
811
820
|
await discardBody(response);
|
|
812
821
|
finalHeaders.set(authHeader, formatToken(fresh));
|
|
@@ -821,15 +830,25 @@ function createFetch(defaults = {}) {
|
|
|
821
830
|
}
|
|
822
831
|
}
|
|
823
832
|
|
|
833
|
+
// The server answered, but with an error, and we still hold a usable copy.
|
|
834
|
+
if (useCache && entry && response.status >= 500 && withinStaleIfError(entry)) {
|
|
835
|
+
await discardBody(response);
|
|
836
|
+
emit("stale-if-error", ctx.url);
|
|
837
|
+
state.cache = "stale-if-error";
|
|
838
|
+
return finish(entryToResponse(entry));
|
|
839
|
+
}
|
|
840
|
+
|
|
824
841
|
if (useCache) {
|
|
825
842
|
if (response.status === 304 && entry) {
|
|
826
843
|
entry.storedAt = Date.now();
|
|
827
844
|
await cacheCfg.store.set(key, entry);
|
|
828
845
|
emit("revalidated", ctx.url);
|
|
846
|
+
state.cache = "revalidated";
|
|
829
847
|
await discardBody(response);
|
|
830
848
|
response = entryToResponse(entry);
|
|
831
849
|
} else {
|
|
832
850
|
emit("miss", ctx.url);
|
|
851
|
+
state.cache = "miss";
|
|
833
852
|
if (response.status === 200) scheduleStore(key, response, ctx.url);
|
|
834
853
|
}
|
|
835
854
|
}
|
|
@@ -837,6 +856,22 @@ function createFetch(defaults = {}) {
|
|
|
837
856
|
return finish(response);
|
|
838
857
|
}
|
|
839
858
|
|
|
859
|
+
async function request(input, init = {}) {
|
|
860
|
+
const state = { startedAt: Date.now(), attempts: 0, responded: false, cache: undefined, url: undefined, method: undefined };
|
|
861
|
+
try {
|
|
862
|
+
return await execute(input, init, state);
|
|
863
|
+
} catch (error) {
|
|
864
|
+
// A non-2xx HTTPError was already reported as a "response" event.
|
|
865
|
+
if (!state.responded) {
|
|
866
|
+
emitEvent({ type: "error", url: state.url, method: state.method, error, durationMs: Date.now() - state.startedAt, attempts: state.attempts });
|
|
867
|
+
}
|
|
868
|
+
throw error;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
|
|
873
|
+
request.baseUrl = baseUrl;
|
|
874
|
+
|
|
840
875
|
/** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
|
|
841
876
|
request.json = async (input, init) => {
|
|
842
877
|
const response = await request(input, init);
|
|
@@ -866,988 +901,992 @@ function createFetch(defaults = {}) {
|
|
|
866
901
|
module.exports = { createFetch, HTTPError };
|
|
867
902
|
|
|
868
903
|
},
|
|
869
|
-
"
|
|
870
|
-
/**
|
|
871
|
-
* Creates a concurrency limiter: at most `concurrency` calls run at once,
|
|
872
|
-
* the rest wait in a FIFO queue. Handy for "download these 200 files, 4 at a
|
|
873
|
-
* time" without pulling in a queue library. (Same idea as p-limit.)
|
|
874
|
-
*
|
|
875
|
-
* ```js
|
|
876
|
-
* const limit = createLimiter(4);
|
|
877
|
-
* const bytes = await Promise.all(urls.map((u) => limit(() => download(u))));
|
|
878
|
-
* // or: await limit.map(urls, (u) => download(u));
|
|
879
|
-
* ```
|
|
880
|
-
*
|
|
881
|
-
* @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
|
|
882
|
-
*/
|
|
883
|
-
function createLimiter(concurrency = Infinity) {
|
|
884
|
-
if (concurrency !== Infinity && !(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
885
|
-
throw new TypeError("createLimiter: `concurrency` must be an integer >= 1 (or Infinity)");
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
const queue = [];
|
|
889
|
-
let active = 0;
|
|
890
|
-
|
|
891
|
-
function next() {
|
|
892
|
-
while (active < concurrency && queue.length > 0) {
|
|
893
|
-
active++;
|
|
894
|
-
queue.shift()();
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
function limit(fn, ...args) {
|
|
899
|
-
return new Promise((resolve, reject) => {
|
|
900
|
-
queue.push(() => {
|
|
901
|
-
Promise.resolve()
|
|
902
|
-
.then(() => fn(...args))
|
|
903
|
-
.then(resolve, reject)
|
|
904
|
-
.finally(() => {
|
|
905
|
-
active--;
|
|
906
|
-
next();
|
|
907
|
-
});
|
|
908
|
-
});
|
|
909
|
-
next();
|
|
910
|
-
});
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
Object.defineProperties(limit, {
|
|
914
|
-
activeCount: { get: () => active },
|
|
915
|
-
pendingCount: { get: () => queue.length },
|
|
916
|
-
});
|
|
917
|
-
|
|
918
|
-
/** Runs `fn(item, index)` for every item through the limiter. Results keep input order. */
|
|
919
|
-
limit.map = (items, fn) => Promise.all(Array.from(items, (item, i) => limit(fn, item, i)));
|
|
920
|
-
|
|
921
|
-
return limit;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
module.exports = { createLimiter };
|
|
925
|
-
|
|
926
|
-
},
|
|
927
|
-
"./createMemoryCache": function (module, exports, require) {
|
|
904
|
+
"http/parseJsonSafe": function (module, exports, require) {
|
|
928
905
|
/**
|
|
929
|
-
*
|
|
930
|
-
*
|
|
931
|
-
* instead (Redis, IndexedDB, a Map...).
|
|
906
|
+
* Parses a fetch Response (or raw string) as JSON without throwing.
|
|
907
|
+
* Returns { data, error } — exactly one is non-null.
|
|
932
908
|
*
|
|
933
|
-
* @param {
|
|
909
|
+
* @param {Response | string} input
|
|
934
910
|
*/
|
|
935
|
-
function
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
911
|
+
async function parseJsonSafe(input) {
|
|
912
|
+
let text;
|
|
913
|
+
try {
|
|
914
|
+
text = typeof input === "string" ? input : await input.text();
|
|
915
|
+
} catch (err) {
|
|
916
|
+
return { data: null, error: err };
|
|
939
917
|
}
|
|
940
|
-
const map = new Map();
|
|
941
|
-
|
|
942
|
-
return {
|
|
943
|
-
get(key) {
|
|
944
|
-
if (!map.has(key)) return undefined;
|
|
945
|
-
const value = map.get(key);
|
|
946
|
-
map.delete(key); // re-insert so it counts as most recently used
|
|
947
|
-
map.set(key, value);
|
|
948
|
-
return value;
|
|
949
|
-
},
|
|
950
|
-
set(key, value) {
|
|
951
|
-
map.delete(key);
|
|
952
|
-
map.set(key, value);
|
|
953
|
-
while (map.size > maxEntries) map.delete(map.keys().next().value);
|
|
954
|
-
},
|
|
955
|
-
delete(key) {
|
|
956
|
-
return map.delete(key);
|
|
957
|
-
},
|
|
958
|
-
clear() {
|
|
959
|
-
map.clear();
|
|
960
|
-
},
|
|
961
|
-
get size() {
|
|
962
|
-
return map.size;
|
|
963
|
-
},
|
|
964
|
-
};
|
|
965
|
-
}
|
|
966
918
|
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
},
|
|
970
|
-
"./createMockFetch": function (module, exports, require) {
|
|
971
|
-
const { abortableSleep } = require("./_sleep");
|
|
972
|
-
|
|
973
|
-
const BODYLESS = new Set([101, 204, 205, 304]);
|
|
974
|
-
|
|
975
|
-
function toBody(body, headers) {
|
|
976
|
-
if (body === null || body === undefined) return null;
|
|
977
|
-
if (typeof body === "string") return body;
|
|
978
|
-
if (
|
|
979
|
-
body instanceof ArrayBuffer ||
|
|
980
|
-
ArrayBuffer.isView(body) ||
|
|
981
|
-
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
982
|
-
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream) ||
|
|
983
|
-
body instanceof URLSearchParams
|
|
984
|
-
) {
|
|
985
|
-
return body;
|
|
919
|
+
if (!text) {
|
|
920
|
+
return { data: null, error: null };
|
|
986
921
|
}
|
|
987
|
-
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
988
|
-
return JSON.stringify(body);
|
|
989
|
-
}
|
|
990
|
-
|
|
991
|
-
function buildResponse(status, body, extraHeaders) {
|
|
992
|
-
const headers = new Headers(extraHeaders || undefined);
|
|
993
|
-
const payload = BODYLESS.has(status) ? null : toBody(body, headers);
|
|
994
|
-
return new Response(payload, { status, headers });
|
|
995
|
-
}
|
|
996
922
|
|
|
997
|
-
function matchesUrl(matcher, url) {
|
|
998
|
-
if (typeof matcher === "function") return Boolean(matcher(url));
|
|
999
|
-
if (matcher instanceof RegExp) return matcher.test(url);
|
|
1000
|
-
if (matcher === "*" || matcher === undefined) return true;
|
|
1001
|
-
if (url === matcher) return true;
|
|
1002
923
|
try {
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
} catch {
|
|
1007
|
-
// relative/odd URL — exact match only
|
|
924
|
+
return { data: JSON.parse(text), error: null };
|
|
925
|
+
} catch (err) {
|
|
926
|
+
return { data: null, error: err };
|
|
1008
927
|
}
|
|
1009
|
-
return false;
|
|
1010
928
|
}
|
|
1011
929
|
|
|
1012
|
-
|
|
1013
|
-
* A scriptable, fetch-compatible test double — for testing code that uses
|
|
1014
|
-
* retries, timeouts, rate limits and so on without a network.
|
|
1015
|
-
*
|
|
1016
|
-
* ```js
|
|
1017
|
-
* const mock = createMockFetch();
|
|
1018
|
-
* mock.on("GET", "/users/1")
|
|
1019
|
-
* .replyOnce(503, "busy", { headers: { "retry-after": "0" } })
|
|
1020
|
-
* .reply(200, { id: 1 });
|
|
1021
|
-
* mock.on("POST", /\/orders/).fail(mock.networkError());
|
|
1022
|
-
*
|
|
1023
|
-
* const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test" });
|
|
1024
|
-
* await api.json("/users/1"); // → { id: 1 } after one retry
|
|
1025
|
-
* mock.calls.length; // 2
|
|
1026
|
-
* ```
|
|
1027
|
-
*
|
|
1028
|
-
* String matchers match the full URL, `pathname + search`, or the bare
|
|
1029
|
-
* `pathname`; you can also pass a RegExp or a `(url) => boolean`. `"*"` as the
|
|
1030
|
-
* method matches any method. Unmatched requests reject with a clear error.
|
|
1031
|
-
*/
|
|
1032
|
-
function createMockFetch() {
|
|
1033
|
-
let routes = [];
|
|
1034
|
-
const calls = [];
|
|
1035
|
-
|
|
1036
|
-
function on(method, matcher) {
|
|
1037
|
-
const route = { method: String(method).toUpperCase(), matcher, once: [], fallback: null };
|
|
1038
|
-
routes.push(route);
|
|
930
|
+
module.exports = { parseJsonSafe };
|
|
1039
931
|
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
fail(err = networkError()) {
|
|
1061
|
-
route.fallback = async () => {
|
|
1062
|
-
throw err;
|
|
1063
|
-
};
|
|
1064
|
-
return chain;
|
|
1065
|
-
},
|
|
1066
|
-
failOnce(err = networkError()) {
|
|
1067
|
-
route.once.push(async () => {
|
|
1068
|
-
throw err;
|
|
1069
|
-
});
|
|
1070
|
-
return chain;
|
|
1071
|
-
},
|
|
1072
|
-
};
|
|
1073
|
-
return chain;
|
|
1074
|
-
}
|
|
932
|
+
},
|
|
933
|
+
"index": function (module, exports, require) {
|
|
934
|
+
const { withRetry, RetryAbortedError, StopRetry } = require("./resilience/retry");
|
|
935
|
+
const { withTimeout, TimeoutError } = require("./resilience/timeout");
|
|
936
|
+
const { normalizeError } = require("./errors/normalizeError");
|
|
937
|
+
const { parseJsonSafe } = require("./http/parseJsonSafe");
|
|
938
|
+
const { buildQuery } = require("./http/buildQuery");
|
|
939
|
+
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./resilience/retryAfterAware");
|
|
940
|
+
const { withProgress, BodyTooLargeError } = require("./streams/progress");
|
|
941
|
+
const { withDedupe } = require("./resilience/dedupe");
|
|
942
|
+
const { createLimiter } = require("./resilience/limiter");
|
|
943
|
+
const { createFetch, HTTPError } = require("./http/createFetch");
|
|
944
|
+
const { createRateLimiter } = require("./resilience/rateLimiter");
|
|
945
|
+
const { createCircuitBreaker, CircuitOpenError } = require("./resilience/circuitBreaker");
|
|
946
|
+
const { createMemoryCache } = require("./http/cache/memoryCache");
|
|
947
|
+
const { createMockFetch } = require("./testing/createMockFetch");
|
|
948
|
+
const { downloadResumable } = require("./streams/download");
|
|
949
|
+
const { readLines, readNDJSON, readSSE } = require("./streams/readers");
|
|
950
|
+
const { streamSSE } = require("./streams/streamSSE");
|
|
951
|
+
const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./pagination/paginate");
|
|
1075
952
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
953
|
+
module.exports = {
|
|
954
|
+
withRetry,
|
|
955
|
+
RetryAbortedError,
|
|
956
|
+
StopRetry,
|
|
957
|
+
withTimeout,
|
|
958
|
+
TimeoutError,
|
|
959
|
+
normalizeError,
|
|
960
|
+
parseJsonSafe,
|
|
961
|
+
buildQuery,
|
|
962
|
+
retryAfterAware,
|
|
963
|
+
parseRetryAfter,
|
|
964
|
+
getWaitMsFromHeaders,
|
|
965
|
+
withProgress,
|
|
966
|
+
BodyTooLargeError,
|
|
967
|
+
withDedupe,
|
|
968
|
+
createLimiter,
|
|
969
|
+
createFetch,
|
|
970
|
+
HTTPError,
|
|
971
|
+
createRateLimiter,
|
|
972
|
+
createCircuitBreaker,
|
|
973
|
+
CircuitOpenError,
|
|
974
|
+
createMemoryCache,
|
|
975
|
+
createMockFetch,
|
|
976
|
+
downloadResumable,
|
|
977
|
+
readLines,
|
|
978
|
+
readNDJSON,
|
|
979
|
+
readSSE,
|
|
980
|
+
streamSSE,
|
|
981
|
+
paginate,
|
|
982
|
+
paginatePages,
|
|
983
|
+
paginateLinks,
|
|
984
|
+
parseLinkHeader,
|
|
985
|
+
};
|
|
1082
986
|
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
987
|
+
},
|
|
988
|
+
"internal/http": function (module, exports, require) {
|
|
989
|
+
/**
|
|
990
|
+
* Parses a Retry-After header value into milliseconds. Per HTTP spec it's
|
|
991
|
+
* either an integer number of seconds, or an HTTP-date.
|
|
992
|
+
*/
|
|
993
|
+
function parseRetryAfter(headerValue) {
|
|
994
|
+
if (headerValue === null || headerValue === undefined) return null;
|
|
995
|
+
const value = String(headerValue).trim();
|
|
996
|
+
if (!value) return null;
|
|
1090
997
|
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
return err;
|
|
998
|
+
// delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
|
|
999
|
+
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
1000
|
+
return Number(value) * 1000;
|
|
1095
1001
|
}
|
|
1096
1002
|
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
signal?.throwIfAborted?.();
|
|
1003
|
+
const dateMs = Date.parse(value);
|
|
1004
|
+
if (!Number.isNaN(dateMs)) {
|
|
1005
|
+
return Math.max(0, dateMs - Date.now());
|
|
1006
|
+
}
|
|
1102
1007
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
headers[k] = v;
|
|
1106
|
-
});
|
|
1008
|
+
return null;
|
|
1009
|
+
}
|
|
1107
1010
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
},
|
|
1121
|
-
};
|
|
1122
|
-
calls.push(call);
|
|
1011
|
+
// Reset headers come in two flavors: a delta in seconds ("30", the IETF
|
|
1012
|
+
// RateLimit-Reset draft, Discord's *-Reset-After) or an absolute epoch
|
|
1013
|
+
// (GitHub/Twitter style, seconds or ms). Anything under ~31 years in seconds
|
|
1014
|
+
// can't be a real epoch, so treat it as a delta.
|
|
1015
|
+
function parseResetMs(value) {
|
|
1016
|
+
if (value === null || value === undefined || String(value).trim() === "") return null;
|
|
1017
|
+
const n = Number(value);
|
|
1018
|
+
if (Number.isNaN(n) || n < 0) return null;
|
|
1019
|
+
if (n < 1e9) return n * 1000;
|
|
1020
|
+
const epochMs = n > 4102444800 ? n : n * 1000;
|
|
1021
|
+
return Math.max(0, epochMs - Date.now());
|
|
1022
|
+
}
|
|
1123
1023
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
);
|
|
1127
|
-
if (
|
|
1128
|
-
throw new Error(`createMockFetch: no mock registered for ${method} ${url}`);
|
|
1129
|
-
}
|
|
1130
|
-
const handler = route.once.length > 0 ? route.once.shift() : route.fallback;
|
|
1131
|
-
return handler(call);
|
|
1024
|
+
function readHeader(headers, ...names) {
|
|
1025
|
+
for (const name of names) {
|
|
1026
|
+
const v = headers.get(name);
|
|
1027
|
+
if (v !== null && v !== undefined && v !== "") return v;
|
|
1132
1028
|
}
|
|
1133
|
-
|
|
1134
|
-
return {
|
|
1135
|
-
fetch: mockFetch,
|
|
1136
|
-
on,
|
|
1137
|
-
get: (m) => on("GET", m),
|
|
1138
|
-
post: (m) => on("POST", m),
|
|
1139
|
-
put: (m) => on("PUT", m),
|
|
1140
|
-
patch: (m) => on("PATCH", m),
|
|
1141
|
-
delete: (m) => on("DELETE", m),
|
|
1142
|
-
/** A TypeError("fetch failed") with `cause.code`, like Node's fetch throws. */
|
|
1143
|
-
networkError,
|
|
1144
|
-
/** Every request made so far, in order. */
|
|
1145
|
-
calls,
|
|
1146
|
-
/** Remove all routes and forget recorded calls. */
|
|
1147
|
-
reset() {
|
|
1148
|
-
routes = [];
|
|
1149
|
-
calls.length = 0;
|
|
1150
|
-
},
|
|
1151
|
-
};
|
|
1029
|
+
return null;
|
|
1152
1030
|
}
|
|
1153
1031
|
|
|
1154
|
-
|
|
1032
|
+
/**
|
|
1033
|
+
* Reads a wait time from common rate-limit headers, trying Retry-After first,
|
|
1034
|
+
* then falling back to X-RateLimit-Reset(-After) if present.
|
|
1035
|
+
*/
|
|
1036
|
+
function getWaitMsFromHeaders(headers) {
|
|
1037
|
+
if (!headers || typeof headers.get !== "function") return null;
|
|
1155
1038
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
const { abortableSleep } = require("./_sleep");
|
|
1159
|
-
const { getRateLimitInfo } = require("./_http");
|
|
1039
|
+
const retryAfter = parseRetryAfter(headers.get("retry-after"));
|
|
1040
|
+
if (retryAfter !== null) return retryAfter;
|
|
1160
1041
|
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
if (signal.aborted) {
|
|
1165
|
-
reject(signal.reason);
|
|
1166
|
-
return;
|
|
1167
|
-
}
|
|
1168
|
-
const onAbort = () => reject(signal.reason);
|
|
1169
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
1170
|
-
promise.then(
|
|
1171
|
-
(v) => (signal.removeEventListener("abort", onAbort), resolve(v)),
|
|
1172
|
-
(e) => (signal.removeEventListener("abort", onAbort), reject(e))
|
|
1173
|
-
);
|
|
1174
|
-
});
|
|
1042
|
+
return parseResetMs(
|
|
1043
|
+
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
1044
|
+
);
|
|
1175
1045
|
}
|
|
1176
1046
|
|
|
1177
1047
|
/**
|
|
1178
|
-
*
|
|
1179
|
-
*
|
|
1180
|
-
*
|
|
1181
|
-
* 2. what the server tells you — `update(response)` reads X-RateLimit-* /
|
|
1182
|
-
* RateLimit-* / Retry-After and pauses everything once `remaining` hits 0
|
|
1183
|
-
* (or the server sent Retry-After), until the reset time.
|
|
1184
|
-
*
|
|
1185
|
-
* Callers are served FIFO.
|
|
1186
|
-
*
|
|
1187
|
-
* ```js
|
|
1188
|
-
* const rl = createRateLimiter({ requests: 10, perMs: 1000 });
|
|
1189
|
-
* const res = await rl.run(() => fetch(url)); // acquire → call → learn from headers
|
|
1190
|
-
* ```
|
|
1191
|
-
*
|
|
1192
|
-
* @param {object} [opts]
|
|
1193
|
-
* @param {number} [opts.requests=Infinity] - max requests per window
|
|
1194
|
-
* @param {number} [opts.perMs=1000] - window length
|
|
1048
|
+
* Everything useful a response says about rate limits:
|
|
1049
|
+
* { remaining: number|null, resetMs: number|null, retryAfterMs: number|null }
|
|
1050
|
+
* Understands X-RateLimit-*, X-Rate-Limit-*, RateLimit-* and Retry-After.
|
|
1195
1051
|
*/
|
|
1196
|
-
function
|
|
1197
|
-
const {
|
|
1198
|
-
if (
|
|
1199
|
-
throw new TypeError("createRateLimiter: `requests` must be an integer >= 1 (or Infinity)");
|
|
1200
|
-
}
|
|
1201
|
-
if (!(typeof perMs === "number" && perMs > 0 && Number.isFinite(perMs))) {
|
|
1202
|
-
throw new TypeError("createRateLimiter: `perMs` must be a positive number");
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
const stamps = [];
|
|
1206
|
-
let blockedUntil = 0;
|
|
1207
|
-
let tail = Promise.resolve();
|
|
1052
|
+
function getRateLimitInfo(headers) {
|
|
1053
|
+
const info = { remaining: null, resetMs: null, retryAfterMs: null };
|
|
1054
|
+
if (!headers || typeof headers.get !== "function") return info;
|
|
1208
1055
|
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
signal?.throwIfAborted?.();
|
|
1212
|
-
const now = Date.now();
|
|
1213
|
-
while (stamps.length > 0 && stamps[0] <= now - perMs) stamps.shift();
|
|
1056
|
+
const remaining = readHeader(headers, "x-ratelimit-remaining", "x-rate-limit-remaining", "ratelimit-remaining");
|
|
1057
|
+
if (remaining !== null && !Number.isNaN(Number(remaining))) info.remaining = Number(remaining);
|
|
1214
1058
|
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1059
|
+
info.resetMs = parseResetMs(
|
|
1060
|
+
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
1061
|
+
);
|
|
1062
|
+
info.retryAfterMs = parseRetryAfter(headers.get("retry-after"));
|
|
1063
|
+
return info;
|
|
1064
|
+
}
|
|
1218
1065
|
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1066
|
+
async function discardBody(response) {
|
|
1067
|
+
// An unread body keeps the underlying connection busy; cancel it before
|
|
1068
|
+
// we sit through a backoff and fire the next request.
|
|
1069
|
+
try {
|
|
1070
|
+
if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
|
|
1071
|
+
await response.body.cancel();
|
|
1224
1072
|
}
|
|
1073
|
+
} catch {
|
|
1074
|
+
// locked / already consumed / not a web stream — safe to ignore
|
|
1225
1075
|
}
|
|
1076
|
+
}
|
|
1226
1077
|
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
return
|
|
1078
|
+
/** The origin ("https://host:port") of an absolute URL, or null if `url` is relative/invalid. */
|
|
1079
|
+
function getOrigin(url) {
|
|
1080
|
+
try {
|
|
1081
|
+
const { origin } = new URL(String(url));
|
|
1082
|
+
return origin === "null" ? null : origin;
|
|
1083
|
+
} catch {
|
|
1084
|
+
return null;
|
|
1232
1085
|
}
|
|
1086
|
+
}
|
|
1233
1087
|
|
|
1234
|
-
|
|
1235
|
-
function update(response) {
|
|
1236
|
-
if (!response || !response.headers) return;
|
|
1237
|
-
const { remaining, resetMs, retryAfterMs } = getRateLimitInfo(response.headers);
|
|
1238
|
-
let blockMs = null;
|
|
1239
|
-
if (retryAfterMs !== null && (response.status === 429 || response.status === 503)) blockMs = retryAfterMs;
|
|
1240
|
-
else if (remaining === 0 && resetMs !== null) blockMs = resetMs;
|
|
1241
|
-
if (blockMs !== null) blockedUntil = Math.max(blockedUntil, Date.now() + blockMs);
|
|
1242
|
-
}
|
|
1088
|
+
module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody, getOrigin };
|
|
1243
1089
|
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1090
|
+
},
|
|
1091
|
+
"internal/sleep": function (module, exports, require) {
|
|
1092
|
+
class RetryAbortedError extends Error {
|
|
1093
|
+
constructor(cause) {
|
|
1094
|
+
super("Retry loop was aborted");
|
|
1095
|
+
this.name = "RetryAbortedError";
|
|
1096
|
+
if (cause !== undefined) this.cause = cause;
|
|
1250
1097
|
}
|
|
1098
|
+
}
|
|
1251
1099
|
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1100
|
+
/**
|
|
1101
|
+
* Abortable sleep shared by withRetry and retryAfterAware. Rejects with
|
|
1102
|
+
* RetryAbortedError (carrying `cause`, usually the last failure) if the
|
|
1103
|
+
* signal aborts before or during the wait.
|
|
1104
|
+
*/
|
|
1105
|
+
function sleep(ms, signal, cause) {
|
|
1106
|
+
return new Promise((resolve, reject) => {
|
|
1107
|
+
if (signal?.aborted) {
|
|
1108
|
+
reject(new RetryAbortedError(cause));
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
const onAbort = () => {
|
|
1112
|
+
clearTimeout(timer);
|
|
1113
|
+
reject(new RetryAbortedError(cause));
|
|
1114
|
+
};
|
|
1115
|
+
const timer = setTimeout(() => {
|
|
1116
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1117
|
+
resolve();
|
|
1118
|
+
}, ms);
|
|
1119
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Like sleep(), but rejects with the signal's own reason (an AbortError by default). */
|
|
1124
|
+
function abortableSleep(ms, signal) {
|
|
1125
|
+
return new Promise((resolve, reject) => {
|
|
1126
|
+
if (signal?.aborted) {
|
|
1127
|
+
reject(signal.reason);
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
const onAbort = () => {
|
|
1131
|
+
clearTimeout(timer);
|
|
1132
|
+
reject(signal.reason);
|
|
1133
|
+
};
|
|
1134
|
+
const timer = setTimeout(() => {
|
|
1135
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1136
|
+
resolve();
|
|
1137
|
+
}, ms);
|
|
1138
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1139
|
+
});
|
|
1266
1140
|
}
|
|
1267
1141
|
|
|
1268
|
-
module.exports = {
|
|
1142
|
+
module.exports = { RetryAbortedError, sleep, abortableSleep };
|
|
1269
1143
|
|
|
1270
1144
|
},
|
|
1271
|
-
"
|
|
1272
|
-
const { HTTPError } = require("
|
|
1273
|
-
const {
|
|
1274
|
-
const { normalizeError } = require("./normalizeError");
|
|
1275
|
-
const { getWaitMsFromHeaders } = require("./_http");
|
|
1276
|
-
const { abortableSleep } = require("./_sleep");
|
|
1145
|
+
"pagination/paginate": function (module, exports, require) {
|
|
1146
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
1147
|
+
const { getOrigin } = require("../internal/http");
|
|
1277
1148
|
|
|
1278
|
-
const
|
|
1149
|
+
const MAX_LINK_HEADER_LENGTH = 64 * 1024;
|
|
1279
1150
|
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1151
|
+
/**
|
|
1152
|
+
* Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
|
|
1153
|
+
* pagination) into `{ rel: url }`. Pass `base` to resolve relative URLs.
|
|
1154
|
+
*
|
|
1155
|
+
* ```js
|
|
1156
|
+
* parseLinkHeader('<https://api.x/items?page=2>; rel="next", <https://api.x/items?page=9>; rel="last"');
|
|
1157
|
+
* // { next: "https://api.x/items?page=2", last: "https://api.x/items?page=9" }
|
|
1158
|
+
* ```
|
|
1159
|
+
*
|
|
1160
|
+
* A link with several rels (`rel="next last"`) is listed under each; the first
|
|
1161
|
+
* link wins when a rel appears twice. Commas inside `<...>` are handled. Input
|
|
1162
|
+
* is treated as untrusted: headers over 64 KB are ignored and parsing is linear-time.
|
|
1163
|
+
*
|
|
1164
|
+
* @param {string | null | undefined} header
|
|
1165
|
+
* @param {string} [base]
|
|
1166
|
+
* @returns {Record<string, string>}
|
|
1167
|
+
*/
|
|
1168
|
+
function parseLinkHeader(header, base) {
|
|
1169
|
+
const links = {};
|
|
1170
|
+
// Link headers are server-controlled; real ones are a few hundred bytes (HTTP stacks cap headers at ~16 KB).
|
|
1171
|
+
if (!header || header.length > MAX_LINK_HEADER_LENGTH) return links;
|
|
1172
|
+
|
|
1173
|
+
// <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
|
|
1174
|
+
const linkRe = /<([^<>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
|
|
1175
|
+
let match;
|
|
1176
|
+
while ((match = linkRe.exec(header)) !== null) {
|
|
1177
|
+
const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
|
|
1178
|
+
if (!rel) continue;
|
|
1179
|
+
|
|
1180
|
+
let url = match[1].trim();
|
|
1181
|
+
if (base) {
|
|
1182
|
+
try {
|
|
1183
|
+
url = new URL(url, base).href;
|
|
1184
|
+
} catch {
|
|
1185
|
+
// leave it as written
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
for (const name of (rel[1] ?? rel[2]).toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
1189
|
+
if (!(name in links)) links[name] = url;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
return links;
|
|
1285
1193
|
}
|
|
1286
1194
|
|
|
1287
|
-
function
|
|
1288
|
-
|
|
1289
|
-
if (err instanceof HTTPError) return RETRYABLE_STATUS.has(err.status);
|
|
1290
|
-
const n = normalizeError(err);
|
|
1291
|
-
if (n.isAborted) return false;
|
|
1292
|
-
return n.isNetworkError || n.isTimeout;
|
|
1195
|
+
function stuckError(message) {
|
|
1196
|
+
return Object.assign(new Error(message), { code: "PAGINATE_STUCK" });
|
|
1293
1197
|
}
|
|
1294
1198
|
|
|
1295
1199
|
/**
|
|
1296
|
-
*
|
|
1297
|
-
*
|
|
1298
|
-
*
|
|
1299
|
-
*
|
|
1300
|
-
* than stitched together wrongly. If the server ignores Range, it simply
|
|
1301
|
-
* restarts from zero.
|
|
1302
|
-
*
|
|
1303
|
-
* `retries` counts consecutive attempts *without progress* — a flaky link
|
|
1304
|
-
* that keeps making headway keeps going. Honors Retry-After on 429/503.
|
|
1200
|
+
* Walks a paginated API page by page. You write `fetchPage(cursor, ctx)` —
|
|
1201
|
+
* it returns `{ items, next }`, where `next` is whatever identifies the next
|
|
1202
|
+
* page (a cursor token, an offset, a URL...) or `undefined`/`null` on the
|
|
1203
|
+
* last page — and you get an async iterator of pages.
|
|
1305
1204
|
*
|
|
1306
1205
|
* ```js
|
|
1307
|
-
* const
|
|
1308
|
-
*
|
|
1309
|
-
*
|
|
1310
|
-
* })
|
|
1206
|
+
* for await (const { items, index } of paginatePages(async (cursor) => {
|
|
1207
|
+
* const r = await api.json("/users", { query: { cursor, limit: 100 } });
|
|
1208
|
+
* return { items: r.data, next: r.next_cursor };
|
|
1209
|
+
* })) { ... }
|
|
1311
1210
|
* ```
|
|
1312
1211
|
*
|
|
1313
|
-
*
|
|
1212
|
+
* A `next` that equals the cursor just used throws `code: "PAGINATE_STUCK"`
|
|
1213
|
+
* instead of looping forever.
|
|
1214
|
+
*
|
|
1215
|
+
* @template T
|
|
1216
|
+
* @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
|
|
1314
1217
|
* @param {object} [opts]
|
|
1315
|
-
* @param {
|
|
1316
|
-
* @param {
|
|
1317
|
-
* @param {number} [opts.retries=5]
|
|
1318
|
-
* @param {number} [opts.baseDelayMs=500]
|
|
1319
|
-
* @param {number} [opts.maxDelayMs=10000]
|
|
1320
|
-
* @param {(p: {loaded: number, total: number | null, percent: number | null}) => void} [opts.onProgress]
|
|
1321
|
-
* @param {number} [opts.maxBytes] - throws BodyTooLargeError past this
|
|
1218
|
+
* @param {any} [opts.start] - the cursor for the first page (default undefined)
|
|
1219
|
+
* @param {number} [opts.maxPages=Infinity]
|
|
1322
1220
|
* @param {AbortSignal} [opts.signal]
|
|
1323
|
-
* @
|
|
1324
|
-
* @returns {Promise<Uint8Array>}
|
|
1221
|
+
* @returns {AsyncGenerator<{ items: T[], index: number, cursor: any }>}
|
|
1325
1222
|
*/
|
|
1326
|
-
async function
|
|
1327
|
-
const {
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
retries = 5,
|
|
1331
|
-
baseDelayMs = 500,
|
|
1332
|
-
maxDelayMs = 10000,
|
|
1333
|
-
onProgress,
|
|
1334
|
-
maxBytes,
|
|
1335
|
-
signal,
|
|
1336
|
-
onRetry,
|
|
1337
|
-
} = opts;
|
|
1338
|
-
|
|
1339
|
-
const chunks = [];
|
|
1340
|
-
let loaded = 0;
|
|
1341
|
-
let total = null;
|
|
1342
|
-
let validator = null;
|
|
1343
|
-
let attempt = 0; // consecutive attempts without progress
|
|
1344
|
-
|
|
1345
|
-
const report = () => {
|
|
1346
|
-
if (onProgress) {
|
|
1347
|
-
onProgress({ loaded, total, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null });
|
|
1348
|
-
}
|
|
1349
|
-
};
|
|
1223
|
+
async function* paginatePages(fetchPage, opts = {}) {
|
|
1224
|
+
const { start, maxPages = Infinity, signal } = opts;
|
|
1225
|
+
let cursor = start;
|
|
1226
|
+
let index = 0;
|
|
1350
1227
|
|
|
1351
1228
|
for (;;) {
|
|
1229
|
+
if (index >= maxPages) return;
|
|
1352
1230
|
signal?.throwIfAborted?.();
|
|
1353
|
-
const loadedBefore = loaded;
|
|
1354
|
-
|
|
1355
|
-
try {
|
|
1356
|
-
const reqHeaders = new Headers(headers || undefined);
|
|
1357
|
-
// Range offsets count encoded bytes, so ask for an uncompressed body.
|
|
1358
|
-
if (!reqHeaders.has("accept-encoding")) reqHeaders.set("accept-encoding", "identity");
|
|
1359
|
-
if (loaded > 0) {
|
|
1360
|
-
reqHeaders.set("range", `bytes=${loaded}-`);
|
|
1361
|
-
if (validator) reqHeaders.set("if-range", validator);
|
|
1362
|
-
}
|
|
1363
|
-
|
|
1364
|
-
const res = await fetchFn(url, { headers: reqHeaders, signal });
|
|
1365
|
-
|
|
1366
|
-
if (res.status === 416 && loaded > 0) {
|
|
1367
|
-
// Our offset no longer fits the file — start over, without burning a retry.
|
|
1368
|
-
await res.body?.cancel?.().catch(() => {});
|
|
1369
|
-
chunks.length = 0;
|
|
1370
|
-
loaded = 0;
|
|
1371
|
-
continue;
|
|
1372
|
-
}
|
|
1373
|
-
if (!res.ok) {
|
|
1374
|
-
await res.body?.cancel?.().catch(() => {});
|
|
1375
|
-
throw new HTTPError(res, { method: "GET", url });
|
|
1376
|
-
}
|
|
1377
|
-
|
|
1378
|
-
const encoding = res.headers.get("content-encoding");
|
|
1379
|
-
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
1380
|
-
|
|
1381
|
-
if (loaded > 0) {
|
|
1382
|
-
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
1383
|
-
if (!range || range.start !== loaded || encoded) {
|
|
1384
|
-
// Server ignored Range, the file changed (If-Range failed), or the
|
|
1385
|
-
// bytes don't line up: discard and restart from the beginning.
|
|
1386
|
-
chunks.length = 0;
|
|
1387
|
-
loaded = 0;
|
|
1388
|
-
}
|
|
1389
|
-
}
|
|
1390
1231
|
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
validator = etag && !etag.startsWith("W/") ? etag : res.headers.get("last-modified");
|
|
1397
|
-
}
|
|
1232
|
+
const result = await fetchPage(cursor, { index, signal });
|
|
1233
|
+
if (!result || !Array.isArray(result.items)) {
|
|
1234
|
+
throw new TypeError("paginate: fetchPage must return { items: array, next?: cursor }");
|
|
1235
|
+
}
|
|
1236
|
+
yield { items: result.items, index, cursor };
|
|
1398
1237
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1238
|
+
const next = result.next;
|
|
1239
|
+
if (next === undefined || next === null) return;
|
|
1240
|
+
if (next === cursor) {
|
|
1241
|
+
throw stuckError("paginate: `next` equals the cursor just used, so the page didn't advance — stopping to avoid an infinite loop");
|
|
1242
|
+
}
|
|
1243
|
+
cursor = next;
|
|
1244
|
+
index++;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1403
1247
|
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
loaded += value.byteLength;
|
|
1423
|
-
if (maxBytes !== undefined && loaded > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
1424
|
-
report();
|
|
1425
|
-
}
|
|
1426
|
-
finished = true;
|
|
1427
|
-
} finally {
|
|
1428
|
-
signal?.removeEventListener("abort", onAbort);
|
|
1429
|
-
if (!finished) reader.cancel().catch(() => {});
|
|
1430
|
-
}
|
|
1431
|
-
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Like `paginatePages`, but yields the items one by one. `maxItems` stops
|
|
1250
|
+
* early (mid-page) — and since nothing is fetched until you ask for it,
|
|
1251
|
+
* `break`-ing out of the loop never fetches a page you didn't need.
|
|
1252
|
+
*
|
|
1253
|
+
* ```js
|
|
1254
|
+
* for await (const user of paginate(fetchUsersPage, { maxItems: 250 })) { ... }
|
|
1255
|
+
* ```
|
|
1256
|
+
*
|
|
1257
|
+
* @template T
|
|
1258
|
+
* @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
|
|
1259
|
+
* @param {object} [opts] - same as paginatePages, plus `maxItems`
|
|
1260
|
+
* @param {number} [opts.maxItems=Infinity]
|
|
1261
|
+
* @returns {AsyncGenerator<T>}
|
|
1262
|
+
*/
|
|
1263
|
+
async function* paginate(fetchPage, opts = {}) {
|
|
1264
|
+
const { maxItems = Infinity } = opts;
|
|
1265
|
+
if (maxItems <= 0) return;
|
|
1432
1266
|
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1267
|
+
let count = 0;
|
|
1268
|
+
for await (const page of paginatePages(fetchPage, opts)) {
|
|
1269
|
+
for (const item of page.items) {
|
|
1270
|
+
yield item;
|
|
1271
|
+
if (++count >= maxItems) return;
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1437
1275
|
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
} catch (err) {
|
|
1446
|
-
if (signal?.aborted) throw signal.reason ?? err;
|
|
1447
|
-
if (!isRetryable(err)) throw err;
|
|
1276
|
+
function defaultGetItems(data) {
|
|
1277
|
+
if (Array.isArray(data)) return data;
|
|
1278
|
+
for (const key of ["items", "data", "results", "records"]) {
|
|
1279
|
+
if (data && Array.isArray(data[key])) return data[key];
|
|
1280
|
+
}
|
|
1281
|
+
throw new TypeError("paginateLinks: couldn't find the items array in the response — pass `getItems(data)`");
|
|
1282
|
+
}
|
|
1448
1283
|
|
|
1449
|
-
|
|
1450
|
-
|
|
1284
|
+
/**
|
|
1285
|
+
* Follows `Link: <...>; rel="next"` headers (GitHub-style) and yields the
|
|
1286
|
+
* items of every page. `request` is anything fetch-like — a `createFetch`
|
|
1287
|
+
* instance or plain `fetch` (a non-2xx response throws `HTTPError`).
|
|
1288
|
+
*
|
|
1289
|
+
* ```js
|
|
1290
|
+
* for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) { ... }
|
|
1291
|
+
* ```
|
|
1292
|
+
*
|
|
1293
|
+
* @param {(url: string, init?: object) => Promise<Response>} request
|
|
1294
|
+
* @param {string} url - the first page
|
|
1295
|
+
* @param {object} [opts]
|
|
1296
|
+
* @param {(data: any, response: Response) => any[]} [opts.getItems] - default: the body if it's an array, else its `items` / `data` / `results` / `records`
|
|
1297
|
+
* @param {number} [opts.maxPages=Infinity]
|
|
1298
|
+
* @param {number} [opts.maxItems=Infinity]
|
|
1299
|
+
* @param {AbortSignal} [opts.signal]
|
|
1300
|
+
* @param {object} [opts.init] - extra options passed to every `request`
|
|
1301
|
+
* @param {string[]} [opts.origins] - origins a `Link` may point to. Default: the origin of `url`
|
|
1302
|
+
* (or of the `createFetch` instance's `baseUrl`). A `Link` to anywhere else throws
|
|
1303
|
+
* `code: "PAGINATE_CROSS_ORIGIN"` — a server could otherwise steer your requests (and any
|
|
1304
|
+
* credentials in `init`) to a third party. The check is skipped only when no origin can be determined.
|
|
1305
|
+
*/
|
|
1306
|
+
async function* paginateLinks(request, url, opts = {}) {
|
|
1307
|
+
const { getItems = defaultGetItems, init = {}, signal } = opts;
|
|
1451
1308
|
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1309
|
+
const allowed = new Set();
|
|
1310
|
+
if (opts.origins) {
|
|
1311
|
+
for (const o of opts.origins) {
|
|
1312
|
+
const origin = getOrigin(o);
|
|
1313
|
+
if (origin) allowed.add(origin);
|
|
1314
|
+
}
|
|
1315
|
+
} else {
|
|
1316
|
+
const origin = getOrigin(url) || getOrigin(request && request.baseUrl) || getOrigin(globalThis.location && globalThis.location.href);
|
|
1317
|
+
if (origin) allowed.add(origin);
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function fetchPage(pageUrl) {
|
|
1321
|
+
const res = await request(pageUrl, { ...init, signal });
|
|
1322
|
+
if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
|
|
1323
|
+
const items = getItems(await res.json(), res);
|
|
1324
|
+
const next = parseLinkHeader(res.headers.get("link"), pageUrl).next;
|
|
1325
|
+
if (next && allowed.size > 0) {
|
|
1326
|
+
const nextOrigin = getOrigin(next);
|
|
1327
|
+
if (nextOrigin !== null && !allowed.has(nextOrigin)) {
|
|
1328
|
+
throw Object.assign(
|
|
1329
|
+
new Error(`paginateLinks: refusing to follow a Link to another origin (${nextOrigin}). Pass { origins: [...] } if that's intended.`),
|
|
1330
|
+
{ code: "PAGINATE_CROSS_ORIGIN" }
|
|
1331
|
+
);
|
|
1456
1332
|
}
|
|
1457
|
-
attempt++;
|
|
1458
|
-
if (onRetry) onRetry(err, attempt, delayMs, { resumeFrom: loaded });
|
|
1459
|
-
await abortableSleep(delayMs, signal);
|
|
1460
1333
|
}
|
|
1334
|
+
return { items, next };
|
|
1461
1335
|
}
|
|
1336
|
+
|
|
1337
|
+
yield* paginate(fetchPage, { ...opts, start: url });
|
|
1462
1338
|
}
|
|
1463
1339
|
|
|
1464
|
-
module.exports = {
|
|
1340
|
+
module.exports = { paginate, paginatePages, paginateLinks, parseLinkHeader };
|
|
1465
1341
|
|
|
1466
1342
|
},
|
|
1467
|
-
"
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
const { createFetch, HTTPError } = require("./createFetch");
|
|
1478
|
-
const { createRateLimiter } = require("./createRateLimiter");
|
|
1479
|
-
const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
|
|
1480
|
-
const { createMemoryCache } = require("./createMemoryCache");
|
|
1481
|
-
const { createMockFetch } = require("./createMockFetch");
|
|
1482
|
-
const { downloadResumable } = require("./downloadResumable");
|
|
1483
|
-
const { readLines, readNDJSON, readSSE } = require("./streams");
|
|
1484
|
-
const { streamSSE } = require("./streamSSE");
|
|
1485
|
-
const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
|
|
1343
|
+
"resilience/circuitBreaker": function (module, exports, require) {
|
|
1344
|
+
class CircuitOpenError extends Error {
|
|
1345
|
+
constructor(retryAfterMs) {
|
|
1346
|
+
super("Circuit is open — failing fast instead of calling a service that keeps failing");
|
|
1347
|
+
this.name = "CircuitOpenError";
|
|
1348
|
+
// Deliberately not an E-code: this is *not* a network error and must not be retried as one.
|
|
1349
|
+
this.code = "CIRCUIT_OPEN";
|
|
1350
|
+
this.retryAfterMs = retryAfterMs;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1486
1353
|
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
withTimeout,
|
|
1492
|
-
TimeoutError,
|
|
1493
|
-
normalizeError,
|
|
1494
|
-
parseJsonSafe,
|
|
1495
|
-
buildQuery,
|
|
1496
|
-
retryAfterAware,
|
|
1497
|
-
parseRetryAfter,
|
|
1498
|
-
getWaitMsFromHeaders,
|
|
1499
|
-
withProgress,
|
|
1500
|
-
BodyTooLargeError,
|
|
1501
|
-
withDedupe,
|
|
1502
|
-
createLimiter,
|
|
1503
|
-
createFetch,
|
|
1504
|
-
HTTPError,
|
|
1505
|
-
createRateLimiter,
|
|
1506
|
-
createCircuitBreaker,
|
|
1507
|
-
CircuitOpenError,
|
|
1508
|
-
createMemoryCache,
|
|
1509
|
-
createMockFetch,
|
|
1510
|
-
downloadResumable,
|
|
1511
|
-
readLines,
|
|
1512
|
-
readNDJSON,
|
|
1513
|
-
readSSE,
|
|
1514
|
-
streamSSE,
|
|
1515
|
-
paginate,
|
|
1516
|
-
paginatePages,
|
|
1517
|
-
paginateLinks,
|
|
1518
|
-
parseLinkHeader,
|
|
1519
|
-
};
|
|
1354
|
+
function defaultIsFailure(valueOrError, isError) {
|
|
1355
|
+
if (isError) return !(valueOrError && valueOrError.name === "AbortError");
|
|
1356
|
+
return Boolean(valueOrError) && typeof valueOrError.status === "number" && valueOrError.status >= 500;
|
|
1357
|
+
}
|
|
1520
1358
|
|
|
1521
|
-
},
|
|
1522
|
-
"./normalizeError": function (module, exports, require) {
|
|
1523
1359
|
/**
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1360
|
+
* Circuit breaker: after `failureThreshold` consecutive failures it "opens"
|
|
1361
|
+
* and fails fast with CircuitOpenError for `resetTimeoutMs`, so a dying
|
|
1362
|
+
* service isn't hammered (and your callers don't wait on it). Then it goes
|
|
1363
|
+
* "half-open" and lets `halfOpenMax` probe through: success closes the
|
|
1364
|
+
* circuit, failure re-opens it.
|
|
1527
1365
|
*
|
|
1528
|
-
*
|
|
1529
|
-
*
|
|
1366
|
+
* By default a thrown error (except AbortError) or a result with status >= 500
|
|
1367
|
+
* counts as a failure; override with `isFailure(valueOrError, isError)`.
|
|
1530
1368
|
*
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
*
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
1369
|
+
* @param {object} [opts]
|
|
1370
|
+
* @param {number} [opts.failureThreshold=5]
|
|
1371
|
+
* @param {number} [opts.resetTimeoutMs=30000]
|
|
1372
|
+
* @param {number} [opts.halfOpenMax=1]
|
|
1373
|
+
* @param {(valueOrError: any, isError: boolean) => boolean} [opts.isFailure]
|
|
1374
|
+
* @param {(state: string, prev: string) => void} [opts.onStateChange]
|
|
1536
1375
|
*/
|
|
1376
|
+
function createCircuitBreaker(opts = {}) {
|
|
1377
|
+
const {
|
|
1378
|
+
failureThreshold = 5,
|
|
1379
|
+
resetTimeoutMs = 30000,
|
|
1380
|
+
halfOpenMax = 1,
|
|
1381
|
+
isFailure = defaultIsFailure,
|
|
1382
|
+
onStateChange,
|
|
1383
|
+
} = opts;
|
|
1384
|
+
|
|
1385
|
+
let state = "closed";
|
|
1386
|
+
let failures = 0;
|
|
1387
|
+
let openedAt = 0;
|
|
1388
|
+
let probes = 0;
|
|
1389
|
+
|
|
1390
|
+
function setState(next) {
|
|
1391
|
+
if (next === state) return;
|
|
1392
|
+
const prev = state;
|
|
1393
|
+
state = next;
|
|
1394
|
+
if (onStateChange) onStateChange(next, prev);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
function open() {
|
|
1398
|
+
openedAt = Date.now();
|
|
1399
|
+
setState("open");
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
function current() {
|
|
1403
|
+
if (state === "open" && Date.now() - openedAt >= resetTimeoutMs) {
|
|
1404
|
+
probes = 0;
|
|
1405
|
+
setState("half-open");
|
|
1406
|
+
}
|
|
1407
|
+
return state;
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
function recordFailure() {
|
|
1411
|
+
if (state === "half-open") {
|
|
1412
|
+
open();
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
failures++;
|
|
1416
|
+
if (failures >= failureThreshold) open();
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
function recordSuccess() {
|
|
1420
|
+
failures = 0;
|
|
1421
|
+
if (state === "half-open") setState("closed");
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
async function run(fn) {
|
|
1425
|
+
const s = current();
|
|
1426
|
+
if (s === "open") {
|
|
1427
|
+
throw new CircuitOpenError(Math.max(0, resetTimeoutMs - (Date.now() - openedAt)));
|
|
1428
|
+
}
|
|
1429
|
+
let probing = false;
|
|
1430
|
+
if (s === "half-open") {
|
|
1431
|
+
if (probes >= halfOpenMax) throw new CircuitOpenError(0);
|
|
1432
|
+
probes++;
|
|
1433
|
+
probing = true;
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
let result;
|
|
1437
|
+
try {
|
|
1438
|
+
result = await fn();
|
|
1439
|
+
} catch (err) {
|
|
1440
|
+
if (isFailure(err, true)) recordFailure();
|
|
1441
|
+
else if (probing) probes--; // e.g. an abort — free the probe slot
|
|
1442
|
+
throw err;
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
if (isFailure(result, false)) recordFailure();
|
|
1446
|
+
else recordSuccess();
|
|
1447
|
+
return result;
|
|
1448
|
+
}
|
|
1537
1449
|
|
|
1538
|
-
function shape(err, overrides) {
|
|
1539
1450
|
return {
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1451
|
+
run,
|
|
1452
|
+
get state() {
|
|
1453
|
+
return current();
|
|
1454
|
+
},
|
|
1455
|
+
get failures() {
|
|
1456
|
+
return failures;
|
|
1457
|
+
},
|
|
1458
|
+
/** Force the circuit closed and forget the failure count. */
|
|
1459
|
+
reset() {
|
|
1460
|
+
failures = 0;
|
|
1461
|
+
probes = 0;
|
|
1462
|
+
setState("closed");
|
|
1463
|
+
},
|
|
1548
1464
|
};
|
|
1549
1465
|
}
|
|
1550
1466
|
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
function
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1467
|
+
module.exports = { createCircuitBreaker, CircuitOpenError };
|
|
1468
|
+
|
|
1469
|
+
},
|
|
1470
|
+
"resilience/dedupe": function (module, exports, require) {
|
|
1471
|
+
// Non-plain objects (AbortSignal, Response, class instances, functions...)
|
|
1472
|
+
// can't be meaningfully JSON-serialized — JSON.stringify turns every
|
|
1473
|
+
// AbortSignal into "{}", which would make unrelated calls share one request.
|
|
1474
|
+
// Give them a stable identity-based id instead.
|
|
1475
|
+
const identityIds = new WeakMap();
|
|
1476
|
+
let nextIdentityId = 1;
|
|
1477
|
+
|
|
1478
|
+
function identityOf(value) {
|
|
1479
|
+
let id = identityIds.get(value);
|
|
1480
|
+
if (id === undefined) {
|
|
1481
|
+
id = nextIdentityId++;
|
|
1482
|
+
identityIds.set(value, id);
|
|
1564
1483
|
}
|
|
1565
|
-
return
|
|
1566
|
-
}
|
|
1567
|
-
|
|
1568
|
-
function isTimeoutCode(code) {
|
|
1569
|
-
return code === "ETIMEDOUT" || code === "ESOCKETTIMEDOUT" || /^UND_ERR_.*TIMEOUT$/.test(code || "");
|
|
1484
|
+
return id;
|
|
1570
1485
|
}
|
|
1571
1486
|
|
|
1572
|
-
function
|
|
1573
|
-
|
|
1574
|
-
return
|
|
1487
|
+
function isPlainObject(value) {
|
|
1488
|
+
const proto = Object.getPrototypeOf(value);
|
|
1489
|
+
return proto === Object.prototype || proto === null;
|
|
1575
1490
|
}
|
|
1576
1491
|
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1492
|
+
function keyReplacer(_key, value) {
|
|
1493
|
+
if (typeof value === "bigint") return { $bigint: String(value) };
|
|
1494
|
+
if (typeof value === "function") return { $ref: identityOf(value) };
|
|
1495
|
+
if (value === null || typeof value !== "object") return value;
|
|
1496
|
+
if (Array.isArray(value)) return value;
|
|
1497
|
+
if (!isPlainObject(value)) return { $ref: identityOf(value) };
|
|
1580
1498
|
|
|
1581
|
-
|
|
1582
|
-
|
|
1499
|
+
// Sort plain-object keys so {a:1,b:2} and {b:2,a:1} dedupe together.
|
|
1500
|
+
const sorted = {};
|
|
1501
|
+
for (const k of Object.keys(value).sort()) sorted[k] = value[k];
|
|
1502
|
+
return sorted;
|
|
1583
1503
|
}
|
|
1584
1504
|
|
|
1585
|
-
function
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
return
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
code: err.code ?? null,
|
|
1594
|
-
isNetworkError: !err.response && Boolean(err.request),
|
|
1595
|
-
isTimeout: err.code === "ECONNABORTED" || err.code === "ETIMEDOUT" || /timeout/i.test(err.message || ""),
|
|
1596
|
-
isAborted: err.code === "ERR_CANCELED" || err.name === "CanceledError",
|
|
1597
|
-
});
|
|
1598
|
-
}
|
|
1599
|
-
|
|
1600
|
-
// fetch Response passed in directly (not thrown, but common enough to handle)
|
|
1601
|
-
if (typeof err.status === "number" && typeof err.ok === "boolean") {
|
|
1602
|
-
return shape(err, {
|
|
1603
|
-
message: err.statusText || `Request failed with status ${err.status}`,
|
|
1604
|
-
status: err.status,
|
|
1605
|
-
});
|
|
1505
|
+
function defaultKeyFn(...args) {
|
|
1506
|
+
try {
|
|
1507
|
+
return JSON.stringify(args, keyReplacer);
|
|
1508
|
+
} catch {
|
|
1509
|
+
// Circular structures etc. Fall back to identity for objects.
|
|
1510
|
+
return args
|
|
1511
|
+
.map((a) => ((a && typeof a === "object") || typeof a === "function" ? `#${identityOf(a)}` : String(a)))
|
|
1512
|
+
.join("|");
|
|
1606
1513
|
}
|
|
1514
|
+
}
|
|
1607
1515
|
|
|
1608
|
-
|
|
1516
|
+
/**
|
|
1517
|
+
* Wraps an async function so that concurrent calls with the same key share
|
|
1518
|
+
* a single in-flight promise instead of firing duplicate requests. Common
|
|
1519
|
+
* use case: several parts of a UI ask for the same resource at once (e.g. a
|
|
1520
|
+
* user profile) — only one network request actually goes out, and every
|
|
1521
|
+
* caller resolves with the same result.
|
|
1522
|
+
*
|
|
1523
|
+
* The in-flight entry is cleared as soon as the call settles (success or
|
|
1524
|
+
* failure), so the next call — even with the same key — always starts a
|
|
1525
|
+
* fresh request. This is deduplication of concurrent calls, not a cache.
|
|
1526
|
+
*
|
|
1527
|
+
* @param {(...args: any[]) => Promise<any>} fn
|
|
1528
|
+
* @param {object} [opts]
|
|
1529
|
+
* @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
|
|
1530
|
+
* from the call arguments. By default plain data is compared by value
|
|
1531
|
+
* (object key order doesn't matter) and anything else — AbortSignals,
|
|
1532
|
+
* Responses, class instances, functions — by identity, so two calls with
|
|
1533
|
+
* different AbortSignals are never merged.
|
|
1534
|
+
* @returns {(...args: any[]) => Promise<any>}
|
|
1535
|
+
*/
|
|
1536
|
+
function withDedupe(fn, opts = {}) {
|
|
1537
|
+
const { keyFn = defaultKeyFn } = opts;
|
|
1538
|
+
const inFlight = new Map();
|
|
1609
1539
|
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
if (err.name === "TimeoutError" || isTimeoutCode(chainCode)) {
|
|
1613
|
-
return shape(err, {
|
|
1614
|
-
message: err.message || "Request timed out",
|
|
1615
|
-
code: chainCode || "ETIMEDOUT",
|
|
1616
|
-
isTimeout: true,
|
|
1617
|
-
});
|
|
1618
|
-
}
|
|
1540
|
+
return function deduped(...args) {
|
|
1541
|
+
const key = keyFn(...args);
|
|
1619
1542
|
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
message: err.message || "Request aborted",
|
|
1624
|
-
code: "ABORT_ERR",
|
|
1625
|
-
isAborted: true,
|
|
1626
|
-
});
|
|
1627
|
-
}
|
|
1543
|
+
if (inFlight.has(key)) {
|
|
1544
|
+
return inFlight.get(key);
|
|
1545
|
+
}
|
|
1628
1546
|
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
code: chainCode,
|
|
1635
|
-
isNetworkError: true,
|
|
1636
|
-
});
|
|
1637
|
-
}
|
|
1547
|
+
const promise = Promise.resolve()
|
|
1548
|
+
.then(() => fn(...args))
|
|
1549
|
+
.finally(() => {
|
|
1550
|
+
inFlight.delete(key);
|
|
1551
|
+
});
|
|
1638
1552
|
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
code: err.code ?? null,
|
|
1643
|
-
});
|
|
1553
|
+
inFlight.set(key, promise);
|
|
1554
|
+
return promise;
|
|
1555
|
+
};
|
|
1644
1556
|
}
|
|
1645
1557
|
|
|
1646
|
-
module.exports = {
|
|
1558
|
+
module.exports = { withDedupe };
|
|
1647
1559
|
|
|
1648
1560
|
},
|
|
1649
|
-
"
|
|
1650
|
-
const { HTTPError } = require("./HTTPError");
|
|
1651
|
-
|
|
1561
|
+
"resilience/limiter": function (module, exports, require) {
|
|
1652
1562
|
/**
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1563
|
+
* Creates a concurrency limiter: at most `concurrency` calls run at once,
|
|
1564
|
+
* the rest wait in a FIFO queue. Handy for "download these 200 files, 4 at a
|
|
1565
|
+
* time" without pulling in a queue library. (Same idea as p-limit.)
|
|
1655
1566
|
*
|
|
1656
1567
|
* ```js
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
1568
|
+
* const limit = createLimiter(4);
|
|
1569
|
+
* const bytes = await Promise.all(urls.map((u) => limit(() => download(u))));
|
|
1570
|
+
* // or: await limit.map(urls, (u) => download(u));
|
|
1659
1571
|
* ```
|
|
1660
1572
|
*
|
|
1661
|
-
*
|
|
1662
|
-
* link wins when a rel appears twice. Commas inside `<...>` are handled.
|
|
1663
|
-
*
|
|
1664
|
-
* @param {string | null | undefined} header
|
|
1665
|
-
* @param {string} [base]
|
|
1666
|
-
* @returns {Record<string, string>}
|
|
1573
|
+
* @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
|
|
1667
1574
|
*/
|
|
1668
|
-
function
|
|
1669
|
-
|
|
1670
|
-
|
|
1575
|
+
function createLimiter(concurrency = Infinity) {
|
|
1576
|
+
if (concurrency !== Infinity && !(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
1577
|
+
throw new TypeError("createLimiter: `concurrency` must be an integer >= 1 (or Infinity)");
|
|
1578
|
+
}
|
|
1671
1579
|
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
let match;
|
|
1675
|
-
while ((match = linkRe.exec(header)) !== null) {
|
|
1676
|
-
const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
|
|
1677
|
-
if (!rel) continue;
|
|
1580
|
+
const queue = [];
|
|
1581
|
+
let active = 0;
|
|
1678
1582
|
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
} catch {
|
|
1684
|
-
// leave it as written
|
|
1685
|
-
}
|
|
1686
|
-
}
|
|
1687
|
-
for (const name of (rel[1] ?? rel[2]).toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
1688
|
-
if (!(name in links)) links[name] = url;
|
|
1583
|
+
function next() {
|
|
1584
|
+
while (active < concurrency && queue.length > 0) {
|
|
1585
|
+
active++;
|
|
1586
|
+
queue.shift()();
|
|
1689
1587
|
}
|
|
1690
1588
|
}
|
|
1691
|
-
return links;
|
|
1692
|
-
}
|
|
1693
1589
|
|
|
1694
|
-
function
|
|
1695
|
-
|
|
1696
|
-
|
|
1590
|
+
function limit(fn, ...args) {
|
|
1591
|
+
return new Promise((resolve, reject) => {
|
|
1592
|
+
queue.push(() => {
|
|
1593
|
+
Promise.resolve()
|
|
1594
|
+
.then(() => fn(...args))
|
|
1595
|
+
.then(resolve, reject)
|
|
1596
|
+
.finally(() => {
|
|
1597
|
+
active--;
|
|
1598
|
+
next();
|
|
1599
|
+
});
|
|
1600
|
+
});
|
|
1601
|
+
next();
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1697
1604
|
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
* last page — and you get an async iterator of pages.
|
|
1703
|
-
*
|
|
1704
|
-
* ```js
|
|
1705
|
-
* for await (const { items, index } of paginatePages(async (cursor) => {
|
|
1706
|
-
* const r = await api.json("/users", { query: { cursor, limit: 100 } });
|
|
1707
|
-
* return { items: r.data, next: r.next_cursor };
|
|
1708
|
-
* })) { ... }
|
|
1709
|
-
* ```
|
|
1710
|
-
*
|
|
1711
|
-
* A `next` that equals the cursor just used throws `code: "PAGINATE_STUCK"`
|
|
1712
|
-
* instead of looping forever.
|
|
1713
|
-
*
|
|
1714
|
-
* @template T
|
|
1715
|
-
* @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
|
|
1716
|
-
* @param {object} [opts]
|
|
1717
|
-
* @param {any} [opts.start] - the cursor for the first page (default undefined)
|
|
1718
|
-
* @param {number} [opts.maxPages=Infinity]
|
|
1719
|
-
* @param {AbortSignal} [opts.signal]
|
|
1720
|
-
* @returns {AsyncGenerator<{ items: T[], index: number, cursor: any }>}
|
|
1721
|
-
*/
|
|
1722
|
-
async function* paginatePages(fetchPage, opts = {}) {
|
|
1723
|
-
const { start, maxPages = Infinity, signal } = opts;
|
|
1724
|
-
let cursor = start;
|
|
1725
|
-
let index = 0;
|
|
1605
|
+
Object.defineProperties(limit, {
|
|
1606
|
+
activeCount: { get: () => active },
|
|
1607
|
+
pendingCount: { get: () => queue.length },
|
|
1608
|
+
});
|
|
1726
1609
|
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
signal?.throwIfAborted?.();
|
|
1610
|
+
/** Runs `fn(item, index)` for every item through the limiter. Results keep input order. */
|
|
1611
|
+
limit.map = (items, fn) => Promise.all(Array.from(items, (item, i) => limit(fn, item, i)));
|
|
1730
1612
|
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
throw new TypeError("paginate: fetchPage must return { items: array, next?: cursor }");
|
|
1734
|
-
}
|
|
1735
|
-
yield { items: result.items, index, cursor };
|
|
1613
|
+
return limit;
|
|
1614
|
+
}
|
|
1736
1615
|
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1616
|
+
module.exports = { createLimiter };
|
|
1617
|
+
|
|
1618
|
+
},
|
|
1619
|
+
"resilience/rateLimiter": function (module, exports, require) {
|
|
1620
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
1621
|
+
const { getRateLimitInfo } = require("../internal/http");
|
|
1622
|
+
|
|
1623
|
+
function abortable(promise, signal) {
|
|
1624
|
+
if (!signal) return promise;
|
|
1625
|
+
return new Promise((resolve, reject) => {
|
|
1626
|
+
if (signal.aborted) {
|
|
1627
|
+
reject(signal.reason);
|
|
1628
|
+
return;
|
|
1741
1629
|
}
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1630
|
+
const onAbort = () => reject(signal.reason);
|
|
1631
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1632
|
+
promise.then(
|
|
1633
|
+
(v) => (signal.removeEventListener("abort", onAbort), resolve(v)),
|
|
1634
|
+
(e) => (signal.removeEventListener("abort", onAbort), reject(e))
|
|
1635
|
+
);
|
|
1636
|
+
});
|
|
1745
1637
|
}
|
|
1746
1638
|
|
|
1747
1639
|
/**
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
*
|
|
1640
|
+
* A *proactive* rate limiter: slows you down before the server has to say
|
|
1641
|
+
* 429. Two things feed it:
|
|
1642
|
+
* 1. your own budget — `requests` per `perMs` (sliding window), and
|
|
1643
|
+
* 2. what the server tells you — `update(response)` reads X-RateLimit-* /
|
|
1644
|
+
* RateLimit-* / Retry-After and pauses everything once `remaining` hits 0
|
|
1645
|
+
* (or the server sent Retry-After), until the reset time.
|
|
1646
|
+
*
|
|
1647
|
+
* Callers are served FIFO.
|
|
1751
1648
|
*
|
|
1752
1649
|
* ```js
|
|
1753
|
-
*
|
|
1650
|
+
* const rl = createRateLimiter({ requests: 10, perMs: 1000 });
|
|
1651
|
+
* const res = await rl.run(() => fetch(url)); // acquire → call → learn from headers
|
|
1754
1652
|
* ```
|
|
1755
1653
|
*
|
|
1756
|
-
* @
|
|
1757
|
-
* @param {
|
|
1758
|
-
* @param {
|
|
1759
|
-
* @param {number} [opts.maxItems=Infinity]
|
|
1760
|
-
* @returns {AsyncGenerator<T>}
|
|
1654
|
+
* @param {object} [opts]
|
|
1655
|
+
* @param {number} [opts.requests=Infinity] - max requests per window
|
|
1656
|
+
* @param {number} [opts.perMs=1000] - window length
|
|
1761
1657
|
*/
|
|
1762
|
-
|
|
1763
|
-
const {
|
|
1764
|
-
if (
|
|
1658
|
+
function createRateLimiter(opts = {}) {
|
|
1659
|
+
const { requests = Infinity, perMs = 1000 } = opts;
|
|
1660
|
+
if (requests !== Infinity && !(Number.isInteger(requests) && requests >= 1)) {
|
|
1661
|
+
throw new TypeError("createRateLimiter: `requests` must be an integer >= 1 (or Infinity)");
|
|
1662
|
+
}
|
|
1663
|
+
if (!(typeof perMs === "number" && perMs > 0 && Number.isFinite(perMs))) {
|
|
1664
|
+
throw new TypeError("createRateLimiter: `perMs` must be a positive number");
|
|
1665
|
+
}
|
|
1765
1666
|
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1667
|
+
const stamps = [];
|
|
1668
|
+
let blockedUntil = 0;
|
|
1669
|
+
let tail = Promise.resolve();
|
|
1670
|
+
|
|
1671
|
+
async function take(signal) {
|
|
1672
|
+
let waitedMs = 0;
|
|
1673
|
+
for (;;) {
|
|
1674
|
+
signal?.throwIfAborted?.();
|
|
1675
|
+
const now = Date.now();
|
|
1676
|
+
while (stamps.length > 0 && stamps[0] <= now - perMs) stamps.shift();
|
|
1677
|
+
|
|
1678
|
+
let waitMs = 0;
|
|
1679
|
+
if (blockedUntil > now) waitMs = blockedUntil - now;
|
|
1680
|
+
else if (stamps.length >= requests) waitMs = stamps[0] + perMs - now;
|
|
1681
|
+
|
|
1682
|
+
if (waitMs <= 0) {
|
|
1683
|
+
stamps.push(now);
|
|
1684
|
+
return waitedMs;
|
|
1685
|
+
}
|
|
1686
|
+
const waitStartedAt = Date.now();
|
|
1687
|
+
await abortableSleep(waitMs, signal);
|
|
1688
|
+
waitedMs += Date.now() - waitStartedAt;
|
|
1771
1689
|
}
|
|
1772
1690
|
}
|
|
1773
|
-
}
|
|
1774
1691
|
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1692
|
+
/** Resolves with the time spent waiting. Rejects if aborted while waiting. */
|
|
1693
|
+
function acquire({ signal } = {}) {
|
|
1694
|
+
const turn = tail.then(() => take(signal));
|
|
1695
|
+
tail = turn.catch(() => {});
|
|
1696
|
+
return abortable(turn, signal);
|
|
1779
1697
|
}
|
|
1780
|
-
throw new TypeError("paginateLinks: couldn't find the items array in the response — pass `getItems(data)`");
|
|
1781
|
-
}
|
|
1782
1698
|
|
|
1783
|
-
/**
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
* @param {(url: string, init?: object) => Promise<Response>} request
|
|
1793
|
-
* @param {string} url - the first page
|
|
1794
|
-
* @param {object} [opts]
|
|
1795
|
-
* @param {(data: any, response: Response) => any[]} [opts.getItems] - default: the body if it's an array, else its `items` / `data` / `results` / `records`
|
|
1796
|
-
* @param {number} [opts.maxPages=Infinity]
|
|
1797
|
-
* @param {number} [opts.maxItems=Infinity]
|
|
1798
|
-
* @param {AbortSignal} [opts.signal]
|
|
1799
|
-
* @param {object} [opts.init] - extra options passed to every `request`
|
|
1800
|
-
*/
|
|
1801
|
-
async function* paginateLinks(request, url, opts = {}) {
|
|
1802
|
-
const { getItems = defaultGetItems, init = {}, signal } = opts;
|
|
1699
|
+
/** Feed a response (anything with `.headers`/`.status`) so the limiter learns the server's limits. */
|
|
1700
|
+
function update(response) {
|
|
1701
|
+
if (!response || !response.headers) return;
|
|
1702
|
+
const { remaining, resetMs, retryAfterMs } = getRateLimitInfo(response.headers);
|
|
1703
|
+
let blockMs = null;
|
|
1704
|
+
if (retryAfterMs !== null && (response.status === 429 || response.status === 503)) blockMs = retryAfterMs;
|
|
1705
|
+
else if (remaining === 0 && resetMs !== null) blockMs = resetMs;
|
|
1706
|
+
if (blockMs !== null) blockedUntil = Math.max(blockedUntil, Date.now() + blockMs);
|
|
1707
|
+
}
|
|
1803
1708
|
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
const
|
|
1808
|
-
|
|
1809
|
-
return
|
|
1709
|
+
/** acquire → fn() → update(result). Errors thrown by fn propagate (the slot stays spent). */
|
|
1710
|
+
async function run(fn, { signal } = {}) {
|
|
1711
|
+
await acquire({ signal });
|
|
1712
|
+
const result = await fn();
|
|
1713
|
+
update(result);
|
|
1714
|
+
return result;
|
|
1810
1715
|
}
|
|
1811
1716
|
|
|
1812
|
-
|
|
1717
|
+
return {
|
|
1718
|
+
acquire,
|
|
1719
|
+
update,
|
|
1720
|
+
run,
|
|
1721
|
+
/** Ms until the server-imposed pause ends (0 if none). */
|
|
1722
|
+
get blockedForMs() {
|
|
1723
|
+
return Math.max(0, blockedUntil - Date.now());
|
|
1724
|
+
},
|
|
1725
|
+
/** Forget all history and server hints. */
|
|
1726
|
+
reset() {
|
|
1727
|
+
stamps.length = 0;
|
|
1728
|
+
blockedUntil = 0;
|
|
1729
|
+
},
|
|
1730
|
+
};
|
|
1813
1731
|
}
|
|
1814
1732
|
|
|
1815
|
-
module.exports = {
|
|
1733
|
+
module.exports = { createRateLimiter };
|
|
1816
1734
|
|
|
1817
1735
|
},
|
|
1818
|
-
"
|
|
1736
|
+
"resilience/retry": function (module, exports, require) {
|
|
1737
|
+
const { RetryAbortedError, sleep } = require("../internal/sleep");
|
|
1738
|
+
const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
|
|
1739
|
+
|
|
1740
|
+
function defaultShouldRetry(err) {
|
|
1741
|
+
// Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
|
|
1742
|
+
// (e.g. network errors only). HTTP *statuses* aren't errors with fetch, so
|
|
1743
|
+
// opt in with `retryStatusCodes` / `shouldRetryResult`.
|
|
1744
|
+
return Boolean(err) && err.name !== "AbortError";
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1819
1747
|
/**
|
|
1820
|
-
*
|
|
1821
|
-
*
|
|
1748
|
+
* Throw this from inside `fn` to stop retrying immediately. If you pass an
|
|
1749
|
+
* Error, that exact error is what withRetry rethrows; with a string, the
|
|
1750
|
+
* StopRetry itself is thrown. (Same idea as p-retry's AbortError.)
|
|
1751
|
+
*/
|
|
1752
|
+
class StopRetry extends Error {
|
|
1753
|
+
constructor(reasonOrMessage) {
|
|
1754
|
+
const isError = reasonOrMessage instanceof Error;
|
|
1755
|
+
super(isError ? reasonOrMessage.message : String(reasonOrMessage ?? "Retrying was stopped"));
|
|
1756
|
+
this.name = "StopRetry";
|
|
1757
|
+
this.original = isError ? reasonOrMessage : null;
|
|
1758
|
+
}
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/**
|
|
1762
|
+
* Retries an async function with exponential backoff + jitter. Retries on
|
|
1763
|
+
* thrown errors, and — opt-in — on *results* too (HTTP status codes, or any
|
|
1764
|
+
* predicate), honoring Retry-After / X-RateLimit-Reset when the result has
|
|
1765
|
+
* headers.
|
|
1822
1766
|
*
|
|
1823
|
-
* @param {
|
|
1767
|
+
* @param {(attempt: number) => Promise<any>} fn
|
|
1768
|
+
* @param {object} [opts]
|
|
1769
|
+
* @param {number} [opts.retries=3] - max retry attempts after the first try (Infinity is fine with maxRetryTime)
|
|
1770
|
+
* @param {number} [opts.baseDelayMs=300] - initial delay
|
|
1771
|
+
* @param {number} [opts.maxDelayMs=10000] - backoff ceiling
|
|
1772
|
+
* @param {number} [opts.factor=2] - backoff multiplier
|
|
1773
|
+
* @param {number|"full"} [opts.jitter=0.2] - fraction of extra random delay
|
|
1774
|
+
* added on top (0 disables); "full" picks uniformly in [0, delay] — the
|
|
1775
|
+
* AWS "full jitter" strategy, best when many clients retry at once
|
|
1776
|
+
* @param {number} [opts.maxRetryTime=Infinity] - total time budget in ms
|
|
1777
|
+
* across all attempts and waits; once the next wait would blow it, stop
|
|
1778
|
+
* and surface the last error/result
|
|
1779
|
+
* @param {(err: any, attempt: number) => boolean} [opts.shouldRetry] - for thrown errors
|
|
1780
|
+
* @param {number[]} [opts.retryStatusCodes=[]] - retry when the result's `.status` is in this list
|
|
1781
|
+
* @param {(result: any, attempt: number) => boolean} [opts.shouldRetryResult] - retry on any result predicate
|
|
1782
|
+
* @param {boolean} [opts.respectRetryAfter=true] - use Retry-After/X-RateLimit-Reset from `result.headers`
|
|
1783
|
+
* @param {number} [opts.maxRetryAfterMs=60000] - cap on a header-provided wait
|
|
1784
|
+
* @param {(info: {attempt: number, error: any, result: any, delayMs: number}) => number|false} [opts.calculateDelay]
|
|
1785
|
+
* - override the wait; return a number of ms, or false to stop retrying
|
|
1786
|
+
* @param {(err: any, attempt: number, delayMs: number, ctx: {reason: "error"|"result", result?: any, status?: number}) => void} [opts.onRetry]
|
|
1787
|
+
* - `err` is undefined when the retry was triggered by a result
|
|
1788
|
+
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
1789
|
+
* including any pending backoff wait, throwing RetryAbortedError (with the
|
|
1790
|
+
* last failure as `cause`). Separate from a per-call signal you pass into
|
|
1791
|
+
* `fn` yourself — this one governs whether *another attempt starts at all*.
|
|
1824
1792
|
*/
|
|
1825
|
-
async function
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1793
|
+
async function withRetry(fn, opts = {}) {
|
|
1794
|
+
const {
|
|
1795
|
+
retries = 3,
|
|
1796
|
+
baseDelayMs = 300,
|
|
1797
|
+
maxDelayMs = 10000,
|
|
1798
|
+
factor = 2,
|
|
1799
|
+
jitter = 0.2,
|
|
1800
|
+
maxRetryTime = Infinity,
|
|
1801
|
+
shouldRetry = defaultShouldRetry,
|
|
1802
|
+
retryStatusCodes = [],
|
|
1803
|
+
shouldRetryResult,
|
|
1804
|
+
respectRetryAfter = true,
|
|
1805
|
+
maxRetryAfterMs = 60000,
|
|
1806
|
+
calculateDelay,
|
|
1807
|
+
onRetry,
|
|
1808
|
+
signal,
|
|
1809
|
+
} = opts;
|
|
1810
|
+
|
|
1811
|
+
const startedAt = Date.now();
|
|
1812
|
+
|
|
1813
|
+
function backoff(attempt) {
|
|
1814
|
+
const exponential = Math.min(baseDelayMs * factor ** attempt, maxDelayMs);
|
|
1815
|
+
if (jitter === "full") return Math.round(Math.random() * exponential);
|
|
1816
|
+
const extra = Math.random() * exponential * (Number(jitter) || 0);
|
|
1817
|
+
return Math.round(exponential + extra);
|
|
1818
|
+
}
|
|
1819
|
+
|
|
1820
|
+
// Returns the wait in ms, or null if retrying should stop (custom
|
|
1821
|
+
// calculateDelay said so, or the time budget would be exceeded).
|
|
1822
|
+
function planWait(defaultMs, attempt, error, result) {
|
|
1823
|
+
let waitMs = defaultMs;
|
|
1824
|
+
if (calculateDelay) {
|
|
1825
|
+
const custom = calculateDelay({ attempt, error, result, delayMs: defaultMs });
|
|
1826
|
+
if (custom === false || custom === null || custom === undefined) return null;
|
|
1827
|
+
waitMs = Math.max(0, Number(custom) || 0);
|
|
1828
|
+
}
|
|
1829
|
+
if (Date.now() - startedAt + waitMs > maxRetryTime) return null;
|
|
1830
|
+
return waitMs;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function wantsResultRetry(result, attempt) {
|
|
1834
|
+
if (
|
|
1835
|
+
retryStatusCodes.length > 0 &&
|
|
1836
|
+
result &&
|
|
1837
|
+
typeof result.status === "number" &&
|
|
1838
|
+
retryStatusCodes.includes(result.status)
|
|
1839
|
+
) {
|
|
1840
|
+
return true;
|
|
1841
|
+
}
|
|
1842
|
+
return shouldRetryResult ? Boolean(shouldRetryResult(result, attempt)) : false;
|
|
1831
1843
|
}
|
|
1832
1844
|
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1845
|
+
let lastErr;
|
|
1846
|
+
for (let attempt = 0; ; attempt++) {
|
|
1847
|
+
if (signal?.aborted) {
|
|
1848
|
+
throw new RetryAbortedError(lastErr);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
let result;
|
|
1852
|
+
try {
|
|
1853
|
+
result = await fn(attempt);
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
if (err instanceof StopRetry) throw err.original ?? err;
|
|
1856
|
+
lastErr = err;
|
|
1857
|
+
if (attempt >= retries || !shouldRetry(err, attempt)) throw err;
|
|
1858
|
+
|
|
1859
|
+
const waitMs = planWait(backoff(attempt), attempt, err, undefined);
|
|
1860
|
+
if (waitMs === null) throw err;
|
|
1861
|
+
|
|
1862
|
+
if (onRetry) onRetry(err, attempt, waitMs, { reason: "error" });
|
|
1863
|
+
await sleep(waitMs, signal, err);
|
|
1864
|
+
continue;
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
if (attempt >= retries || !wantsResultRetry(result, attempt)) return result;
|
|
1836
1868
|
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1869
|
+
let defaultMs = backoff(attempt);
|
|
1870
|
+
if (respectRetryAfter) {
|
|
1871
|
+
const headerMs = getWaitMsFromHeaders(result && result.headers);
|
|
1872
|
+
if (headerMs !== null) defaultMs = Math.min(headerMs, maxRetryAfterMs);
|
|
1873
|
+
}
|
|
1874
|
+
const waitMs = planWait(defaultMs, attempt, undefined, result);
|
|
1875
|
+
if (waitMs === null) return result;
|
|
1876
|
+
|
|
1877
|
+
await discardBody(result);
|
|
1878
|
+
if (onRetry) onRetry(undefined, attempt, waitMs, { reason: "result", result, status: result && result.status });
|
|
1879
|
+
await sleep(waitMs, signal);
|
|
1841
1880
|
}
|
|
1842
1881
|
}
|
|
1843
1882
|
|
|
1844
|
-
module.exports = {
|
|
1883
|
+
module.exports = { withRetry, RetryAbortedError, StopRetry };
|
|
1845
1884
|
|
|
1846
1885
|
},
|
|
1847
|
-
"
|
|
1848
|
-
const { withRetry } = require("./
|
|
1849
|
-
const { normalizeError } = require("
|
|
1850
|
-
const { parseRetryAfter, getWaitMsFromHeaders } = require("
|
|
1886
|
+
"resilience/retryAfterAware": function (module, exports, require) {
|
|
1887
|
+
const { withRetry } = require("./retry");
|
|
1888
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
1889
|
+
const { parseRetryAfter, getWaitMsFromHeaders } = require("../internal/http");
|
|
1851
1890
|
|
|
1852
1891
|
function defaultShouldRetryError(err) {
|
|
1853
1892
|
const normalized = normalizeError(err);
|
|
@@ -1919,210 +1958,397 @@ function retryAfterAware(fn, opts = {}) {
|
|
|
1919
1958
|
});
|
|
1920
1959
|
}
|
|
1921
1960
|
|
|
1922
|
-
module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
|
|
1961
|
+
module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
|
|
1962
|
+
|
|
1963
|
+
},
|
|
1964
|
+
"resilience/timeout": function (module, exports, require) {
|
|
1965
|
+
class TimeoutError extends Error {
|
|
1966
|
+
constructor(ms) {
|
|
1967
|
+
super(`Timed out after ${ms}ms`);
|
|
1968
|
+
this.name = "TimeoutError";
|
|
1969
|
+
this.code = "ETIMEDOUT";
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
/**
|
|
1974
|
+
* Races an async function against a timeout. `fn` is handed an AbortSignal
|
|
1975
|
+
* so the underlying request can actually be cancelled (not just abandoned) —
|
|
1976
|
+
* e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
|
|
1977
|
+
*
|
|
1978
|
+
* Pass your own `signal` to also cancel from the outside (user navigated
|
|
1979
|
+
* away, shutdown...). That signal is linked into the one `fn` receives, and
|
|
1980
|
+
* aborting it rejects with its reason even if `fn` ignores the signal.
|
|
1981
|
+
* `ms: Infinity` disables the timer (handy to only link a signal).
|
|
1982
|
+
*
|
|
1983
|
+
* @param {(signal: AbortSignal) => Promise<any>} fn
|
|
1984
|
+
* @param {number} ms
|
|
1985
|
+
* @param {object} [opts]
|
|
1986
|
+
* @param {AbortSignal} [opts.signal] - external cancellation
|
|
1987
|
+
*/
|
|
1988
|
+
async function withTimeout(fn, ms, opts = {}) {
|
|
1989
|
+
const { signal } = opts;
|
|
1990
|
+
|
|
1991
|
+
if (typeof ms !== "number" || Number.isNaN(ms) || ms < 0) {
|
|
1992
|
+
throw new TypeError("withTimeout: `ms` must be a non-negative number (or Infinity)");
|
|
1993
|
+
}
|
|
1994
|
+
signal?.throwIfAborted?.();
|
|
1995
|
+
|
|
1996
|
+
const controller = new AbortController();
|
|
1997
|
+
let timedOut = false;
|
|
1998
|
+
|
|
1999
|
+
const timer =
|
|
2000
|
+
ms === Infinity
|
|
2001
|
+
? null
|
|
2002
|
+
: setTimeout(() => {
|
|
2003
|
+
timedOut = true;
|
|
2004
|
+
controller.abort(new TimeoutError(ms));
|
|
2005
|
+
}, ms);
|
|
2006
|
+
|
|
2007
|
+
const onExternalAbort = () => controller.abort(signal.reason);
|
|
2008
|
+
signal?.addEventListener("abort", onExternalAbort, { once: true });
|
|
2009
|
+
|
|
2010
|
+
const guard = new Promise((_, reject) => {
|
|
2011
|
+
controller.signal.addEventListener(
|
|
2012
|
+
"abort",
|
|
2013
|
+
() => reject(timedOut ? new TimeoutError(ms) : controller.signal.reason),
|
|
2014
|
+
{ once: true }
|
|
2015
|
+
);
|
|
2016
|
+
});
|
|
2017
|
+
|
|
2018
|
+
try {
|
|
2019
|
+
return await Promise.race([fn(controller.signal), guard]);
|
|
2020
|
+
} catch (err) {
|
|
2021
|
+
// If our timeout is what triggered the abort, always surface TimeoutError —
|
|
2022
|
+
// regardless of whether fn's own rejection or our guard "won" the race
|
|
2023
|
+
// (that's a listener-registration-order detail callers shouldn't have to
|
|
2024
|
+
// think about).
|
|
2025
|
+
if (timedOut) throw new TimeoutError(ms);
|
|
2026
|
+
throw err;
|
|
2027
|
+
} finally {
|
|
2028
|
+
if (timer) clearTimeout(timer);
|
|
2029
|
+
signal?.removeEventListener("abort", onExternalAbort);
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
module.exports = { withTimeout, TimeoutError };
|
|
2034
|
+
|
|
2035
|
+
},
|
|
2036
|
+
"streams/download": function (module, exports, require) {
|
|
2037
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
2038
|
+
const { BodyTooLargeError } = require("./progress");
|
|
2039
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
2040
|
+
const { getWaitMsFromHeaders } = require("../internal/http");
|
|
2041
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
2042
|
+
|
|
2043
|
+
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
2044
|
+
|
|
2045
|
+
function parseContentRange(header) {
|
|
2046
|
+
// "bytes 100-199/1000" (total may be "*")
|
|
2047
|
+
const m = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(String(header || "").trim());
|
|
2048
|
+
if (!m) return null;
|
|
2049
|
+
return { start: Number(m[1]), end: Number(m[2]), total: m[3] === "*" ? null : Number(m[3]) };
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
function isRetryable(err) {
|
|
2053
|
+
if (err instanceof BodyTooLargeError) return false;
|
|
2054
|
+
if (err instanceof HTTPError) return RETRYABLE_STATUS.has(err.status);
|
|
2055
|
+
const n = normalizeError(err);
|
|
2056
|
+
if (n.isAborted) return false;
|
|
2057
|
+
return n.isNetworkError || n.isTimeout;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
/**
|
|
2061
|
+
* Downloads a URL into memory and, if the connection drops mid-body, resumes
|
|
2062
|
+
* from where it stopped with an HTTP `Range` request instead of starting
|
|
2063
|
+
* over. Uses `If-Range` with the first response's strong ETag (or
|
|
2064
|
+
* Last-Modified) so a file that changed on the server is restarted rather
|
|
2065
|
+
* than stitched together wrongly. If the server ignores Range, it simply
|
|
2066
|
+
* restarts from zero.
|
|
2067
|
+
*
|
|
2068
|
+
* `retries` counts consecutive attempts *without progress* — a flaky link
|
|
2069
|
+
* that keeps making headway keeps going. Honors Retry-After on 429/503.
|
|
2070
|
+
*
|
|
2071
|
+
* ```js
|
|
2072
|
+
* const bytes = await downloadResumable(url, {
|
|
2073
|
+
* onProgress: ({ percent }) => console.log(percent),
|
|
2074
|
+
* maxBytes: 200 * 1024 * 1024,
|
|
2075
|
+
* });
|
|
2076
|
+
* ```
|
|
2077
|
+
*
|
|
2078
|
+
* @param {string} url
|
|
2079
|
+
* @param {object} [opts]
|
|
2080
|
+
* @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. `createFetch(...)` or a mock
|
|
2081
|
+
* @param {HeadersInit} [opts.headers]
|
|
2082
|
+
* @param {number} [opts.retries=5]
|
|
2083
|
+
* @param {number} [opts.baseDelayMs=500]
|
|
2084
|
+
* @param {number} [opts.maxDelayMs=10000]
|
|
2085
|
+
* @param {(p: {loaded: number, total: number | null, percent: number | null}) => void} [opts.onProgress]
|
|
2086
|
+
* @param {number} [opts.maxBytes] - throws BodyTooLargeError past this
|
|
2087
|
+
* @param {AbortSignal} [opts.signal]
|
|
2088
|
+
* @param {(err: any, attempt: number, delayMs: number, info: {resumeFrom: number}) => void} [opts.onRetry]
|
|
2089
|
+
* @returns {Promise<Uint8Array>}
|
|
2090
|
+
*/
|
|
2091
|
+
async function downloadResumable(url, opts = {}) {
|
|
2092
|
+
const {
|
|
2093
|
+
fetch: fetchFn = globalThis.fetch,
|
|
2094
|
+
headers,
|
|
2095
|
+
retries = 5,
|
|
2096
|
+
baseDelayMs = 500,
|
|
2097
|
+
maxDelayMs = 10000,
|
|
2098
|
+
onProgress,
|
|
2099
|
+
maxBytes,
|
|
2100
|
+
signal,
|
|
2101
|
+
onRetry,
|
|
2102
|
+
} = opts;
|
|
2103
|
+
|
|
2104
|
+
const chunks = [];
|
|
2105
|
+
let loaded = 0;
|
|
2106
|
+
let total = null;
|
|
2107
|
+
let validator = null;
|
|
2108
|
+
let attempt = 0; // consecutive attempts without progress
|
|
2109
|
+
|
|
2110
|
+
const report = () => {
|
|
2111
|
+
if (onProgress) {
|
|
2112
|
+
onProgress({ loaded, total, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null });
|
|
2113
|
+
}
|
|
2114
|
+
};
|
|
2115
|
+
|
|
2116
|
+
for (;;) {
|
|
2117
|
+
signal?.throwIfAborted?.();
|
|
2118
|
+
const loadedBefore = loaded;
|
|
2119
|
+
|
|
2120
|
+
try {
|
|
2121
|
+
const reqHeaders = new Headers(headers || undefined);
|
|
2122
|
+
// Range offsets count encoded bytes, so ask for an uncompressed body.
|
|
2123
|
+
if (!reqHeaders.has("accept-encoding")) reqHeaders.set("accept-encoding", "identity");
|
|
2124
|
+
if (loaded > 0) {
|
|
2125
|
+
reqHeaders.set("range", `bytes=${loaded}-`);
|
|
2126
|
+
if (validator) reqHeaders.set("if-range", validator);
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
const res = await fetchFn(url, { headers: reqHeaders, signal });
|
|
2130
|
+
|
|
2131
|
+
if (res.status === 416 && loaded > 0) {
|
|
2132
|
+
// Our offset no longer fits the file — start over, without burning a retry.
|
|
2133
|
+
await res.body?.cancel?.().catch(() => {});
|
|
2134
|
+
chunks.length = 0;
|
|
2135
|
+
loaded = 0;
|
|
2136
|
+
continue;
|
|
2137
|
+
}
|
|
2138
|
+
if (!res.ok) {
|
|
2139
|
+
await res.body?.cancel?.().catch(() => {});
|
|
2140
|
+
throw new HTTPError(res, { method: "GET", url });
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const encoding = res.headers.get("content-encoding");
|
|
2144
|
+
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
2145
|
+
|
|
2146
|
+
if (loaded > 0) {
|
|
2147
|
+
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
2148
|
+
if (!range || range.start !== loaded || encoded) {
|
|
2149
|
+
// Server ignored Range, the file changed (If-Range failed), or the
|
|
2150
|
+
// bytes don't line up: discard and restart from the beginning.
|
|
2151
|
+
chunks.length = 0;
|
|
2152
|
+
loaded = 0;
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
if (loaded === 0) {
|
|
2157
|
+
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
2158
|
+
const length = Number(res.headers.get("content-length"));
|
|
2159
|
+
total = encoded ? null : range ? range.total : Number.isFinite(length) && res.headers.has("content-length") ? length : null;
|
|
2160
|
+
const etag = res.headers.get("etag");
|
|
2161
|
+
validator = etag && !etag.startsWith("W/") ? etag : res.headers.get("last-modified");
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
2165
|
+
await res.body?.cancel?.().catch(() => {});
|
|
2166
|
+
throw new BodyTooLargeError(maxBytes);
|
|
2167
|
+
}
|
|
2168
|
+
|
|
2169
|
+
if (!res.body || typeof res.body.getReader !== "function") {
|
|
2170
|
+
const buf = new Uint8Array(await res.arrayBuffer());
|
|
2171
|
+
if (maxBytes !== undefined && buf.byteLength > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
2172
|
+
chunks.push(buf);
|
|
2173
|
+
loaded += buf.byteLength;
|
|
2174
|
+
total = total ?? loaded;
|
|
2175
|
+
report();
|
|
2176
|
+
} else {
|
|
2177
|
+
const reader = res.body.getReader();
|
|
2178
|
+
const onAbort = () => reader.cancel(signal.reason).catch(() => {});
|
|
2179
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2180
|
+
let finished = false;
|
|
2181
|
+
try {
|
|
2182
|
+
for (;;) {
|
|
2183
|
+
const { done, value } = await reader.read();
|
|
2184
|
+
signal?.throwIfAborted?.();
|
|
2185
|
+
if (done) break;
|
|
2186
|
+
chunks.push(value);
|
|
2187
|
+
loaded += value.byteLength;
|
|
2188
|
+
if (maxBytes !== undefined && loaded > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
2189
|
+
report();
|
|
2190
|
+
}
|
|
2191
|
+
finished = true;
|
|
2192
|
+
} finally {
|
|
2193
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2194
|
+
if (!finished) reader.cancel().catch(() => {});
|
|
2195
|
+
}
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
if (total !== null && loaded < total) {
|
|
2199
|
+
// Stream ended early without an error (some servers just close the socket).
|
|
2200
|
+
throw Object.assign(new Error(`Incomplete download: got ${loaded} of ${total} bytes`), { code: "ECONNRESET" });
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
const out = new Uint8Array(loaded);
|
|
2204
|
+
let offset = 0;
|
|
2205
|
+
for (const chunk of chunks) {
|
|
2206
|
+
out.set(chunk, offset);
|
|
2207
|
+
offset += chunk.byteLength;
|
|
2208
|
+
}
|
|
2209
|
+
return out;
|
|
2210
|
+
} catch (err) {
|
|
2211
|
+
if (signal?.aborted) throw signal.reason ?? err;
|
|
2212
|
+
if (!isRetryable(err)) throw err;
|
|
1923
2213
|
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
const { readSSE } = require("./streams");
|
|
1927
|
-
const { HTTPError } = require("./HTTPError");
|
|
1928
|
-
const { normalizeError } = require("./normalizeError");
|
|
1929
|
-
const { getWaitMsFromHeaders, discardBody } = require("./_http");
|
|
1930
|
-
const { abortableSleep } = require("./_sleep");
|
|
2214
|
+
if (loaded > loadedBefore) attempt = 0; // it made progress — doesn't count against the budget
|
|
2215
|
+
if (attempt >= retries) throw err;
|
|
1931
2216
|
|
|
1932
|
-
|
|
1933
|
-
|
|
2217
|
+
let delayMs = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
2218
|
+
if (err instanceof HTTPError) {
|
|
2219
|
+
const headerMs = getWaitMsFromHeaders(err.response && err.response.headers);
|
|
2220
|
+
if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
|
|
2221
|
+
}
|
|
2222
|
+
attempt++;
|
|
2223
|
+
if (onRetry) onRetry(err, attempt, delayMs, { resumeFrom: loaded });
|
|
2224
|
+
await abortableSleep(delayMs, signal);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
1934
2227
|
}
|
|
1935
2228
|
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2229
|
+
module.exports = { downloadResumable };
|
|
2230
|
+
|
|
2231
|
+
},
|
|
2232
|
+
"streams/progress": function (module, exports, require) {
|
|
2233
|
+
class BodyTooLargeError extends Error {
|
|
2234
|
+
constructor(maxBytes) {
|
|
2235
|
+
super(`Response body exceeded the ${maxBytes}-byte limit`);
|
|
2236
|
+
this.name = "BodyTooLargeError";
|
|
2237
|
+
this.code = "EBODYTOOLARGE";
|
|
2238
|
+
this.maxBytes = maxBytes;
|
|
2239
|
+
}
|
|
1939
2240
|
}
|
|
1940
2241
|
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
2242
|
+
function cancelQuietly(reader) {
|
|
2243
|
+
try {
|
|
2244
|
+
const p = reader.cancel();
|
|
2245
|
+
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
2246
|
+
} catch {
|
|
2247
|
+
// already closed / released — nothing to do
|
|
2248
|
+
}
|
|
1944
2249
|
}
|
|
1945
2250
|
|
|
1946
2251
|
/**
|
|
1947
|
-
*
|
|
1948
|
-
*
|
|
1949
|
-
*
|
|
1950
|
-
*
|
|
1951
|
-
* POST bodies, AbortSignal, your own retry/backoff rules.
|
|
1952
|
-
*
|
|
1953
|
-
* ```js
|
|
1954
|
-
* for await (const { data } of streamSSE("https://api.example.com/events", { signal })) {
|
|
1955
|
-
* console.log(data);
|
|
1956
|
-
* }
|
|
1957
|
-
* ```
|
|
2252
|
+
* Reads a fetch Response body while reporting progress, and returns the
|
|
2253
|
+
* fully collected bytes as a Uint8Array. Works for any streamed response,
|
|
2254
|
+
* not just file downloads — same shape whether it's a media file, a large
|
|
2255
|
+
* JSON payload, or anything else worth showing progress for.
|
|
1958
2256
|
*
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
*
|
|
1962
|
-
*
|
|
1963
|
-
* failing; 429/503 `Retry-After` is honored. A connection that delivered at
|
|
1964
|
-
* least one event resets the failure count (only no-progress attempts count
|
|
1965
|
-
* toward `retries`).
|
|
1966
|
-
* - HTTP 204 means "stop reconnecting" (iteration ends). A non-retryable
|
|
1967
|
-
* status (e.g. 401/404) or a wrong content-type throws immediately.
|
|
1968
|
-
* - Only GET reconnects by default. For other methods (e.g. a POST that starts
|
|
1969
|
-
* an LLM stream) re-sending the request could repeat side effects, so
|
|
1970
|
-
* nothing is retried unless you pass `reconnect: true`.
|
|
1971
|
-
* - Breaking out of the loop, or aborting `signal`, closes the connection.
|
|
2257
|
+
* `total` is null (and `percent` with it) when the size isn't trustworthy:
|
|
2258
|
+
* no Content-Length, or a Content-Encoding is applied (Content-Length is then
|
|
2259
|
+
* the *compressed* size while fetch hands you *decompressed* bytes, so
|
|
2260
|
+
* loaded could exceed total). `percent` is always clamped to 0–100.
|
|
1972
2261
|
*
|
|
1973
|
-
* @param {
|
|
2262
|
+
* @param {Response} response
|
|
2263
|
+
* @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} [onProgress]
|
|
1974
2264
|
* @param {object} [opts]
|
|
1975
|
-
* @param {
|
|
1976
|
-
*
|
|
1977
|
-
* @param {
|
|
1978
|
-
*
|
|
1979
|
-
* @
|
|
1980
|
-
* @param {AbortSignal} [opts.signal]
|
|
1981
|
-
* @param {string} [opts.lastEventId] - resume from here on the very first connect
|
|
1982
|
-
* @param {boolean} [opts.reconnect] - default: true for GET/HEAD, false otherwise
|
|
1983
|
-
* @param {number} [opts.retries=10] - consecutive failed connections before giving up (Infinity = never)
|
|
1984
|
-
* @param {number} [opts.reconnectDelayMs=1000] - base delay (the server's `retry:` overrides it)
|
|
1985
|
-
* @param {number} [opts.maxDelayMs=30000]
|
|
1986
|
-
* @param {(event: object) => boolean} [opts.stopWhen] - end the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`
|
|
1987
|
-
* @param {(info: {connections: number, lastEventId: string}) => void} [opts.onOpen]
|
|
1988
|
-
* @param {(info: {reason: "error"|"end"|"status", error?: any, status?: number, attempt: number, delayMs: number, lastEventId: string}) => void} [opts.onReconnect]
|
|
1989
|
-
* @returns {AsyncGenerator<{event: string, data: string, id: string, retry: number|undefined}>}
|
|
2265
|
+
* @param {number} [opts.maxBytes] - abort with BodyTooLargeError once more
|
|
2266
|
+
* than this many bytes have been received
|
|
2267
|
+
* @param {AbortSignal} [opts.signal] - cancels the read; rejects with the
|
|
2268
|
+
* signal's reason (an AbortError by default)
|
|
2269
|
+
* @returns {Promise<Uint8Array>}
|
|
1990
2270
|
*/
|
|
1991
|
-
async function
|
|
1992
|
-
const {
|
|
1993
|
-
|
|
1994
|
-
method = "GET",
|
|
1995
|
-
headers,
|
|
1996
|
-
body,
|
|
1997
|
-
signal,
|
|
1998
|
-
reconnect = method === "GET" || method === "HEAD",
|
|
1999
|
-
retries = 10,
|
|
2000
|
-
reconnectDelayMs = 1000,
|
|
2001
|
-
maxDelayMs = 30000,
|
|
2002
|
-
stopWhen,
|
|
2003
|
-
onOpen,
|
|
2004
|
-
onReconnect,
|
|
2005
|
-
} = opts;
|
|
2006
|
-
|
|
2007
|
-
let lastId = opts.lastEventId || "";
|
|
2008
|
-
let baseDelay = reconnectDelayMs;
|
|
2009
|
-
let failures = 0; // consecutive connections that delivered nothing
|
|
2010
|
-
let connections = 0;
|
|
2271
|
+
async function withProgress(response, onProgress, opts = {}) {
|
|
2272
|
+
const { maxBytes, signal } = opts;
|
|
2273
|
+
signal?.throwIfAborted?.();
|
|
2011
2274
|
|
|
2012
|
-
|
|
2275
|
+
if (!response.body || typeof response.body.getReader !== "function") {
|
|
2276
|
+
// No streaming support available — fall back to a single-shot read,
|
|
2277
|
+
// still reporting one final progress event so callers don't need a branch.
|
|
2278
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
2013
2279
|
signal?.throwIfAborted?.();
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
// ---- connect -----------------------------------------------------------
|
|
2018
|
-
try {
|
|
2019
|
-
const h = await resolveHeaders(headers);
|
|
2020
|
-
if (!h.has("accept")) h.set("accept", "text/event-stream");
|
|
2021
|
-
if (!h.has("cache-control")) h.set("cache-control", "no-cache");
|
|
2022
|
-
if (lastId) h.set("last-event-id", lastId);
|
|
2023
|
-
res = await fetchFn(url, { method, headers: h, body, signal });
|
|
2024
|
-
} catch (err) {
|
|
2025
|
-
if (signal?.aborted) throw signal.reason ?? err;
|
|
2026
|
-
if (err instanceof HTTPError) {
|
|
2027
|
-
if (!isRetryableStatus(err.status)) throw err;
|
|
2028
|
-
failure = { reason: "status", status: err.status, response: err.response, error: err };
|
|
2029
|
-
} else if (isRetryableError(err)) {
|
|
2030
|
-
failure = { reason: "error", error: err };
|
|
2031
|
-
} else {
|
|
2032
|
-
throw err;
|
|
2033
|
-
}
|
|
2280
|
+
if (maxBytes !== undefined && buf.byteLength > maxBytes) {
|
|
2281
|
+
throw new BodyTooLargeError(maxBytes);
|
|
2034
2282
|
}
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
if (!failure) {
|
|
2038
|
-
if (res.status === 204) {
|
|
2039
|
-
await discardBody(res);
|
|
2040
|
-
return; // the server's way of saying "don't reconnect"
|
|
2041
|
-
}
|
|
2042
|
-
if (!res.ok) {
|
|
2043
|
-
if (!isRetryableStatus(res.status)) {
|
|
2044
|
-
await discardBody(res);
|
|
2045
|
-
throw new HTTPError(res, { method, url });
|
|
2046
|
-
}
|
|
2047
|
-
failure = { reason: "status", status: res.status, response: res };
|
|
2048
|
-
} else {
|
|
2049
|
-
const type = res.headers.get("content-type") || "";
|
|
2050
|
-
if (!/text\/event-stream/i.test(type)) {
|
|
2051
|
-
await discardBody(res);
|
|
2052
|
-
throw Object.assign(new Error(`streamSSE: expected "text/event-stream" but the server sent "${type || "no content-type"}"`), {
|
|
2053
|
-
code: "SSE_BAD_CONTENT_TYPE",
|
|
2054
|
-
});
|
|
2055
|
-
}
|
|
2056
|
-
}
|
|
2283
|
+
if (onProgress) {
|
|
2284
|
+
onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
|
|
2057
2285
|
}
|
|
2286
|
+
return buf;
|
|
2287
|
+
}
|
|
2058
2288
|
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
try {
|
|
2065
|
-
for await (const event of readSSE(res, { signal })) {
|
|
2066
|
-
gotEvent = true;
|
|
2067
|
-
lastId = event.id;
|
|
2068
|
-
if (event.retry !== undefined) baseDelay = event.retry;
|
|
2069
|
-
yield event;
|
|
2070
|
-
if (stopWhen && stopWhen(event)) return;
|
|
2071
|
-
}
|
|
2072
|
-
failure = { reason: "end" };
|
|
2073
|
-
} catch (err) {
|
|
2074
|
-
if (signal?.aborted) throw signal.reason ?? err;
|
|
2075
|
-
if (!isRetryableError(err)) throw err;
|
|
2076
|
-
failure = { reason: "error", error: err };
|
|
2077
|
-
}
|
|
2078
|
-
}
|
|
2289
|
+
const encoding = response.headers?.get?.("content-encoding");
|
|
2290
|
+
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
2291
|
+
const contentLength = response.headers?.get?.("content-length");
|
|
2292
|
+
const parsed = contentLength ? Number(contentLength) : NaN;
|
|
2293
|
+
const total = !encoded && Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
2079
2294
|
|
|
2080
|
-
|
|
2081
|
-
if (!reconnect) {
|
|
2082
|
-
if (failure.reason === "end") return;
|
|
2083
|
-
if (failure.reason === "status") {
|
|
2084
|
-
throw failure.error || new HTTPError(failure.response, { method, url });
|
|
2085
|
-
}
|
|
2086
|
-
throw failure.error;
|
|
2087
|
-
}
|
|
2295
|
+
const reader = response.body.getReader();
|
|
2088
2296
|
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
if (failure.reason === "error") throw failure.error;
|
|
2095
|
-
throw Object.assign(new Error(`streamSSE: the server keeps closing the stream without sending events — gave up after ${retries} retries`), {
|
|
2096
|
-
code: "SSE_GAVE_UP",
|
|
2097
|
-
});
|
|
2098
|
-
}
|
|
2297
|
+
// Fail fast when the declared size already exceeds the cap.
|
|
2298
|
+
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
2299
|
+
cancelQuietly(reader);
|
|
2300
|
+
throw new BodyTooLargeError(maxBytes);
|
|
2301
|
+
}
|
|
2099
2302
|
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2303
|
+
const onAbort = () => cancelQuietly(reader);
|
|
2304
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2305
|
+
|
|
2306
|
+
const chunks = [];
|
|
2307
|
+
let loaded = 0;
|
|
2308
|
+
let finished = false;
|
|
2309
|
+
|
|
2310
|
+
try {
|
|
2311
|
+
for (;;) {
|
|
2312
|
+
const { done, value } = await reader.read();
|
|
2313
|
+
// reader.cancel() on abort makes read() resolve as done — check the
|
|
2314
|
+
// signal first so an aborted read never looks like a complete one.
|
|
2315
|
+
signal?.throwIfAborted?.();
|
|
2316
|
+
if (done) break;
|
|
2317
|
+
|
|
2318
|
+
chunks.push(value);
|
|
2319
|
+
loaded += value.byteLength;
|
|
2107
2320
|
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2321
|
+
if (maxBytes !== undefined && loaded > maxBytes) {
|
|
2322
|
+
throw new BodyTooLargeError(maxBytes);
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
if (onProgress) {
|
|
2326
|
+
onProgress({
|
|
2327
|
+
loaded,
|
|
2328
|
+
total,
|
|
2329
|
+
percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2117
2332
|
}
|
|
2118
|
-
|
|
2333
|
+
finished = true;
|
|
2334
|
+
} finally {
|
|
2335
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2336
|
+
if (!finished) cancelQuietly(reader);
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
const result = new Uint8Array(loaded);
|
|
2340
|
+
let offset = 0;
|
|
2341
|
+
for (const chunk of chunks) {
|
|
2342
|
+
result.set(chunk, offset);
|
|
2343
|
+
offset += chunk.byteLength;
|
|
2119
2344
|
}
|
|
2345
|
+
return result;
|
|
2120
2346
|
}
|
|
2121
2347
|
|
|
2122
|
-
module.exports = {
|
|
2348
|
+
module.exports = { withProgress, BodyTooLargeError };
|
|
2123
2349
|
|
|
2124
2350
|
},
|
|
2125
|
-
"
|
|
2351
|
+
"streams/readers": function (module, exports, require) {
|
|
2126
2352
|
const encoder = new TextEncoder();
|
|
2127
2353
|
|
|
2128
2354
|
// Yields Uint8Array chunks from a fetch Response, a web ReadableStream, or any
|
|
@@ -2159,46 +2385,83 @@ async function* chunks(source, signal) {
|
|
|
2159
2385
|
}
|
|
2160
2386
|
}
|
|
2161
2387
|
|
|
2388
|
+
const DEFAULT_MAX_LINE_LENGTH = 16 * 1024 * 1024;
|
|
2389
|
+
|
|
2390
|
+
function lineTooLong(limit) {
|
|
2391
|
+
return Object.assign(
|
|
2392
|
+
new Error(`reqkit streams: a line exceeded ${limit} characters without a line break (raise \`maxLineLength\` if that's expected)`),
|
|
2393
|
+
{ code: "LINE_TOO_LONG" }
|
|
2394
|
+
);
|
|
2395
|
+
}
|
|
2396
|
+
|
|
2162
2397
|
/**
|
|
2163
2398
|
* Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
|
|
2164
2399
|
* multi-byte characters split across chunks, and a leading BOM.
|
|
2165
2400
|
*
|
|
2401
|
+
* Runs in linear time and bounded memory: only newly arrived text is scanned,
|
|
2402
|
+
* and a "line" longer than `maxLineLength` characters (default 16 Mi — a
|
|
2403
|
+
* server that never sends a newline) throws `code: "LINE_TOO_LONG"` instead
|
|
2404
|
+
* of buffering forever.
|
|
2405
|
+
*
|
|
2166
2406
|
* ```js
|
|
2167
2407
|
* for await (const line of readLines(response)) console.log(line);
|
|
2168
2408
|
* ```
|
|
2169
2409
|
*
|
|
2170
2410
|
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
2171
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
2411
|
+
* @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts]
|
|
2172
2412
|
* @returns {AsyncGenerator<string>}
|
|
2173
2413
|
*/
|
|
2174
2414
|
async function* readLines(source, opts = {}) {
|
|
2415
|
+
const { maxLineLength = DEFAULT_MAX_LINE_LENGTH } = opts;
|
|
2175
2416
|
const decoder = new TextDecoder("utf-8");
|
|
2176
|
-
|
|
2417
|
+
const terminator = /[\r\n]/g;
|
|
2418
|
+
|
|
2419
|
+
let parts = []; // pieces of the line being assembled (no terminators in them)
|
|
2420
|
+
let partsLength = 0;
|
|
2421
|
+
let skipLF = false; // the previous line ended with "\r", so a leading "\n" belongs to it
|
|
2177
2422
|
let first = true;
|
|
2178
2423
|
|
|
2179
2424
|
for await (const chunk of chunks(source, opts.signal)) {
|
|
2180
|
-
|
|
2181
|
-
if (first &&
|
|
2182
|
-
|
|
2425
|
+
let text = decoder.decode(chunk, { stream: true });
|
|
2426
|
+
if (first && text.length > 0) {
|
|
2427
|
+
text = text.replace(/^\uFEFF/, "");
|
|
2183
2428
|
first = false;
|
|
2184
2429
|
}
|
|
2185
2430
|
|
|
2186
|
-
let
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2431
|
+
let start = 0;
|
|
2432
|
+
terminator.lastIndex = 0;
|
|
2433
|
+
let match;
|
|
2434
|
+
while ((match = terminator.exec(text)) !== null) {
|
|
2435
|
+
const ch = match[0];
|
|
2436
|
+
if (ch === "\n" && skipLF && match.index === start) {
|
|
2437
|
+
skipLF = false; // the "\n" of a "\r\n" pair — swallow it
|
|
2438
|
+
start = match.index + 1;
|
|
2439
|
+
continue;
|
|
2440
|
+
}
|
|
2441
|
+
skipLF = false;
|
|
2442
|
+
|
|
2443
|
+
const piece = text.slice(start, match.index);
|
|
2444
|
+
if (partsLength + piece.length > maxLineLength) throw lineTooLong(maxLineLength);
|
|
2445
|
+
const line = parts.length > 0 ? parts.join("") + piece : piece;
|
|
2446
|
+
parts = [];
|
|
2447
|
+
partsLength = 0;
|
|
2448
|
+
yield line;
|
|
2449
|
+
|
|
2450
|
+
start = match.index + 1;
|
|
2451
|
+
skipLF = ch === "\r";
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
if (start < text.length) {
|
|
2455
|
+
const rest = text.slice(start);
|
|
2456
|
+
partsLength += rest.length;
|
|
2457
|
+
if (partsLength > maxLineLength) throw lineTooLong(maxLineLength);
|
|
2458
|
+
parts.push(rest);
|
|
2459
|
+
skipLF = false;
|
|
2195
2460
|
}
|
|
2196
|
-
buffer = buffer.slice(pos);
|
|
2197
2461
|
}
|
|
2198
2462
|
|
|
2199
|
-
|
|
2200
|
-
if (
|
|
2201
|
-
if (buffer.length > 0) yield buffer;
|
|
2463
|
+
const tail = parts.join("") + decoder.decode();
|
|
2464
|
+
if (tail.length > 0) yield tail;
|
|
2202
2465
|
}
|
|
2203
2466
|
|
|
2204
2467
|
/**
|
|
@@ -2238,11 +2501,13 @@ async function* readNDJSON(source, opts = {}) {
|
|
|
2238
2501
|
* ```
|
|
2239
2502
|
*
|
|
2240
2503
|
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
2241
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
2504
|
+
* @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts] - `maxLineLength` also caps the data of a single event
|
|
2242
2505
|
*/
|
|
2243
2506
|
async function* readSSE(source, opts = {}) {
|
|
2507
|
+
const maxEventLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
|
|
2244
2508
|
let event = "";
|
|
2245
2509
|
let data = [];
|
|
2510
|
+
let dataLength = 0;
|
|
2246
2511
|
let retry;
|
|
2247
2512
|
let lastId = "";
|
|
2248
2513
|
|
|
@@ -2251,6 +2516,7 @@ async function* readSSE(source, opts = {}) {
|
|
|
2251
2516
|
if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
|
|
2252
2517
|
event = "";
|
|
2253
2518
|
data = [];
|
|
2519
|
+
dataLength = 0;
|
|
2254
2520
|
retry = undefined;
|
|
2255
2521
|
continue;
|
|
2256
2522
|
}
|
|
@@ -2261,462 +2527,432 @@ async function* readSSE(source, opts = {}) {
|
|
|
2261
2527
|
let value = idx === -1 ? "" : line.slice(idx + 1);
|
|
2262
2528
|
if (value.startsWith(" ")) value = value.slice(1);
|
|
2263
2529
|
|
|
2264
|
-
if (field === "event") event = value;
|
|
2265
|
-
else if (field === "data")
|
|
2266
|
-
|
|
2267
|
-
if (
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
}
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
},
|
|
2277
|
-
"./withDedupe": function (module, exports, require) {
|
|
2278
|
-
// Non-plain objects (AbortSignal, Response, class instances, functions...)
|
|
2279
|
-
// can't be meaningfully JSON-serialized — JSON.stringify turns every
|
|
2280
|
-
// AbortSignal into "{}", which would make unrelated calls share one request.
|
|
2281
|
-
// Give them a stable identity-based id instead.
|
|
2282
|
-
const identityIds = new WeakMap();
|
|
2283
|
-
let nextIdentityId = 1;
|
|
2284
|
-
|
|
2285
|
-
function identityOf(value) {
|
|
2286
|
-
let id = identityIds.get(value);
|
|
2287
|
-
if (id === undefined) {
|
|
2288
|
-
id = nextIdentityId++;
|
|
2289
|
-
identityIds.set(value, id);
|
|
2290
|
-
}
|
|
2291
|
-
return id;
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
function isPlainObject(value) {
|
|
2295
|
-
const proto = Object.getPrototypeOf(value);
|
|
2296
|
-
return proto === Object.prototype || proto === null;
|
|
2297
|
-
}
|
|
2298
|
-
|
|
2299
|
-
function keyReplacer(_key, value) {
|
|
2300
|
-
if (typeof value === "bigint") return { $bigint: String(value) };
|
|
2301
|
-
if (typeof value === "function") return { $ref: identityOf(value) };
|
|
2302
|
-
if (value === null || typeof value !== "object") return value;
|
|
2303
|
-
if (Array.isArray(value)) return value;
|
|
2304
|
-
if (!isPlainObject(value)) return { $ref: identityOf(value) };
|
|
2305
|
-
|
|
2306
|
-
// Sort plain-object keys so {a:1,b:2} and {b:2,a:1} dedupe together.
|
|
2307
|
-
const sorted = {};
|
|
2308
|
-
for (const k of Object.keys(value).sort()) sorted[k] = value[k];
|
|
2309
|
-
return sorted;
|
|
2310
|
-
}
|
|
2311
|
-
|
|
2312
|
-
function defaultKeyFn(...args) {
|
|
2313
|
-
try {
|
|
2314
|
-
return JSON.stringify(args, keyReplacer);
|
|
2315
|
-
} catch {
|
|
2316
|
-
// Circular structures etc. Fall back to identity for objects.
|
|
2317
|
-
return args
|
|
2318
|
-
.map((a) => ((a && typeof a === "object") || typeof a === "function" ? `#${identityOf(a)}` : String(a)))
|
|
2319
|
-
.join("|");
|
|
2320
|
-
}
|
|
2321
|
-
}
|
|
2322
|
-
|
|
2323
|
-
/**
|
|
2324
|
-
* Wraps an async function so that concurrent calls with the same key share
|
|
2325
|
-
* a single in-flight promise instead of firing duplicate requests. Common
|
|
2326
|
-
* use case: several parts of a UI ask for the same resource at once (e.g. a
|
|
2327
|
-
* user profile) — only one network request actually goes out, and every
|
|
2328
|
-
* caller resolves with the same result.
|
|
2329
|
-
*
|
|
2330
|
-
* The in-flight entry is cleared as soon as the call settles (success or
|
|
2331
|
-
* failure), so the next call — even with the same key — always starts a
|
|
2332
|
-
* fresh request. This is deduplication of concurrent calls, not a cache.
|
|
2333
|
-
*
|
|
2334
|
-
* @param {(...args: any[]) => Promise<any>} fn
|
|
2335
|
-
* @param {object} [opts]
|
|
2336
|
-
* @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
|
|
2337
|
-
* from the call arguments. By default plain data is compared by value
|
|
2338
|
-
* (object key order doesn't matter) and anything else — AbortSignals,
|
|
2339
|
-
* Responses, class instances, functions — by identity, so two calls with
|
|
2340
|
-
* different AbortSignals are never merged.
|
|
2341
|
-
* @returns {(...args: any[]) => Promise<any>}
|
|
2342
|
-
*/
|
|
2343
|
-
function withDedupe(fn, opts = {}) {
|
|
2344
|
-
const { keyFn = defaultKeyFn } = opts;
|
|
2345
|
-
const inFlight = new Map();
|
|
2346
|
-
|
|
2347
|
-
return function deduped(...args) {
|
|
2348
|
-
const key = keyFn(...args);
|
|
2349
|
-
|
|
2350
|
-
if (inFlight.has(key)) {
|
|
2351
|
-
return inFlight.get(key);
|
|
2352
|
-
}
|
|
2353
|
-
|
|
2354
|
-
const promise = Promise.resolve()
|
|
2355
|
-
.then(() => fn(...args))
|
|
2356
|
-
.finally(() => {
|
|
2357
|
-
inFlight.delete(key);
|
|
2358
|
-
});
|
|
2359
|
-
|
|
2360
|
-
inFlight.set(key, promise);
|
|
2361
|
-
return promise;
|
|
2362
|
-
};
|
|
2363
|
-
}
|
|
2364
|
-
|
|
2365
|
-
module.exports = { withDedupe };
|
|
2366
|
-
|
|
2367
|
-
},
|
|
2368
|
-
"./withProgress": function (module, exports, require) {
|
|
2369
|
-
class BodyTooLargeError extends Error {
|
|
2370
|
-
constructor(maxBytes) {
|
|
2371
|
-
super(`Response body exceeded the ${maxBytes}-byte limit`);
|
|
2372
|
-
this.name = "BodyTooLargeError";
|
|
2373
|
-
this.code = "EBODYTOOLARGE";
|
|
2374
|
-
this.maxBytes = maxBytes;
|
|
2375
|
-
}
|
|
2376
|
-
}
|
|
2377
|
-
|
|
2378
|
-
function cancelQuietly(reader) {
|
|
2379
|
-
try {
|
|
2380
|
-
const p = reader.cancel();
|
|
2381
|
-
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
2382
|
-
} catch {
|
|
2383
|
-
// already closed / released — nothing to do
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
|
|
2387
|
-
/**
|
|
2388
|
-
* Reads a fetch Response body while reporting progress, and returns the
|
|
2389
|
-
* fully collected bytes as a Uint8Array. Works for any streamed response,
|
|
2390
|
-
* not just file downloads — same shape whether it's a media file, a large
|
|
2391
|
-
* JSON payload, or anything else worth showing progress for.
|
|
2392
|
-
*
|
|
2393
|
-
* `total` is null (and `percent` with it) when the size isn't trustworthy:
|
|
2394
|
-
* no Content-Length, or a Content-Encoding is applied (Content-Length is then
|
|
2395
|
-
* the *compressed* size while fetch hands you *decompressed* bytes, so
|
|
2396
|
-
* loaded could exceed total). `percent` is always clamped to 0–100.
|
|
2397
|
-
*
|
|
2398
|
-
* @param {Response} response
|
|
2399
|
-
* @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} [onProgress]
|
|
2400
|
-
* @param {object} [opts]
|
|
2401
|
-
* @param {number} [opts.maxBytes] - abort with BodyTooLargeError once more
|
|
2402
|
-
* than this many bytes have been received
|
|
2403
|
-
* @param {AbortSignal} [opts.signal] - cancels the read; rejects with the
|
|
2404
|
-
* signal's reason (an AbortError by default)
|
|
2405
|
-
* @returns {Promise<Uint8Array>}
|
|
2406
|
-
*/
|
|
2407
|
-
async function withProgress(response, onProgress, opts = {}) {
|
|
2408
|
-
const { maxBytes, signal } = opts;
|
|
2409
|
-
signal?.throwIfAborted?.();
|
|
2410
|
-
|
|
2411
|
-
if (!response.body || typeof response.body.getReader !== "function") {
|
|
2412
|
-
// No streaming support available — fall back to a single-shot read,
|
|
2413
|
-
// still reporting one final progress event so callers don't need a branch.
|
|
2414
|
-
const buf = new Uint8Array(await response.arrayBuffer());
|
|
2415
|
-
signal?.throwIfAborted?.();
|
|
2416
|
-
if (maxBytes !== undefined && buf.byteLength > maxBytes) {
|
|
2417
|
-
throw new BodyTooLargeError(maxBytes);
|
|
2418
|
-
}
|
|
2419
|
-
if (onProgress) {
|
|
2420
|
-
onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
|
|
2421
|
-
}
|
|
2422
|
-
return buf;
|
|
2423
|
-
}
|
|
2424
|
-
|
|
2425
|
-
const encoding = response.headers?.get?.("content-encoding");
|
|
2426
|
-
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
2427
|
-
const contentLength = response.headers?.get?.("content-length");
|
|
2428
|
-
const parsed = contentLength ? Number(contentLength) : NaN;
|
|
2429
|
-
const total = !encoded && Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
2430
|
-
|
|
2431
|
-
const reader = response.body.getReader();
|
|
2432
|
-
|
|
2433
|
-
// Fail fast when the declared size already exceeds the cap.
|
|
2434
|
-
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
2435
|
-
cancelQuietly(reader);
|
|
2436
|
-
throw new BodyTooLargeError(maxBytes);
|
|
2437
|
-
}
|
|
2438
|
-
|
|
2439
|
-
const onAbort = () => cancelQuietly(reader);
|
|
2440
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2441
|
-
|
|
2442
|
-
const chunks = [];
|
|
2443
|
-
let loaded = 0;
|
|
2444
|
-
let finished = false;
|
|
2445
|
-
|
|
2446
|
-
try {
|
|
2447
|
-
for (;;) {
|
|
2448
|
-
const { done, value } = await reader.read();
|
|
2449
|
-
// reader.cancel() on abort makes read() resolve as done — check the
|
|
2450
|
-
// signal first so an aborted read never looks like a complete one.
|
|
2451
|
-
signal?.throwIfAborted?.();
|
|
2452
|
-
if (done) break;
|
|
2453
|
-
|
|
2454
|
-
chunks.push(value);
|
|
2455
|
-
loaded += value.byteLength;
|
|
2456
|
-
|
|
2457
|
-
if (maxBytes !== undefined && loaded > maxBytes) {
|
|
2458
|
-
throw new BodyTooLargeError(maxBytes);
|
|
2459
|
-
}
|
|
2460
|
-
|
|
2461
|
-
if (onProgress) {
|
|
2462
|
-
onProgress({
|
|
2463
|
-
loaded,
|
|
2464
|
-
total,
|
|
2465
|
-
percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null,
|
|
2466
|
-
});
|
|
2467
|
-
}
|
|
2468
|
-
}
|
|
2469
|
-
finished = true;
|
|
2470
|
-
} finally {
|
|
2471
|
-
signal?.removeEventListener("abort", onAbort);
|
|
2472
|
-
if (!finished) cancelQuietly(reader);
|
|
2473
|
-
}
|
|
2474
|
-
|
|
2475
|
-
const result = new Uint8Array(loaded);
|
|
2476
|
-
let offset = 0;
|
|
2477
|
-
for (const chunk of chunks) {
|
|
2478
|
-
result.set(chunk, offset);
|
|
2479
|
-
offset += chunk.byteLength;
|
|
2530
|
+
if (field === "event") event = value;
|
|
2531
|
+
else if (field === "data") {
|
|
2532
|
+
dataLength += value.length + 1;
|
|
2533
|
+
if (dataLength > maxEventLength) throw lineTooLong(maxEventLength);
|
|
2534
|
+
data.push(value);
|
|
2535
|
+
}
|
|
2536
|
+
else if (field === "id") {
|
|
2537
|
+
if (!value.includes("\0")) lastId = value;
|
|
2538
|
+
} else if (field === "retry") {
|
|
2539
|
+
if (/^\d+$/.test(value)) retry = Number(value);
|
|
2540
|
+
}
|
|
2480
2541
|
}
|
|
2481
|
-
return result;
|
|
2482
2542
|
}
|
|
2483
2543
|
|
|
2484
|
-
module.exports = {
|
|
2544
|
+
module.exports = { readLines, readNDJSON, readSSE };
|
|
2485
2545
|
|
|
2486
2546
|
},
|
|
2487
|
-
"
|
|
2488
|
-
const {
|
|
2489
|
-
const {
|
|
2547
|
+
"streams/streamSSE": function (module, exports, require) {
|
|
2548
|
+
const { readSSE } = require("./readers");
|
|
2549
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
2550
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
2551
|
+
const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
|
|
2552
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
2490
2553
|
|
|
2491
|
-
function
|
|
2492
|
-
|
|
2493
|
-
// (e.g. network errors only). HTTP *statuses* aren't errors with fetch, so
|
|
2494
|
-
// opt in with `retryStatusCodes` / `shouldRetryResult`.
|
|
2495
|
-
return Boolean(err) && err.name !== "AbortError";
|
|
2554
|
+
function isRetryableStatus(status) {
|
|
2555
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
2496
2556
|
}
|
|
2497
2557
|
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
super(isError ? reasonOrMessage.message : String(reasonOrMessage ?? "Retrying was stopped"));
|
|
2507
|
-
this.name = "StopRetry";
|
|
2508
|
-
this.original = isError ? reasonOrMessage : null;
|
|
2509
|
-
}
|
|
2558
|
+
function isRetryableError(err) {
|
|
2559
|
+
const n = normalizeError(err);
|
|
2560
|
+
return !n.isAborted && (n.isNetworkError || n.isTimeout);
|
|
2561
|
+
}
|
|
2562
|
+
|
|
2563
|
+
async function resolveHeaders(source) {
|
|
2564
|
+
const value = typeof source === "function" ? await source() : source;
|
|
2565
|
+
return new Headers(value || undefined);
|
|
2510
2566
|
}
|
|
2511
2567
|
|
|
2512
2568
|
/**
|
|
2513
|
-
*
|
|
2514
|
-
*
|
|
2515
|
-
*
|
|
2516
|
-
* headers
|
|
2569
|
+
* Connects to a Server-Sent Events endpoint and yields its events — and when
|
|
2570
|
+
* the connection drops (or the server closes it), reconnects by itself,
|
|
2571
|
+
* sending `Last-Event-ID` so the server can resume where you left off. This is
|
|
2572
|
+
* what the browser's `EventSource` does, but with `fetch`: custom headers,
|
|
2573
|
+
* POST bodies, AbortSignal, your own retry/backoff rules.
|
|
2517
2574
|
*
|
|
2518
|
-
*
|
|
2575
|
+
* ```js
|
|
2576
|
+
* for await (const { data } of streamSSE("https://api.example.com/events", { signal })) {
|
|
2577
|
+
* console.log(data);
|
|
2578
|
+
* }
|
|
2579
|
+
* ```
|
|
2580
|
+
*
|
|
2581
|
+
* Reconnect behavior:
|
|
2582
|
+
* - `Last-Event-ID` is sent on every reconnect (the last `id:` seen).
|
|
2583
|
+
* - A server `retry: <ms>` field sets the base reconnect delay, like EventSource.
|
|
2584
|
+
* - Waits back off exponentially (capped at `maxDelayMs`) while connecting keeps
|
|
2585
|
+
* failing; 429/503 `Retry-After` is honored. A connection that delivered at
|
|
2586
|
+
* least one event resets the failure count (only no-progress attempts count
|
|
2587
|
+
* toward `retries`).
|
|
2588
|
+
* - HTTP 204 means "stop reconnecting" (iteration ends). A non-retryable
|
|
2589
|
+
* status (e.g. 401/404) or a wrong content-type throws immediately.
|
|
2590
|
+
* - Only GET reconnects by default. For other methods (e.g. a POST that starts
|
|
2591
|
+
* an LLM stream) re-sending the request could repeat side effects, so
|
|
2592
|
+
* nothing is retried unless you pass `reconnect: true`.
|
|
2593
|
+
* - Breaking out of the loop, or aborting `signal`, closes the connection.
|
|
2594
|
+
*
|
|
2595
|
+
* @param {string} url
|
|
2519
2596
|
* @param {object} [opts]
|
|
2520
|
-
* @param {
|
|
2521
|
-
* @param {
|
|
2522
|
-
* @param {
|
|
2523
|
-
*
|
|
2524
|
-
* @param {
|
|
2525
|
-
*
|
|
2526
|
-
*
|
|
2527
|
-
* @param {
|
|
2528
|
-
*
|
|
2529
|
-
*
|
|
2530
|
-
* @param {
|
|
2531
|
-
* @param {
|
|
2532
|
-
* @param {(
|
|
2533
|
-
* @param {
|
|
2534
|
-
* @
|
|
2535
|
-
* @param {(info: {attempt: number, error: any, result: any, delayMs: number}) => number|false} [opts.calculateDelay]
|
|
2536
|
-
* - override the wait; return a number of ms, or false to stop retrying
|
|
2537
|
-
* @param {(err: any, attempt: number, delayMs: number, ctx: {reason: "error"|"result", result?: any, status?: number}) => void} [opts.onRetry]
|
|
2538
|
-
* - `err` is undefined when the retry was triggered by a result
|
|
2539
|
-
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
2540
|
-
* including any pending backoff wait, throwing RetryAbortedError (with the
|
|
2541
|
-
* last failure as `cause`). Separate from a per-call signal you pass into
|
|
2542
|
-
* `fn` yourself — this one governs whether *another attempt starts at all*.
|
|
2597
|
+
* @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. a createFetch() instance
|
|
2598
|
+
* @param {string} [opts.method="GET"]
|
|
2599
|
+
* @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [opts.headers] -
|
|
2600
|
+
* a function is called before every (re)connect — handy for refreshing a token
|
|
2601
|
+
* @param {BodyInit} [opts.body]
|
|
2602
|
+
* @param {AbortSignal} [opts.signal]
|
|
2603
|
+
* @param {string} [opts.lastEventId] - resume from here on the very first connect
|
|
2604
|
+
* @param {boolean} [opts.reconnect] - default: true for GET/HEAD, false otherwise
|
|
2605
|
+
* @param {number} [opts.retries=10] - consecutive failed connections before giving up (Infinity = never)
|
|
2606
|
+
* @param {number} [opts.reconnectDelayMs=1000] - base delay (the server's `retry:` overrides it)
|
|
2607
|
+
* @param {number} [opts.maxDelayMs=30000]
|
|
2608
|
+
* @param {(event: object) => boolean} [opts.stopWhen] - end the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`
|
|
2609
|
+
* @param {(info: {connections: number, lastEventId: string}) => void} [opts.onOpen]
|
|
2610
|
+
* @param {(info: {reason: "error"|"end"|"status", error?: any, status?: number, attempt: number, delayMs: number, lastEventId: string}) => void} [opts.onReconnect]
|
|
2611
|
+
* @returns {AsyncGenerator<{event: string, data: string, id: string, retry: number|undefined}>}
|
|
2543
2612
|
*/
|
|
2544
|
-
async function
|
|
2613
|
+
async function* streamSSE(url, opts = {}) {
|
|
2545
2614
|
const {
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
jitter = 0.2,
|
|
2551
|
-
maxRetryTime = Infinity,
|
|
2552
|
-
shouldRetry = defaultShouldRetry,
|
|
2553
|
-
retryStatusCodes = [],
|
|
2554
|
-
shouldRetryResult,
|
|
2555
|
-
respectRetryAfter = true,
|
|
2556
|
-
maxRetryAfterMs = 60000,
|
|
2557
|
-
calculateDelay,
|
|
2558
|
-
onRetry,
|
|
2615
|
+
fetch: fetchFn = globalThis.fetch,
|
|
2616
|
+
method = "GET",
|
|
2617
|
+
headers,
|
|
2618
|
+
body,
|
|
2559
2619
|
signal,
|
|
2620
|
+
reconnect = method === "GET" || method === "HEAD",
|
|
2621
|
+
retries = 10,
|
|
2622
|
+
reconnectDelayMs = 1000,
|
|
2623
|
+
maxDelayMs = 30000,
|
|
2624
|
+
stopWhen,
|
|
2625
|
+
onOpen,
|
|
2626
|
+
onReconnect,
|
|
2560
2627
|
} = opts;
|
|
2561
2628
|
|
|
2562
|
-
|
|
2629
|
+
let lastId = opts.lastEventId || "";
|
|
2630
|
+
let baseDelay = reconnectDelayMs;
|
|
2631
|
+
let failures = 0; // consecutive connections that delivered nothing
|
|
2632
|
+
let connections = 0;
|
|
2563
2633
|
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
return Math.round(exponential + extra);
|
|
2569
|
-
}
|
|
2634
|
+
for (;;) {
|
|
2635
|
+
signal?.throwIfAborted?.();
|
|
2636
|
+
let failure = null; // { reason, error?, status?, response? }
|
|
2637
|
+
let res = null;
|
|
2570
2638
|
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2639
|
+
// ---- connect -----------------------------------------------------------
|
|
2640
|
+
try {
|
|
2641
|
+
const h = await resolveHeaders(headers);
|
|
2642
|
+
if (!h.has("accept")) h.set("accept", "text/event-stream");
|
|
2643
|
+
if (!h.has("cache-control")) h.set("cache-control", "no-cache");
|
|
2644
|
+
if (lastId) h.set("last-event-id", lastId);
|
|
2645
|
+
res = await fetchFn(url, { method, headers: h, body, signal });
|
|
2646
|
+
} catch (err) {
|
|
2647
|
+
if (signal?.aborted) throw signal.reason ?? err;
|
|
2648
|
+
if (err instanceof HTTPError) {
|
|
2649
|
+
if (!isRetryableStatus(err.status)) throw err;
|
|
2650
|
+
failure = { reason: "status", status: err.status, response: err.response, error: err };
|
|
2651
|
+
} else if (isRetryableError(err)) {
|
|
2652
|
+
failure = { reason: "error", error: err };
|
|
2653
|
+
} else {
|
|
2654
|
+
throw err;
|
|
2655
|
+
}
|
|
2579
2656
|
}
|
|
2580
|
-
if (Date.now() - startedAt + waitMs > maxRetryTime) return null;
|
|
2581
|
-
return waitMs;
|
|
2582
|
-
}
|
|
2583
2657
|
|
|
2584
|
-
|
|
2585
|
-
if (
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2658
|
+
// ---- inspect the response ---------------------------------------------
|
|
2659
|
+
if (!failure) {
|
|
2660
|
+
if (res.status === 204) {
|
|
2661
|
+
await discardBody(res);
|
|
2662
|
+
return; // the server's way of saying "don't reconnect"
|
|
2663
|
+
}
|
|
2664
|
+
if (!res.ok) {
|
|
2665
|
+
if (!isRetryableStatus(res.status)) {
|
|
2666
|
+
await discardBody(res);
|
|
2667
|
+
throw new HTTPError(res, { method, url });
|
|
2668
|
+
}
|
|
2669
|
+
failure = { reason: "status", status: res.status, response: res };
|
|
2670
|
+
} else {
|
|
2671
|
+
const type = res.headers.get("content-type") || "";
|
|
2672
|
+
if (!/text\/event-stream/i.test(type)) {
|
|
2673
|
+
await discardBody(res);
|
|
2674
|
+
throw Object.assign(new Error(`streamSSE: expected "text/event-stream" but the server sent "${type || "no content-type"}"`), {
|
|
2675
|
+
code: "SSE_BAD_CONTENT_TYPE",
|
|
2676
|
+
});
|
|
2677
|
+
}
|
|
2678
|
+
}
|
|
2592
2679
|
}
|
|
2593
|
-
return shouldRetryResult ? Boolean(shouldRetryResult(result, attempt)) : false;
|
|
2594
|
-
}
|
|
2595
2680
|
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
if (
|
|
2599
|
-
|
|
2681
|
+
// ---- stream ------------------------------------------------------------
|
|
2682
|
+
let gotEvent = false;
|
|
2683
|
+
if (!failure) {
|
|
2684
|
+
connections++;
|
|
2685
|
+
if (onOpen) onOpen({ connections, lastEventId: lastId });
|
|
2686
|
+
try {
|
|
2687
|
+
for await (const event of readSSE(res, { signal })) {
|
|
2688
|
+
gotEvent = true;
|
|
2689
|
+
lastId = event.id;
|
|
2690
|
+
if (event.retry !== undefined) baseDelay = event.retry;
|
|
2691
|
+
yield event;
|
|
2692
|
+
if (stopWhen && stopWhen(event)) return;
|
|
2693
|
+
}
|
|
2694
|
+
failure = { reason: "end" };
|
|
2695
|
+
} catch (err) {
|
|
2696
|
+
if (signal?.aborted) throw signal.reason ?? err;
|
|
2697
|
+
if (!isRetryableError(err)) throw err;
|
|
2698
|
+
failure = { reason: "error", error: err };
|
|
2699
|
+
}
|
|
2600
2700
|
}
|
|
2601
2701
|
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2702
|
+
// ---- decide whether and when to reconnect ------------------------------
|
|
2703
|
+
if (!reconnect) {
|
|
2704
|
+
if (failure.reason === "end") return;
|
|
2705
|
+
if (failure.reason === "status") {
|
|
2706
|
+
throw failure.error || new HTTPError(failure.response, { method, url });
|
|
2707
|
+
}
|
|
2708
|
+
throw failure.error;
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
// A connection that delivered events made progress: reconnect promptly and
|
|
2712
|
+
// start the failure count over. Only no-progress attempts count toward `retries`.
|
|
2713
|
+
failures = gotEvent ? 0 : failures + 1;
|
|
2714
|
+
if (failures > retries) {
|
|
2715
|
+
if (failure.reason === "status") throw failure.error || new HTTPError(failure.response, { method, url });
|
|
2716
|
+
if (failure.reason === "error") throw failure.error;
|
|
2717
|
+
throw Object.assign(new Error(`streamSSE: the server keeps closing the stream without sending events — gave up after ${retries} retries`), {
|
|
2718
|
+
code: "SSE_GAVE_UP",
|
|
2719
|
+
});
|
|
2720
|
+
}
|
|
2609
2721
|
|
|
2610
|
-
|
|
2611
|
-
|
|
2722
|
+
let delayMs = Math.min(baseDelay * 2 ** Math.max(failures - 1, 0), maxDelayMs);
|
|
2723
|
+
if (failure.reason === "status" && failure.response) {
|
|
2724
|
+
const headerMs = getWaitMsFromHeaders(failure.response.headers);
|
|
2725
|
+
if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
|
|
2726
|
+
await discardBody(failure.response);
|
|
2727
|
+
}
|
|
2728
|
+
delayMs += Math.random() * delayMs * 0.1; // a little jitter so clients don't reconnect in lockstep
|
|
2612
2729
|
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2730
|
+
if (onReconnect) {
|
|
2731
|
+
onReconnect({
|
|
2732
|
+
reason: failure.reason,
|
|
2733
|
+
error: failure.error,
|
|
2734
|
+
status: failure.status,
|
|
2735
|
+
attempt: Math.max(failures, 1),
|
|
2736
|
+
delayMs,
|
|
2737
|
+
lastEventId: lastId,
|
|
2738
|
+
});
|
|
2616
2739
|
}
|
|
2740
|
+
await abortableSleep(delayMs, signal);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2617
2743
|
|
|
2618
|
-
|
|
2744
|
+
module.exports = { streamSSE };
|
|
2619
2745
|
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
if (headerMs !== null) defaultMs = Math.min(headerMs, maxRetryAfterMs);
|
|
2624
|
-
}
|
|
2625
|
-
const waitMs = planWait(defaultMs, attempt, undefined, result);
|
|
2626
|
-
if (waitMs === null) return result;
|
|
2746
|
+
},
|
|
2747
|
+
"testing/createMockFetch": function (module, exports, require) {
|
|
2748
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
2627
2749
|
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2750
|
+
const BODYLESS = new Set([101, 204, 205, 304]);
|
|
2751
|
+
|
|
2752
|
+
function toBody(body, headers) {
|
|
2753
|
+
if (body === null || body === undefined) return null;
|
|
2754
|
+
if (typeof body === "string") return body;
|
|
2755
|
+
if (
|
|
2756
|
+
body instanceof ArrayBuffer ||
|
|
2757
|
+
ArrayBuffer.isView(body) ||
|
|
2758
|
+
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
2759
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream) ||
|
|
2760
|
+
body instanceof URLSearchParams
|
|
2761
|
+
) {
|
|
2762
|
+
return body;
|
|
2631
2763
|
}
|
|
2764
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
2765
|
+
return JSON.stringify(body);
|
|
2632
2766
|
}
|
|
2633
2767
|
|
|
2634
|
-
|
|
2768
|
+
function buildResponse(status, body, extraHeaders) {
|
|
2769
|
+
const headers = new Headers(extraHeaders || undefined);
|
|
2770
|
+
const payload = BODYLESS.has(status) ? null : toBody(body, headers);
|
|
2771
|
+
return new Response(payload, { status, headers });
|
|
2772
|
+
}
|
|
2635
2773
|
|
|
2636
|
-
|
|
2637
|
-
"
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2774
|
+
function matchesUrl(matcher, url) {
|
|
2775
|
+
if (typeof matcher === "function") return Boolean(matcher(url));
|
|
2776
|
+
if (matcher instanceof RegExp) return matcher.test(url);
|
|
2777
|
+
if (matcher === "*" || matcher === undefined) return true;
|
|
2778
|
+
if (url === matcher) return true;
|
|
2779
|
+
try {
|
|
2780
|
+
const u = new URL(url);
|
|
2781
|
+
if (matcher === u.pathname + u.search) return true;
|
|
2782
|
+
if (!String(matcher).includes("?") && matcher === u.pathname) return true;
|
|
2783
|
+
} catch {
|
|
2784
|
+
// relative/odd URL — exact match only
|
|
2643
2785
|
}
|
|
2786
|
+
return false;
|
|
2644
2787
|
}
|
|
2645
2788
|
|
|
2646
2789
|
/**
|
|
2647
|
-
*
|
|
2648
|
-
*
|
|
2649
|
-
* e.g. `withTimeout(signal => fetch(url, { signal }), 5000)`.
|
|
2790
|
+
* A scriptable, fetch-compatible test double — for testing code that uses
|
|
2791
|
+
* retries, timeouts, rate limits and so on without a network.
|
|
2650
2792
|
*
|
|
2651
|
-
*
|
|
2652
|
-
*
|
|
2653
|
-
*
|
|
2654
|
-
*
|
|
2793
|
+
* ```js
|
|
2794
|
+
* const mock = createMockFetch();
|
|
2795
|
+
* mock.on("GET", "/users/1")
|
|
2796
|
+
* .replyOnce(503, "busy", { headers: { "retry-after": "0" } })
|
|
2797
|
+
* .reply(200, { id: 1 });
|
|
2798
|
+
* mock.on("POST", /\/orders/).fail(mock.networkError());
|
|
2655
2799
|
*
|
|
2656
|
-
*
|
|
2657
|
-
*
|
|
2658
|
-
*
|
|
2659
|
-
*
|
|
2800
|
+
* const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test" });
|
|
2801
|
+
* await api.json("/users/1"); // → { id: 1 } after one retry
|
|
2802
|
+
* mock.calls.length; // 2
|
|
2803
|
+
* ```
|
|
2804
|
+
*
|
|
2805
|
+
* String matchers match the full URL, `pathname + search`, or the bare
|
|
2806
|
+
* `pathname`; you can also pass a RegExp or a `(url) => boolean`. `"*"` as the
|
|
2807
|
+
* method matches any method. Unmatched requests reject with a clear error.
|
|
2660
2808
|
*/
|
|
2661
|
-
|
|
2662
|
-
|
|
2809
|
+
function createMockFetch() {
|
|
2810
|
+
let routes = [];
|
|
2811
|
+
const calls = [];
|
|
2663
2812
|
|
|
2664
|
-
|
|
2665
|
-
|
|
2813
|
+
function on(method, matcher) {
|
|
2814
|
+
const route = { method: String(method).toUpperCase(), matcher, once: [], fallback: null };
|
|
2815
|
+
routes.push(route);
|
|
2816
|
+
|
|
2817
|
+
const chain = {
|
|
2818
|
+
/** Always answer like this (once any `…Once` replies are used up). */
|
|
2819
|
+
reply(status = 200, body, opts) {
|
|
2820
|
+
route.fallback = replyHandler(status, body, opts);
|
|
2821
|
+
return chain;
|
|
2822
|
+
},
|
|
2823
|
+
replyOnce(status = 200, body, opts) {
|
|
2824
|
+
route.once.push(replyHandler(status, body, opts));
|
|
2825
|
+
return chain;
|
|
2826
|
+
},
|
|
2827
|
+
/** `fn(call)` returns a Response, `{ status, body, headers }`, or throws/rejects. */
|
|
2828
|
+
replyWith(fn) {
|
|
2829
|
+
route.fallback = customHandler(fn);
|
|
2830
|
+
return chain;
|
|
2831
|
+
},
|
|
2832
|
+
replyWithOnce(fn) {
|
|
2833
|
+
route.once.push(customHandler(fn));
|
|
2834
|
+
return chain;
|
|
2835
|
+
},
|
|
2836
|
+
/** Reject like fetch does on a network failure. */
|
|
2837
|
+
fail(err = networkError()) {
|
|
2838
|
+
route.fallback = async () => {
|
|
2839
|
+
throw err;
|
|
2840
|
+
};
|
|
2841
|
+
return chain;
|
|
2842
|
+
},
|
|
2843
|
+
failOnce(err = networkError()) {
|
|
2844
|
+
route.once.push(async () => {
|
|
2845
|
+
throw err;
|
|
2846
|
+
});
|
|
2847
|
+
return chain;
|
|
2848
|
+
},
|
|
2849
|
+
};
|
|
2850
|
+
return chain;
|
|
2666
2851
|
}
|
|
2667
|
-
signal?.throwIfAborted?.();
|
|
2668
2852
|
|
|
2669
|
-
|
|
2670
|
-
|
|
2853
|
+
function replyHandler(status, body, opts = {}) {
|
|
2854
|
+
return async (call) => {
|
|
2855
|
+
if (opts.delayMs) await abortableSleep(opts.delayMs, call.signal);
|
|
2856
|
+
return buildResponse(status, body, opts.headers);
|
|
2857
|
+
};
|
|
2858
|
+
}
|
|
2671
2859
|
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2860
|
+
function customHandler(fn) {
|
|
2861
|
+
return async (call) => {
|
|
2862
|
+
const out = await fn(call);
|
|
2863
|
+
if (out instanceof Response) return out;
|
|
2864
|
+
return buildResponse(out.status ?? 200, out.body, out.headers);
|
|
2865
|
+
};
|
|
2866
|
+
}
|
|
2679
2867
|
|
|
2680
|
-
|
|
2681
|
-
|
|
2868
|
+
function networkError(code = "ECONNREFUSED") {
|
|
2869
|
+
const err = new TypeError("fetch failed");
|
|
2870
|
+
err.cause = Object.assign(new Error(`connect ${code}`), { code });
|
|
2871
|
+
return err;
|
|
2872
|
+
}
|
|
2682
2873
|
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
);
|
|
2689
|
-
});
|
|
2874
|
+
async function mockFetch(input, init = {}) {
|
|
2875
|
+
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
|
2876
|
+
const method = String(init.method || (typeof input === "object" && input.method) || "GET").toUpperCase();
|
|
2877
|
+
const signal = init.signal || (typeof input === "object" ? input.signal : undefined);
|
|
2878
|
+
signal?.throwIfAborted?.();
|
|
2690
2879
|
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2880
|
+
const headers = {};
|
|
2881
|
+
new Headers(init.headers || (typeof input === "object" ? input.headers : undefined)).forEach((v, k) => {
|
|
2882
|
+
headers[k] = v;
|
|
2883
|
+
});
|
|
2884
|
+
|
|
2885
|
+
const call = {
|
|
2886
|
+
url,
|
|
2887
|
+
method,
|
|
2888
|
+
headers,
|
|
2889
|
+
body: init.body,
|
|
2890
|
+
signal,
|
|
2891
|
+
get json() {
|
|
2892
|
+
try {
|
|
2893
|
+
return typeof this.body === "string" ? JSON.parse(this.body) : undefined;
|
|
2894
|
+
} catch {
|
|
2895
|
+
return undefined;
|
|
2896
|
+
}
|
|
2897
|
+
},
|
|
2898
|
+
};
|
|
2899
|
+
calls.push(call);
|
|
2900
|
+
|
|
2901
|
+
const route = routes.find(
|
|
2902
|
+
(r) => (r.method === "*" || r.method === method) && matchesUrl(r.matcher, url) && (r.once.length > 0 || r.fallback)
|
|
2903
|
+
);
|
|
2904
|
+
if (!route) {
|
|
2905
|
+
throw new Error(`createMockFetch: no mock registered for ${method} ${url}`);
|
|
2906
|
+
}
|
|
2907
|
+
const handler = route.once.length > 0 ? route.once.shift() : route.fallback;
|
|
2908
|
+
return handler(call);
|
|
2703
2909
|
}
|
|
2910
|
+
|
|
2911
|
+
return {
|
|
2912
|
+
fetch: mockFetch,
|
|
2913
|
+
on,
|
|
2914
|
+
get: (m) => on("GET", m),
|
|
2915
|
+
post: (m) => on("POST", m),
|
|
2916
|
+
put: (m) => on("PUT", m),
|
|
2917
|
+
patch: (m) => on("PATCH", m),
|
|
2918
|
+
delete: (m) => on("DELETE", m),
|
|
2919
|
+
/** A TypeError("fetch failed") with `cause.code`, like Node's fetch throws. */
|
|
2920
|
+
networkError,
|
|
2921
|
+
/** Every request made so far, in order. */
|
|
2922
|
+
calls,
|
|
2923
|
+
/** Remove all routes and forget recorded calls. */
|
|
2924
|
+
reset() {
|
|
2925
|
+
routes = [];
|
|
2926
|
+
calls.length = 0;
|
|
2927
|
+
},
|
|
2928
|
+
};
|
|
2704
2929
|
}
|
|
2705
2930
|
|
|
2706
|
-
module.exports = {
|
|
2931
|
+
module.exports = { createMockFetch };
|
|
2707
2932
|
|
|
2708
2933
|
},
|
|
2709
2934
|
};
|
|
2710
2935
|
const __cache = {};
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2936
|
+
// Resolve a relative request the way node would, against the requiring module's id.
|
|
2937
|
+
function __resolve(fromId, request) {
|
|
2938
|
+
const parts = fromId.split("/").slice(0, -1);
|
|
2939
|
+
for (const seg of request.replace(/\.js$/, "").split("/")) {
|
|
2940
|
+
if (seg === "..") parts.pop();
|
|
2941
|
+
else if (seg !== "." && seg !== "") parts.push(seg);
|
|
2942
|
+
}
|
|
2943
|
+
const id = parts.join("/");
|
|
2944
|
+
if (id in __defs) return id;
|
|
2945
|
+
if (`${id}/index` in __defs) return `${id}/index`;
|
|
2946
|
+
if (id === "" && "index" in __defs) return "index";
|
|
2947
|
+
throw new Error(`reqkit bundle: cannot resolve ${request} from ${fromId}`);
|
|
2948
|
+
}
|
|
2949
|
+
function __load(id) {
|
|
2950
|
+
if (__cache[id]) return __cache[id].exports;
|
|
2951
|
+
const module = (__cache[id] = { exports: {} });
|
|
2952
|
+
__defs[id].call(module.exports, module, module.exports, (request) => __load(__resolve(id, request)));
|
|
2717
2953
|
return module.exports;
|
|
2718
2954
|
}
|
|
2719
|
-
const __reqkit =
|
|
2955
|
+
const __reqkit = __load("index");
|
|
2720
2956
|
export const {
|
|
2721
2957
|
BodyTooLargeError,
|
|
2722
2958
|
CircuitOpenError,
|