@boy-offi9-inc/reqkit 0.7.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,14 +48,13 @@ const pages = await limit.map(urls, (url) =>
48
48
  )
49
49
  );
50
50
  ```
51
-
52
- ---
51
+ __
53
52
 
54
53
  ## Playground
55
54
 
56
55
  Try everything in your browser — retries obeying `Retry-After`, rate limiting, the circuit breaker, caching, SSE streaming, an SSE stream that reconnects itself, and a resumable download that survives a dropped connection. It runs against a fake in-memory server, so there's nothing to install and no network involved.
57
56
 
58
- Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.)
57
+ Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.) The repo's `pages.yml` workflow publishes the same folder to GitHub Pages — enable it once under Settings → Pages → Source: "GitHub Actions".
59
58
 
60
59
  ## Works with what you already use
61
60
 
@@ -76,6 +75,18 @@ Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming li
76
75
 
77
76
  ---
78
77
 
78
+ ## Security
79
+
80
+ reqkit handles data a server controls (headers, streams, redirects), so the defaults are conservative:
81
+
82
+ - **Credentials stay home.** With a `baseUrl`, the default `auth` token and default `Authorization` / `Proxy-Authorization` / `Cookie` / `X-API-Key` / `X-Auth-Token` headers are only sent to that origin (plus `trustedOrigins`). Pass an absolute URL to another host and those headers are dropped — the classic "absolute URL leaks the token" bug (the one axios had) can't happen. Headers you pass *for a specific call* are deliberate and always sent. (With neither `baseUrl` nor `trustedOrigins` there's nothing to compare against, so set at least one.)
83
+ - **A server can't steer pagination.** `paginateLinks` refuses a `Link: rel="next"` that points at another origin (`code: "PAGINATE_CROSS_ORIGIN"`) — otherwise a hostile API could send your requests, and any credentials you pass in `init`, to a third party. Allow extra origins with `origins: [...]`.
84
+ - **Streams are bounded.** `readLines` / `readNDJSON` / `readSSE` run in linear time and throw `code: "LINE_TOO_LONG"` past `maxLineLength` (default 16 Mi characters) instead of buffering a never-ending line forever; `withProgress` and `downloadResumable` have `maxBytes`.
85
+ - **Hostile headers are cheap.** `parseLinkHeader` is linear-time and ignores headers over 64 KB.
86
+ - **Safe retries.** `createFetch` only retries idempotent methods (or requests with an `Idempotency-Key`), and a cross-origin redirect is left to `fetch`, which strips `Authorization` itself.
87
+
88
+ What reqkit does **not** do: it doesn't block requests to private/internal addresses (SSRF protection for server-side use needs DNS-level control). If you fetch user-supplied URLs on a server, validate them first.
89
+
79
90
  ## Pick what you need
80
91
 
81
92
  | You want to… | Use |
@@ -362,6 +373,8 @@ for await (const row of readNDJSON(response)) console.log(row); // newline-delim
362
373
  for await (const line of readLines(response)) console.log(line);
363
374
  ```
364
375
 
