@boy-offi9-inc/reqkit 0.7.1 → 0.8.0

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