@boy-offi9-inc/reqkit 0.5.0 → 0.7.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.
package/dist/reqkit.mjs CHANGED
@@ -1,4 +1,4 @@
1
- /*! @boy-offi9-inc/reqkit v0.5.0 — ESM bundle (generated by scripts/build-esm.js, do not edit) */
1
+ /*! @boy-offi9-inc/reqkit v0.7.0 — 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 {
@@ -454,6 +454,18 @@ function originOf(url) {
454
454
  }
455
455
  }
456
456
 
457
+ // One in-flight token refresh per `auth` config object, so concurrent 401s —
458
+ // even across extend() children — trigger exactly one refresh.
459
+ const refreshStates = new WeakMap();
460
+ function refreshStateFor(auth) {
461
+ let state = refreshStates.get(auth);
462
+ if (!state) {
463
+ state = { inflight: null };
464
+ refreshStates.set(auth, state);
465
+ }
466
+ return state;
467
+ }
468
+
457
469
  function mergeDefaults(base, extra) {
458
470
  const merged = { ...base, ...extra };
459
471
 
@@ -487,6 +499,8 @@ function mergeDefaults(base, extra) {
487
499
  * - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
488
500
  * ETag / Last-Modified revalidation (304s)
489
501
  * - concurrent GET de-duplication (withDedupe)
502
+ * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
503
+ * concurrent requests) and replay the request
490
504
  * - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
491
505
  * - `onRequest` / `onResponse` hooks
492
506
  *
@@ -504,6 +518,11 @@ function mergeDefaults(base, extra) {
504
518
  * a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
505
519
  * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
506
520
  * maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
521
+ * @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
522
+ * `getToken()` supplies the current token (sync or async); on a 401 (or
523
+ * whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
524
+ * called once for all concurrent callers and the request is replayed with the
525
+ * new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
507
526
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
508
527
  * @param {boolean} [defaults.throwHttpErrors=true]
509
528
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
@@ -520,6 +539,7 @@ function createFetch(defaults = {}) {
520
539
  rateLimit,
521
540
  circuitBreaker,
522
541
  cache,
542
+ auth,
523
543
  dedupe = false,
524
544
  throwHttpErrors: defaultThrow = true,
525
545
  fetch: customFetch,
@@ -590,6 +610,27 @@ function createFetch(defaults = {}) {
590
610
  pendingStores.set(key, p);
591
611
  }
592
612
 
613
+ // Returns a token newer than `usedToken`, refreshing at most once no matter
614
+ // how many requests were rejected at the same time. If someone else already
615
+ // refreshed (the current token isn't the one we used), just use that.
616
+ async function obtainFreshToken(authCfg, tokenUsed, response) {
617
+ const current = await authCfg.getToken();
618
+ if (current && current !== tokenUsed) return current;
619
+
620
+ const state = refreshStateFor(authCfg);
621
+ if (!state.inflight) {
622
+ state.inflight = (async () => {
623
+ try {
624
+ const out = await authCfg.refresh({ token: tokenUsed, response });
625
+ return typeof out === "string" && out ? out : await authCfg.getToken();
626
+ } finally {
627
+ state.inflight = null;
628
+ }
629
+ })();
630
+ }
631
+ return state.inflight;
632
+ }
633
+
593
634
  async function request(input, init = {}) {
594
635
  const {
595
636
  query,
@@ -617,6 +658,15 @@ function createFetch(defaults = {}) {
617
658
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
618
659
  const finalMethod = String(ctx.init.method || method).toUpperCase();
619
660
 
661
+ // --- auth: attach the current token (unless the caller set the header) ----
662
+ const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
663
+ const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
664
+ let usedToken = null;
665
+ if (auth && !finalHeaders.has(authHeader)) {
666
+ usedToken = (await auth.getToken()) || null;
667
+ if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
668
+ }
669
+
620
670
  // --- retry policy ------------------------------------------------------
621
671
  const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
622
672
  const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
@@ -692,7 +742,7 @@ function createFetch(defaults = {}) {
692
742
  !ctx.init.body &&
693
743
  cacheMode !== false &&
694
744
  cacheMode !== "no-store";
695
- const key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
745
+ let key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
696
746
  let entry = null;
697
747
 
698
748
  const finish = async (response) => {
@@ -753,6 +803,24 @@ function createFetch(defaults = {}) {
753
803
  throw err;
754
804
  }
755
805
 
806
+ // --- auth: a rejected token is refreshed once, then the request replays ----
807
+ const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
808
+ if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
809
+ const fresh = await obtainFreshToken(auth, usedToken, response);
810
+ if (fresh) {
811
+ await discardBody(response);
812
+ finalHeaders.set(authHeader, formatToken(fresh));
813
+ entry = null; // belongs to the old token's cache key
814
+ if (useCache) key = cacheKey(finalMethod, ctx.url, finalHeaders);
815
+ try {
816
+ response = await network(finalHeaders, signal);
817
+ } catch (err) {
818
+ if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
819
+ throw err;
820
+ }
821
+ }
822
+ }
823
+
756
824
  if (useCache) {
757
825
  if (response.status === 304 && entry) {
758
826
  entry.storedAt = Date.now();
@@ -1413,6 +1481,8 @@ const { createMemoryCache } = require("./createMemoryCache");
1413
1481
  const { createMockFetch } = require("./createMockFetch");
1414
1482
  const { downloadResumable } = require("./downloadResumable");
1415
1483
  const { readLines, readNDJSON, readSSE } = require("./streams");
1484
+ const { streamSSE } = require("./streamSSE");
1485
+ const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
1416
1486
 
1417
1487
  module.exports = {
1418
1488
  withRetry,
@@ -1441,6 +1511,11 @@ module.exports = {
1441
1511
  readLines,
1442
1512
  readNDJSON,
1443
1513
  readSSE,
1514
+ streamSSE,
1515
+ paginate,
1516
+ paginatePages,
1517
+ paginateLinks,
1518
+ parseLinkHeader,
1444
1519
  };
1445
1520
 
1446
1521
  },
@@ -1501,7 +1576,7 @@ function isNetworkCode(code) {
1501
1576
 
1502
1577
  // Browsers and Node throw a bare TypeError for network-level fetch failures.
1503
1578
  const FETCH_NETWORK_MESSAGE =
1504
- /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch/i;
1579
+ /fetch failed|failed to fetch|network ?request failed|load failed|networkerror when attempting to fetch|^terminated$|^network error$/i;
1505
1580
 
1506
1581
  function isFetchNetworkTypeError(err) {
1507
1582
  return err.name === "TypeError" && FETCH_NETWORK_MESSAGE.test(err.message || "");
@@ -1570,6 +1645,175 @@ function normalizeError(err) {
1570
1645
 
1571
1646
  module.exports = { normalizeError };
1572
1647
 
1648
+ },
1649
+ "./paginate": function (module, exports, require) {
1650
+ const { HTTPError } = require("./HTTPError");
1651
+
1652
+ /**
1653
+ * Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
1654
+ * pagination) into `{ rel: url }`. Pass `base` to resolve relative URLs.
1655
+ *
1656
+ * ```js
1657
+ * parseLinkHeader('<https://api.x/items?page=2>; rel="next", <https://api.x/items?page=9>; rel="last"');
1658
+ * // { next: "https://api.x/items?page=2", last: "https://api.x/items?page=9" }
1659
+ * ```
1660
+ *
1661
+ * A link with several rels (`rel="next last"`) is listed under each; the first
1662
+ * link wins when a rel appears twice. Commas inside `<...>` are handled.
1663
+ *
1664
+ * @param {string | null | undefined} header
1665
+ * @param {string} [base]
1666
+ * @returns {Record<string, string>}
1667
+ */
1668
+ function parseLinkHeader(header, base) {
1669
+ const links = {};
1670
+ if (!header) return links;
1671
+
1672
+ // <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
1673
+ const linkRe = /<([^>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
1674
+ let match;
1675
+ while ((match = linkRe.exec(header)) !== null) {
1676
+ const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
1677
+ if (!rel) continue;
1678
+
1679
+ let url = match[1].trim();
1680
+ if (base) {
1681
+ try {
1682
+ url = new URL(url, base).href;
1683
+ } catch {
1684
+ // leave it as written
1685
+ }
1686
+ }
1687
+ for (const name of (rel[1] ?? rel[2]).toLowerCase().split(/\s+/).filter(Boolean)) {
1688
+ if (!(name in links)) links[name] = url;
1689
+ }
1690
+ }
1691
+ return links;
1692
+ }
1693
+
1694
+ function stuckError(message) {
1695
+ return Object.assign(new Error(message), { code: "PAGINATE_STUCK" });
1696
+ }
1697
+
1698
+ /**
1699
+ * Walks a paginated API page by page. You write `fetchPage(cursor, ctx)` —
1700
+ * it returns `{ items, next }`, where `next` is whatever identifies the next
1701
+ * page (a cursor token, an offset, a URL...) or `undefined`/`null` on the
1702
+ * last page — and you get an async iterator of pages.
1703
+ *
1704
+ * ```js
1705
+ * for await (const { items, index } of paginatePages(async (cursor) => {
1706
+ * const r = await api.json("/users", { query: { cursor, limit: 100 } });
1707
+ * return { items: r.data, next: r.next_cursor };
1708
+ * })) { ... }
1709
+ * ```
1710
+ *
1711
+ * A `next` that equals the cursor just used throws `code: "PAGINATE_STUCK"`
1712
+ * instead of looping forever.
1713
+ *
1714
+ * @template T
1715
+ * @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
1716
+ * @param {object} [opts]
1717
+ * @param {any} [opts.start] - the cursor for the first page (default undefined)
1718
+ * @param {number} [opts.maxPages=Infinity]
1719
+ * @param {AbortSignal} [opts.signal]
1720
+ * @returns {AsyncGenerator<{ items: T[], index: number, cursor: any }>}
1721
+ */
1722
+ async function* paginatePages(fetchPage, opts = {}) {
1723
+ const { start, maxPages = Infinity, signal } = opts;
1724
+ let cursor = start;
1725
+ let index = 0;
1726
+
1727
+ for (;;) {
1728
+ if (index >= maxPages) return;
1729
+ signal?.throwIfAborted?.();
1730
+
1731
+ const result = await fetchPage(cursor, { index, signal });
1732
+ if (!result || !Array.isArray(result.items)) {
1733
+ throw new TypeError("paginate: fetchPage must return { items: array, next?: cursor }");
1734
+ }
1735
+ yield { items: result.items, index, cursor };
1736
+
1737
+ const next = result.next;
1738
+ if (next === undefined || next === null) return;
1739
+ if (next === cursor) {
1740
+ throw stuckError("paginate: `next` equals the cursor just used, so the page didn't advance — stopping to avoid an infinite loop");
1741
+ }
1742
+ cursor = next;
1743
+ index++;
1744
+ }
1745
+ }
1746
+
1747
+ /**
1748
+ * Like `paginatePages`, but yields the items one by one. `maxItems` stops
1749
+ * early (mid-page) — and since nothing is fetched until you ask for it,
1750
+ * `break`-ing out of the loop never fetches a page you didn't need.
1751
+ *
1752
+ * ```js
1753
+ * for await (const user of paginate(fetchUsersPage, { maxItems: 250 })) { ... }
1754
+ * ```
1755
+ *
1756
+ * @template T
1757
+ * @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
1758
+ * @param {object} [opts] - same as paginatePages, plus `maxItems`
1759
+ * @param {number} [opts.maxItems=Infinity]
1760
+ * @returns {AsyncGenerator<T>}
1761
+ */
1762
+ async function* paginate(fetchPage, opts = {}) {
1763
+ const { maxItems = Infinity } = opts;
1764
+ if (maxItems <= 0) return;
1765
+
1766
+ let count = 0;
1767
+ for await (const page of paginatePages(fetchPage, opts)) {
1768
+ for (const item of page.items) {
1769
+ yield item;
1770
+ if (++count >= maxItems) return;
1771
+ }
1772
+ }
1773
+ }
1774
+
1775
+ function defaultGetItems(data) {
1776
+ if (Array.isArray(data)) return data;
1777
+ for (const key of ["items", "data", "results", "records"]) {
1778
+ if (data && Array.isArray(data[key])) return data[key];
1779
+ }
1780
+ throw new TypeError("paginateLinks: couldn't find the items array in the response — pass `getItems(data)`");
1781
+ }
1782
+
1783
+ /**
1784
+ * Follows `Link: <...>; rel="next"` headers (GitHub-style) and yields the
1785
+ * items of every page. `request` is anything fetch-like — a `createFetch`
1786
+ * instance or plain `fetch` (a non-2xx response throws `HTTPError`).
1787
+ *
1788
+ * ```js
1789
+ * for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) { ... }
1790
+ * ```
1791
+ *
1792
+ * @param {(url: string, init?: object) => Promise<Response>} request
1793
+ * @param {string} url - the first page
1794
+ * @param {object} [opts]
1795
+ * @param {(data: any, response: Response) => any[]} [opts.getItems] - default: the body if it's an array, else its `items` / `data` / `results` / `records`
1796
+ * @param {number} [opts.maxPages=Infinity]
1797
+ * @param {number} [opts.maxItems=Infinity]
1798
+ * @param {AbortSignal} [opts.signal]
1799
+ * @param {object} [opts.init] - extra options passed to every `request`
1800
+ */
1801
+ async function* paginateLinks(request, url, opts = {}) {
1802
+ const { getItems = defaultGetItems, init = {}, signal } = opts;
1803
+
1804
+ async function fetchPage(pageUrl) {
1805
+ const res = await request(pageUrl, { ...init, signal });
1806
+ if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
1807
+ const items = getItems(await res.json(), res);
1808
+ const next = parseLinkHeader(res.headers.get("link"), pageUrl).next;
1809
+ return { items, next };
1810
+ }
1811
+
1812
+ yield* paginate(fetchPage, { ...opts, start: url });
1813
+ }
1814
+
1815
+ module.exports = { paginate, paginatePages, paginateLinks, parseLinkHeader };
1816
+
1573
1817
  },
1574
1818
  "./parseJsonSafe": function (module, exports, require) {
1575
1819
  /**
@@ -1677,6 +1921,206 @@ function retryAfterAware(fn, opts = {}) {
1677
1921
 
1678
1922
  module.exports = { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders };
1679
1923
 
1924
+ },
1925
+ "./streamSSE": function (module, exports, require) {
1926
+ const { readSSE } = require("./streams");
1927
+ const { HTTPError } = require("./HTTPError");
1928
+ const { normalizeError } = require("./normalizeError");
1929
+ const { getWaitMsFromHeaders, discardBody } = require("./_http");
1930
+ const { abortableSleep } = require("./_sleep");
1931
+
1932
+ function isRetryableStatus(status) {
1933
+ return status === 408 || status === 425 || status === 429 || status >= 500;
1934
+ }
1935
+
1936
+ function isRetryableError(err) {
1937
+ const n = normalizeError(err);
1938
+ return !n.isAborted && (n.isNetworkError || n.isTimeout);
1939
+ }
1940
+
1941
+ async function resolveHeaders(source) {
1942
+ const value = typeof source === "function" ? await source() : source;
1943
+ return new Headers(value || undefined);
1944
+ }
1945
+
1946
+ /**
1947
+ * Connects to a Server-Sent Events endpoint and yields its events — and when
1948
+ * the connection drops (or the server closes it), reconnects by itself,
1949
+ * sending `Last-Event-ID` so the server can resume where you left off. This is
1950
+ * what the browser's `EventSource` does, but with `fetch`: custom headers,
1951
+ * POST bodies, AbortSignal, your own retry/backoff rules.
1952
+ *
1953
+ * ```js
1954
+ * for await (const { data } of streamSSE("https://api.example.com/events", { signal })) {
1955
+ * console.log(data);
1956
+ * }
1957
+ * ```
1958
+ *
1959
+ * Reconnect behavior:
1960
+ * - `Last-Event-ID` is sent on every reconnect (the last `id:` seen).
1961
+ * - A server `retry: <ms>` field sets the base reconnect delay, like EventSource.
1962
+ * - Waits back off exponentially (capped at `maxDelayMs`) while connecting keeps
1963
+ * failing; 429/503 `Retry-After` is honored. A connection that delivered at
1964
+ * least one event resets the failure count (only no-progress attempts count
1965
+ * toward `retries`).
1966
+ * - HTTP 204 means "stop reconnecting" (iteration ends). A non-retryable
1967
+ * status (e.g. 401/404) or a wrong content-type throws immediately.
1968
+ * - Only GET reconnects by default. For other methods (e.g. a POST that starts
1969
+ * an LLM stream) re-sending the request could repeat side effects, so
1970
+ * nothing is retried unless you pass `reconnect: true`.
1971
+ * - Breaking out of the loop, or aborting `signal`, closes the connection.
1972
+ *
1973
+ * @param {string} url
1974
+ * @param {object} [opts]
1975
+ * @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. a createFetch() instance
1976
+ * @param {string} [opts.method="GET"]
1977
+ * @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [opts.headers] -
1978
+ * a function is called before every (re)connect — handy for refreshing a token
1979
+ * @param {BodyInit} [opts.body]
1980
+ * @param {AbortSignal} [opts.signal]
1981
+ * @param {string} [opts.lastEventId] - resume from here on the very first connect
1982
+ * @param {boolean} [opts.reconnect] - default: true for GET/HEAD, false otherwise
1983
+ * @param {number} [opts.retries=10] - consecutive failed connections before giving up (Infinity = never)
1984
+ * @param {number} [opts.reconnectDelayMs=1000] - base delay (the server's `retry:` overrides it)
1985
+ * @param {number} [opts.maxDelayMs=30000]
1986
+ * @param {(event: object) => boolean} [opts.stopWhen] - end the stream (no reconnect) after this event, e.g. `e => e.data === "[DONE]"`
1987
+ * @param {(info: {connections: number, lastEventId: string}) => void} [opts.onOpen]
1988
+ * @param {(info: {reason: "error"|"end"|"status", error?: any, status?: number, attempt: number, delayMs: number, lastEventId: string}) => void} [opts.onReconnect]
1989
+ * @returns {AsyncGenerator<{event: string, data: string, id: string, retry: number|undefined}>}
1990
+ */
1991
+ async function* streamSSE(url, opts = {}) {
1992
+ const {
1993
+ fetch: fetchFn = globalThis.fetch,
1994
+ method = "GET",
1995
+ headers,
1996
+ body,
1997
+ signal,
1998
+ reconnect = method === "GET" || method === "HEAD",
1999
+ retries = 10,
2000
+ reconnectDelayMs = 1000,
2001
+ maxDelayMs = 30000,
2002
+ stopWhen,
2003
+ onOpen,
2004
+ onReconnect,
2005
+ } = opts;
2006
+
2007
+ let lastId = opts.lastEventId || "";
2008
+ let baseDelay = reconnectDelayMs;
2009
+ let failures = 0; // consecutive connections that delivered nothing
2010
+ let connections = 0;
2011
+
2012
+ for (;;) {
2013
+ signal?.throwIfAborted?.();
2014
+ let failure = null; // { reason, error?, status?, response? }
2015
+ let res = null;
2016
+
2017
+ // ---- connect -----------------------------------------------------------
2018
+ try {
2019
+ const h = await resolveHeaders(headers);
2020
+ if (!h.has("accept")) h.set("accept", "text/event-stream");
2021
+ if (!h.has("cache-control")) h.set("cache-control", "no-cache");
2022
+ if (lastId) h.set("last-event-id", lastId);
2023
+ res = await fetchFn(url, { method, headers: h, body, signal });
2024
+ } catch (err) {
2025
+ if (signal?.aborted) throw signal.reason ?? err;
2026
+ if (err instanceof HTTPError) {
2027
+ if (!isRetryableStatus(err.status)) throw err;
2028
+ failure = { reason: "status", status: err.status, response: err.response, error: err };
2029
+ } else if (isRetryableError(err)) {
2030
+ failure = { reason: "error", error: err };
2031
+ } else {
2032
+ throw err;
2033
+ }
2034
+ }
2035
+
2036
+ // ---- inspect the response ---------------------------------------------
2037
+ if (!failure) {
2038
+ if (res.status === 204) {
2039
+ await discardBody(res);
2040
+ return; // the server's way of saying "don't reconnect"
2041
+ }
2042
+ if (!res.ok) {
2043
+ if (!isRetryableStatus(res.status)) {
2044
+ await discardBody(res);
2045
+ throw new HTTPError(res, { method, url });
2046
+ }
2047
+ failure = { reason: "status", status: res.status, response: res };
2048
+ } else {
2049
+ const type = res.headers.get("content-type") || "";
2050
+ if (!/text\/event-stream/i.test(type)) {
2051
+ await discardBody(res);
2052
+ throw Object.assign(new Error(`streamSSE: expected "text/event-stream" but the server sent "${type || "no content-type"}"`), {
2053
+ code: "SSE_BAD_CONTENT_TYPE",
2054
+ });
2055
+ }
2056
+ }
2057
+ }
2058
+
2059
+ // ---- stream ------------------------------------------------------------
2060
+ let gotEvent = false;
2061
+ if (!failure) {
2062
+ connections++;
2063
+ if (onOpen) onOpen({ connections, lastEventId: lastId });
2064
+ try {
2065
+ for await (const event of readSSE(res, { signal })) {
2066
+ gotEvent = true;
2067
+ lastId = event.id;
2068
+ if (event.retry !== undefined) baseDelay = event.retry;
2069
+ yield event;
2070
+ if (stopWhen && stopWhen(event)) return;
2071
+ }
2072
+ failure = { reason: "end" };
2073
+ } catch (err) {
2074
+ if (signal?.aborted) throw signal.reason ?? err;
2075
+ if (!isRetryableError(err)) throw err;
2076
+ failure = { reason: "error", error: err };
2077
+ }
2078
+ }
2079
+
2080
+ // ---- decide whether and when to reconnect ------------------------------
2081
+ if (!reconnect) {
2082
+ if (failure.reason === "end") return;
2083
+ if (failure.reason === "status") {
2084
+ throw failure.error || new HTTPError(failure.response, { method, url });
2085
+ }
2086
+ throw failure.error;
2087
+ }
2088
+
2089
+ // A connection that delivered events made progress: reconnect promptly and
2090
+ // start the failure count over. Only no-progress attempts count toward `retries`.
2091
+ failures = gotEvent ? 0 : failures + 1;
2092
+ if (failures > retries) {
2093
+ if (failure.reason === "status") throw failure.error || new HTTPError(failure.response, { method, url });
2094
+ if (failure.reason === "error") throw failure.error;
2095
+ throw Object.assign(new Error(`streamSSE: the server keeps closing the stream without sending events — gave up after ${retries} retries`), {
2096
+ code: "SSE_GAVE_UP",
2097
+ });
2098
+ }
2099
+
2100
+ let delayMs = Math.min(baseDelay * 2 ** Math.max(failures - 1, 0), maxDelayMs);
2101
+ if (failure.reason === "status" && failure.response) {
2102
+ const headerMs = getWaitMsFromHeaders(failure.response.headers);
2103
+ if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
2104
+ await discardBody(failure.response);
2105
+ }
2106
+ delayMs += Math.random() * delayMs * 0.1; // a little jitter so clients don't reconnect in lockstep
2107
+
2108
+ if (onReconnect) {
2109
+ onReconnect({
2110
+ reason: failure.reason,
2111
+ error: failure.error,
2112
+ status: failure.status,
2113
+ attempt: Math.max(failures, 1),
2114
+ delayMs,
2115
+ lastEventId: lastId,
2116
+ });
2117
+ }
2118
+ await abortableSleep(delayMs, signal);
2119
+ }
2120
+ }
2121
+
2122
+ module.exports = { streamSSE };
2123
+
1680
2124
  },
1681
2125
  "./streams": function (module, exports, require) {
1682
2126
  const encoder = new TextEncoder();
@@ -2290,12 +2734,17 @@ export const {
2290
2734
  downloadResumable,
2291
2735
  getWaitMsFromHeaders,
2292
2736
  normalizeError,
2737
+ paginate,
2738
+ paginateLinks,
2739
+ paginatePages,
2293
2740
  parseJsonSafe,
2741
+ parseLinkHeader,
2294
2742
  parseRetryAfter,
2295
2743
  readLines,
2296
2744
  readNDJSON,
2297
2745
  readSSE,
2298
2746
  retryAfterAware,
2747
+ streamSSE,
2299
2748
  withDedupe,
2300
2749
  withProgress,
2301
2750
  withRetry,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boy-offi9-inc/reqkit",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
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",