376
+ All three take `{ signal, maxLineLength }` — a line (or one SSE event's data) longer than `maxLineLength` characters (default 16 Mi) throws `code: "LINE_TOO_LONG"` rather than buffering forever.
377
+
365
378
  `readSSE` follows the spec: multi-line `data:` is joined with `\n`, comments are ignored, `event` defaults to `"message"`, `id` carries over, `retry` is parsed, and an event cut off by the end of the stream is dropped. `readNDJSON` skips blank lines and names the bad line on a parse error (or pass `skipInvalid: true`). Line splitting handles `\n`, `\r\n`, `\r`, a BOM, and multi-byte characters split across chunks.
366
379
 
367
380
  ### Pagination: `paginate`, `paginatePages`, `paginateLinks`
@@ -502,6 +515,7 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
502
515
  | `retry` | see below | `false` disables; otherwise `{ retries, statusCodes, methods, baseDelayMs, maxDelayMs, factor, jitter, maxRetryTime, shouldRetry, calculateDelay, onRetry }` |
503
516
  | `rateLimit` | — | `true` just learns from `X-RateLimit-*` / `Retry-After`; `{ requests, perMs }` also enforces a budget; or pass a shared [`createRateLimiter`](#createratelimiteropts) |
504
517
  | `circuitBreaker` | — | `true` / options → one breaker **per origin**; or pass a shared [`createCircuitBreaker`](#createcircuitbreakeropts), or `(url) => breaker`. An open circuit stops retries immediately. |
518
+ | `trustedOrigins` | — | extra origins that may receive the default credentials — see [Security](#security) |
505
519
  | `auth` | — | attach a token and refresh it on 401 — see [Auth token refresh](#auth-token-refresh) |
506
520
  | `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
507
521
  | `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
package/dist/reqkit.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @boy-offi9-inc/reqkit v0.7.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
1
+ /*! @boy-offi9-inc/reqkit v0.7.1 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
2
2
  const __defs = {
3
3
  "./HTTPError": function (module, exports, require) {
4
4
  class HTTPError extends Error {
@@ -165,7 +165,17 @@ async function discardBody(response) {
165
165
  }
166
166
  }
167
167
 
168
- module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody };
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;
175
+ }
176
+ }
177
+
178
+ module.exports = { parseRetryAfter, parseResetMs, getWaitMsFromHeaders, getRateLimitInfo, discardBody, getOrigin };
169
179
 
170
180
  },
171
181
  "./_sleep": function (module, exports, require) {
@@ -409,11 +419,13 @@ const { createRateLimiter } = require("./createRateLimiter");
409
419
  const { createCircuitBreaker } = require("./createCircuitBreaker");
410
420
  const { createMemoryCache } = require("./createMemoryCache");
411
421
  const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
412
- const { discardBody } = require("./_http");
422
+ const { discardBody, getOrigin } = require("./_http");
413
423
 
414
424
  const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
415
425
  // Safe to repeat without side effects. POST/PATCH are deliberately absent.
416
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"];
417
429
 
418
430
  function isAbsoluteUrl(url) {
419
431
  return /^[a-z][a-z0-9+.-]*:/i.test(url);
@@ -447,11 +459,7 @@ function isStreamBody(body) {
447
459
  }
448
460
 
449
461
  function originOf(url) {
450
- try {
451
- return new URL(url).origin;
452
- } catch {
453
- return url;
454
- }
462
+ return getOrigin(url) || url;
455
463
  }
456
464
 
457
465
  // One in-flight token refresh per `auth` config object, so concurrent 401s —
@@ -501,6 +509,9 @@ function mergeDefaults(base, extra) {
501
509
  * - concurrent GET de-duplication (withDedupe)
502
510
  * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
503
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
504
515
  * - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
505
516
  * - `onRequest` / `onResponse` hooks
506
517
  *
@@ -523,6 +534,9 @@ function mergeDefaults(base, extra) {
523
534
  * whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
524
535
  * called once for all concurrent callers and the request is replayed with the
525
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.
526
540
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
527
541
  * @param {boolean} [defaults.throwHttpErrors=true]
528
542
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
@@ -540,6 +554,7 @@ function createFetch(defaults = {}) {
540
554
  circuitBreaker,
541
555
  cache,
542
556
  auth,
557
+ trustedOrigins,
543
558
  dedupe = false,
544
559
  throwHttpErrors: defaultThrow = true,
545
560
  fetch: customFetch,
@@ -549,6 +564,18 @@ function createFetch(defaults = {}) {
549
564
 
550
565
  const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
551
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
+
552
579
  // --- stateful pieces, built once so `extend()` children share them -------
553
580
  const limiter = !rateLimit
554
581
  ? null
@@ -645,7 +672,18 @@ function createFetch(defaults = {}) {
645
672
  } = init;
646
673
 
647
674
  const method = String(fetchInit.method || "GET").toUpperCase();
648
- const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
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));
649
687
 
650
688
  let body = fetchInit.body;
651
689
  if (json !== undefined) {
@@ -653,7 +691,7 @@ function createFetch(defaults = {}) {
653
691
  if (!headers.has("content-type")) headers.set("content-type", "application/json");
654
692
  }
655
693
 
656
- const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
694
+ const ctx = { url, init: { ...fetchInit, method, headers, body } };
657
695
  if (onRequest) await onRequest(ctx);
658
696
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
659
697
  const finalMethod = String(ctx.init.method || method).toUpperCase();
@@ -662,7 +700,7 @@ function createFetch(defaults = {}) {
662
700
  const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
663
701
  const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
664
702
  let usedToken = null;
665
- if (auth && !finalHeaders.has(authHeader)) {
703
+ if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
666
704
  usedToken = (await auth.getToken()) || null;
667
705
  if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
668
706
  }
@@ -837,6 +875,9 @@ function createFetch(defaults = {}) {
837
875
  return finish(response);
838
876
  }
839
877
 
878
+ /** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
879
+ request.baseUrl = baseUrl;
880
+
840
881
  /** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
841
882
  request.json = async (input, init) => {
842
883
  const response = await request(input, init);
@@ -1648,6 +1689,9 @@ module.exports = { normalizeError };
1648
1689
  },
1649
1690
  "./paginate": function (module, exports, require) {
1650
1691
  const { HTTPError } = require("./HTTPError");
1692
+ const { getOrigin } = require("./_http");
1693
+
1694
+ const MAX_LINK_HEADER_LENGTH = 64 * 1024;
1651
1695
 
1652
1696
  /**
1653
1697
  * Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
@@ -1659,7 +1703,8 @@ const { HTTPError } = require("./HTTPError");
1659
1703
  * ```
1660
1704
  *
1661
1705
  * A link with several rels (`rel="next last"`) is listed under each; the first
1662
- * link wins when a rel appears twice. Commas inside `<...>` are handled.
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.
1663
1708
  *
1664
1709
  * @param {string | null | undefined} header
1665
1710
  * @param {string} [base]
@@ -1667,10 +1712,11 @@ const { HTTPError } = require("./HTTPError");
1667
1712
  */
1668
1713
  function parseLinkHeader(header, base) {
1669
1714
  const links = {};
1670
- if (!header) return 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;
1671
1717
 
1672
1718
  // <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
1673
- const linkRe = /<([^>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
1719
+ const linkRe = /<([^<>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
1674
1720
  let match;
1675
1721
  while ((match = linkRe.exec(header)) !== null) {
1676
1722
  const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
@@ -1797,15 +1843,39 @@ function defaultGetItems(data) {
1797
1843
  * @param {number} [opts.maxItems=Infinity]
1798
1844
  * @param {AbortSignal} [opts.signal]
1799
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.
1800
1850
  */
1801
1851
  async function* paginateLinks(request, url, opts = {}) {
1802
1852
  const { getItems = defaultGetItems, init = {}, signal } = opts;
1803
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);
1863
+ }
1864
+
1804
1865
  async function fetchPage(pageUrl) {
1805
1866
  const res = await request(pageUrl, { ...init, signal });
1806
1867
  if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
1807
1868
  const items = getItems(await res.json(), res);
1808
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
+ );
1877
+ }
1878
+ }
1809
1879
  return { items, next };
1810
1880
  }
1811
1881
 
@@ -2159,46 +2229,83 @@ async function* chunks(source, signal) {
2159
2229
  }
2160
2230
  }
2161
2231
 
2232
+ const DEFAULT_MAX_LINE_LENGTH = 16 * 1024 * 1024;
2233
+
2234
+ function lineTooLong(limit) {
2235
+ return Object.assign(
2236
+ new Error(`reqkit streams: a line exceeded ${limit} characters without a line break (raise \`maxLineLength\` if that's expected)`),
2237
+ { code: "LINE_TOO_LONG" }
2238
+ );
2239
+ }
2240
+
2162
2241
  /**
2163
2242
  * Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
2164
2243
  * multi-byte characters split across chunks, and a leading BOM.
2165
2244
  *
2245
+ * Runs in linear time and bounded memory: only newly arrived text is scanned,
2246
+ * and a "line" longer than `maxLineLength` characters (default 16 Mi — a
2247
+ * server that never sends a newline) throws `code: "LINE_TOO_LONG"` instead
2248
+ * of buffering forever.
2249
+ *
2166
2250
  * ```js
2167
2251
  * for await (const line of readLines(response)) console.log(line);
2168
2252
  * ```
2169
2253
  *
2170
2254
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
2171
- * @param {{ signal?: AbortSignal }} [opts]
2255
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts]
2172
2256
  * @returns {AsyncGenerator<string>}
2173
2257
  */
2174
2258
  async function* readLines(source, opts = {}) {
2259
+ const { maxLineLength = DEFAULT_MAX_LINE_LENGTH } = opts;
2175
2260
  const decoder = new TextDecoder("utf-8");
2176
- let buffer = "";
2261
+ const terminator = /[\r\n]/g;
2262
+
2263
+ let parts = []; // pieces of the line being assembled (no terminators in them)
2264
+ let partsLength = 0;
2265
+ let skipLF = false; // the previous line ended with "\r", so a leading "\n" belongs to it
2177
2266
  let first = true;
2178
2267
 
2179
2268
  for await (const chunk of chunks(source, opts.signal)) {
2180
- buffer += decoder.decode(chunk, { stream: true });
2181
- if (first && buffer.length > 0) {
2182
- buffer = buffer.replace(/^\uFEFF/, "");
2269
+ let text = decoder.decode(chunk, { stream: true });
2270
+ if (first && text.length > 0) {
2271
+ text = text.replace(/^\uFEFF/, "");
2183
2272
  first = false;
2184
2273
  }
2185
2274
 
2186
- let pos = 0;
2187
- for (;;) {
2188
- const rest = buffer.slice(pos);
2189
- const i = rest.search(/\r\n|\n|\r/);
2190
- if (i === -1) break;
2191
- // A trailing "\r" might be the first half of "\r\n" — wait for more data.
2192
- if (rest[i] === "\r" && i === rest.length - 1) break;
2193
- yield rest.slice(0, i);
2194
- pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
2275
+ let start = 0;
2276
+ terminator.lastIndex = 0;
2277
+ let match;
2278
+ while ((match = terminator.exec(text)) !== null) {
2279
+ const ch = match[0];
2280
+ if (ch === "\n" && skipLF && match.index === start) {
2281
+ skipLF = false; // the "\n" of a "\r\n" pair — swallow it
2282
+ start = match.index + 1;
2283
+ continue;
2284
+ }
2285
+ skipLF = false;
2286
+
2287
+ const piece = text.slice(start, match.index);
2288
+ if (partsLength + piece.length > maxLineLength) throw lineTooLong(maxLineLength);
2289
+ const line = parts.length > 0 ? parts.join("") + piece : piece;
2290
+ parts = [];
2291
+ partsLength = 0;
2292
+ yield line;
2293
+
2294
+ start = match.index + 1;
2295
+ skipLF = ch === "\r";
2296
+ }
2297
+
2298
+ if (start < text.length) {
2299
+ const rest = text.slice(start);
2300
+ partsLength += rest.length;
2301
+ if (partsLength > maxLineLength) throw lineTooLong(maxLineLength);
2302
+ parts.push(rest);
2303
+ skipLF = false;
2195
2304
  }
2196
- buffer = buffer.slice(pos);
2197
2305
  }
2198
2306
 
2199
- buffer += decoder.decode();
2200
- if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
2201
- if (buffer.length > 0) yield buffer;
2307
+ const tail = parts.join("") + decoder.decode();
2308
+ if (tail.length > 0) yield tail;
2202
2309
  }
2203
2310
 
2204
2311
  /**
@@ -2238,11 +2345,13 @@ async function* readNDJSON(source, opts = {}) {
2238
2345
  * ```
2239
2346
  *
2240
2347
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
2241
- * @param {{ signal?: AbortSignal }} [opts]
2348
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts] - `maxLineLength` also caps the data of a single event
2242
2349
  */
2243
2350
  async function* readSSE(source, opts = {}) {
2351
+ const maxEventLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
2244
2352
  let event = "";
2245
2353
  let data = [];
2354
+ let dataLength = 0;
2246
2355
  let retry;
2247
2356
  let lastId = "";
2248
2357
 
@@ -2251,6 +2360,7 @@ async function* readSSE(source, opts = {}) {
2251
2360
  if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
2252
2361
  event = "";
2253
2362
  data = [];
2363
+ dataLength = 0;
2254
2364
  retry = undefined;
2255
2365
  continue;
2256
2366
  }
@@ -2262,7 +2372,11 @@ async function* readSSE(source, opts = {}) {
2262
2372
  if (value.startsWith(" ")) value = value.slice(1);
2263
2373
 
2264
2374
  if (field === "event") event = value;
2265
- else if (field === "data") data.push(value);
2375
+ else if (field === "data") {
2376
+ dataLength += value.length + 1;
2377
+ if (dataLength > maxEventLength) throw lineTooLong(maxEventLength);
2378
+ data.push(value);
2379
+ }
2266
2380
  else if (field === "id") {
2267
2381
  if (!value.includes("\0")) lastId = value;
2268
2382
  } else if (field === "retry") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
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.",
5
5
  "main": "./src/index.js",
6
6
  "types": "./types/index.d.ts",
package/src/_http.js CHANGED
@@ -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 };
@@ -9,11 +9,13 @@ const { createRateLimiter } = require("./createRateLimiter");
9
9
  const { createCircuitBreaker } = require("./createCircuitBreaker");
10
10
  const { createMemoryCache } = require("./createMemoryCache");
11
11
  const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
12
- const { discardBody } = require("./_http");
12
+ const { discardBody, getOrigin } = require("./_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 —
@@ -101,6 +99,9 @@ function mergeDefaults(base, extra) {
101
99
  * - concurrent GET de-duplication (withDedupe)
102
100
  * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
103
101
  * concurrent requests) and replay the request
102
+ * - credential safety: default credentials (`auth`, and default headers like
103
+ * Authorization/Cookie/X-API-Key) are only sent to `baseUrl`'s origin and
104
+ * `trustedOrigins` — pass an absolute URL to another host and they stay home
104
105
  * - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
105
106
  * - `onRequest` / `onResponse` hooks
106
107
  *
@@ -123,6 +124,9 @@ function mergeDefaults(base, extra) {
123
124
  * whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
124
125
  * called once for all concurrent callers and the request is replayed with the
125
126
  * new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
127
+ * @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
128
+ * receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
129
+ * `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
126
130
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
127
131
  * @param {boolean} [defaults.throwHttpErrors=true]
128
132
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
@@ -140,6 +144,7 @@ function createFetch(defaults = {}) {
140
144
  circuitBreaker,
141
145
  cache,
142
146
  auth,
147
+ trustedOrigins,
143
148
  dedupe = false,
144
149
  throwHttpErrors: defaultThrow = true,
145
150
  fetch: customFetch,
@@ -149,6 +154,18 @@ function createFetch(defaults = {}) {
149
154
 
150
155
  const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
151
156
 
157
+ // Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
158
+ const trusted = new Set();
159
+ for (const o of [baseUrl, ...(trustedOrigins || [])]) {
160
+ const origin = o && getOrigin(o);
161
+ if (origin) trusted.add(origin);
162
+ }
163
+ const isTrustedTarget = (url) => {
164
+ if (trusted.size === 0 || !isAbsoluteUrl(url)) return true; // relative URLs resolve against our own base
165
+ const origin = getOrigin(url);
166
+ return origin !== null && trusted.has(origin);
167
+ };
168
+
152
169
  // --- stateful pieces, built once so `extend()` children share them -------
153
170
  const limiter = !rateLimit
154
171
  ? null
@@ -245,7 +262,18 @@ function createFetch(defaults = {}) {
245
262
  } = init;
246
263
 
247
264
  const method = String(fetchInit.method || "GET").toUpperCase();
248
- const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
265
+ const url = joinUrl(baseUrl, input, query);
266
+ const credentialsAllowed = isTrustedTarget(url);
267
+
268
+ const defaultHeaderSet = await resolveHeaders(defaultHeaders);
269
+ if (!credentialsAllowed) {
270
+ // An absolute URL to some other host: don't hand it our default credentials.
271
+ for (const name of [...SENSITIVE_HEADERS, ...(auth && auth.header ? [String(auth.header).toLowerCase()] : [])]) {
272
+ defaultHeaderSet.delete(name);
273
+ }
274
+ }
275
+ // Headers the caller passes for *this* call are deliberate, so they always go through.
276
+ const headers = mergeHeaders(defaultHeaderSet, new Headers(initHeaders || undefined));
249
277
 
250
278
  let body = fetchInit.body;
251
279
  if (json !== undefined) {
@@ -253,7 +281,7 @@ function createFetch(defaults = {}) {
253
281
  if (!headers.has("content-type")) headers.set("content-type", "application/json");
254
282
  }
255
283
 
256
- const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
284
+ const ctx = { url, init: { ...fetchInit, method, headers, body } };
257
285
  if (onRequest) await onRequest(ctx);
258
286
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
259
287
  const finalMethod = String(ctx.init.method || method).toUpperCase();
@@ -262,7 +290,7 @@ function createFetch(defaults = {}) {
262
290
  const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
263
291
  const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
264
292
  let usedToken = null;
265
- if (auth && !finalHeaders.has(authHeader)) {
293
+ if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
266
294
  usedToken = (await auth.getToken()) || null;
267
295
  if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
268
296
  }
@@ -437,6 +465,9 @@ function createFetch(defaults = {}) {
437
465
  return finish(response);
438
466
  }
439
467
 
468
+ /** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
469
+ request.baseUrl = baseUrl;
470
+
440
471
  /** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
441
472
  request.json = async (input, init) => {
442
473
  const response = await request(input, init);
package/src/paginate.js CHANGED
@@ -1,4 +1,7 @@
1
1
  const { HTTPError } = require("./HTTPError");
2
+ const { getOrigin } = require("./_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
 
package/src/streams.js CHANGED
@@ -34,46 +34,83 @@ async function* chunks(source, signal) {
34
34
  }
35
35
  }
36
36
 
37
+ const DEFAULT_MAX_LINE_LENGTH = 16 * 1024 * 1024;
38
+
39
+ function lineTooLong(limit) {
40
+ return Object.assign(
41
+ new Error(`reqkit streams: a line exceeded ${limit} characters without a line break (raise \`maxLineLength\` if that's expected)`),
42
+ { code: "LINE_TOO_LONG" }
43
+ );
44
+ }
45
+
37
46
  /**
38
47
  * Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
39
48
  * multi-byte characters split across chunks, and a leading BOM.
40
49
  *
50
+ * Runs in linear time and bounded memory: only newly arrived text is scanned,
51
+ * and a "line" longer than `maxLineLength` characters (default 16 Mi — a
52
+ * server that never sends a newline) throws `code: "LINE_TOO_LONG"` instead
53
+ * of buffering forever.
54
+ *
41
55
  * ```js
42
56
  * for await (const line of readLines(response)) console.log(line);
43
57
  * ```
44
58
  *
45
59
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
46
- * @param {{ signal?: AbortSignal }} [opts]
60
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts]
47
61
  * @returns {AsyncGenerator<string>}
48
62
  */
49
63
  async function* readLines(source, opts = {}) {
64
+ const { maxLineLength = DEFAULT_MAX_LINE_LENGTH } = opts;
50
65
  const decoder = new TextDecoder("utf-8");
51
- let buffer = "";
66
+ const terminator = /[\r\n]/g;
67
+
68
+ let parts = []; // pieces of the line being assembled (no terminators in them)
69
+ let partsLength = 0;
70
+ let skipLF = false; // the previous line ended with "\r", so a leading "\n" belongs to it
52
71
  let first = true;
53
72
 
54
73
  for await (const chunk of chunks(source, opts.signal)) {
55
- buffer += decoder.decode(chunk, { stream: true });
56
- if (first && buffer.length > 0) {
57
- buffer = buffer.replace(/^\uFEFF/, "");
74
+ let text = decoder.decode(chunk, { stream: true });
75
+ if (first && text.length > 0) {
76
+ text = text.replace(/^\uFEFF/, "");
58
77
  first = false;
59
78
  }
60
79
 
61
- let pos = 0;
62
- for (;;) {
63
- const rest = buffer.slice(pos);
64
- const i = rest.search(/\r\n|\n|\r/);
65
- if (i === -1) break;
66
- // A trailing "\r" might be the first half of "\r\n" — wait for more data.
67
- if (rest[i] === "\r" && i === rest.length - 1) break;
68
- yield rest.slice(0, i);
69
- pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
80
+ let start = 0;
81
+ terminator.lastIndex = 0;
82
+ let match;
83
+ while ((match = terminator.exec(text)) !== null) {
84
+ const ch = match[0];
85
+ if (ch === "\n" && skipLF && match.index === start) {
86
+ skipLF = false; // the "\n" of a "\r\n" pair — swallow it
87
+ start = match.index + 1;
88
+ continue;
89
+ }
90
+ skipLF = false;
91
+
92
+ const piece = text.slice(start, match.index);
93
+ if (partsLength + piece.length > maxLineLength) throw lineTooLong(maxLineLength);
94
+ const line = parts.length > 0 ? parts.join("") + piece : piece;
95
+ parts = [];
96
+ partsLength = 0;
97
+ yield line;
98
+
99
+ start = match.index + 1;
100
+ skipLF = ch === "\r";
101
+ }
102
+
103
+ if (start < text.length) {
104
+ const rest = text.slice(start);
105
+ partsLength += rest.length;
106
+ if (partsLength > maxLineLength) throw lineTooLong(maxLineLength);
107
+ parts.push(rest);
108
+ skipLF = false;
70
109
  }
71
- buffer = buffer.slice(pos);
72
110
  }
73
111
 
74
- buffer += decoder.decode();
75
- if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
76
- if (buffer.length > 0) yield buffer;
112
+ const tail = parts.join("") + decoder.decode();
113
+ if (tail.length > 0) yield tail;
77
114
  }
78
115
 
79
116
  /**
@@ -113,11 +150,13 @@ async function* readNDJSON(source, opts = {}) {
113
150
  * ```
114
151
  *
115
152
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
116
- * @param {{ signal?: AbortSignal }} [opts]
153
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts] - `maxLineLength` also caps the data of a single event
117
154
  */
118
155
  async function* readSSE(source, opts = {}) {
156
+ const maxEventLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
119
157
  let event = "";
120
158
  let data = [];
159
+ let dataLength = 0;
121
160
  let retry;
122
161
  let lastId = "";
123
162
 
@@ -126,6 +165,7 @@ async function* readSSE(source, opts = {}) {
126
165
  if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
127
166
  event = "";
128
167
  data = [];
168
+ dataLength = 0;
129
169
  retry = undefined;
130
170
  continue;
131
171
  }
@@ -137,7 +177,11 @@ async function* readSSE(source, opts = {}) {
137
177
  if (value.startsWith(" ")) value = value.slice(1);
138
178
 
139
179
  if (field === "event") event = value;
140
- else if (field === "data") data.push(value);
180
+ else if (field === "data") {
181
+ dataLength += value.length + 1;
182
+ if (dataLength > maxEventLength) throw lineTooLong(maxEventLength);
183
+ data.push(value);
184
+ }
141
185
  else if (field === "id") {
142
186
  if (!value.includes("\0")) lastId = value;
143
187
  } else if (field === "retry") {
package/types/index.d.ts CHANGED
@@ -210,6 +210,13 @@ export interface CreateFetchDefaults {
210
210
  rateLimit?: boolean | RateLimiterOptions | RateLimiter;
211
211
  /** Options for a per-origin breaker, a shared breaker instance, or `(url) => breaker`. */
212
212
  circuitBreaker?: boolean | CircuitBreakerOptions | CircuitBreaker | ((url: string) => CircuitBreaker);
213
+ /**
214
+ * Extra origins ("https://cdn.example.com") that may receive the default credentials
215
+ * (`auth` and default Authorization/Cookie/X-API-Key headers), besides `baseUrl`'s.
216
+ * Absolute URLs to any other origin don't get them. With neither `baseUrl` nor
217
+ * `trustedOrigins` there's nothing to compare against and credentials go everywhere.
218
+ */
219
+ trustedOrigins?: string[];
213
220
  /** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
214
221
  auth?: AuthOptions;
215
222
  /** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
@@ -237,6 +244,8 @@ export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"
237
244
 
238
245
  export interface ReqkitFetch {
239
246
  (input: string, init?: ReqkitRequestInit): Promise<Response>;
247
+ /** The `baseUrl` this instance was created with. */
248
+ readonly baseUrl: string | undefined;
240
249
  /** Resolves to the parsed JSON body (null for an empty body). */
241
250
  json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
242
251
  /** New instance with these defaults merged over the current ones. */
@@ -392,6 +401,12 @@ export type StreamSource = Response | ReadableStream<Uint8Array> | AsyncIterable
392
401
 
393
402
  export interface StreamOptions {
394
403
  signal?: AbortSignal;
404
+ /**
405
+ * Longest line (or, for readSSE, one event's data) in characters before throwing
406
+ * `code: "LINE_TOO_LONG"` — protects against a server that never sends a newline.
407
+ * Default 16 Mi (16 * 1024 * 1024); pass Infinity to disable.
408
+ */
409
+ maxLineLength?: number;
395
410
  }
396
411
 
397
412
  export interface SSEEvent {
@@ -505,6 +520,11 @@ export interface PaginateLinksOptions<T = any> {
505
520
  signal?: AbortSignal;
506
521
  /** Extra options passed to every request. */
507
522
  init?: Record<string, any>;
523
+ /**
524
+ * Origins a `Link` may point to. Default: the origin of `url` (or of the createFetch
525
+ * instance's `baseUrl`). Any other origin throws `code: "PAGINATE_CROSS_ORIGIN"`.
526
+ */
527
+ origins?: string[];
508
528
  }
509
529
 
510
530
  /** Follows `Link: <...>; rel="next"` headers and yields the items of every page. */