@boy-offi9-inc/reqkit 0.7.0 → 0.8.0

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