@boy-offi9-inc/reqkit 0.6.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/dist/reqkit.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @boy-offi9-inc/reqkit v0.6.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,19 @@ 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;
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);
454
473
  }
474
+ return state;
455
475
  }
456
476
 
457
477
  function mergeDefaults(base, extra) {
@@ -487,6 +507,11 @@ function mergeDefaults(base, extra) {
487
507
  * - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
488
508
  * ETag / Last-Modified revalidation (304s)
489
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
490
515
  * - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
491
516
  * - `onRequest` / `onResponse` hooks
492
517
  *
@@ -504,6 +529,14 @@ function mergeDefaults(base, extra) {
504
529
  * a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
505
530
  * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
506
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.
507
540
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
508
541
  * @param {boolean} [defaults.throwHttpErrors=true]
509
542
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
@@ -520,6 +553,8 @@ function createFetch(defaults = {}) {
520
553
  rateLimit,
521
554
  circuitBreaker,
522
555
  cache,
556
+ auth,
557
+ trustedOrigins,
523
558
  dedupe = false,
524
559
  throwHttpErrors: defaultThrow = true,
525
560
  fetch: customFetch,
@@ -529,6 +564,18 @@ function createFetch(defaults = {}) {
529
564
 
530
565
  const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
531
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
+
532
579
  // --- stateful pieces, built once so `extend()` children share them -------
533
580
  const limiter = !rateLimit
534
581
  ? null
@@ -590,6 +637,27 @@ function createFetch(defaults = {}) {
590
637
  pendingStores.set(key, p);
591
638
  }
592
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
+
593
661
  async function request(input, init = {}) {
594
662
  const {
595
663
  query,
@@ -604,7 +672,18 @@ function createFetch(defaults = {}) {
604
672
  } = init;
605
673
 
606
674
  const method = String(fetchInit.method || "GET").toUpperCase();
607
- 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));
608
687
 
609
688
  let body = fetchInit.body;
610
689
  if (json !== undefined) {
@@ -612,11 +691,20 @@ function createFetch(defaults = {}) {
612
691
  if (!headers.has("content-type")) headers.set("content-type", "application/json");
613
692
  }
614
693
 
615
- const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
694
+ const ctx = { url, init: { ...fetchInit, method, headers, body } };
616
695
  if (onRequest) await onRequest(ctx);
617
696
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
618
697
  const finalMethod = String(ctx.init.method || method).toUpperCase();
619
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
+
620
708
  // --- retry policy ------------------------------------------------------
621
709
  const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
622
710
  const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
@@ -692,7 +780,7 @@ function createFetch(defaults = {}) {
692
780
  !ctx.init.body &&
693
781
  cacheMode !== false &&
694
782
  cacheMode !== "no-store";
695
- const key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
783
+ let key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
696
784
  let entry = null;
697
785
 
698
786
  const finish = async (response) => {
@@ -753,6 +841,24 @@ function createFetch(defaults = {}) {
753
841
  throw err;
754
842
  }
755
843
 
844
+ // --- auth: a rejected token is refreshed once, then the request replays ----
845
+ const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
846
+ if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
847
+ const fresh = await obtainFreshToken(auth, usedToken, response);
848
+ if (fresh) {
849
+ await discardBody(response);
850
+ finalHeaders.set(authHeader, formatToken(fresh));
851
+ entry = null; // belongs to the old token's cache key
852
+ if (useCache) key = cacheKey(finalMethod, ctx.url, finalHeaders);
853
+ try {
854
+ response = await network(finalHeaders, signal);
855
+ } catch (err) {
856
+ if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
857
+ throw err;
858
+ }
859
+ }
860
+ }
861
+
756
862
  if (useCache) {
757
863
  if (response.status === 304 && entry) {
758
864
  entry.storedAt = Date.now();
@@ -769,6 +875,9 @@ function createFetch(defaults = {}) {
769
875
  return finish(response);
770
876
  }
771
877
 
878
+ /** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
879
+ request.baseUrl = baseUrl;
880
+
772
881
  /** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
773
882
  request.json = async (input, init) => {
774
883
  const response = await request(input, init);
@@ -1414,6 +1523,7 @@ const { createMockFetch } = require("./createMockFetch");
1414
1523
  const { downloadResumable } = require("./downloadResumable");
1415
1524
  const { readLines, readNDJSON, readSSE } = require("./streams");
1416
1525
  const { streamSSE } = require("./streamSSE");
1526
+ const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
1417
1527
 
1418
1528
  module.exports = {
1419
1529
  withRetry,
@@ -1443,6 +1553,10 @@ module.exports = {
1443
1553
  readNDJSON,
1444
1554
  readSSE,
1445
1555
  streamSSE,
1556
+ paginate,
1557
+ paginatePages,
1558
+ paginateLinks,
1559
+ parseLinkHeader,
1446
1560
  };
1447
1561
 
1448
1562
  },
@@ -1572,6 +1686,204 @@ function normalizeError(err) {
1572
1686
 
1573
1687
  module.exports = { normalizeError };
1574
1688
 
1689
+ },
1690
+ "./paginate": function (module, exports, require) {
1691
+ const { HTTPError } = require("./HTTPError");
1692
+ const { getOrigin } = require("./_http");
1693
+
1694
+ const MAX_LINK_HEADER_LENGTH = 64 * 1024;
1695
+
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;
1717
+
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;
1724
+
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;
1738
+ }
1739
+
1740
+ function stuckError(message) {
1741
+ return Object.assign(new Error(message), { code: "PAGINATE_STUCK" });
1742
+ }
1743
+
1744
+ /**
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
+ * ```
1756
+ *
1757
+ * A `next` that equals the cursor just used throws `code: "PAGINATE_STUCK"`
1758
+ * instead of looping forever.
1759
+ *
1760
+ * @template T
1761
+ * @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
1762
+ * @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 }>}
1767
+ */
1768
+ async function* paginatePages(fetchPage, opts = {}) {
1769
+ const { start, maxPages = Infinity, signal } = opts;
1770
+ let cursor = start;
1771
+ let index = 0;
1772
+
1773
+ for (;;) {
1774
+ if (index >= maxPages) return;
1775
+ signal?.throwIfAborted?.();
1776
+
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 }");
1780
+ }
1781
+ yield { items: result.items, index, cursor };
1782
+
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
+ }
1791
+ }
1792
+
1793
+ /**
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.
1797
+ *
1798
+ * ```js
1799
+ * for await (const user of paginate(fetchUsersPage, { maxItems: 250 })) { ... }
1800
+ * ```
1801
+ *
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>}
1807
+ */
1808
+ async function* paginate(fetchPage, opts = {}) {
1809
+ const { maxItems = Infinity } = opts;
1810
+ if (maxItems <= 0) return;
1811
+
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;
1817
+ }
1818
+ }
1819
+ }
1820
+
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];
1825
+ }
1826
+ throw new TypeError("paginateLinks: couldn't find the items array in the response — pass `getItems(data)`");
1827
+ }
1828
+
1829
+ /**
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`).
1833
+ *
1834
+ * ```js
1835
+ * for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) { ... }
1836
+ * ```
1837
+ *
1838
+ * @param {(url: string, init?: object) => Promise<Response>} request
1839
+ * @param {string} url - the first page
1840
+ * @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.
1850
+ */
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);
1863
+ }
1864
+
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
+ );
1877
+ }
1878
+ }
1879
+ return { items, next };
1880
+ }
1881
+
1882
+ yield* paginate(fetchPage, { ...opts, start: url });
1883
+ }
1884
+
1885
+ module.exports = { paginate, paginatePages, paginateLinks, parseLinkHeader };
1886
+
1575
1887
  },
