@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/README.md +79 -3
- package/dist/reqkit.mjs +390 -30
- package/package.json +6 -2
- package/src/_http.js +11 -1
- package/src/createFetch.js +107 -8
- package/src/index.js +5 -0
- package/src/index.mjs +4 -0
- package/src/paginate.js +195 -0
- package/src/streams.js +64 -20
- package/types/index.d.ts +95 -0
package/dist/reqkit.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @boy-offi9-inc/reqkit v0.
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1939
|
-
if (first &&
|
|
1940
|
-
|
|
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
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
1958
|
-
if (
|
|
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")
|
|
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.
|
|
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
|
-
|
|
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 };
|