@boy-offi9-inc/reqkit 0.7.1 → 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 +92 -66
- package/dist/reqkit.mjs +2136 -2014
- package/package.json +9 -5
- package/src/{createFetch.js → http/createFetch.js} +130 -22
- package/src/index.js +18 -18
- package/src/{paginate.js → pagination/paginate.js} +2 -2
- 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/{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 -538
- 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/{_http.js → internal/http.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/src/{streams.js → streams/readers.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,653 +250,417 @@ 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);
|
|
148
|
-
|
|
149
|
-
info.resetMs = parseResetMs(
|
|
150
|
-
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
151
|
-
);
|
|
152
|
-
info.retryAfterMs = parseRetryAfter(headers.get("retry-after"));
|
|
153
|
-
return info;
|
|
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;
|
|
154
328
|
}
|
|
155
329
|
|
|
156
|
-
async function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
|
|
161
|
-
await response.body.cancel();
|
|
162
|
-
}
|
|
163
|
-
} catch {
|
|
164
|
-
// locked / already consumed / not a web stream — safe to ignore
|
|
165
|
-
}
|
|
330
|
+
async function resolveHeaders(source) {
|
|
331
|
+
const value = typeof source === "function" ? await source() : source;
|
|
332
|
+
return new Headers(value || undefined);
|
|
166
333
|
}
|
|
167
334
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
return origin === "null" ? null : origin;
|
|
173
|
-
} catch {
|
|
174
|
-
return null;
|
|
335
|
+
function mergeHeaders(...headerSets) {
|
|
336
|
+
const out = new Headers();
|
|
337
|
+
for (const set of headerSets) {
|
|
338
|
+
set.forEach((value, key) => out.set(key, value));
|
|
175
339
|
}
|
|
340
|
+
return out;
|
|
176
341
|
}
|
|
177
342
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
},
|
|
181
|
-
"./_sleep": function (module, exports, require) {
|
|
182
|
-
class RetryAbortedError extends Error {
|
|
183
|
-
constructor(cause) {
|
|
184
|
-
super("Retry loop was aborted");
|
|
185
|
-
this.name = "RetryAbortedError";
|
|
186
|
-
if (cause !== undefined) this.cause = cause;
|
|
187
|
-
}
|
|
343
|
+
function isStreamBody(body) {
|
|
344
|
+
return Boolean(body) && typeof body === "object" && typeof body.getReader === "function";
|
|
188
345
|
}
|
|
189
346
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
* RetryAbortedError (carrying `cause`, usually the last failure) if the
|
|
193
|
-
* signal aborts before or during the wait.
|
|
194
|
-
*/
|
|
195
|
-
function sleep(ms, signal, cause) {
|
|
196
|
-
return new Promise((resolve, reject) => {
|
|
197
|
-
if (signal?.aborted) {
|
|
198
|
-
reject(new RetryAbortedError(cause));
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
const onAbort = () => {
|
|
202
|
-
clearTimeout(timer);
|
|
203
|
-
reject(new RetryAbortedError(cause));
|
|
204
|
-
};
|
|
205
|
-
const timer = setTimeout(() => {
|
|
206
|
-
signal?.removeEventListener("abort", onAbort);
|
|
207
|
-
resolve();
|
|
208
|
-
}, ms);
|
|
209
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
210
|
-
});
|
|
347
|
+
function originOf(url) {
|
|
348
|
+
return getOrigin(url) || url;
|
|
211
349
|
}
|
|
212
350
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
};
|
|
224
|
-
const timer = setTimeout(() => {
|
|
225
|
-
signal?.removeEventListener("abort", onAbort);
|
|
226
|
-
resolve();
|
|
227
|
-
}, ms);
|
|
228
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
229
|
-
});
|
|
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;
|
|
230
361
|
}
|
|
231
362
|
|
|
232
|
-
|
|
363
|
+
function mergeDefaults(base, extra) {
|
|
364
|
+
const merged = { ...base, ...extra };
|
|
233
365
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
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
|
+
};
|
|
242
372
|
}
|
|
243
|
-
if (
|
|
244
|
-
|
|
245
|
-
`buildQuery: value for "${key}" is not a primitive, Date or array of those — flatten it or JSON.stringify it first`
|
|
246
|
-
);
|
|
373
|
+
if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
|
|
374
|
+
merged.retry = { ...base.retry, ...extra.retry };
|
|
247
375
|
}
|
|
248
|
-
return
|
|
376
|
+
return merged;
|
|
249
377
|
}
|
|
250
378
|
|
|
251
379
|
/**
|
|
252
|
-
* Builds a
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* 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.
|
|
256
383
|
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
|
260
406
|
*
|
|
261
|
-
* @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.
|
|
262
439
|
*/
|
|
263
|
-
function
|
|
264
|
-
|
|
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;
|
|
265
458
|
|
|
266
|
-
const
|
|
267
|
-
for (const [key, value] of Object.entries(params)) {
|
|
268
|
-
if (value === null || value === undefined) continue;
|
|
459
|
+
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
269
460
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
274
469
|
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return pairs.join("&");
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
module.exports = { buildQuery };
|
|
470
|
+
};
|
|
281
471
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
this.name = "CircuitOpenError";
|
|
288
|
-
// Deliberately not an E-code: this is *not* a network error and must not be retried as one.
|
|
289
|
-
this.code = "CIRCUIT_OPEN";
|
|
290
|
-
this.retryAfterMs = retryAfterMs;
|
|
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);
|
|
291
477
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
}
|
|
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
|
+
};
|
|
298
483
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
*
|
|
306
|
-
* By default a thrown error (except AbortError) or a result with status >= 500
|
|
307
|
-
* counts as a failure; override with `isFailure(valueOrError, isError)`.
|
|
308
|
-
*
|
|
309
|
-
* @param {object} [opts]
|
|
310
|
-
* @param {number} [opts.failureThreshold=5]
|
|
311
|
-
* @param {number} [opts.resetTimeoutMs=30000]
|
|
312
|
-
* @param {number} [opts.halfOpenMax=1]
|
|
313
|
-
* @param {(valueOrError: any, isError: boolean) => boolean} [opts.isFailure]
|
|
314
|
-
* @param {(state: string, prev: string) => void} [opts.onStateChange]
|
|
315
|
-
*/
|
|
316
|
-
function createCircuitBreaker(opts = {}) {
|
|
317
|
-
const {
|
|
318
|
-
failureThreshold = 5,
|
|
319
|
-
resetTimeoutMs = 30000,
|
|
320
|
-
halfOpenMax = 1,
|
|
321
|
-
isFailure = defaultIsFailure,
|
|
322
|
-
onStateChange,
|
|
323
|
-
} = opts;
|
|
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);
|
|
324
490
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
+
};
|
|
329
511
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
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();
|
|
335
528
|
}
|
|
529
|
+
const pendingStores = cacheCfg ? cacheCfg.pending : null;
|
|
530
|
+
const revalidating = cacheCfg ? cacheCfg.revalidating : null;
|
|
531
|
+
const emit = (type, url) => {
|
|
532
|
+
if (cacheCfg && cacheCfg.onEvent) cacheCfg.onEvent({ type, url });
|
|
533
|
+
emitEvent({ type: "cache", url, method: "GET", event: type });
|
|
534
|
+
};
|
|
336
535
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
}
|
|
536
|
+
// Shared in-flight GETs. Every caller gets its own clone of the response so
|
|
537
|
+
// body reads don't fight each other.
|
|
538
|
+
const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
|
|
341
539
|
|
|
342
|
-
function
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
540
|
+
function scheduleStore(key, response, url) {
|
|
541
|
+
const p = snapshotResponse(response, cacheCfg.maxBodyBytes)
|
|
542
|
+
.then(async (entry) => {
|
|
543
|
+
if (entry) {
|
|
544
|
+
await cacheCfg.store.set(key, entry);
|
|
545
|
+
emit("store", url);
|
|
546
|
+
}
|
|
547
|
+
})
|
|
548
|
+
.catch(() => {})
|
|
549
|
+
.finally(() => {
|
|
550
|
+
if (pendingStores.get(key) === p) pendingStores.delete(key);
|
|
551
|
+
});
|
|
552
|
+
pendingStores.set(key, p);
|
|
348
553
|
}
|
|
349
554
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
555
|
+
// Returns a token newer than `usedToken`, refreshing at most once no matter
|
|
556
|
+
// how many requests were rejected at the same time. If someone else already
|
|
557
|
+
// refreshed (the current token isn't the one we used), just use that.
|
|
558
|
+
async function obtainFreshToken(authCfg, tokenUsed, response, info) {
|
|
559
|
+
const current = await authCfg.getToken();
|
|
560
|
+
if (current && current !== tokenUsed) {
|
|
561
|
+
emitEvent({ type: "auth", ...info, outcome: "reused" });
|
|
562
|
+
return current;
|
|
354
563
|
}
|
|
355
|
-
failures++;
|
|
356
|
-
if (failures >= failureThreshold) open();
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function recordSuccess() {
|
|
360
|
-
failures = 0;
|
|
361
|
-
if (state === "half-open") setState("closed");
|
|
362
|
-
}
|
|
363
564
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
565
|
+
const state = refreshStateFor(authCfg);
|
|
566
|
+
if (!state.inflight) {
|
|
567
|
+
state.inflight = (async () => {
|
|
568
|
+
try {
|
|
569
|
+
const out = await authCfg.refresh({ token: tokenUsed, response });
|
|
570
|
+
return typeof out === "string" && out ? out : await authCfg.getToken();
|
|
571
|
+
} finally {
|
|
572
|
+
state.inflight = null;
|
|
573
|
+
}
|
|
574
|
+
})();
|
|
374
575
|
}
|
|
375
|
-
|
|
376
|
-
let result;
|
|
377
576
|
try {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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;
|
|
383
583
|
}
|
|
384
|
-
|
|
385
|
-
if (isFailure(result, false)) recordFailure();
|
|
386
|
-
else recordSuccess();
|
|
387
|
-
return result;
|
|
388
584
|
}
|
|
389
585
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
return current();
|
|
394
|
-
},
|
|
395
|
-
get failures() {
|
|
396
|
-
return failures;
|
|
397
|
-
},
|
|
398
|
-
/** Force the circuit closed and forget the failure count. */
|
|
399
|
-
reset() {
|
|
400
|
-
failures = 0;
|
|
401
|
-
probes = 0;
|
|
402
|
-
setState("closed");
|
|
403
|
-
},
|
|
404
|
-
};
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
module.exports = { createCircuitBreaker, CircuitOpenError };
|
|
408
|
-
|
|
409
|
-
},
|
|
410
|
-
"./createFetch": function (module, exports, require) {
|
|
411
|
-
const { withRetry, RetryAbortedError } = require("./withRetry");
|
|
412
|
-
const { withTimeout } = require("./withTimeout");
|
|
413
|
-
const { withDedupe } = require("./withDedupe");
|
|
414
|
-
const { buildQuery } = require("./buildQuery");
|
|
415
|
-
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
416
|
-
const { normalizeError } = require("./normalizeError");
|
|
417
|
-
const { HTTPError } = require("./HTTPError");
|
|
418
|
-
const { createRateLimiter } = require("./createRateLimiter");
|
|
419
|
-
const { createCircuitBreaker } = require("./createCircuitBreaker");
|
|
420
|
-
const { createMemoryCache } = require("./createMemoryCache");
|
|
421
|
-
const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
|
|
422
|
-
const { discardBody, getOrigin } = require("./_http");
|
|
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;
|
|
423
589
|
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
590
|
+
async function execute(input, init, state) {
|
|
591
|
+
const {
|
|
592
|
+
query,
|
|
593
|
+
json,
|
|
594
|
+
timeout = defaultTimeout,
|
|
595
|
+
retry: retryOpt,
|
|
596
|
+
throwHttpErrors = defaultThrow,
|
|
597
|
+
signal,
|
|
598
|
+
headers: initHeaders,
|
|
599
|
+
cache: cacheMode,
|
|
600
|
+
...fetchInit
|
|
601
|
+
} = init;
|
|
429
602
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
603
|
+
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
604
|
+
const url = joinUrl(baseUrl, input, query);
|
|
605
|
+
state.url = url;
|
|
606
|
+
state.method = method;
|
|
607
|
+
const credentialsAllowed = isTrustedTarget(url);
|
|
433
608
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
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));
|
|
443
618
|
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
619
|
+
let body = fetchInit.body;
|
|
620
|
+
if (json !== undefined) {
|
|
621
|
+
body = JSON.stringify(json);
|
|
622
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
623
|
+
}
|
|
448
624
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
}
|
|
625
|
+
const ctx = { url, init: { ...fetchInit, method, headers, body } };
|
|
626
|
+
if (onRequest) await onRequest(ctx);
|
|
627
|
+
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
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 });
|
|
456
632
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
}
|
|
633
|
+
// --- auth: attach the current token (unless the caller set the header) ----
|
|
634
|
+
const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
|
|
635
|
+
const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
|
|
636
|
+
let usedToken = null;
|
|
637
|
+
if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
|
|
638
|
+
usedToken = (await auth.getToken()) || null;
|
|
639
|
+
if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
|
|
640
|
+
}
|
|
460
641
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
if (!state) {
|
|
471
|
-
state = { inflight: null };
|
|
472
|
-
refreshStates.set(auth, state);
|
|
473
|
-
}
|
|
474
|
-
return state;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function mergeDefaults(base, extra) {
|
|
478
|
-
const merged = { ...base, ...extra };
|
|
479
|
-
|
|
480
|
-
if (base.headers || extra.headers) {
|
|
481
|
-
merged.headers = async () => {
|
|
482
|
-
const a = await resolveHeaders(base.headers);
|
|
483
|
-
const b = await resolveHeaders(extra.headers);
|
|
484
|
-
return mergeHeaders(a, b);
|
|
485
|
-
};
|
|
486
|
-
}
|
|
487
|
-
if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
|
|
488
|
-
merged.retry = { ...base.retry, ...extra.retry };
|
|
489
|
-
}
|
|
490
|
-
return merged;
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
/**
|
|
494
|
-
* Builds a fetch-like function from the other reqkit pieces — only if you
|
|
495
|
-
* want that. It's still plain `fetch` underneath: you get a standard
|
|
496
|
-
* `Response` back, and `fetch` itself stays swappable.
|
|
497
|
-
*
|
|
498
|
-
* What you get on top of fetch:
|
|
499
|
-
* - `baseUrl`, `query` (via buildQuery), `json` request bodies
|
|
500
|
-
* - default headers (an object, or a function for tokens that change)
|
|
501
|
-
* - per-attempt `timeout` (withTimeout) and external `signal`
|
|
502
|
-
* - retries (withRetry) on network errors/timeouts and retryable statuses,
|
|
503
|
-
* honoring Retry-After; only for idempotent methods unless you opt in
|
|
504
|
-
* - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
|
|
505
|
-
* *before* the server has to say 429
|
|
506
|
-
* - `circuitBreaker`: fail fast against a service that keeps failing
|
|
507
|
-
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
|
|
508
|
-
* ETag / Last-Modified revalidation (304s)
|
|
509
|
-
* - concurrent GET de-duplication (withDedupe)
|
|
510
|
-
* - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
|
|
511
|
-
* concurrent requests) and replay the request
|
|
512
|
-
* - credential safety: default credentials (`auth`, and default headers like
|
|
513
|
-
* Authorization/Cookie/X-API-Key) are only sent to `baseUrl`'s origin and
|
|
514
|
-
* `trustedOrigins` — pass an absolute URL to another host and they stay home
|
|
515
|
-
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
516
|
-
* - `onRequest` / `onResponse` hooks
|
|
517
|
-
*
|
|
518
|
-
* @param {object} [defaults]
|
|
519
|
-
* @param {string} [defaults.baseUrl]
|
|
520
|
-
* @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [defaults.headers]
|
|
521
|
-
* @param {number | false} [defaults.timeout=30000] - per attempt, ms
|
|
522
|
-
* @param {false | object} [defaults.retry] - false disables. Options:
|
|
523
|
-
* `retries`(2), `statusCodes`, `methods`, `baseDelayMs`, `maxDelayMs`,
|
|
524
|
-
* `factor`, `jitter`, `maxRetryTime`, `onRetry`, `calculateDelay`
|
|
525
|
-
* @param {boolean | object} [defaults.rateLimit] - `true` just learns from
|
|
526
|
-
* response headers; `{ requests, perMs }` also enforces a budget; or pass a
|
|
527
|
-
* createRateLimiter() instance to share one budget
|
|
528
|
-
* @param {boolean | object | Function} [defaults.circuitBreaker] - options for
|
|
529
|
-
* a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
|
|
530
|
-
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
|
|
531
|
-
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
|
|
532
|
-
* @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
|
|
533
|
-
* `getToken()` supplies the current token (sync or async); on a 401 (or
|
|
534
|
-
* whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
|
|
535
|
-
* called once for all concurrent callers and the request is replayed with the
|
|
536
|
-
* new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
|
|
537
|
-
* @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
|
|
538
|
-
* receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
|
|
539
|
-
* `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
|
|
540
|
-
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
541
|
-
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
542
|
-
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
543
|
-
* @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
|
|
544
|
-
* runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
|
|
545
|
-
* @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
|
|
546
|
-
*/
|
|
547
|
-
function createFetch(defaults = {}) {
|
|
548
|
-
const {
|
|
549
|
-
baseUrl,
|
|
550
|
-
headers: defaultHeaders,
|
|
551
|
-
timeout: defaultTimeout = 30000,
|
|
552
|
-
retry: defaultRetry = {},
|
|
553
|
-
rateLimit,
|
|
554
|
-
circuitBreaker,
|
|
555
|
-
cache,
|
|
556
|
-
auth,
|
|
557
|
-
trustedOrigins,
|
|
558
|
-
dedupe = false,
|
|
559
|
-
throwHttpErrors: defaultThrow = true,
|
|
560
|
-
fetch: customFetch,
|
|
561
|
-
onRequest,
|
|
562
|
-
onResponse,
|
|
563
|
-
} = defaults;
|
|
564
|
-
|
|
565
|
-
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
566
|
-
|
|
567
|
-
// Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
|
|
568
|
-
const trusted = new Set();
|
|
569
|
-
for (const o of [baseUrl, ...(trustedOrigins || [])]) {
|
|
570
|
-
const origin = o && getOrigin(o);
|
|
571
|
-
if (origin) trusted.add(origin);
|
|
572
|
-
}
|
|
573
|
-
const isTrustedTarget = (url) => {
|
|
574
|
-
if (trusted.size === 0 || !isAbsoluteUrl(url)) return true; // relative URLs resolve against our own base
|
|
575
|
-
const origin = getOrigin(url);
|
|
576
|
-
return origin !== null && trusted.has(origin);
|
|
577
|
-
};
|
|
578
|
-
|
|
579
|
-
// --- stateful pieces, built once so `extend()` children share them -------
|
|
580
|
-
const limiter = !rateLimit
|
|
581
|
-
? null
|
|
582
|
-
: typeof rateLimit.acquire === "function"
|
|
583
|
-
? rateLimit
|
|
584
|
-
: createRateLimiter(rateLimit === true ? {} : rateLimit);
|
|
585
|
-
|
|
586
|
-
const breakerRegistry = new Map();
|
|
587
|
-
const breakerFor = !circuitBreaker
|
|
588
|
-
? null
|
|
589
|
-
: typeof circuitBreaker === "function"
|
|
590
|
-
? circuitBreaker
|
|
591
|
-
: typeof circuitBreaker.run === "function"
|
|
592
|
-
? () => circuitBreaker
|
|
593
|
-
: (url) => {
|
|
594
|
-
const origin = originOf(url);
|
|
595
|
-
if (!breakerRegistry.has(origin)) {
|
|
596
|
-
breakerRegistry.set(origin, createCircuitBreaker(circuitBreaker === true ? {} : circuitBreaker));
|
|
597
|
-
}
|
|
598
|
-
return breakerRegistry.get(origin);
|
|
599
|
-
};
|
|
600
|
-
|
|
601
|
-
const cacheCfg = !cache
|
|
602
|
-
? null
|
|
603
|
-
: {
|
|
604
|
-
ttl: 60000,
|
|
605
|
-
staleWhileRevalidate: 0,
|
|
606
|
-
maxEntries: 100,
|
|
607
|
-
maxBodyBytes: undefined,
|
|
608
|
-
...(cache === true ? {} : cache),
|
|
609
|
-
};
|
|
610
|
-
if (cacheCfg && !cacheCfg.store) cacheCfg.store = createMemoryCache({ maxEntries: cacheCfg.maxEntries });
|
|
611
|
-
// In-flight bookkeeping lives on the (shared) cache config so `extend()`
|
|
612
|
-
// children see the parent's pending writes and revalidations.
|
|
613
|
-
if (cacheCfg) {
|
|
614
|
-
cacheCfg.pending = cacheCfg.pending || new Map();
|
|
615
|
-
cacheCfg.revalidating = cacheCfg.revalidating || new Set();
|
|
616
|
-
}
|
|
617
|
-
const pendingStores = cacheCfg ? cacheCfg.pending : null;
|
|
618
|
-
const revalidating = cacheCfg ? cacheCfg.revalidating : null;
|
|
619
|
-
const emit = (type, url) => cacheCfg && cacheCfg.onEvent && cacheCfg.onEvent({ type, url });
|
|
620
|
-
|
|
621
|
-
// Shared in-flight GETs. Every caller gets its own clone of the response so
|
|
622
|
-
// body reads don't fight each other.
|
|
623
|
-
const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
|
|
624
|
-
|
|
625
|
-
function scheduleStore(key, response, url) {
|
|
626
|
-
const p = snapshotResponse(response, cacheCfg.maxBodyBytes)
|
|
627
|
-
.then(async (entry) => {
|
|
628
|
-
if (entry) {
|
|
629
|
-
await cacheCfg.store.set(key, entry);
|
|
630
|
-
emit("store", url);
|
|
631
|
-
}
|
|
632
|
-
})
|
|
633
|
-
.catch(() => {})
|
|
634
|
-
.finally(() => {
|
|
635
|
-
if (pendingStores.get(key) === p) pendingStores.delete(key);
|
|
636
|
-
});
|
|
637
|
-
pendingStores.set(key, p);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
// Returns a token newer than `usedToken`, refreshing at most once no matter
|
|
641
|
-
// how many requests were rejected at the same time. If someone else already
|
|
642
|
-
// refreshed (the current token isn't the one we used), just use that.
|
|
643
|
-
async function obtainFreshToken(authCfg, tokenUsed, response) {
|
|
644
|
-
const current = await authCfg.getToken();
|
|
645
|
-
if (current && current !== tokenUsed) return current;
|
|
646
|
-
|
|
647
|
-
const state = refreshStateFor(authCfg);
|
|
648
|
-
if (!state.inflight) {
|
|
649
|
-
state.inflight = (async () => {
|
|
650
|
-
try {
|
|
651
|
-
const out = await authCfg.refresh({ token: tokenUsed, response });
|
|
652
|
-
return typeof out === "string" && out ? out : await authCfg.getToken();
|
|
653
|
-
} finally {
|
|
654
|
-
state.inflight = null;
|
|
655
|
-
}
|
|
656
|
-
})();
|
|
657
|
-
}
|
|
658
|
-
return state.inflight;
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
async function request(input, init = {}) {
|
|
662
|
-
const {
|
|
663
|
-
query,
|
|
664
|
-
json,
|
|
665
|
-
timeout = defaultTimeout,
|
|
666
|
-
retry: retryOpt,
|
|
667
|
-
throwHttpErrors = defaultThrow,
|
|
668
|
-
signal,
|
|
669
|
-
headers: initHeaders,
|
|
670
|
-
cache: cacheMode,
|
|
671
|
-
...fetchInit
|
|
672
|
-
} = init;
|
|
673
|
-
|
|
674
|
-
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
675
|
-
const url = joinUrl(baseUrl, input, query);
|
|
676
|
-
const credentialsAllowed = isTrustedTarget(url);
|
|
677
|
-
|
|
678
|
-
const defaultHeaderSet = await resolveHeaders(defaultHeaders);
|
|
679
|
-
if (!credentialsAllowed) {
|
|
680
|
-
// An absolute URL to some other host: don't hand it our default credentials.
|
|
681
|
-
for (const name of [...SENSITIVE_HEADERS, ...(auth && auth.header ? [String(auth.header).toLowerCase()] : [])]) {
|
|
682
|
-
defaultHeaderSet.delete(name);
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
// Headers the caller passes for *this* call are deliberate, so they always go through.
|
|
686
|
-
const headers = mergeHeaders(defaultHeaderSet, new Headers(initHeaders || undefined));
|
|
687
|
-
|
|
688
|
-
let body = fetchInit.body;
|
|
689
|
-
if (json !== undefined) {
|
|
690
|
-
body = JSON.stringify(json);
|
|
691
|
-
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const ctx = { url, init: { ...fetchInit, method, headers, body } };
|
|
695
|
-
if (onRequest) await onRequest(ctx);
|
|
696
|
-
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
697
|
-
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
698
|
-
|
|
699
|
-
// --- auth: attach the current token (unless the caller set the header) ----
|
|
700
|
-
const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
|
|
701
|
-
const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
|
|
702
|
-
let usedToken = null;
|
|
703
|
-
if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
|
|
704
|
-
usedToken = (await auth.getToken()) || null;
|
|
705
|
-
if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
// --- retry policy ------------------------------------------------------
|
|
709
|
-
const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
|
|
710
|
-
const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
|
|
711
|
-
const hasIdempotencyKey = finalHeaders.has("idempotency-key");
|
|
712
|
-
const canRetry =
|
|
713
|
-
retryCfg !== false &&
|
|
714
|
-
!isStreamBody(ctx.init.body) && // a consumed stream can't be replayed
|
|
715
|
-
(methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
|
|
716
|
-
const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
|
|
642
|
+
// --- retry policy ------------------------------------------------------
|
|
643
|
+
const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
|
|
644
|
+
const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
|
|
645
|
+
const hasIdempotencyKey = finalHeaders.has("idempotency-key");
|
|
646
|
+
const canRetry =
|
|
647
|
+
retryCfg !== false &&
|
|
648
|
+
!isStreamBody(ctx.init.body) && // a consumed stream can't be replayed
|
|
649
|
+
(methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
|
|
650
|
+
const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
|
|
717
651
|
|
|
718
652
|
// One trip to the network: rate limit → circuit breaker → per-attempt
|
|
719
653
|
// timeout → fetch, retried per the policy above. `netHeaders` and
|
|
720
654
|
// `netSignal` are parameters so background revalidation can reuse this.
|
|
721
655
|
function network(netHeaders, netSignal) {
|
|
722
656
|
const attemptOnce = () => {
|
|
657
|
+
state.attempts++;
|
|
658
|
+
emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
|
|
723
659
|
const attempt = async () => {
|
|
724
|
-
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
|
+
}
|
|
725
664
|
const res = await withTimeout(
|
|
726
665
|
(attemptSignal) => {
|
|
727
666
|
const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
|
|
@@ -753,6 +692,19 @@ function createFetch(defaults = {}) {
|
|
|
753
692
|
...retryRest,
|
|
754
693
|
retries,
|
|
755
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
|
+
},
|
|
756
708
|
shouldRetry:
|
|
757
709
|
customShouldRetry ||
|
|
758
710
|
((err) => {
|
|
@@ -784,6 +736,17 @@ function createFetch(defaults = {}) {
|
|
|
784
736
|
let entry = null;
|
|
785
737
|
|
|
786
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
|
+
});
|
|
787
750
|
if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
|
|
788
751
|
if (throwHttpErrors && !response.ok) {
|
|
789
752
|
throw new HTTPError(response, { method: finalMethod, url: ctx.url });
|
|
@@ -798,10 +761,12 @@ function createFetch(defaults = {}) {
|
|
|
798
761
|
const age = Date.now() - entry.storedAt;
|
|
799
762
|
if (age < cacheCfg.ttl) {
|
|
800
763
|
emit("hit", ctx.url);
|
|
764
|
+
state.cache = "hit";
|
|
801
765
|
return finish(entryToResponse(entry));
|
|
802
766
|
}
|
|
803
767
|
if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
|
|
804
768
|
emit("stale", ctx.url);
|
|
769
|
+
state.cache = "stale";
|
|
805
770
|
if (!revalidating.has(key)) {
|
|
806
771
|
revalidating.add(key);
|
|
807
772
|
const staleEntry = entry;
|
|
@@ -838,13 +803,19 @@ function createFetch(defaults = {}) {
|
|
|
838
803
|
} catch (err) {
|
|
839
804
|
// Present a user abort as the plain AbortError they caused, not as retry internals.
|
|
840
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
|
+
}
|
|
841
812
|
throw err;
|
|
842
813
|
}
|
|
843
814
|
|
|
844
815
|
// --- auth: a rejected token is refreshed once, then the request replays ----
|
|
845
816
|
const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
|
|
846
817
|
if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
|
|
847
|
-
const fresh = await obtainFreshToken(auth, usedToken, response);
|
|
818
|
+
const fresh = await obtainFreshToken(auth, usedToken, response, { url: ctx.url, method: finalMethod });
|
|
848
819
|
if (fresh) {
|
|
849
820
|
await discardBody(response);
|
|
850
821
|
finalHeaders.set(authHeader, formatToken(fresh));
|
|
@@ -859,15 +830,25 @@ function createFetch(defaults = {}) {
|
|
|
859
830
|
}
|
|
860
831
|
}
|
|
861
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
|
+
|
|
862
841
|
if (useCache) {
|
|
863
842
|
if (response.status === 304 && entry) {
|
|
864
843
|
entry.storedAt = Date.now();
|
|
865
844
|
await cacheCfg.store.set(key, entry);
|
|
866
845
|
emit("revalidated", ctx.url);
|
|
846
|
+
state.cache = "revalidated";
|
|
867
847
|
await discardBody(response);
|
|
868
848
|
response = entryToResponse(entry);
|
|
869
849
|
} else {
|
|
870
850
|
emit("miss", ctx.url);
|
|
851
|
+
state.cache = "miss";
|
|
871
852
|
if (response.status === 200) scheduleStore(key, response, ctx.url);
|
|
872
853
|
}
|
|
873
854
|
}
|
|
@@ -875,6 +856,19 @@ function createFetch(defaults = {}) {
|
|
|
875
856
|
return finish(response);
|
|
876
857
|
}
|
|
877
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
|
+
|
|
878
872
|
/** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
|
|
879
873
|
request.baseUrl = baseUrl;
|
|
880
874
|
|
|
@@ -907,1017 +901,992 @@ function createFetch(defaults = {}) {
|
|
|
907
901
|
module.exports = { createFetch, HTTPError };
|
|
908
902
|
|
|
909
903
|
},
|
|
910
|
-
"
|
|
904
|
+
"http/parseJsonSafe": function (module, exports, require) {
|
|
911
905
|
/**
|
|
912
|
-
*
|
|
913
|
-
*
|
|
914
|
-
* time" without pulling in a queue library. (Same idea as p-limit.)
|
|
906
|
+
* Parses a fetch Response (or raw string) as JSON without throwing.
|
|
907
|
+
* Returns { data, error } — exactly one is non-null.
|
|
915
908
|
*
|
|
916
|
-
*
|
|
917
|
-
* const limit = createLimiter(4);
|
|
918
|
-
* const bytes = await Promise.all(urls.map((u) => limit(() => download(u))));
|
|
919
|
-
* // or: await limit.map(urls, (u) => download(u));
|
|
920
|
-
* ```
|
|
921
|
-
*
|
|
922
|
-
* @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
|
|
909
|
+
* @param {Response | string} input
|
|
923
910
|
*/
|
|
924
|
-
function
|
|
925
|
-
|
|
926
|
-
|
|
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 };
|
|
927
917
|
}
|
|
928
918
|
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
function next() {
|
|
933
|
-
while (active < concurrency && queue.length > 0) {
|
|
934
|
-
active++;
|
|
935
|
-
queue.shift()();
|
|
936
|
-
}
|
|
919
|
+
if (!text) {
|
|
920
|
+
return { data: null, error: null };
|
|
937
921
|
}
|
|
938
922
|
|
|
939
|
-
|
|
940
|
-
return
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
.then(() => fn(...args))
|
|
944
|
-
.then(resolve, reject)
|
|
945
|
-
.finally(() => {
|
|
946
|
-
active--;
|
|
947
|
-
next();
|
|
948
|
-
});
|
|
949
|
-
});
|
|
950
|
-
next();
|
|
951
|
-
});
|
|
923
|
+
try {
|
|
924
|
+
return { data: JSON.parse(text), error: null };
|
|
925
|
+
} catch (err) {
|
|
926
|
+
return { data: null, error: err };
|
|
952
927
|
}
|
|
928
|
+
}
|
|
953
929
|
|
|
954
|
-
|
|
955
|
-
activeCount: { get: () => active },
|
|
956
|
-
pendingCount: { get: () => queue.length },
|
|
957
|
-
});
|
|
958
|
-
|
|
959
|
-
/** Runs `fn(item, index)` for every item through the limiter. Results keep input order. */
|
|
960
|
-
limit.map = (items, fn) => Promise.all(Array.from(items, (item, i) => limit(fn, item, i)));
|
|
930
|
+
module.exports = { parseJsonSafe };
|
|
961
931
|
|
|
962
|
-
|
|
963
|
-
|
|
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");
|
|
964
952
|
|
|
965
|
-
module.exports = {
|
|
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
|
+
};
|
|
966
986
|
|
|
967
987
|
},
|
|
968
|
-
"
|
|
988
|
+
"internal/http": function (module, exports, require) {
|
|
969
989
|
/**
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
* instead (Redis, IndexedDB, a Map...).
|
|
973
|
-
*
|
|
974
|
-
* @param {{ maxEntries?: number }} [opts]
|
|
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.
|
|
975
992
|
*/
|
|
976
|
-
function
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
993
|
+
function parseRetryAfter(headerValue) {
|
|
994
|
+
if (headerValue === null || headerValue === undefined) return null;
|
|
995
|
+
const value = String(headerValue).trim();
|
|
996
|
+
if (!value) return null;
|
|
997
|
+
|
|
998
|
+
// delta-seconds (spec says integer; tolerate "1.5" from sloppy servers)
|
|
999
|
+
if (/^\d+(\.\d+)?$/.test(value)) {
|
|
1000
|
+
return Number(value) * 1000;
|
|
980
1001
|
}
|
|
981
|
-
const map = new Map();
|
|
982
1002
|
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
return value;
|
|
990
|
-
},
|
|
991
|
-
set(key, value) {
|
|
992
|
-
map.delete(key);
|
|
993
|
-
map.set(key, value);
|
|
994
|
-
while (map.size > maxEntries) map.delete(map.keys().next().value);
|
|
995
|
-
},
|
|
996
|
-
delete(key) {
|
|
997
|
-
return map.delete(key);
|
|
998
|
-
},
|
|
999
|
-
clear() {
|
|
1000
|
-
map.clear();
|
|
1001
|
-
},
|
|
1002
|
-
get size() {
|
|
1003
|
-
return map.size;
|
|
1004
|
-
},
|
|
1005
|
-
};
|
|
1003
|
+
const dateMs = Date.parse(value);
|
|
1004
|
+
if (!Number.isNaN(dateMs)) {
|
|
1005
|
+
return Math.max(0, dateMs - Date.now());
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
return null;
|
|
1006
1009
|
}
|
|
1007
1010
|
|
|
1008
|
-
|
|
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
|
+
}
|
|
1009
1023
|
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
const
|
|
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;
|
|
1028
|
+
}
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1013
1031
|
|
|
1014
|
-
|
|
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;
|
|
1015
1038
|
|
|
1016
|
-
|
|
1017
|
-
if (
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
1023
|
-
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream) ||
|
|
1024
|
-
body instanceof URLSearchParams
|
|
1025
|
-
) {
|
|
1026
|
-
return body;
|
|
1027
|
-
}
|
|
1028
|
-
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
1029
|
-
return JSON.stringify(body);
|
|
1039
|
+
const retryAfter = parseRetryAfter(headers.get("retry-after"));
|
|
1040
|
+
if (retryAfter !== null) return retryAfter;
|
|
1041
|
+
|
|
1042
|
+
return parseResetMs(
|
|
1043
|
+
readHeader(headers, "x-ratelimit-reset-after", "x-ratelimit-reset", "x-rate-limit-reset", "ratelimit-reset")
|
|
1044
|
+
);
|
|
1030
1045
|
}
|
|
1031
1046
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1047
|
+
/**
|
|
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.
|
|
1051
|
+
*/
|
|
1052
|
+
function getRateLimitInfo(headers) {
|
|
1053
|
+
const info = { remaining: null, resetMs: null, retryAfterMs: null };
|
|
1054
|
+
if (!headers || typeof headers.get !== "function") return info;
|
|
1055
|
+
|
|
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);
|
|
1058
|
+
|
|
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;
|
|
1036
1064
|
}
|
|
1037
1065
|
|
|
1038
|
-
function
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
if (matcher === "*" || matcher === undefined) return true;
|
|
1042
|
-
if (url === matcher) return true;
|
|
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.
|
|
1043
1069
|
try {
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1070
|
+
if (response.body && typeof response.body.cancel === "function" && !response.bodyUsed) {
|
|
1071
|
+
await response.body.cancel();
|
|
1072
|
+
}
|
|
1047
1073
|
} catch {
|
|
1048
|
-
//
|
|
1074
|
+
// locked / already consumed / not a web stream — safe to ignore
|
|
1049
1075
|
}
|
|
1050
|
-
return false;
|
|
1051
1076
|
}
|
|
1052
1077
|
|
|
1053
|
-
/**
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
* mock.on("POST", /\/orders/).fail(mock.networkError());
|
|
1063
|
-
*
|
|
1064
|
-
* const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test" });
|
|
1065
|
-
* await api.json("/users/1"); // → { id: 1 } after one retry
|
|
1066
|
-
* mock.calls.length; // 2
|
|
1067
|
-
* ```
|
|
1068
|
-
*
|
|
1069
|
-
* String matchers match the full URL, `pathname + search`, or the bare
|
|
1070
|
-
* `pathname`; you can also pass a RegExp or a `(url) => boolean`. `"*"` as the
|
|
1071
|
-
* method matches any method. Unmatched requests reject with a clear error.
|
|
1072
|
-
*/
|
|
1073
|
-
function createMockFetch() {
|
|
1074
|
-
let routes = [];
|
|
1075
|
-
const calls = [];
|
|
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;
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1076
1087
|
|
|
1077
|
-
|
|
1078
|
-
const route = { method: String(method).toUpperCase(), matcher, once: [], fallback: null };
|
|
1079
|
-
routes.push(route);
|
|
1080
|
-
|
|
1081
|
-
const chain = {
|
|
1082
|
-
/** Always answer like this (once any `…Once` replies are used up). */
|
|
1083
|
-
reply(status = 200, body, opts) {
|
|
1084
|
-
route.fallback = replyHandler(status, body, opts);
|
|
1085
|
-
return chain;
|
|
1086
|
-
},
|
|
1087
|
-
replyOnce(status = 200, body, opts) {
|
|
1088
|
-
route.once.push(replyHandler(status, body, opts));
|
|
1089
|
-
return chain;
|
|
1090
|
-
},
|
|
1091
|
-
/** `fn(call)` returns a Response, `{ status, body, headers }`, or throws/rejects. */
|
|
1092
|
-
replyWith(fn) {
|
|
1093
|
-
route.fallback = customHandler(fn);
|
|
1094
|
-
return chain;
|
|
1095
|
-
},
|
|
1096
|
-
replyWithOnce(fn) {
|
|
1097
|
-
route.once.push(customHandler(fn));
|
|
1098
|
-
return chain;
|
|
1099
|
-
},
|
|
1100
|
-
/** Reject like fetch does on a network failure. */
|
|
1101
|
-
fail(err = networkError()) {
|
|
1102
|
-
route.fallback = async () => {
|
|
1103
|
-
throw err;
|
|
1104
|
-
};
|
|
1105
|
-
return chain;
|
|
1106
|
-
},
|
|
1107
|
-
failOnce(err = networkError()) {
|
|
1108
|
-
route.once.push(async () => {
|
|
1109
|
-
throw err;
|
|
1110
|
-
});
|
|
1111
|
-
return chain;
|
|
1112
|
-
},
|
|
1113
|
-
};
|
|
1114
|
-
return chain;
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
function replyHandler(status, body, opts = {}) {
|
|
1118
|
-
return async (call) => {
|
|
1119
|
-
if (opts.delayMs) await abortableSleep(opts.delayMs, call.signal);
|
|
1120
|
-
return buildResponse(status, body, opts.headers);
|
|
1121
|
-
};
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
function customHandler(fn) {
|
|
1125
|
-
return async (call) => {
|
|
1126
|
-
const out = await fn(call);
|
|
1127
|
-
if (out instanceof Response) return out;
|
|
1128
|
-
return buildResponse(out.status ?? 200, out.body, out.headers);
|
|
1129
|
-
};
|
|
1130
|
-
}
|
|
1088
|
+
module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody, getOrigin };
|
|
1131
1089
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
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;
|
|
1136
1097
|
}
|
|
1098
|
+
}
|
|
1137
1099
|
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
const call = {
|
|
1150
|
-
url,
|
|
1151
|
-
method,
|
|
1152
|
-
headers,
|
|
1153
|
-
body: init.body,
|
|
1154
|
-
signal,
|
|
1155
|
-
get json() {
|
|
1156
|
-
try {
|
|
1157
|
-
return typeof this.body === "string" ? JSON.parse(this.body) : undefined;
|
|
1158
|
-
} catch {
|
|
1159
|
-
return undefined;
|
|
1160
|
-
}
|
|
1161
|
-
},
|
|
1162
|
-
};
|
|
1163
|
-
calls.push(call);
|
|
1164
|
-
|
|
1165
|
-
const route = routes.find(
|
|
1166
|
-
(r) => (r.method === "*" || r.method === method) && matchesUrl(r.matcher, url) && (r.once.length > 0 || r.fallback)
|
|
1167
|
-
);
|
|
1168
|
-
if (!route) {
|
|
1169
|
-
throw new Error(`createMockFetch: no mock registered for ${method} ${url}`);
|
|
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;
|
|
1170
1110
|
}
|
|
1171
|
-
const
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
patch: (m) => on("PATCH", m),
|
|
1182
|
-
delete: (m) => on("DELETE", m),
|
|
1183
|
-
/** A TypeError("fetch failed") with `cause.code`, like Node's fetch throws. */
|
|
1184
|
-
networkError,
|
|
1185
|
-
/** Every request made so far, in order. */
|
|
1186
|
-
calls,
|
|
1187
|
-
/** Remove all routes and forget recorded calls. */
|
|
1188
|
-
reset() {
|
|
1189
|
-
routes = [];
|
|
1190
|
-
calls.length = 0;
|
|
1191
|
-
},
|
|
1192
|
-
};
|
|
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
|
+
});
|
|
1193
1121
|
}
|
|
1194
1122
|
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
},
|
|
1198
|
-
"./createRateLimiter": function (module, exports, require) {
|
|
1199
|
-
const { abortableSleep } = require("./_sleep");
|
|
1200
|
-
const { getRateLimitInfo } = require("./_http");
|
|
1201
|
-
|
|
1202
|
-
function abortable(promise, signal) {
|
|
1203
|
-
if (!signal) return promise;
|
|
1123
|
+
/** Like sleep(), but rejects with the signal's own reason (an AbortError by default). */
|
|
1124
|
+
function abortableSleep(ms, signal) {
|
|
1204
1125
|
return new Promise((resolve, reject) => {
|
|
1205
|
-
if (signal
|
|
1126
|
+
if (signal?.aborted) {
|
|
1206
1127
|
reject(signal.reason);
|
|
1207
1128
|
return;
|
|
1208
1129
|
}
|
|
1209
|
-
const onAbort = () =>
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
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 });
|
|
1215
1139
|
});
|
|
1216
1140
|
}
|
|
1217
1141
|
|
|
1142
|
+
module.exports = { RetryAbortedError, sleep, abortableSleep };
|
|
1143
|
+
|
|
1144
|
+
},
|
|
1145
|
+
"pagination/paginate": function (module, exports, require) {
|
|
1146
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
1147
|
+
const { getOrigin } = require("../internal/http");
|
|
1148
|
+
|
|
1149
|
+
const MAX_LINK_HEADER_LENGTH = 64 * 1024;
|
|
1150
|
+
|
|
1218
1151
|
/**
|
|
1219
|
-
*
|
|
1220
|
-
*
|
|
1221
|
-
* 1. your own budget — `requests` per `perMs` (sliding window), and
|
|
1222
|
-
* 2. what the server tells you — `update(response)` reads X-RateLimit-* /
|
|
1223
|
-
* RateLimit-* / Retry-After and pauses everything once `remaining` hits 0
|
|
1224
|
-
* (or the server sent Retry-After), until the reset time.
|
|
1225
|
-
*
|
|
1226
|
-
* Callers are served FIFO.
|
|
1152
|
+
* Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
|
|
1153
|
+
* pagination) into `{ rel: url }`. Pass `base` to resolve relative URLs.
|
|
1227
1154
|
*
|
|
1228
1155
|
* ```js
|
|
1229
|
-
*
|
|
1230
|
-
*
|
|
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" }
|
|
1231
1158
|
* ```
|
|
1232
1159
|
*
|
|
1233
|
-
*
|
|
1234
|
-
*
|
|
1235
|
-
*
|
|
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>}
|
|
1236
1167
|
*/
|
|
1237
|
-
function
|
|
1238
|
-
const
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
}
|
|
1242
|
-
if (!(typeof perMs === "number" && perMs > 0 && Number.isFinite(perMs))) {
|
|
1243
|
-
throw new TypeError("createRateLimiter: `perMs` must be a positive number");
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
const stamps = [];
|
|
1247
|
-
let blockedUntil = 0;
|
|
1248
|
-
let tail = Promise.resolve();
|
|
1249
|
-
|
|
1250
|
-
async function take(signal) {
|
|
1251
|
-
for (;;) {
|
|
1252
|
-
signal?.throwIfAborted?.();
|
|
1253
|
-
const now = Date.now();
|
|
1254
|
-
while (stamps.length > 0 && stamps[0] <= now - perMs) stamps.shift();
|
|
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;
|
|
1255
1172
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
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;
|
|
1259
1179
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
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
|
|
1263
1186
|
}
|
|
1264
|
-
|
|
1187
|
+
}
|
|
1188
|
+
for (const name of (rel[1] ?? rel[2]).toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
1189
|
+
if (!(name in links)) links[name] = url;
|
|
1265
1190
|
}
|
|
1266
1191
|
}
|
|
1192
|
+
return links;
|
|
1193
|
+
}
|
|
1267
1194
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
tail = turn.catch(() => {});
|
|
1272
|
-
return abortable(turn, signal);
|
|
1273
|
-
}
|
|
1195
|
+
function stuckError(message) {
|
|
1196
|
+
return Object.assign(new Error(message), { code: "PAGINATE_STUCK" });
|
|
1197
|
+
}
|
|
1274
1198
|
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1199
|
+
/**
|
|
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.
|
|
1204
|
+
*
|
|
1205
|
+
* ```js
|
|
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
|
+
* })) { ... }
|
|
1210
|
+
* ```
|
|
1211
|
+
*
|
|
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
|
|
1217
|
+
* @param {object} [opts]
|
|
1218
|
+
* @param {any} [opts.start] - the cursor for the first page (default undefined)
|
|
1219
|
+
* @param {number} [opts.maxPages=Infinity]
|
|
1220
|
+
* @param {AbortSignal} [opts.signal]
|
|
1221
|
+
* @returns {AsyncGenerator<{ items: T[], index: number, cursor: any }>}
|
|
1222
|
+
*/
|
|
1223
|
+
async function* paginatePages(fetchPage, opts = {}) {
|
|
1224
|
+
const { start, maxPages = Infinity, signal } = opts;
|
|
1225
|
+
let cursor = start;
|
|
1226
|
+
let index = 0;
|
|
1292
1227
|
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
run,
|
|
1297
|
-
/** Ms until the server-imposed pause ends (0 if none). */
|
|
1298
|
-
get blockedForMs() {
|
|
1299
|
-
return Math.max(0, blockedUntil - Date.now());
|
|
1300
|
-
},
|
|
1301
|
-
/** Forget all history and server hints. */
|
|
1302
|
-
reset() {
|
|
1303
|
-
stamps.length = 0;
|
|
1304
|
-
blockedUntil = 0;
|
|
1305
|
-
},
|
|
1306
|
-
};
|
|
1307
|
-
}
|
|
1228
|
+
for (;;) {
|
|
1229
|
+
if (index >= maxPages) return;
|
|
1230
|
+
signal?.throwIfAborted?.();
|
|
1308
1231
|
|
|
1309
|
-
|
|
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 };
|
|
1310
1237
|
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
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
|
+
}
|
|
1318
1247
|
|
|
1319
|
-
|
|
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;
|
|
1320
1266
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
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
|
+
}
|
|
1326
1274
|
}
|
|
1327
1275
|
|
|
1328
|
-
function
|
|
1329
|
-
if (
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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)`");
|
|
1334
1282
|
}
|
|
1335
1283
|
|
|
1336
1284
|
/**
|
|
1337
|
-
*
|
|
1338
|
-
*
|
|
1339
|
-
*
|
|
1340
|
-
* Last-Modified) so a file that changed on the server is restarted rather
|
|
1341
|
-
* than stitched together wrongly. If the server ignores Range, it simply
|
|
1342
|
-
* restarts from zero.
|
|
1343
|
-
*
|
|
1344
|
-
* `retries` counts consecutive attempts *without progress* — a flaky link
|
|
1345
|
-
* that keeps making headway keeps going. Honors Retry-After on 429/503.
|
|
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`).
|
|
1346
1288
|
*
|
|
1347
1289
|
* ```js
|
|
1348
|
-
* const
|
|
1349
|
-
* onProgress: ({ percent }) => console.log(percent),
|
|
1350
|
-
* maxBytes: 200 * 1024 * 1024,
|
|
1351
|
-
* });
|
|
1290
|
+
* for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) { ... }
|
|
1352
1291
|
* ```
|
|
1353
1292
|
*
|
|
1354
|
-
* @param {string}
|
|
1293
|
+
* @param {(url: string, init?: object) => Promise<Response>} request
|
|
1294
|
+
* @param {string} url - the first page
|
|
1355
1295
|
* @param {object} [opts]
|
|
1356
|
-
* @param {
|
|
1357
|
-
* @param {
|
|
1358
|
-
* @param {number} [opts.
|
|
1359
|
-
* @param {number} [opts.baseDelayMs=500]
|
|
1360
|
-
* @param {number} [opts.maxDelayMs=10000]
|
|
1361
|
-
* @param {(p: {loaded: number, total: number | null, percent: number | null}) => void} [opts.onProgress]
|
|
1362
|
-
* @param {number} [opts.maxBytes] - throws BodyTooLargeError past this
|
|
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]
|
|
1363
1299
|
* @param {AbortSignal} [opts.signal]
|
|
1364
|
-
* @param {
|
|
1365
|
-
* @
|
|
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.
|
|
1366
1305
|
*/
|
|
1367
|
-
async function
|
|
1368
|
-
const {
|
|
1369
|
-
fetch: fetchFn = globalThis.fetch,
|
|
1370
|
-
headers,
|
|
1371
|
-
retries = 5,
|
|
1372
|
-
baseDelayMs = 500,
|
|
1373
|
-
maxDelayMs = 10000,
|
|
1374
|
-
onProgress,
|
|
1375
|
-
maxBytes,
|
|
1376
|
-
signal,
|
|
1377
|
-
onRetry,
|
|
1378
|
-
} = opts;
|
|
1379
|
-
|
|
1380
|
-
const chunks = [];
|
|
1381
|
-
let loaded = 0;
|
|
1382
|
-
let total = null;
|
|
1383
|
-
let validator = null;
|
|
1384
|
-
let attempt = 0; // consecutive attempts without progress
|
|
1306
|
+
async function* paginateLinks(request, url, opts = {}) {
|
|
1307
|
+
const { getItems = defaultGetItems, init = {}, signal } = opts;
|
|
1385
1308
|
|
|
1386
|
-
const
|
|
1387
|
-
|
|
1388
|
-
|
|
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);
|
|
1389
1314
|
}
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
const loadedBefore = loaded;
|
|
1315
|
+
} else {
|
|
1316
|
+
const origin = getOrigin(url) || getOrigin(request && request.baseUrl) || getOrigin(globalThis.location && globalThis.location.href);
|
|
1317
|
+
if (origin) allowed.add(origin);
|
|
1318
|
+
}
|
|
1395
1319
|
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
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
|
+
);
|
|
1403
1332
|
}
|
|
1333
|
+
}
|
|
1334
|
+
return { items, next };
|
|
1335
|
+
}
|
|
1404
1336
|
|
|
1405
|
-
|
|
1337
|
+
yield* paginate(fetchPage, { ...opts, start: url });
|
|
1338
|
+
}
|
|
1406
1339
|
|
|
1407
|
-
|
|
1408
|
-
// Our offset no longer fits the file — start over, without burning a retry.
|
|
1409
|
-
await res.body?.cancel?.().catch(() => {});
|
|
1410
|
-
chunks.length = 0;
|
|
1411
|
-
loaded = 0;
|
|
1412
|
-
continue;
|
|
1413
|
-
}
|
|
1414
|
-
if (!res.ok) {
|
|
1415
|
-
await res.body?.cancel?.().catch(() => {});
|
|
1416
|
-
throw new HTTPError(res, { method: "GET", url });
|
|
1417
|
-
}
|
|
1340
|
+
module.exports = { paginate, paginatePages, paginateLinks, parseLinkHeader };
|
|
1418
1341
|
|
|
1419
|
-
|
|
1420
|
-
|
|
1342
|
+
},
|
|
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
|
+
}
|
|
1421
1353
|
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
// bytes don't line up: discard and restart from the beginning.
|
|
1427
|
-
chunks.length = 0;
|
|
1428
|
-
loaded = 0;
|
|
1429
|
-
}
|
|
1430
|
-
}
|
|
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
|
+
}
|
|
1431
1358
|
|
|
1432
|
-
if (loaded === 0) {
|
|
1433
|
-
const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
|
|
1434
|
-
const length = Number(res.headers.get("content-length"));
|
|
1435
|
-
total = encoded ? null : range ? range.total : Number.isFinite(length) && res.headers.has("content-length") ? length : null;
|
|
1436
|
-
const etag = res.headers.get("etag");
|
|
1437
|
-
validator = etag && !etag.startsWith("W/") ? etag : res.headers.get("last-modified");
|
|
1438
|
-
}
|
|
1439
|
-
|
|
1440
|
-
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
1441
|
-
await res.body?.cancel?.().catch(() => {});
|
|
1442
|
-
throw new BodyTooLargeError(maxBytes);
|
|
1443
|
-
}
|
|
1444
|
-
|
|
1445
|
-
if (!res.body || typeof res.body.getReader !== "function") {
|
|
1446
|
-
const buf = new Uint8Array(await res.arrayBuffer());
|
|
1447
|
-
if (maxBytes !== undefined && buf.byteLength > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
1448
|
-
chunks.push(buf);
|
|
1449
|
-
loaded += buf.byteLength;
|
|
1450
|
-
total = total ?? loaded;
|
|
1451
|
-
report();
|
|
1452
|
-
} else {
|
|
1453
|
-
const reader = res.body.getReader();
|
|
1454
|
-
const onAbort = () => reader.cancel(signal.reason).catch(() => {});
|
|
1455
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1456
|
-
let finished = false;
|
|
1457
|
-
try {
|
|
1458
|
-
for (;;) {
|
|
1459
|
-
const { done, value } = await reader.read();
|
|
1460
|
-
signal?.throwIfAborted?.();
|
|
1461
|
-
if (done) break;
|
|
1462
|
-
chunks.push(value);
|
|
1463
|
-
loaded += value.byteLength;
|
|
1464
|
-
if (maxBytes !== undefined && loaded > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
1465
|
-
report();
|
|
1466
|
-
}
|
|
1467
|
-
finished = true;
|
|
1468
|
-
} finally {
|
|
1469
|
-
signal?.removeEventListener("abort", onAbort);
|
|
1470
|
-
if (!finished) reader.cancel().catch(() => {});
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
|
|
1474
|
-
if (total !== null && loaded < total) {
|
|
1475
|
-
// Stream ended early without an error (some servers just close the socket).
|
|
1476
|
-
throw Object.assign(new Error(`Incomplete download: got ${loaded} of ${total} bytes`), { code: "ECONNRESET" });
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
const out = new Uint8Array(loaded);
|
|
1480
|
-
let offset = 0;
|
|
1481
|
-
for (const chunk of chunks) {
|
|
1482
|
-
out.set(chunk, offset);
|
|
1483
|
-
offset += chunk.byteLength;
|
|
1484
|
-
}
|
|
1485
|
-
return out;
|
|
1486
|
-
} catch (err) {
|
|
1487
|
-
if (signal?.aborted) throw signal.reason ?? err;
|
|
1488
|
-
if (!isRetryable(err)) throw err;
|
|
1489
|
-
|
|
1490
|
-
if (loaded > loadedBefore) attempt = 0; // it made progress — doesn't count against the budget
|
|
1491
|
-
if (attempt >= retries) throw err;
|
|
1492
|
-
|
|
1493
|
-
let delayMs = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
|
|
1494
|
-
if (err instanceof HTTPError) {
|
|
1495
|
-
const headerMs = getWaitMsFromHeaders(err.response && err.response.headers);
|
|
1496
|
-
if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
|
|
1497
|
-
}
|
|
1498
|
-
attempt++;
|
|
1499
|
-
if (onRetry) onRetry(err, attempt, delayMs, { resumeFrom: loaded });
|
|
1500
|
-
await abortableSleep(delayMs, signal);
|
|
1501
|
-
}
|
|
1502
|
-
}
|
|
1503
|
-
}
|
|
1504
|
-
|
|
1505
|
-
module.exports = { downloadResumable };
|
|
1506
|
-
|
|
1507
|
-
},
|
|
1508
|
-
"./index": function (module, exports, require) {
|
|
1509
|
-
const { withRetry, RetryAbortedError, StopRetry } = require("./withRetry");
|
|
1510
|
-
const { withTimeout, TimeoutError } = require("./withTimeout");
|
|
1511
|
-
const { normalizeError } = require("./normalizeError");
|
|
1512
|
-
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
1513
|
-
const { buildQuery } = require("./buildQuery");
|
|
1514
|
-
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
|
|
1515
|
-
const { withProgress, BodyTooLargeError } = require("./withProgress");
|
|
1516
|
-
const { withDedupe } = require("./withDedupe");
|
|
1517
|
-
const { createLimiter } = require("./createLimiter");
|
|
1518
|
-
const { createFetch, HTTPError } = require("./createFetch");
|
|
1519
|
-
const { createRateLimiter } = require("./createRateLimiter");
|
|
1520
|
-
const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
|
|
1521
|
-
const { createMemoryCache } = require("./createMemoryCache");
|
|
1522
|
-
const { createMockFetch } = require("./createMockFetch");
|
|
1523
|
-
const { downloadResumable } = require("./downloadResumable");
|
|
1524
|
-
const { readLines, readNDJSON, readSSE } = require("./streams");
|
|
1525
|
-
const { streamSSE } = require("./streamSSE");
|
|
1526
|
-
const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
|
|
1527
|
-
|
|
1528
|
-
module.exports = {
|
|
1529
|
-
withRetry,
|
|
1530
|
-
RetryAbortedError,
|
|
1531
|
-
StopRetry,
|
|
1532
|
-
withTimeout,
|
|
1533
|
-
TimeoutError,
|
|
1534
|
-
normalizeError,
|
|
1535
|
-
parseJsonSafe,
|
|
1536
|
-
buildQuery,
|
|
1537
|
-
retryAfterAware,
|
|
1538
|
-
parseRetryAfter,
|
|
1539
|
-
getWaitMsFromHeaders,
|
|
1540
|
-
withProgress,
|
|
1541
|
-
BodyTooLargeError,
|
|
1542
|
-
withDedupe,
|
|
1543
|
-
createLimiter,
|
|
1544
|
-
createFetch,
|
|
1545
|
-
HTTPError,
|
|
1546
|
-
createRateLimiter,
|
|
1547
|
-
createCircuitBreaker,
|
|
1548
|
-
CircuitOpenError,
|
|
1549
|
-
createMemoryCache,
|
|
1550
|
-
createMockFetch,
|
|
1551
|
-
downloadResumable,
|
|
1552
|
-
readLines,
|
|
1553
|
-
readNDJSON,
|
|
1554
|
-
readSSE,
|
|
1555
|
-
streamSSE,
|
|
1556
|
-
paginate,
|
|
1557
|
-
paginatePages,
|
|
1558
|
-
paginateLinks,
|
|
1559
|
-
parseLinkHeader,
|
|
1560
|
-
};
|
|
1561
|
-
|
|
1562
|
-
},
|
|
1563
|
-
"./normalizeError": function (module, exports, require) {
|
|
1564
1359
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
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.
|
|
1568
1365
|
*
|
|
1569
|
-
*
|
|
1570
|
-
*
|
|
1366
|
+
* By default a thrown error (except AbortError) or a result with status >= 500
|
|
1367
|
+
* counts as a failure; override with `isFailure(valueOrError, isError)`.
|
|
1571
1368
|
*
|
|
1572
|
-
*
|
|
1573
|
-
*
|
|
1574
|
-
*
|
|
1575
|
-
*
|
|
1576
|
-
*
|
|
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]
|
|
1577
1375
|
*/
|
|
1376
|
+
function createCircuitBreaker(opts = {}) {
|
|
1377
|
+
const {
|
|
1378
|
+
failureThreshold = 5,
|
|
1379
|
+
resetTimeoutMs = 30000,
|
|
1380
|
+
halfOpenMax = 1,
|
|
1381
|
+
isFailure = defaultIsFailure,
|
|
1382
|
+
onStateChange,
|
|
1383
|
+
} = opts;
|
|
1578
1384
|
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
code: null,
|
|
1584
|
-
isNetworkError: false,
|
|
1585
|
-
isTimeout: false,
|
|
1586
|
-
isAborted: false,
|
|
1587
|
-
cause: err,
|
|
1588
|
-
...overrides,
|
|
1589
|
-
};
|
|
1590
|
-
}
|
|
1385
|
+
let state = "closed";
|
|
1386
|
+
let failures = 0;
|
|
1387
|
+
let openedAt = 0;
|
|
1388
|
+
let probes = 0;
|
|
1591
1389
|
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
const queue = [err];
|
|
1598
|
-
while (queue.length && seen.size < 10) {
|
|
1599
|
-
const cur = queue.shift();
|
|
1600
|
-
if (!cur || typeof cur !== "object" || seen.has(cur)) continue;
|
|
1601
|
-
seen.add(cur);
|
|
1602
|
-
if (typeof cur.code === "string") return cur.code;
|
|
1603
|
-
queue.push(cur.cause);
|
|
1604
|
-
if (Array.isArray(cur.errors)) queue.push(...cur.errors);
|
|
1390
|
+
function setState(next) {
|
|
1391
|
+
if (next === state) return;
|
|
1392
|
+
const prev = state;
|
|
1393
|
+
state = next;
|
|
1394
|
+
if (onStateChange) onStateChange(next, prev);
|
|
1605
1395
|
}
|
|
1606
|
-
return null;
|
|
1607
|
-
}
|
|
1608
1396
|
|
|
1609
|
-
function
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
function isNetworkCode(code) {
|
|
1614
|
-
if (!code) return false;
|
|
1615
|
-
return /^E[A-Z0-9]+$/.test(code) || code === "EAI_AGAIN" || code === "EAI_FAIL" || code.startsWith("UND_ERR_");
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
// Browsers and Node throw a bare TypeError for network-level fetch failures.
|
|
1619
|
-
const FETCH_NETWORK_MESSAGE =
|
|
1620
|
-
/fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch|^terminated$|^network error$/i;
|
|
1621
|
-
|
|
1622
|
-
function isFetchNetworkTypeError(err) {
|
|
1623
|
-
return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
function normalizeError(err) {
|
|
1627
|
-
if (!err) return shape(err);
|
|
1628
|
-
|
|
1629
|
-
// axios error shape: err.response.{status,data}, err.request, err.code
|
|
1630
|
-
if (err.isAxiosError || (err.response && typeof err.response.status === "number" && err.config)) {
|
|
1631
|
-
return shape(err, {
|
|
1632
|
-
message: err.response?.data?.message || err.message || "Request failed",
|
|
1633
|
-
status: err.response?.status ?? null,
|
|
1634
|
-
code: err.code ?? null,
|
|
1635
|
-
isNetworkError: !err.response && Boolean(err.request),
|
|
1636
|
-
isTimeout: err.code === "ECONNABORTED" || err.code === "ETIMEDOUT" || /timeout/i.test(err.message || ""),
|
|
1637
|
-
isAborted: err.code === "ERR_CANCELED" || err.name === "CanceledError",
|
|
1638
|
-
});
|
|
1397
|
+
function open() {
|
|
1398
|
+
openedAt = Date.now();
|
|
1399
|
+
setState("open");
|
|
1639
1400
|
}
|
|
1640
1401
|
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1402
|
+
function current() {
|
|
1403
|
+
if (state === "open" && Date.now() - openedAt >= resetTimeoutMs) {
|
|
1404
|
+
probes = 0;
|
|
1405
|
+
setState("half-open");
|
|
1406
|
+
}
|
|
1407
|
+
return state;
|
|
1647
1408
|
}
|
|
1648
1409
|
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
code: chainCode || "ETIMEDOUT",
|
|
1657
|
-
isTimeout: true,
|
|
1658
|
-
});
|
|
1410
|
+
function recordFailure() {
|
|
1411
|
+
if (state === "half-open") {
|
|
1412
|
+
open();
|
|
1413
|
+
return;
|
|
1414
|
+
}
|
|
1415
|
+
failures++;
|
|
1416
|
+
if (failures >= failureThreshold) open();
|
|
1659
1417
|
}
|
|
1660
1418
|
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
message: err.message || "Request aborted",
|
|
1665
|
-
code: "ABORT_ERR",
|
|
1666
|
-
isAborted: true,
|
|
1667
|
-
});
|
|
1419
|
+
function recordSuccess() {
|
|
1420
|
+
failures = 0;
|
|
1421
|
+
if (state === "half-open") setState("closed");
|
|
1668
1422
|
}
|
|
1669
1423
|
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
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
|
+
}
|
|
1679
1435
|
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
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
|
+
}
|
|
1449
|
+
|
|
1450
|
+
return {
|
|
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
|
+
},
|
|
1464
|
+
};
|
|
1685
1465
|
}
|
|
1686
1466
|
|
|
1687
|
-
module.exports = {
|
|
1467
|
+
module.exports = { createCircuitBreaker, CircuitOpenError };
|
|
1688
1468
|
|
|
1689
1469
|
},
|
|
1690
|
-
"
|
|
1691
|
-
|
|
1692
|
-
|
|
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;
|
|
1693
1477
|
|
|
1694
|
-
|
|
1478
|
+
function identityOf(value) {
|
|
1479
|
+
let id = identityIds.get(value);
|
|
1480
|
+
if (id === undefined) {
|
|
1481
|
+
id = nextIdentityId++;
|
|
1482
|
+
identityIds.set(value, id);
|
|
1483
|
+
}
|
|
1484
|
+
return id;
|
|
1485
|
+
}
|
|
1695
1486
|
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
* ```js
|
|
1701
|
-
* parseLinkHeader('<https://api.x/items?page=2>; rel="next", <https://api.x/items?page=9>; rel="last"');
|
|
1702
|
-
* // { next: "https://api.x/items?page=2", last: "https://api.x/items?page=9" }
|
|
1703
|
-
* ```
|
|
1704
|
-
*
|
|
1705
|
-
* A link with several rels (`rel="next last"`) is listed under each; the first
|
|
1706
|
-
* link wins when a rel appears twice. Commas inside `<...>` are handled. Input
|
|
1707
|
-
* is treated as untrusted: headers over 64 KB are ignored and parsing is linear-time.
|
|
1708
|
-
*
|
|
1709
|
-
* @param {string | null | undefined} header
|
|
1710
|
-
* @param {string} [base]
|
|
1711
|
-
* @returns {Record<string, string>}
|
|
1712
|
-
*/
|
|
1713
|
-
function parseLinkHeader(header, base) {
|
|
1714
|
-
const links = {};
|
|
1715
|
-
// Link headers are server-controlled; real ones are a few hundred bytes (HTTP stacks cap headers at ~16 KB).
|
|
1716
|
-
if (!header || header.length > MAX_LINK_HEADER_LENGTH) return links;
|
|
1487
|
+
function isPlainObject(value) {
|
|
1488
|
+
const proto = Object.getPrototypeOf(value);
|
|
1489
|
+
return proto === Object.prototype || proto === null;
|
|
1490
|
+
}
|
|
1717
1491
|
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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) };
|
|
1724
1498
|
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
} catch {
|
|
1730
|
-
// leave it as written
|
|
1731
|
-
}
|
|
1732
|
-
}
|
|
1733
|
-
for (const name of (rel[1] ?? rel[2]).toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
1734
|
-
if (!(name in links)) links[name] = url;
|
|
1735
|
-
}
|
|
1736
|
-
}
|
|
1737
|
-
return links;
|
|
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;
|
|
1738
1503
|
}
|
|
1739
1504
|
|
|
1740
|
-
function
|
|
1741
|
-
|
|
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("|");
|
|
1513
|
+
}
|
|
1742
1514
|
}
|
|
1743
1515
|
|
|
1744
1516
|
/**
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
1749
|
-
*
|
|
1750
|
-
* ```js
|
|
1751
|
-
* for await (const { items, index } of paginatePages(async (cursor) => {
|
|
1752
|
-
* const r = await api.json("/users", { query: { cursor, limit: 100 } });
|
|
1753
|
-
* return { items: r.data, next: r.next_cursor };
|
|
1754
|
-
* })) { ... }
|
|
1755
|
-
* ```
|
|
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.
|
|
1756
1522
|
*
|
|
1757
|
-
*
|
|
1758
|
-
*
|
|
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.
|
|
1759
1526
|
*
|
|
1760
|
-
* @
|
|
1761
|
-
* @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
|
|
1527
|
+
* @param {(...args: any[]) => Promise<any>} fn
|
|
1762
1528
|
* @param {object} [opts]
|
|
1763
|
-
* @param {any} [opts.
|
|
1764
|
-
*
|
|
1765
|
-
*
|
|
1766
|
-
*
|
|
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>}
|
|
1767
1535
|
*/
|
|
1768
|
-
|
|
1769
|
-
const {
|
|
1770
|
-
|
|
1771
|
-
let index = 0;
|
|
1536
|
+
function withDedupe(fn, opts = {}) {
|
|
1537
|
+
const { keyFn = defaultKeyFn } = opts;
|
|
1538
|
+
const inFlight = new Map();
|
|
1772
1539
|
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
signal?.throwIfAborted?.();
|
|
1540
|
+
return function deduped(...args) {
|
|
1541
|
+
const key = keyFn(...args);
|
|
1776
1542
|
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
throw new TypeError("paginate: fetchPage must return { items: array, next?: cursor }");
|
|
1543
|
+
if (inFlight.has(key)) {
|
|
1544
|
+
return inFlight.get(key);
|
|
1780
1545
|
}
|
|
1781
|
-
yield { items: result.items, index, cursor };
|
|
1782
1546
|
|
|
1783
|
-
const
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1547
|
+
const promise = Promise.resolve()
|
|
1548
|
+
.then(() => fn(...args))
|
|
1549
|
+
.finally(() => {
|
|
1550
|
+
inFlight.delete(key);
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
inFlight.set(key, promise);
|
|
1554
|
+
return promise;
|
|
1555
|
+
};
|
|
1791
1556
|
}
|
|
1792
1557
|
|
|
1558
|
+
module.exports = { withDedupe };
|
|
1559
|
+
|
|
1560
|
+
},
|
|
1561
|
+
"resilience/limiter": function (module, exports, require) {
|
|
1793
1562
|
/**
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
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.)
|
|
1797
1566
|
*
|
|
1798
1567
|
* ```js
|
|
1799
|
-
*
|
|
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));
|
|
1800
1571
|
* ```
|
|
1801
1572
|
*
|
|
1802
|
-
* @
|
|
1803
|
-
* @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
|
|
1804
|
-
* @param {object} [opts] - same as paginatePages, plus `maxItems`
|
|
1805
|
-
* @param {number} [opts.maxItems=Infinity]
|
|
1806
|
-
* @returns {AsyncGenerator<T>}
|
|
1573
|
+
* @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
|
|
1807
1574
|
*/
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
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
|
+
}
|
|
1811
1579
|
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1580
|
+
const queue = [];
|
|
1581
|
+
let active = 0;
|
|
1582
|
+
|
|
1583
|
+
function next() {
|
|
1584
|
+
while (active < concurrency && queue.length > 0) {
|
|
1585
|
+
active++;
|
|
1586
|
+
queue.shift()();
|
|
1817
1587
|
}
|
|
1818
1588
|
}
|
|
1819
|
-
}
|
|
1820
1589
|
|
|
1821
|
-
function
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
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
|
+
});
|
|
1825
1603
|
}
|
|
1826
|
-
|
|
1604
|
+
|
|
1605
|
+
Object.defineProperties(limit, {
|
|
1606
|
+
activeCount: { get: () => active },
|
|
1607
|
+
pendingCount: { get: () => queue.length },
|
|
1608
|
+
});
|
|
1609
|
+
|
|
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)));
|
|
1612
|
+
|
|
1613
|
+
return limit;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
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;
|
|
1629
|
+
}
|
|
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
|
+
});
|
|
1827
1637
|
}
|
|
1828
1638
|
|
|
1829
1639
|
/**
|
|
1830
|
-
*
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
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.
|
|
1833
1648
|
*
|
|
1834
1649
|
* ```js
|
|
1835
|
-
*
|
|
1650
|
+
* const rl = createRateLimiter({ requests: 10, perMs: 1000 });
|
|
1651
|
+
* const res = await rl.run(() => fetch(url)); // acquire → call → learn from headers
|
|
1836
1652
|
* ```
|
|
1837
1653
|
*
|
|
1838
|
-
* @param {(url: string, init?: object) => Promise<Response>} request
|
|
1839
|
-
* @param {string} url - the first page
|
|
1840
1654
|
* @param {object} [opts]
|
|
1841
|
-
* @param {
|
|
1842
|
-
* @param {number} [opts.
|
|
1843
|
-
* @param {number} [opts.maxItems=Infinity]
|
|
1844
|
-
* @param {AbortSignal} [opts.signal]
|
|
1845
|
-
* @param {object} [opts.init] - extra options passed to every `request`
|
|
1846
|
-
* @param {string[]} [opts.origins] - origins a `Link` may point to. Default: the origin of `url`
|
|
1847
|
-
* (or of the `createFetch` instance's `baseUrl`). A `Link` to anywhere else throws
|
|
1848
|
-
* `code: "PAGINATE_CROSS_ORIGIN"` — a server could otherwise steer your requests (and any
|
|
1849
|
-
* credentials in `init`) to a third party. The check is skipped only when no origin can be determined.
|
|
1655
|
+
* @param {number} [opts.requests=Infinity] - max requests per window
|
|
1656
|
+
* @param {number} [opts.perMs=1000] - window length
|
|
1850
1657
|
*/
|
|
1851
|
-
|
|
1852
|
-
const {
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
if (origin) allowed.add(origin);
|
|
1859
|
-
}
|
|
1860
|
-
} else {
|
|
1861
|
-
const origin = getOrigin(url) || getOrigin(request && request.baseUrl) || getOrigin(globalThis.location && globalThis.location.href);
|
|
1862
|
-
if (origin) allowed.add(origin);
|
|
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");
|
|
1863
1665
|
}
|
|
1864
1666
|
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
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;
|
|
1877
1685
|
}
|
|
1686
|
+
const waitStartedAt = Date.now();
|
|
1687
|
+
await abortableSleep(waitMs, signal);
|
|
1688
|
+
waitedMs += Date.now() - waitStartedAt;
|
|
1878
1689
|
}
|
|
1879
|
-
return { items, next };
|
|
1880
1690
|
}
|
|
1881
1691
|
|
|
1882
|
-
|
|
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);
|
|
1697
|
+
}
|
|
1698
|
+
|
|
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
|
+
}
|
|
1708
|
+
|
|
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;
|
|
1715
|
+
}
|
|
1716
|
+
|
|
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
|
+
};
|
|
1883
1731
|
}
|
|
1884
1732
|
|
|
1885
|
-
module.exports = {
|
|
1733
|
+
module.exports = { createRateLimiter };
|
|
1886
1734
|
|
|
1887
1735
|
},
|
|
1888
|
-
"
|
|
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
|
+
|
|
1889
1747
|
/**
|
|
1890
|
-
*
|
|
1891
|
-
*
|
|
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.
|
|
1892
1766
|
*
|
|
1893
|
-
* @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*.
|
|
1894
1792
|
*/
|
|
1895
|
-
async function
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
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);
|
|
1901
1818
|
}
|
|
1902
1819
|
|
|
1903
|
-
if (
|
|
1904
|
-
|
|
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;
|
|
1905
1843
|
}
|
|
1906
1844
|
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
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;
|
|
1868
|
+
|
|
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);
|
|
1911
1880
|
}
|
|
1912
1881
|
}
|
|
1913
1882
|
|
|
1914
|
-
module.exports = {
|
|
1883
|
+
module.exports = { withRetry, RetryAbortedError, StopRetry };
|
|
1915
1884
|
|
|
1916
1885
|
},
|
|
1917
|
-
"
|
|
1918
|
-
const { withRetry } = require("./
|
|
1919
|
-
const { normalizeError } = require("
|
|
1920
|
-
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");
|
|
1921
1890
|
|
|
1922
1891
|
function defaultShouldRetryError(err) {
|
|
1923
1892
|
const normalized = normalizeError(err);
|
|
@@ -1969,230 +1938,417 @@ function retryAfterAware(fn, opts = {}) {
|
|
|
1969
1938
|
signal,
|
|
1970
1939
|
} = opts;
|
|
1971
1940
|
|
|
1972
|
-
return withRetry(fn, {
|
|
1973
|
-
retries,
|
|
1974
|
-
retryStatusCodes,
|
|
1975
|
-
baseDelayMs: fallbackDelayMs,
|
|
1976
|
-
maxDelayMs,
|
|
1977
|
-
maxRetryAfterMs: maxDelayMs,
|
|
1978
|
-
factor: 2,
|
|
1979
|
-
jitter: 0,
|
|
1980
|
-
shouldRetry: shouldRetryError,
|
|
1981
|
-
onRetry: (err, attempt, waitMs, ctx) => {
|
|
1982
|
-
if (ctx.reason === "result") {
|
|
1983
|
-
if (onRetry) onRetry(ctx.status, waitMs, attempt);
|
|
1984
|
-
} else if (onRetryError) {
|
|
1985
|
-
onRetryError(err, waitMs, attempt);
|
|
1941
|
+
return withRetry(fn, {
|
|
1942
|
+
retries,
|
|
1943
|
+
retryStatusCodes,
|
|
1944
|
+
baseDelayMs: fallbackDelayMs,
|
|
1945
|
+
maxDelayMs,
|
|
1946
|
+
maxRetryAfterMs: maxDelayMs,
|
|
1947
|
+
factor: 2,
|
|
1948
|
+
jitter: 0,
|
|
1949
|
+
shouldRetry: shouldRetryError,
|
|
1950
|
+
onRetry: (err, attempt, waitMs, ctx) => {
|
|
1951
|
+
if (ctx.reason === "result") {
|
|
1952
|
+
if (onRetry) onRetry(ctx.status, waitMs, attempt);
|
|
1953
|
+
} else if (onRetryError) {
|
|
1954
|
+
onRetryError(err, waitMs, attempt);
|
|
1955
|
+
}
|
|
1956
|
+
},
|
|
1957
|
+
signal,
|
|
1958
|
+
});
|
|
1959
|
+
}
|
|
1960
|
+
|
|
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" });
|
|
1986
2201
|
}
|
|
1987
|
-
},
|
|
1988
|
-
signal,
|
|
1989
|
-
});
|
|
1990
|
-
}
|
|
1991
2202
|
|
|
1992
|
-
|
|
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;
|
|
1993
2213
|
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
const { readSSE } = require("./streams");
|
|
1997
|
-
const { HTTPError } = require("./HTTPError");
|
|
1998
|
-
const { normalizeError } = require("./normalizeError");
|
|
1999
|
-
const { getWaitMsFromHeaders, discardBody } = require("./_http");
|
|
2000
|
-
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;
|
|
2001
2216
|
|
|
2002
|
-
|
|
2003
|
-
|
|
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
|
+
}
|
|
2004
2227
|
}
|
|
2005
2228
|
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
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
|
+
}
|
|
2009
2240
|
}
|
|
2010
2241
|
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
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
|
+
}
|
|
2014
2249
|
}
|
|
2015
2250
|
|
|
2016
2251
|
/**
|
|
2017
|
-
*
|
|
2018
|
-
*
|
|
2019
|
-
*
|
|
2020
|
-
*
|
|
2021
|
-
* POST bodies, AbortSignal, your own retry/backoff rules.
|
|
2022
|
-
*
|
|
2023
|
-
* ```js
|
|
2024
|
-
* for await (const { data } of streamSSE("https://api.example.com/events", { signal })) {
|
|
2025
|
-
* console.log(data);
|
|
2026
|
-
* }
|
|
2027
|
-
* ```
|
|
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.
|
|
2028
2256
|
*
|
|
2029
|
-
*
|
|
2030
|
-
*
|
|
2031
|
-
*
|
|
2032
|
-
*
|
|
2033
|
-
* failing; 429/503 `Retry-After` is honored. A connection that delivered at
|
|
2034
|
-
* least one event resets the failure count (only no-progress attempts count
|
|
2035
|
-
* toward `retries`).
|
|
2036
|
-
* - HTTP 204 means "stop reconnecting" (iteration ends). A non-retryable
|
|
2037
|
-
* status (e.g. 401/404) or a wrong content-type throws immediately.
|
|
2038
|
-
* - Only GET reconnects by default. For other methods (e.g. a POST that starts
|
|
2039
|
-
* an LLM stream) re-sending the request could repeat side effects, so
|
|
2040
|
-
* nothing is retried unless you pass `reconnect: true`.
|
|
2041
|
-
* - 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.
|
|
2042
2261
|
*
|
|
2043
|
-
* @param {
|
|
2262
|
+
* @param {Response} response
|
|
2263
|
+
* @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} [onProgress]
|
|
2044
2264
|
* @param {object} [opts]
|
|
2045
|
-
* @param {
|
|
2046
|
-
*
|
|
2047
|
-
* @param {
|
|
2048
|
-
*
|
|
2049
|
-
* @
|
|
2050
|
-
* @param {AbortSignal} [opts.signal]
|
|
2051
|
-
* @param {string} [opts.lastEventId] - resume from here on the very first connect
|
|
2052
|
-
* @param {boolean} [opts.reconnect] - default: true for GET/HEAD, false otherwise
|
|
2053
|
-
* @param {number} [opts.retries=10] - consecutive failed connections before giving up (Infinity = never)
|
|
2054
|
-
* @param {number} [opts.reconnectDelayMs=1000] - base delay (the server's `retry:` overrides it)
|
|
2055
|
-
* @param {number} [opts.maxDelayMs=30000]
|
|
2056
|
-
* @param {(event: object) => boolean} [opts.stopWhen] - end the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`
|
|
2057
|
-
* @param {(info: {connections: number, lastEventId: string}) => void} [opts.onOpen]
|
|
2058
|
-
* @param {(info: {reason: "error"|"end"|"status", error?: any, status?: number, attempt: number, delayMs: number, lastEventId: string}) => void} [opts.onReconnect]
|
|
2059
|
-
* @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>}
|
|
2060
2270
|
*/
|
|
2061
|
-
async function
|
|
2062
|
-
const {
|
|
2063
|
-
|
|
2064
|
-
method = "GET",
|
|
2065
|
-
headers,
|
|
2066
|
-
body,
|
|
2067
|
-
signal,
|
|
2068
|
-
reconnect = method === "GET" || method === "HEAD",
|
|
2069
|
-
retries = 10,
|
|
2070
|
-
reconnectDelayMs = 1000,
|
|
2071
|
-
maxDelayMs = 30000,
|
|
2072
|
-
stopWhen,
|
|
2073
|
-
onOpen,
|
|
2074
|
-
onReconnect,
|
|
2075
|
-
} = opts;
|
|
2076
|
-
|
|
2077
|
-
let lastId = opts.lastEventId || "";
|
|
2078
|
-
let baseDelay = reconnectDelayMs;
|
|
2079
|
-
let failures = 0; // consecutive connections that delivered nothing
|
|
2080
|
-
let connections = 0;
|
|
2271
|
+
async function withProgress(response, onProgress, opts = {}) {
|
|
2272
|
+
const { maxBytes, signal } = opts;
|
|
2273
|
+
signal?.throwIfAborted?.();
|
|
2081
2274
|
|
|
2082
|
-
|
|
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());
|
|
2083
2279
|
signal?.throwIfAborted?.();
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
// ---- connect -----------------------------------------------------------
|
|
2088
|
-
try {
|
|
2089
|
-
const h = await resolveHeaders(headers);
|
|
2090
|
-
if (!h.has("accept")) h.set("accept", "text/event-stream");
|
|
2091
|
-
if (!h.has("cache-control")) h.set("cache-control", "no-cache");
|
|
2092
|
-
if (lastId) h.set("last-event-id", lastId);
|
|
2093
|
-
res = await fetchFn(url, { method, headers: h, body, signal });
|
|
2094
|
-
} catch (err) {
|
|
2095
|
-
if (signal?.aborted) throw signal.reason ?? err;
|
|
2096
|
-
if (err instanceof HTTPError) {
|
|
2097
|
-
if (!isRetryableStatus(err.status)) throw err;
|
|
2098
|
-
failure = { reason: "status", status: err.status, response: err.response, error: err };
|
|
2099
|
-
} else if (isRetryableError(err)) {
|
|
2100
|
-
failure = { reason: "error", error: err };
|
|
2101
|
-
} else {
|
|
2102
|
-
throw err;
|
|
2103
|
-
}
|
|
2280
|
+
if (maxBytes !== undefined && buf.byteLength > maxBytes) {
|
|
2281
|
+
throw new BodyTooLargeError(maxBytes);
|
|
2104
2282
|
}
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
if (!failure) {
|
|
2108
|
-
if (res.status === 204) {
|
|
2109
|
-
await discardBody(res);
|
|
2110
|
-
return; // the server's way of saying "don't reconnect"
|
|
2111
|
-
}
|
|
2112
|
-
if (!res.ok) {
|
|
2113
|
-
if (!isRetryableStatus(res.status)) {
|
|
2114
|
-
await discardBody(res);
|
|
2115
|
-
throw new HTTPError(res, { method, url });
|
|
2116
|
-
}
|
|
2117
|
-
failure = { reason: "status", status: res.status, response: res };
|
|
2118
|
-
} else {
|
|
2119
|
-
const type = res.headers.get("content-type") || "";
|
|
2120
|
-
if (!/text\/event-stream/i.test(type)) {
|
|
2121
|
-
await discardBody(res);
|
|
2122
|
-
throw Object.assign(new Error(`streamSSE: expected "text/event-stream" but the server sent "${type || "no content-type"}"`), {
|
|
2123
|
-
code: "SSE_BAD_CONTENT_TYPE",
|
|
2124
|
-
});
|
|
2125
|
-
}
|
|
2126
|
-
}
|
|
2283
|
+
if (onProgress) {
|
|
2284
|
+
onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
|
|
2127
2285
|
}
|
|
2286
|
+
return buf;
|
|
2287
|
+
}
|
|
2128
2288
|
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
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;
|
|
2294
|
+
|
|
2295
|
+
const reader = response.body.getReader();
|
|
2296
|
+
|
|
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
|
+
}
|
|
2302
|
+
|
|
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;
|
|
2149
2320
|
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
if (failure.reason === "end") return;
|
|
2153
|
-
if (failure.reason === "status") {
|
|
2154
|
-
throw failure.error || new HTTPError(failure.response, { method, url });
|
|
2321
|
+
if (maxBytes !== undefined && loaded > maxBytes) {
|
|
2322
|
+
throw new BodyTooLargeError(maxBytes);
|
|
2155
2323
|
}
|
|
2156
|
-
throw failure.error;
|
|
2157
|
-
}
|
|
2158
|
-
|
|
2159
|
-
// A connection that delivered events made progress: reconnect promptly and
|
|
2160
|
-
// start the failure count over. Only no-progress attempts count toward `retries`.
|
|
2161
|
-
failures = gotEvent ? 0 : failures + 1;
|
|
2162
|
-
if (failures > retries) {
|
|
2163
|
-
if (failure.reason === "status") throw failure.error || new HTTPError(failure.response, { method, url });
|
|
2164
|
-
if (failure.reason === "error") throw failure.error;
|
|
2165
|
-
throw Object.assign(new Error(`streamSSE: the server keeps closing the stream without sending events — gave up after ${retries} retries`), {
|
|
2166
|
-
code: "SSE_GAVE_UP",
|
|
2167
|
-
});
|
|
2168
|
-
}
|
|
2169
2324
|
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2325
|
+
if (onProgress) {
|
|
2326
|
+
onProgress({
|
|
2327
|
+
loaded,
|
|
2328
|
+
total,
|
|
2329
|
+
percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null,
|
|
2330
|
+
});
|
|
2331
|
+
}
|
|
2175
2332
|
}
|
|
2176
|
-
|
|
2333
|
+
finished = true;
|
|
2334
|
+
} finally {
|
|
2335
|
+
signal?.removeEventListener("abort", onAbort);
|
|
2336
|
+
if (!finished) cancelQuietly(reader);
|
|
2337
|
+
}
|
|
2177
2338
|
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
attempt: Math.max(failures, 1),
|
|
2184
|
-
delayMs,
|
|
2185
|
-
lastEventId: lastId,
|
|
2186
|
-
});
|
|
2187
|
-
}
|
|
2188
|
-
await abortableSleep(delayMs, signal);
|
|
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;
|
|
2189
2344
|
}
|
|
2345
|
+
return result;
|
|
2190
2346
|
}
|
|
2191
2347
|
|
|
2192
|
-
module.exports = {
|
|
2348
|
+
module.exports = { withProgress, BodyTooLargeError };
|
|
2193
2349
|
|
|
2194
2350
|
},
|
|
2195
|
-
"
|
|
2351
|
+
"streams/readers": function (module, exports, require) {
|
|
2196
2352
|
const encoder = new TextEncoder();
|
|
2197
2353
|
|
|
2198
2354
|
// Yields Uint8Array chunks from a fetch Response, a web ReadableStream, or any
|
|
@@ -2380,457 +2536,423 @@ async function* readSSE(source, opts = {}) {
|
|
|
2380
2536
|
else if (field === "id") {
|
|
2381
2537
|
if (!value.includes("\0")) lastId = value;
|
|
2382
2538
|
} else if (field === "retry") {
|
|
2383
|
-
if (/^\d+$/.test(value)) retry = Number(value);
|
|
2384
|
-
}
|
|
2385
|
-
}
|
|
2386
|
-
}
|
|
2387
|
-
|
|
2388
|
-
module.exports = { readLines, readNDJSON, readSSE };
|
|
2389
|
-
|
|
2390
|
-
},
|
|
2391
|
-
"./withDedupe": function (module, exports, require) {
|
|
2392
|
-
// Non-plain objects (AbortSignal, Response, class instances, functions...)
|
|
2393
|
-
// can't be meaningfully JSON-serialized — JSON.stringify turns every
|
|
2394
|
-
// AbortSignal into "{}", which would make unrelated calls share one request.
|
|
2395
|
-
// Give them a stable identity-based id instead.
|
|
2396
|
-
const identityIds = new WeakMap();
|
|
2397
|
-
let nextIdentityId = 1;
|
|
2398
|
-
|
|
2399
|
-
function identityOf(value) {
|
|
2400
|
-
let id = identityIds.get(value);
|
|
2401
|
-
if (id === undefined) {
|
|
2402
|
-
id = nextIdentityId++;
|
|
2403
|
-
identityIds.set(value, id);
|
|
2404
|
-
}
|
|
2405
|
-
return id;
|
|
2406
|
-
}
|
|
2407
|
-
|
|
2408
|
-
function isPlainObject(value) {
|
|
2409
|
-
const proto = Object.getPrototypeOf(value);
|
|
2410
|
-
return proto === Object.prototype || proto === null;
|
|
2411
|
-
}
|
|
2412
|
-
|
|
2413
|
-
function keyReplacer(_key, value) {
|
|
2414
|
-
if (typeof value === "bigint") return { $bigint: String(value) };
|
|
2415
|
-
if (typeof value === "function") return { $ref: identityOf(value) };
|
|
2416
|
-
if (value === null || typeof value !== "object") return value;
|
|
2417
|
-
if (Array.isArray(value)) return value;
|
|
2418
|
-
if (!isPlainObject(value)) return { $ref: identityOf(value) };
|
|
2419
|
-
|
|
2420
|
-
// Sort plain-object keys so {a:1,b:2} and {b:2,a:1} dedupe together.
|
|
2421
|
-
const sorted = {};
|
|
2422
|
-
for (const k of Object.keys(value).sort()) sorted[k] = value[k];
|
|
2423
|
-
return sorted;
|
|
2424
|
-
}
|
|
2425
|
-
|
|
2426
|
-
function defaultKeyFn(...args) {
|
|
2427
|
-
try {
|
|
2428
|
-
return JSON.stringify(args, keyReplacer);
|
|
2429
|
-
} catch {
|
|
2430
|
-
// Circular structures etc. Fall back to identity for objects.
|
|
2431
|
-
return args
|
|
2432
|
-
.map((a) => ((a && typeof a === "object") || typeof a === "function" ? `#${identityOf(a)}` : String(a)))
|
|
2433
|
-
.join("|");
|
|
2434
|
-
}
|
|
2435
|
-
}
|
|
2436
|
-
|
|
2437
|
-
/**
|
|
2438
|
-
* Wraps an async function so that concurrent calls with the same key share
|
|
2439
|
-
* a single in-flight promise instead of firing duplicate requests. Common
|
|
2440
|
-
* use case: several parts of a UI ask for the same resource at once (e.g. a
|
|
2441
|
-
* user profile) — only one network request actually goes out, and every
|
|
2442
|
-
* caller resolves with the same result.
|
|
2443
|
-
*
|
|
2444
|
-
* The in-flight entry is cleared as soon as the call settles (success or
|
|
2445
|
-
* failure), so the next call — even with the same key — always starts a
|
|
2446
|
-
* fresh request. This is deduplication of concurrent calls, not a cache.
|
|
2447
|
-
*
|
|
2448
|
-
* @param {(...args: any[]) => Promise<any>} fn
|
|
2449
|
-
* @param {object} [opts]
|
|
2450
|
-
* @param {(...args: any[]) => string} [opts.keyFn] - derives a dedupe key
|
|
2451
|
-
* from the call arguments. By default plain data is compared by value
|
|
2452
|
-
* (object key order doesn't matter) and anything else — AbortSignals,
|
|
2453
|
-
* Responses, class instances, functions — by identity, so two calls with
|
|
2454
|
-
* different AbortSignals are never merged.
|
|
2455
|
-
* @returns {(...args: any[]) => Promise<any>}
|
|
2456
|
-
*/
|
|
2457
|
-
function withDedupe(fn, opts = {}) {
|
|
2458
|
-
const { keyFn = defaultKeyFn } = opts;
|
|
2459
|
-
const inFlight = new Map();
|
|
2460
|
-
|
|
2461
|
-
return function deduped(...args) {
|
|
2462
|
-
const key = keyFn(...args);
|
|
2463
|
-
|
|
2464
|
-
if (inFlight.has(key)) {
|
|
2465
|
-
return inFlight.get(key);
|
|
2466
|
-
}
|
|
2467
|
-
|
|
2468
|
-
const promise = Promise.resolve()
|
|
2469
|
-
.then(() => fn(...args))
|
|
2470
|
-
.finally(() => {
|
|
2471
|
-
inFlight.delete(key);
|
|
2472
|
-
});
|
|
2473
|
-
|
|
2474
|
-
inFlight.set(key, promise);
|
|
2475
|
-
return promise;
|
|
2476
|
-
};
|
|
2477
|
-
}
|
|
2478
|
-
|
|
2479
|
-
module.exports = { withDedupe };
|
|
2480
|
-
|
|
2481
|
-
},
|
|
2482
|
-
"./withProgress": function (module, exports, require) {
|
|
2483
|
-
class BodyTooLargeError extends Error {
|
|
2484
|
-
constructor(maxBytes) {
|
|
2485
|
-
super(`Response body exceeded the ${maxBytes}-byte limit`);
|
|
2486
|
-
this.name = "BodyTooLargeError";
|
|
2487
|
-
this.code = "EBODYTOOLARGE";
|
|
2488
|
-
this.maxBytes = maxBytes;
|
|
2489
|
-
}
|
|
2490
|
-
}
|
|
2491
|
-
|
|
2492
|
-
function cancelQuietly(reader) {
|
|
2493
|
-
try {
|
|
2494
|
-
const p = reader.cancel();
|
|
2495
|
-
if (p && typeof p.catch === "function") p.catch(() => {});
|
|
2496
|
-
} catch {
|
|
2497
|
-
// already closed / released — nothing to do
|
|
2498
|
-
}
|
|
2499
|
-
}
|
|
2500
|
-
|
|
2501
|
-
/**
|
|
2502
|
-
* Reads a fetch Response body while reporting progress, and returns the
|
|
2503
|
-
* fully collected bytes as a Uint8Array. Works for any streamed response,
|
|
2504
|
-
* not just file downloads — same shape whether it's a media file, a large
|
|
2505
|
-
* JSON payload, or anything else worth showing progress for.
|
|
2506
|
-
*
|
|
2507
|
-
* `total` is null (and `percent` with it) when the size isn't trustworthy:
|
|
2508
|
-
* no Content-Length, or a Content-Encoding is applied (Content-Length is then
|
|
2509
|
-
* the *compressed* size while fetch hands you *decompressed* bytes, so
|
|
2510
|
-
* loaded could exceed total). `percent` is always clamped to 0–100.
|
|
2511
|
-
*
|
|
2512
|
-
* @param {Response} response
|
|
2513
|
-
* @param {(progress: { loaded: number, total: number | null, percent: number | null }) => void} [onProgress]
|
|
2514
|
-
* @param {object} [opts]
|
|
2515
|
-
* @param {number} [opts.maxBytes] - abort with BodyTooLargeError once more
|
|
2516
|
-
* than this many bytes have been received
|
|
2517
|
-
* @param {AbortSignal} [opts.signal] - cancels the read; rejects with the
|
|
2518
|
-
* signal's reason (an AbortError by default)
|
|
2519
|
-
* @returns {Promise<Uint8Array>}
|
|
2520
|
-
*/
|
|
2521
|
-
async function withProgress(response, onProgress, opts = {}) {
|
|
2522
|
-
const { maxBytes, signal } = opts;
|
|
2523
|
-
signal?.throwIfAborted?.();
|
|
2524
|
-
|
|
2525
|
-
if (!response.body || typeof response.body.getReader !== "function") {
|
|
2526
|
-
// No streaming support available — fall back to a single-shot read,
|
|
2527
|
-
// still reporting one final progress event so callers don't need a branch.
|
|
2528
|
-
const buf = new Uint8Array(await response.arrayBuffer());
|
|
2529
|
-
signal?.throwIfAborted?.();
|
|
2530
|
-
if (maxBytes !== undefined && buf.byteLength > maxBytes) {
|
|
2531
|
-
throw new BodyTooLargeError(maxBytes);
|
|
2532
|
-
}
|
|
2533
|
-
if (onProgress) {
|
|
2534
|
-
onProgress({ loaded: buf.byteLength, total: buf.byteLength, percent: 100 });
|
|
2535
|
-
}
|
|
2536
|
-
return buf;
|
|
2537
|
-
}
|
|
2538
|
-
|
|
2539
|
-
const encoding = response.headers?.get?.("content-encoding");
|
|
2540
|
-
const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
|
|
2541
|
-
const contentLength = response.headers?.get?.("content-length");
|
|
2542
|
-
const parsed = contentLength ? Number(contentLength) : NaN;
|
|
2543
|
-
const total = !encoded && Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
|
|
2544
|
-
|
|
2545
|
-
const reader = response.body.getReader();
|
|
2546
|
-
|
|
2547
|
-
// Fail fast when the declared size already exceeds the cap.
|
|
2548
|
-
if (maxBytes !== undefined && total !== null && total > maxBytes) {
|
|
2549
|
-
cancelQuietly(reader);
|
|
2550
|
-
throw new BodyTooLargeError(maxBytes);
|
|
2551
|
-
}
|
|
2552
|
-
|
|
2553
|
-
const onAbort = () => cancelQuietly(reader);
|
|
2554
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
2555
|
-
|
|
2556
|
-
const chunks = [];
|
|
2557
|
-
let loaded = 0;
|
|
2558
|
-
let finished = false;
|
|
2559
|
-
|
|
2560
|
-
try {
|
|
2561
|
-
for (;;) {
|
|
2562
|
-
const { done, value } = await reader.read();
|
|
2563
|
-
// reader.cancel() on abort makes read() resolve as done — check the
|
|
2564
|
-
// signal first so an aborted read never looks like a complete one.
|
|
2565
|
-
signal?.throwIfAborted?.();
|
|
2566
|
-
if (done) break;
|
|
2567
|
-
|
|
2568
|
-
chunks.push(value);
|
|
2569
|
-
loaded += value.byteLength;
|
|
2570
|
-
|
|
2571
|
-
if (maxBytes !== undefined && loaded > maxBytes) {
|
|
2572
|
-
throw new BodyTooLargeError(maxBytes);
|
|
2573
|
-
}
|
|
2574
|
-
|
|
2575
|
-
if (onProgress) {
|
|
2576
|
-
onProgress({
|
|
2577
|
-
loaded,
|
|
2578
|
-
total,
|
|
2579
|
-
percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null,
|
|
2580
|
-
});
|
|
2581
|
-
}
|
|
2539
|
+
if (/^\d+$/.test(value)) retry = Number(value);
|
|
2582
2540
|
}
|
|
2583
|
-
finished = true;
|
|
2584
|
-
} finally {
|
|
2585
|
-
signal?.removeEventListener("abort", onAbort);
|
|
2586
|
-
if (!finished) cancelQuietly(reader);
|
|
2587
|
-
}
|
|
2588
|
-
|
|
2589
|
-
const result = new Uint8Array(loaded);
|
|
2590
|
-
let offset = 0;
|
|
2591
|
-
for (const chunk of chunks) {
|
|
2592
|
-
result.set(chunk, offset);
|
|
2593
|
-
offset += chunk.byteLength;
|
|
2594
2541
|
}
|
|
2595
|
-
return result;
|
|
2596
2542
|
}
|
|
2597
2543
|
|
|
2598
|
-
module.exports = {
|
|
2544
|
+
module.exports = { readLines, readNDJSON, readSSE };
|
|
2599
2545
|
|
|
2600
2546
|
},
|
|
2601
|
-
"
|
|
2602
|
-
const {
|
|
2603
|
-
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");
|
|
2604
2553
|
|
|
2605
|
-
function
|
|
2606
|
-
|
|
2607
|
-
// (e.g. network errors only). HTTP *statuses* aren't errors with fetch, so
|
|
2608
|
-
// opt in with `retryStatusCodes` / `shouldRetryResult`.
|
|
2609
|
-
return Boolean(err) && err.name !== "AbortError";
|
|
2554
|
+
function isRetryableStatus(status) {
|
|
2555
|
+
return status === 408 || status === 425 || status === 429 || status >= 500;
|
|
2610
2556
|
}
|
|
2611
2557
|
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
super(isError ? reasonOrMessage.message : String(reasonOrMessage ?? "Retrying was stopped"));
|
|
2621
|
-
this.name = "StopRetry";
|
|
2622
|
-
this.original = isError ? reasonOrMessage : null;
|
|
2623
|
-
}
|
|
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);
|
|
2624
2566
|
}
|
|
2625
2567
|
|
|
2626
2568
|
/**
|
|
2627
|
-
*
|
|
2628
|
-
*
|
|
2629
|
-
*
|
|
2630
|
-
* 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.
|
|
2631
2574
|
*
|
|
2632
|
-
*
|
|
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
|
|
2633
2596
|
* @param {object} [opts]
|
|
2634
|
-
* @param {
|
|
2635
|
-
* @param {
|
|
2636
|
-
* @param {
|
|
2637
|
-
*
|
|
2638
|
-
* @param {
|
|
2639
|
-
*
|
|
2640
|
-
*
|
|
2641
|
-
* @param {
|
|
2642
|
-
*
|
|
2643
|
-
*
|
|
2644
|
-
* @param {
|
|
2645
|
-
* @param {
|
|
2646
|
-
* @param {(
|
|
2647
|
-
* @param {
|
|
2648
|
-
* @
|
|
2649
|
-
* @param {(info: {attempt: number, error: any, result: any, delayMs: number}) => number|false} [opts.calculateDelay]
|
|
2650
|
-
* - override the wait; return a number of ms, or false to stop retrying
|
|
2651
|
-
* @param {(err: any, attempt: number, delayMs: number, ctx: {reason: "error"|"result", result?: any, status?: number}) => void} [opts.onRetry]
|
|
2652
|
-
* - `err` is undefined when the retry was triggered by a result
|
|
2653
|
-
* @param {AbortSignal} [opts.signal] - cancels the whole retry loop,
|
|
2654
|
-
* including any pending backoff wait, throwing RetryAbortedError (with the
|
|
2655
|
-
* last failure as `cause`). Separate from a per-call signal you pass into
|
|
2656
|
-
* `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}>}
|
|
2657
2612
|
*/
|
|
2658
|
-
async function
|
|
2613
|
+
async function* streamSSE(url, opts = {}) {
|
|
2659
2614
|
const {
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
jitter = 0.2,
|
|
2665
|
-
maxRetryTime = Infinity,
|
|
2666
|
-
shouldRetry = defaultShouldRetry,
|
|
2667
|
-
retryStatusCodes = [],
|
|
2668
|
-
shouldRetryResult,
|
|
2669
|
-
respectRetryAfter = true,
|
|
2670
|
-
maxRetryAfterMs = 60000,
|
|
2671
|
-
calculateDelay,
|
|
2672
|
-
onRetry,
|
|
2615
|
+
fetch: fetchFn = globalThis.fetch,
|
|
2616
|
+
method = "GET",
|
|
2617
|
+
headers,
|
|
2618
|
+
body,
|
|
2673
2619
|
signal,
|
|
2620
|
+
reconnect = method === "GET" || method === "HEAD",
|
|
2621
|
+
retries = 10,
|
|
2622
|
+
reconnectDelayMs = 1000,
|
|
2623
|
+
maxDelayMs = 30000,
|
|
2624
|
+
stopWhen,
|
|
2625
|
+
onOpen,
|
|
2626
|
+
onReconnect,
|
|
2674
2627
|
} = opts;
|
|
2675
2628
|
|
|
2676
|
-
|
|
2629
|
+
let lastId = opts.lastEventId || "";
|
|
2630
|
+
let baseDelay = reconnectDelayMs;
|
|
2631
|
+
let failures = 0; // consecutive connections that delivered nothing
|
|
2632
|
+
let connections = 0;
|
|
2677
2633
|
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
return Math.round(exponential + extra);
|
|
2683
|
-
}
|
|
2634
|
+
for (;;) {
|
|
2635
|
+
signal?.throwIfAborted?.();
|
|
2636
|
+
let failure = null; // { reason, error?, status?, response? }
|
|
2637
|
+
let res = null;
|
|
2684
2638
|
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
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
|
+
}
|
|
2693
2656
|
}
|
|
2694
|
-
if (Date.now() - startedAt + waitMs > maxRetryTime) return null;
|
|
2695
|
-
return waitMs;
|
|
2696
|
-
}
|
|
2697
2657
|
|
|
2698
|
-
|
|
2699
|
-
if (
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
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
|
+
}
|
|
2706
2679
|
}
|
|
2707
|
-
return shouldRetryResult ? Boolean(shouldRetryResult(result, attempt)) : false;
|
|
2708
|
-
}
|
|
2709
2680
|
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
if (
|
|
2713
|
-
|
|
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
|
+
}
|
|
2714
2700
|
}
|
|
2715
2701
|
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
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
|
+
}
|
|
2723
2721
|
|
|
2724
|
-
|
|
2725
|
-
|
|
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
|
|
2726
2729
|
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
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
|
+
});
|
|
2730
2739
|
}
|
|
2740
|
+
await abortableSleep(delayMs, signal);
|
|
2741
|
+
}
|
|
2742
|
+
}
|
|
2731
2743
|
|
|
2732
|
-
|
|
2744
|
+
module.exports = { streamSSE };
|
|
2733
2745
|
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
if (headerMs !== null) defaultMs = Math.min(headerMs, maxRetryAfterMs);
|
|
2738
|
-
}
|
|
2739
|
-
const waitMs = planWait(defaultMs, attempt, undefined, result);
|
|
2740
|
-
if (waitMs === null) return result;
|
|
2746
|
+
},
|
|
2747
|
+
"testing/createMockFetch": function (module, exports, require) {
|
|
2748
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
2741
2749
|
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
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;
|
|
2745
2763
|
}
|
|
2764
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
2765
|
+
return JSON.stringify(body);
|
|
2746
2766
|
}
|
|
2747
2767
|
|
|
2748
|
-
|
|
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
|
+
}
|
|
2749
2773
|
|
|
2750
|
-
|
|
2751
|
-
"
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
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
|
|
2757
2785
|
}
|
|
2786
|
+
return false;
|
|
2758
2787
|
}
|
|
2759
2788
|
|
|
2760
2789
|
/**
|
|
2761
|
-
*
|
|
2762
|
-
*
|
|
2763
|
-
* 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.
|
|
2764
2792
|
*
|
|
2765
|
-
*
|
|
2766
|
-
*
|
|
2767
|
-
*
|
|
2768
|
-
*
|
|
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());
|
|
2769
2799
|
*
|
|
2770
|
-
*
|
|
2771
|
-
*
|
|
2772
|
-
*
|
|
2773
|
-
*
|
|
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.
|
|
2774
2808
|
*/
|
|
2775
|
-
|
|
2776
|
-
|
|
2809
|
+
function createMockFetch() {
|
|
2810
|
+
let routes = [];
|
|
2811
|
+
const calls = [];
|
|
2777
2812
|
|
|
2778
|
-
|
|
2779
|
-
|
|
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;
|
|
2780
2851
|
}
|
|
2781
|
-
signal?.throwIfAborted?.();
|
|
2782
2852
|
|
|
2783
|
-
|
|
2784
|
-
|
|
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
|
+
}
|
|
2785
2859
|
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
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
|
+
}
|
|
2793
2867
|
|
|
2794
|
-
|
|
2795
|
-
|
|
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
|
+
}
|
|
2796
2873
|
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
);
|
|
2803
|
-
});
|
|
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?.();
|
|
2804
2879
|
|
|
2805
|
-
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
|
|
2809
|
-
|
|
2810
|
-
|
|
2811
|
-
|
|
2812
|
-
|
|
2813
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
2816
|
-
|
|
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);
|
|
2817
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
|
+
};
|
|
2818
2929
|
}
|
|
2819
2930
|
|
|
2820
|
-
module.exports = {
|
|
2931
|
+
module.exports = { createMockFetch };
|
|
2821
2932
|
|
|
2822
2933
|
},
|
|
2823
2934
|
};
|
|
2824
2935
|
const __cache = {};
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
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)));
|
|
2831
2953
|
return module.exports;
|
|
2832
2954
|
}
|
|
2833
|
-
const __reqkit =
|
|
2955
|
+
const __reqkit = __load("index");
|
|
2834
2956
|
export const {
|
|
2835
2957
|
BodyTooLargeError,
|
|
2836
2958
|
CircuitOpenError,
|