@boy-offi9-inc/reqkit 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +95 -55
  2. package/dist/reqkit.mjs +2143 -1907
  3. package/package.json +9 -5
  4. package/src/{createFetch.js → http/createFetch.js} +169 -30
  5. package/src/index.js +18 -18
  6. package/src/{_http.js → internal/http.js} +11 -1
  7. package/src/{paginate.js → pagination/paginate.js} +33 -4
  8. package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
  9. package/src/{withRetry.js → resilience/retry.js} +2 -2
  10. package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
  11. package/src/{downloadResumable.js → streams/download.js} +5 -5
  12. package/src/{streams.js → streams/readers.js} +64 -20
  13. package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
  14. package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
  15. package/types/errors.d.ts +24 -0
  16. package/types/http.d.ts +180 -0
  17. package/types/index.d.ts +7 -518
  18. package/types/pagination.d.ts +62 -0
  19. package/types/resilience.d.ts +171 -0
  20. package/types/streams.d.ts +105 -0
  21. package/types/testing.d.ts +44 -0
  22. /package/{RECIPES.md → docs/recipes.md} +0 -0
  23. /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
  24. /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
  25. /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
  26. /package/src/{_cache.js → http/cache/entry.js} +0 -0
  27. /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
  28. /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
  29. /package/src/{_sleep.js → internal/sleep.js} +0 -0
  30. /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
  31. /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
  32. /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
  33. /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
  34. /package/src/{withProgress.js → streams/progress.js} +0 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.7.0",
4
- "description": "Small, composable HTTP helpers — retry with backoff and Retry-After, timeouts, normalized errors, rate limiter, circuit breaker, response cache, resumable downloads, SSE/NDJSON streams, mock fetch, and an opt-in createFetch. Not a client; works alongside fetch/axios/anything.",
3
+ "version": "0.8.0",
4
+ "description": "Small, composable HTTP helpers \u2014 retry with backoff and Retry-After, timeouts, normalized errors, rate limiter, circuit breaker, response cache, resumable downloads, SSE/NDJSON streams, mock fetch, and an opt-in createFetch. Not a client; works alongside fetch/axios/anything.",
5
5
  "main": "./src/index.js",
6
6
  "types": "./types/index.d.ts",
7
7
  "exports": {
@@ -27,7 +27,8 @@
27
27
  "test": "node --test",
28
28
  "typecheck": "tsc -p tsconfig.json",
29
29
  "smoke": "node smoke/run.mjs",
30
- "prepublishOnly": "npm run build && npm test && npm run typecheck"
30
+ "check": "npm run build && npm test && npm run typecheck && npm run smoke",
31
+ "prepublishOnly": "npm run check"
31
32
  },
32
33
  "keywords": [
33
34
  "http",
@@ -61,7 +62,10 @@
61
62
  "pagination",
62
63
  "auth",
63
64
  "token-refresh",
64
- "link-header"
65
+ "link-header",
66
+ "stale-if-error",
67
+ "observability",
68
+ "logging"
65
69
  ],
66
70
  "author": "Boy Offi9",
67
71
  "license": "MIT",
@@ -83,8 +87,8 @@
83
87
  "src",
84
88
  "types",
85
89
  "dist",
90
+ "docs/recipes.md",
86
91
  "README.md",
87
- "RECIPES.md",
88
92
  "LICENSE"
89
93
  ],