1576
1888
  "./parseJsonSafe": function (module, exports, require) {
1577
1889
  /**
@@ -1917,46 +2229,83 @@ async function* chunks(source, signal) {
1917
2229
  }
1918
2230
  }
1919
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
+
1920
2241
  /**
1921
2242
  * Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
1922
2243
  * multi-byte characters split across chunks, and a leading BOM.
1923
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
+ *
1924
2250
  * ```js
1925
2251
  * for await (const line of readLines(response)) console.log(line);
1926
2252
  * ```
1927
2253
  *
1928
2254
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
1929
- * @param {{ signal?: AbortSignal }} [opts]
2255
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts]
1930
2256
  * @returns {AsyncGenerator<string>}
1931
2257
  */
1932
2258
  async function* readLines(source, opts = {}) {
2259
+ const { maxLineLength = DEFAULT_MAX_LINE_LENGTH } = opts;
1933
2260
  const decoder = new TextDecoder("utf-8");
1934
- 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
1935
2266
  let first = true;
1936
2267
 
1937
2268
  for await (const chunk of chunks(source, opts.signal)) {
1938
- buffer += decoder.decode(chunk, { stream: true });
1939
- if (first && buffer.length > 0) {
1940
- buffer = buffer.replace(/^\uFEFF/, "");
2269
+ let text = decoder.decode(chunk, { stream: true });
2270
+ if (first && text.length > 0) {
2271
+ text = text.replace(/^\uFEFF/, "");
1941
2272
  first = false;
1942
2273
  }
1943
2274
 
1944
- let pos = 0;
1945
- for (;;) {
1946
- const rest = buffer.slice(pos);
1947
- const i = rest.search(/\r\n|\n|\r/);
1948
- if (i === -1) break;
1949
- // A trailing "\r" might be the first half of "\r\n" — wait for more data.
1950
- if (rest[i] === "\r" && i === rest.length - 1) break;
1951
- yield rest.slice(0, i);
1952
- 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;
1953
2304
  }
1954
- buffer = buffer.slice(pos);
1955
2305
  }
1956
2306
 
1957
- buffer += decoder.decode();
1958
- if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
1959
- if (buffer.length > 0) yield buffer;
2307
+ const tail = parts.join("") + decoder.decode();
2308
+ if (tail.length > 0) yield tail;
1960
2309
  }
1961
2310
 
1962
2311
  /**
@@ -1996,11 +2345,13 @@ async function* readNDJSON(source, opts = {}) {
1996
2345
  * ```
1997
2346
  *
1998
2347
  * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
1999
- * @param {{ signal?: AbortSignal }} [opts]
2348
+ * @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts] - `maxLineLength` also caps the data of a single event
2000
2349
  */
2001
2350
  async function* readSSE(source, opts = {}) {
2351
+ const maxEventLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
2002
2352
  let event = "";
2003
2353
  let data = [];
2354
+ let dataLength = 0;
2004
2355
  let retry;
2005
2356
  let lastId = "";
2006
2357
 
@@ -2009,6 +2360,7 @@ async function* readSSE(source, opts = {}) {
2009
2360
  if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
2010
2361
  event = "";
2011
2362
  data = [];
2363
+ dataLength = 0;
2012
2364
  retry = undefined;
2013
2365
  continue;
2014
2366
  }
@@ -2020,7 +2372,11 @@ async function* readSSE(source, opts = {}) {
2020
2372
  if (value.startsWith(" ")) value = value.slice(1);
2021
2373
 
2022
2374
  if (field === "event") event = value;
2023
- 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
+ }
2024
2380
  else if (field === "id") {
2025
2381
  if (!value.includes("\0")) lastId = value;
2026
2382
  } else if (field === "retry") {
@@ -2492,7 +2848,11 @@ export const {
2492
2848
  downloadResumable,
2493
2849
  getWaitMsFromHeaders,
2494
2850
  normalizeError,
2851
+ paginate,
2852
+ paginateLinks,
2853
+ paginatePages,
2495
2854
  parseJsonSafe,
2855
+ parseLinkHeader,
2496
2856
  parseRetryAfter,
2497
2857
  readLines,
2498
2858
  readNDJSON,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.6.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",
@@ -57,7 +57,11 @@
57
57
  "deno",
58
58
  "bun",
59
59
  "cloudflare-workers",
60
- "esm"
60
+ "esm",
61
+ "pagination",
62
+ "auth",
63
+ "token-refresh",
64
+ "link-header"
61
65
  ],
62
66
  "author": "Boy Offi9",
63
67
  "license": "MIT",
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 };