90
94
  "devDependencies": {
@@ -1,19 +1,21 @@
1
- const { withRetry, RetryAbortedError } = require("./withRetry");
2
- const { withTimeout } = require("./withTimeout");
3
- const { withDedupe } = require("./withDedupe");
1
+ const { withRetry, RetryAbortedError } = require("../resilience/retry");
2
+ const { withTimeout } = require("../resilience/timeout");
3
+ const { withDedupe } = require("../resilience/dedupe");
4
4
  const { buildQuery } = require("./buildQuery");
5
5
  const { parseJsonSafe } = require("./parseJsonSafe");
6
- const { normalizeError } = require("./normalizeError");
7
- const { HTTPError } = require("./HTTPError");
8
- const { createRateLimiter } = require("./createRateLimiter");
9
- const { createCircuitBreaker } = require("./createCircuitBreaker");
10
- const { createMemoryCache } = require("./createMemoryCache");
11
- const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
12
- const { discardBody } = require("./_http");
6
+ const { normalizeError } = require("../errors/normalizeError");
7
+ const { HTTPError } = require("../errors/HTTPError");
8
+ const { createRateLimiter } = require("../resilience/rateLimiter");
9
+ const { createCircuitBreaker } = require("../resilience/circuitBreaker");
10
+ const { createMemoryCache } = require("./cache/memoryCache");
11
+ const { cacheKey, snapshotResponse, entryToResponse } = require("./cache/entry");
12
+ const { discardBody, getOrigin } = require("../internal/http");
13
13
 
14
14
  const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
15
15
  // Safe to repeat without side effects. POST/PATCH are deliberately absent.
16
16
  const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
17
+ // Default headers that carry credentials. They're only sent to trusted origins.
18
+ const SENSITIVE_HEADERS = ["authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"];
17
19
 
18
20
  function isAbsoluteUrl(url) {
19
21
  return /^[a-z][a-z0-9+.-]*:/i.test(url);
@@ -47,11 +49,7 @@ function isStreamBody(body) {
47
49
  }
48
50
 
49
51
  function originOf(url) {
50
- try {
51
- return new URL(url).origin;
52
- } catch {
53
- return url;
54
- }
52
+ return getOrigin(url) || url;
55
53
  }
56
54
 
57
55
  // One in-flight token refresh per `auth` config object, so concurrent 401s —
@@ -96,11 +94,17 @@ function mergeDefaults(base, extra) {
96
94
  * - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
97
95
  * *before* the server has to say 429
98
96
  * - `circuitBreaker`: fail fast against a service that keeps failing
99
- * - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
100
- * ETag / Last-Modified revalidation (304s)
97
+ * - `cache`: in-memory GET cache with TTL, stale-while-revalidate,
98
+ * stale-if-error and ETag / Last-Modified revalidation (304s)
99
+ * - `onEvent`: one callback for everything that happens (request, attempt,
100
+ * retry, response, error, cache, rate-limit waits, circuit changes, token
101
+ * refreshes) — plug it into your logger or metrics
101
102
  * - concurrent GET de-duplication (withDedupe)
102
103
  * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
103
104
  * concurrent requests) and replay the request
105
+ * - credential safety: default credentials (`auth`, and default headers like
106
+ * Authorization/Cookie/X-API-Key) are only sent to `baseUrl`'s origin and
107
+ * `trustedOrigins` — pass an absolute URL to another host and they stay home
104
108
  * - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
105
109
  * - `onRequest` / `onResponse` hooks
106
110
  *
@@ -116,19 +120,26 @@ function mergeDefaults(base, extra) {
116
120
  * createRateLimiter() instance to share one budget
117
121
  * @param {boolean | object | Function} [defaults.circuitBreaker] - options for
118
122
  * a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
119
- * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
120
- * maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
123
+ * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate, staleIfError,
124
+ * maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL. `staleIfError` (ms) lets a
125
+ * cached copy be served for that long past its TTL when the network fails or the server answers 5xx.
121
126
  * @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
122
127
  * `getToken()` supplies the current token (sync or async); on a 401 (or
123
128
  * whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
124
129
  * called once for all concurrent callers and the request is replayed with the
125
130
  * new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
131
+ * @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
132
+ * receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
133
+ * `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
126
134
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
127
135
  * @param {boolean} [defaults.throwHttpErrors=true]
128
136
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
129
137
  * @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
130
138
  * runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
131
139
  * @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
140
+ * @param {(event: object) => void | Promise<void>} [defaults.onEvent] - receives `{ type, ... }` events:
141
+ * "request", "attempt", "retry", "response", "error", "cache", "ratelimit", "circuit", "auth".
142
+ * Errors thrown by the handler are swallowed — logging must never break a request.
132
143
  */
133
144
  function createFetch(defaults = {}) {
134
145
  const {
@@ -140,15 +151,40 @@ function createFetch(defaults = {}) {
140
151
  circuitBreaker,
141
152
  cache,
142
153
  auth,
154
+ trustedOrigins,
143
155
  dedupe = false,
144
156
  throwHttpErrors: defaultThrow = true,
145
157
  fetch: customFetch,
146
158
  onRequest,
147
159
  onResponse,
160
+ onEvent,
148
161
  } = defaults;
149
162
 
150
163
  const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
151
164
 
165
+ // A throwing (or rejecting) handler must never break a request.
166
+ const emitEvent = (event) => {
167
+ if (!onEvent) return;
168
+ try {
169
+ const result = onEvent(event);
170
+ if (result && typeof result.catch === "function") result.catch(() => {});
171
+ } catch {
172
+ // ignore
173
+ }
174
+ };
175
+
176
+ // Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
177
+ const trusted = new Set();
178
+ for (const o of [baseUrl, ...(trustedOrigins || [])]) {
179
+ const origin = o && getOrigin(o);
180
+ if (origin) trusted.add(origin);
181
+ }
182
+ const isTrustedTarget = (url) => {
183
+ if (trusted.size === 0 || !isAbsoluteUrl(url)) return true; // relative URLs resolve against our own base
184
+ const origin = getOrigin(url);
185
+ return origin !== null && trusted.has(origin);
186
+ };
187
+
152
188
  // --- stateful pieces, built once so `extend()` children share them -------
153
189
  const limiter = !rateLimit
154
190
  ? null
@@ -166,7 +202,13 @@ function createFetch(defaults = {}) {
166
202
  : (url) => {
167
203
  const origin = originOf(url);
168
204
  if (!breakerRegistry.has(origin)) {
169
- breakerRegistry.set(origin, createCircuitBreaker(circuitBreaker === true ? {} : circuitBreaker));
205
+ const options = circuitBreaker === true ? {} : { ...circuitBreaker };
206
+ const userOnStateChange = options.onStateChange;
207
+ options.onStateChange = (state, previous) => {
208
+ if (userOnStateChange) userOnStateChange(state, previous);
209
+ emitEvent({ type: "circuit", origin, state, previous });
210
+ };
211
+ breakerRegistry.set(origin, createCircuitBreaker(options));
170
212
  }
171
213
  return breakerRegistry.get(origin);
172
214
  };
@@ -176,6 +218,7 @@ function createFetch(defaults = {}) {
176
218
  : {
177
219
  ttl: 60000,
178
220
  staleWhileRevalidate: 0,
221
+ staleIfError: 0,
179
222
  maxEntries: 100,
180
223
  maxBodyBytes: undefined,
181
224
  ...(cache === true ? {} : cache),
@@ -189,7 +232,10 @@ function createFetch(defaults = {}) {
189
232
  }
190
233
  const pendingStores = cacheCfg ? cacheCfg.pending : null;
191
234
  const revalidating = cacheCfg ? cacheCfg.revalidating : null;
192
- const emit = (type, url) => cacheCfg && cacheCfg.onEvent && cacheCfg.onEvent({ type, url });
235
+ const emit = (type, url) => {
236
+ if (cacheCfg && cacheCfg.onEvent) cacheCfg.onEvent({ type, url });
237
+ emitEvent({ type: "cache", url, method: "GET", event: type });
238
+ };
193
239
 
194
240
  // Shared in-flight GETs. Every caller gets its own clone of the response so
195
241
  // body reads don't fight each other.
@@ -213,9 +259,12 @@ function createFetch(defaults = {}) {
213
259
  // Returns a token newer than `usedToken`, refreshing at most once no matter
214
260
  // how many requests were rejected at the same time. If someone else already
215
261
  // refreshed (the current token isn't the one we used), just use that.
216
- async function obtainFreshToken(authCfg, tokenUsed, response) {
262
+ async function obtainFreshToken(authCfg, tokenUsed, response, info) {
217
263
  const current = await authCfg.getToken();
218
- if (current && current !== tokenUsed) return current;
264
+ if (current && current !== tokenUsed) {
265
+ emitEvent({ type: "auth", ...info, outcome: "reused" });
266
+ return current;
267
+ }
219
268
 
220
269
  const state = refreshStateFor(authCfg);
221
270
  if (!state.inflight) {
@@ -228,10 +277,21 @@ function createFetch(defaults = {}) {
228
277
  }
229
278
  })();
230
279
  }
231
- return state.inflight;
280
+ try {
281
+ const token = await state.inflight;
282
+ emitEvent({ type: "auth", ...info, outcome: "refreshed" });
283
+ return token;
284
+ } catch (error) {
285
+ emitEvent({ type: "auth", ...info, outcome: "failed", error });
286
+ throw error;
287
+ }
232
288
  }
233
289
 
234
- async function request(input, init = {}) {
290
+ // May a cached copy be served because the origin is failing?
291
+ const withinStaleIfError = (entry) =>
292
+ cacheCfg && cacheCfg.staleIfError > 0 && Date.now() - entry.storedAt < cacheCfg.ttl + cacheCfg.staleIfError;
293
+
294
+ async function execute(input, init, state) {
235
295
  const {
236
296
  query,
237
297
  json,
@@ -245,7 +305,20 @@ function createFetch(defaults = {}) {
245
305
  } = init;
246
306
 
247
307
  const method = String(fetchInit.method || "GET").toUpperCase();
248
- const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
308
+ const url = joinUrl(baseUrl, input, query);
309
+ state.url = url;
310
+ state.method = method;
311
+ const credentialsAllowed = isTrustedTarget(url);
312
+
313
+ const defaultHeaderSet = await resolveHeaders(defaultHeaders);
314
+ if (!credentialsAllowed) {
315
+ // An absolute URL to some other host: don't hand it our default credentials.
316
+ for (const name of [...SENSITIVE_HEADERS, ...(auth && auth.header ? [String(auth.header).toLowerCase()] : [])]) {
317
+ defaultHeaderSet.delete(name);
318
+ }
319
+ }
320
+ // Headers the caller passes for *this* call are deliberate, so they always go through.
321
+ const headers = mergeHeaders(defaultHeaderSet, new Headers(initHeaders || undefined));
249
322
 
250
323
  let body = fetchInit.body;
251
324
  if (json !== undefined) {
@@ -253,16 +326,19 @@ function createFetch(defaults = {}) {
253
326
  if (!headers.has("content-type")) headers.set("content-type", "application/json");
254
327
  }
255
328
 
256
- const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
329
+ const ctx = { url, init: { ...fetchInit, method, headers, body } };
257
330
  if (onRequest) await onRequest(ctx);
258
331
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
259
332
  const finalMethod = String(ctx.init.method || method).toUpperCase();
333
+ state.url = ctx.url;
334
+ state.method = finalMethod;
335
+ emitEvent({ type: "request", url: ctx.url, method: finalMethod });
260
336
 
261
337
  // --- auth: attach the current token (unless the caller set the header) ----
262
338
  const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
263
339
  const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
264
340
  let usedToken = null;
265
- if (auth && !finalHeaders.has(authHeader)) {
341
+ if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
266
342
  usedToken = (await auth.getToken()) || null;
267
343
  if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
268
344
  }
@@ -282,8 +358,13 @@ function createFetch(defaults = {}) {
282
358
  // `netSignal` are parameters so background revalidation can reuse this.
283
359
  function network(netHeaders, netSignal) {
284
360
  const attemptOnce = () => {
361
+ state.attempts++;
362
+ emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
285
363
  const attempt = async () => {
286
- if (limiter) await limiter.acquire({ signal: netSignal });
364
+ if (limiter) {
365
+ const waitedMs = await limiter.acquire({ signal: netSignal });
366
+ if (waitedMs > 0) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
367
+ }
287
368
  const res = await withTimeout(
288
369
  (attemptSignal) => {
289
370
  const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
@@ -315,6 +396,19 @@ function createFetch(defaults = {}) {
315
396
  ...retryRest,
316
397
  retries,
317
398
  retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
399
+ onRetry: (err, attempt, delayMs, rctx) => {
400
+ if (retryRest.onRetry) retryRest.onRetry(err, attempt, delayMs, rctx);
401
+ emitEvent({
402
+ type: "retry",
403
+ url: ctx.url,
404
+ method: finalMethod,
405
+ attempt: attempt + 1,
406
+ delayMs,
407
+ reason: rctx.reason === "result" ? "status" : "error",
408
+ status: rctx.status,
409
+ error: err,
410
+ });
411
+ },
318
412
  shouldRetry:
319
413
  customShouldRetry ||
320
414
  ((err) => {
@@ -346,6 +440,17 @@ function createFetch(defaults = {}) {
346
440
  let entry = null;
347
441
 
348
442
  const finish = async (response) => {
443
+ state.responded = true;
444
+ emitEvent({
445
+ type: "response",
446
+ url: ctx.url,
447
+ method: finalMethod,
448
+ status: response.status,
449
+ ok: response.ok,
450
+ durationMs: Date.now() - state.startedAt,
451
+ attempts: state.attempts,
452
+ cache: state.cache,
453
+ });
349
454
  if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
350
455
  if (throwHttpErrors && !response.ok) {
351
456
  throw new HTTPError(response, { method: finalMethod, url: ctx.url });
@@ -360,10 +465,12 @@ function createFetch(defaults = {}) {
360
465
  const age = Date.now() - entry.storedAt;
361
466
  if (age < cacheCfg.ttl) {
362
467
  emit("hit", ctx.url);
468
+ state.cache = "hit";
363
469
  return finish(entryToResponse(entry));
364
470
  }
365
471
  if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
366
472
  emit("stale", ctx.url);
473
+ state.cache = "stale";
367
474
  if (!revalidating.has(key)) {
368
475
  revalidating.add(key);
369
476
  const staleEntry = entry;
@@ -400,13 +507,19 @@ function createFetch(defaults = {}) {
400
507
  } catch (err) {
401
508
  // Present a user abort as the plain AbortError they caused, not as retry internals.
402
509
  if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
510
+ // The network failed but we still hold a usable copy: serve it (never for a deliberate abort).
511
+ if (entry && withinStaleIfError(entry) && !(signal && signal.aborted) && !(err && err.name === "AbortError")) {
512
+ emit("stale-if-error", ctx.url);
513
+ state.cache = "stale-if-error";
514
+ return finish(entryToResponse(entry));
515
+ }
403
516
  throw err;
404
517
  }
405
518
 
406
519
  // --- auth: a rejected token is refreshed once, then the request replays ----
407
520
  const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
408
521
  if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
409
- const fresh = await obtainFreshToken(auth, usedToken, response);
522
+ const fresh = await obtainFreshToken(auth, usedToken, response, { url: ctx.url, method: finalMethod });
410
523
  if (fresh) {
411
524
  await discardBody(response);
412
525
  finalHeaders.set(authHeader, formatToken(fresh));
@@ -421,15 +534,25 @@ function createFetch(defaults = {}) {
421
534
  }
422
535
  }
423
536
 
537
+ // The server answered, but with an error, and we still hold a usable copy.
538
+ if (useCache && entry && response.status >= 500 && withinStaleIfError(entry)) {
539
+ await discardBody(response);
540
+ emit("stale-if-error", ctx.url);
541
+ state.cache = "stale-if-error";
542
+ return finish(entryToResponse(entry));
543
+ }
544
+
424
545
  if (useCache) {
425
546
  if (response.status === 304 && entry) {
426
547
  entry.storedAt = Date.now();
427
548
  await cacheCfg.store.set(key, entry);
428
549
  emit("revalidated", ctx.url);
550
+ state.cache = "revalidated";
429
551
  await discardBody(response);
430
552
  response = entryToResponse(entry);
431
553
  } else {
432
554
  emit("miss", ctx.url);
555
+ state.cache = "miss";
433
556
  if (response.status === 200) scheduleStore(key, response, ctx.url);
434
557
  }
435
558
  }
@@ -437,6 +560,22 @@ function createFetch(defaults = {}) {
437
560
  return finish(response);
438
561
  }
439
562
 
563
+ async function request(input, init = {}) {
564
+ const state = { startedAt: Date.now(), attempts: 0, responded: false, cache: undefined, url: undefined, method: undefined };
565
+ try {
566
+ return await execute(input, init, state);
567
+ } catch (error) {
568
+ // A non-2xx HTTPError was already reported as a "response" event.
569
+ if (!state.responded) {
570
+ emitEvent({ type: "error", url: state.url, method: state.method, error, durationMs: Date.now() - state.startedAt, attempts: state.attempts });
571
+ }
572
+ throw error;
573
+ }
574
+ }
575
+
576
+ /** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
577
+ request.baseUrl = baseUrl;
578
+
440
579
  /** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
441
580
  request.json = async (input, init) => {
442
581
  const response = await request(input, init);
package/src/index.js CHANGED
@@ -1,21 +1,21 @@
1
- const { withRetry, RetryAbortedError, StopRetry } = require("./withRetry");
2
- const { withTimeout, TimeoutError } = require("./withTimeout");
3
- const { normalizeError } = require("./normalizeError");
4
- const { parseJsonSafe } = require("./parseJsonSafe");
5
- const { buildQuery } = require("./buildQuery");
6
- const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
7
- const { withProgress, BodyTooLargeError } = require("./withProgress");
8
- const { withDedupe } = require("./withDedupe");
9
- const { createLimiter } = require("./createLimiter");
10
- const { createFetch, HTTPError } = require("./createFetch");
11
- const { createRateLimiter } = require("./createRateLimiter");
12
- const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
13
- const { createMemoryCache } = require("./createMemoryCache");
14
- const { createMockFetch } = require("./createMockFetch");
15
- const { downloadResumable } = require("./downloadResumable");
16
- const { readLines, readNDJSON, readSSE } = require("./streams");
17
- const { streamSSE } = require("./streamSSE");
18
- const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
1
+ const { withRetry, RetryAbortedError, StopRetry } = require("./resilience/retry");
2
+ const { withTimeout, TimeoutError } = require("./resilience/timeout");
3
+ const { normalizeError } = require("./errors/normalizeError");
4
+ const { parseJsonSafe } = require("./http/parseJsonSafe");
5
+ const { buildQuery } = require("./http/buildQuery");
6
+ const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./resilience/retryAfterAware");
7
+ const { withProgress, BodyTooLargeError } = require("./streams/progress");
8
+ const { withDedupe } = require("./resilience/dedupe");
9
+ const { createLimiter } = require("./resilience/limiter");
10
+ const { createFetch, HTTPError } = require("./http/createFetch");
11
+ const { createRateLimiter } = require("./resilience/rateLimiter");
12
+ const { createCircuitBreaker, CircuitOpenError } = require("./resilience/circuitBreaker");
13
+ const { createMemoryCache } = require("./http/cache/memoryCache");
14
+ const { createMockFetch } = require("./testing/createMockFetch");
15
+ const { downloadResumable } = require("./streams/download");
16
+ const { readLines, readNDJSON, readSSE } = require("./streams/readers");
17
+ const { streamSSE } = require("./streams/streamSSE");
18
+ const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./pagination/paginate");
19
19
 
20
20
  module.exports = {
21
21
  withRetry,
@@ -87,4 +87,14 @@ async function discardBody(response) {
87
87
  }
88
88
  }
89
89
 
90
- module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody };
90
+ /** The origin ("https://host:port") of an absolute URL, or null if `url` is relative/invalid. */
91
+ function getOrigin(url) {
92
+ try {
93
+ const { origin } = new URL(String(url));
94
+ return origin === "null" ? null : origin;
95
+ } catch {
96
+ return null;
97
+ }
98
+ }
99
+
100
+ module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody, getOrigin };
@@ -1,4 +1,7 @@
1
- const { HTTPError } = require("./HTTPError");
1
+ const { HTTPError } = require("../errors/HTTPError");
2
+ const { getOrigin } = require("../internal/http");
3
+
4
+ const MAX_LINK_HEADER_LENGTH = 64 * 1024;
2
5
 
3
6
  /**
4
7
  * Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
@@ -10,7 +13,8 @@ const { HTTPError } = require("./HTTPError");
10
13
  * ```
11
14
  *
12
15
  * A link with several rels (`rel="next last"`) is listed under each; the first
13
- * link wins when a rel appears twice. Commas inside `<...>` are handled.
16
+ * link wins when a rel appears twice. Commas inside `<...>` are handled. Input
17
+ * is treated as untrusted: headers over 64 KB are ignored and parsing is linear-time.
14
18
  *
15
19
  * @param {string | null | undefined} header
16
20
  * @param {string} [base]
@@ -18,10 +22,11 @@ const { HTTPError } = require("./HTTPError");
18
22
  */
19
23
  function parseLinkHeader(header, base) {
20
24
  const links = {};
21
- if (!header) return links;
25
+ // Link headers are server-controlled; real ones are a few hundred bytes (HTTP stacks cap headers at ~16 KB).
26
+ if (!header || header.length > MAX_LINK_HEADER_LENGTH) return links;
22
27
 
23
28
  // <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
24
- const linkRe = /<([^>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
29
+ const linkRe = /<([^<>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
25
30
  let match;
26
31
  while ((match = linkRe.exec(header)) !== null) {
27
32
  const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
@@ -148,15 +153,39 @@ function defaultGetItems(data) {
148
153
  * @param {number} [opts.maxItems=Infinity]
149
154
  * @param {AbortSignal} [opts.signal]
150
155
  * @param {object} [opts.init] - extra options passed to every `request`
156
+ * @param {string[]} [opts.origins] - origins a `Link` may point to. Default: the origin of `url`
157
+ * (or of the `createFetch` instance's `baseUrl`). A `Link` to anywhere else throws
158
+ * `code: "PAGINATE_CROSS_ORIGIN"` — a server could otherwise steer your requests (and any
159
+ * credentials in `init`) to a third party. The check is skipped only when no origin can be determined.
151
160
  */
152
161
  async function* paginateLinks(request, url, opts = {}) {
153
162
  const { getItems = defaultGetItems, init = {}, signal } = opts;
154
163
 
164
+ const allowed = new Set();
165
+ if (opts.origins) {
166
+ for (const o of opts.origins) {
167
+ const origin = getOrigin(o);
168
+ if (origin) allowed.add(origin);
169
+ }
170
+ } else {
171
+ const origin = getOrigin(url) || getOrigin(request && request.baseUrl) || getOrigin(globalThis.location && globalThis.location.href);
172
+ if (origin) allowed.add(origin);
173
+ }
174
+
155
175
  async function fetchPage(pageUrl) {
156
176
  const res = await request(pageUrl, { ...init, signal });
157
177
  if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
158
178
  const items = getItems(await res.json(), res);
159
179
  const next = parseLinkHeader(res.headers.get("link"), pageUrl).next;
180
+ if (next && allowed.size > 0) {
181
+ const nextOrigin = getOrigin(next);
182
+ if (nextOrigin !== null && !allowed.has(nextOrigin)) {
183
+ throw Object.assign(
184
+ new Error(`paginateLinks: refusing to follow a Link to another origin (${nextOrigin}). Pass { origins: [...] } if that's intended.`),
185
+ { code: "PAGINATE_CROSS_ORIGIN" }
186
+ );
187
+ }
188
+ }
160
189
  return { items, next };
161
190
  }
162
191
 
@@ -1,5 +1,5 @@
1
- const { abortableSleep } = require("./_sleep");
2
- const { getRateLimitInfo } = require("./_http");
1
+ const { abortableSleep } = require("../internal/sleep");
2
+ const { getRateLimitInfo } = require("../internal/http");
3
3
 
4
4
  function abortable(promise, signal) {
5
5
  if (!signal) return promise;
@@ -50,6 +50,7 @@ function createRateLimiter(opts = {}) {
50
50
  let tail = Promise.resolve();
51
51
 
52
52
  async function take(signal) {
53
+ let waitedMs = 0;
53
54
  for (;;) {
54
55
  signal?.throwIfAborted?.();
55
56
  const now = Date.now();
@@ -61,13 +62,15 @@ function createRateLimiter(opts = {}) {
61
62
 
62
63
  if (waitMs <= 0) {
63
64
  stamps.push(now);
64
- return;
65
+ return waitedMs;
65
66
  }
67
+ const waitStartedAt = Date.now();
66
68
  await abortableSleep(waitMs, signal);
69
+ waitedMs += Date.now() - waitStartedAt;
67
70
  }
68
71
  }
69
72
 
70
- /** Resolves when it's your turn. Rejects with the signal's reason if aborted while waiting. */
73
+ /** Resolves with the time spent waiting. Rejects if aborted while waiting. */
71
74
  function acquire({ signal } = {}) {
72
75
  const turn = tail.then(() => take(signal));
73
76
  tail = turn.catch(() => {});
@@ -1,5 +1,5 @@
1
- const { RetryAbortedError, sleep } = require("./_sleep");
2
- const { getWaitMsFromHeaders, discardBody } = require("./_http");
1
+ const { RetryAbortedError, sleep } = require("../internal/sleep");
2
+ const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
3
3
 
4
4
  function defaultShouldRetry(err) {
5
5
  // Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
@@ -1,6 +1,6 @@
1
- const { withRetry } = require("./withRetry");
2
- const { normalizeError } = require("./normalizeError");
3
- const { parseRetryAfter, getWaitMsFromHeaders } = require("./_http");
1
+ const { withRetry } = require("./retry");
2
+ const { normalizeError } = require("../errors/normalizeError");
3
+ const { parseRetryAfter, getWaitMsFromHeaders } = require("../internal/http");
4
4
 
5
5
  function defaultShouldRetryError(err) {
6
6
  const normalized = normalizeError(err);
@@ -1,8 +1,8 @@
1
- const { HTTPError } = require("./HTTPError");
2
- const { BodyTooLargeError } = require("./withProgress");
3
- const { normalizeError } = require("./normalizeError");
4
- const { getWaitMsFromHeaders } = require("./_http");
5
- const { abortableSleep } = require("./_sleep");
1
+ const { HTTPError } = require("../errors/HTTPError");
2
+ const { BodyTooLargeError } = require("./progress");
3
+ const { normalizeError } = require("../errors/normalizeError");
4
+ const { getWaitMsFromHeaders } = require("../internal/http");
5
+ const { abortableSleep } = require("../internal/sleep");
6
6
 
7
7
  const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
8
8