@boy-offi9-inc/reqkit 0.6.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/README.md +62 -0
- package/dist/reqkit.mjs +248 -2
- package/package.json +6 -2
- package/src/createFetch.js +69 -1
- package/src/index.js +5 -0
- package/src/index.mjs +4 -0
- package/src/paginate.js +166 -0
- package/types/index.d.ts +75 -0
package/README.md
CHANGED
|
@@ -56,6 +56,7 @@ const pages = await limit.map(urls, (url) =>
|
|
|
56
56
|
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
57
|
|
|
58
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.)
|
|
59
|
+
|
|
59
60
|
## Works with what you already use
|
|
60
61
|
|
|
61
62
|
Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming live in [RECIPES.md](./RECIPES.md): keep your client, bolt on just the piece you're missing (`Retry-After`-aware retries, a rate limiter, a circuit breaker).
|
|
@@ -90,6 +91,8 @@ Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming li
|
|
|
90
91
|
| Slow down *before* the server says 429 (obey `X-RateLimit-*`) | [`createRateLimiter`](#createratelimiteropts) |
|
|
91
92
|
| Stop hammering a service that keeps failing | [`createCircuitBreaker`](#createcircuitbreakeropts) |
|
|
92
93
|
| Cache GETs (TTL, stale-while-revalidate, ETag/304) | [`createFetch` → `cache`](#caching) |
|
|
94
|
+
| Attach a token and refresh it on 401 — once, even with many requests in flight | [`createFetch` → `auth`](#auth-token-refresh) |
|
|
95
|
+
| Walk a paginated API (cursor, offset, `Link` headers) | [`paginate` / `paginateLinks`](#pagination-paginate-paginatepages-paginatelinks) |
|
|
93
96
|
| Resume a download after the connection drops | [`downloadResumable`](#downloadresumableurl-opts) |
|
|
94
97
|
| Stay connected to an SSE endpoint (auto-reconnect, `Last-Event-ID`) | [`streamSSE`](#auto-reconnecting-sse-streamsse) |
|
|
95
98
|
| Read SSE / NDJSON / lines from a streamed body (LLM tokens!) | [`readSSE` / `readNDJSON` / `readLines`](#streaming-readsse-readndjson-readlines) |
|
|
@@ -361,6 +364,42 @@ for await (const line of readLines(response)) console.log(line);
|
|
|
361
364
|
|
|
362
365
|
`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.
|
|
363
366
|
|
|
367
|
+
### Pagination: `paginate`, `paginatePages`, `paginateLinks`
|
|
368
|
+
|
|
369
|
+
Walk any paginated API with a plain `for await` loop. Pages are fetched **lazily** — `break` out early (or set `maxItems`) and the pages you didn't need are never requested.
|
|
370
|
+
|
|
371
|
+
```js
|
|
372
|
+
import { paginate, paginateLinks } from "@boy-offi9-inc/reqkit";
|
|
373
|
+
|
|
374
|
+
// Cursor-based: you say how to fetch a page and what the next cursor is
|
|
375
|
+
for await (const user of paginate(async (cursor) => {
|
|
376
|
+
const r = await api.json("/users", { query: { cursor, limit: 100 } });
|
|
377
|
+
return { items: r.data, next: r.next_cursor }; // undefined/null on the last page
|
|
378
|
+
}, { maxItems: 500 })) {
|
|
379
|
+
console.log(user.name);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Offset-based is the same idea: the cursor is just a number
|
|
383
|
+
for await (const row of paginate(async (offset = 0) => {
|
|
384
|
+
const rows = await api.json("/rows", { query: { offset, limit: 50 } });
|
|
385
|
+
return { items: rows, next: rows.length === 50 ? offset + 50 : undefined };
|
|
386
|
+
})) { /* ... */ }
|
|
387
|
+
|
|
388
|
+
// GitHub-style `Link: <...>; rel="next"` headers — nothing to write
|
|
389
|
+
for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) {
|
|
390
|
+
console.log(repo.full_name);
|
|
391
|
+
}
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
| API | What it does |
|
|
395
|
+
|---|---|
|
|
396
|
+
| `paginate(fetchPage, opts?)` | yields **items**; `opts`: `start`, `maxPages`, `maxItems`, `signal` |
|
|
397
|
+
| `paginatePages(fetchPage, opts?)` | yields **pages** `{ items, index, cursor }` |
|
|
398
|
+
| `paginateLinks(request, url, opts?)` | follows `Link` `rel="next"`; `request` is any fetch-like (a `createFetch` instance or plain `fetch` — a non-2xx page throws `HTTPError`); `getItems(data)` defaults to the body if it's an array, else `items` / `data` / `results` / `records` |
|
|
399
|
+
| `parseLinkHeader(header, base?)` | RFC 8288 `Link` parser → `{ next, prev, first, last, ... }`; handles commas in URLs and quoted params |
|
|
400
|
+
|
|
401
|
+
A `next` that equals the cursor just used (or a `Link` that points back at the same page) throws `code: "PAGINATE_STUCK"` instead of looping forever.
|
|
402
|
+
|
|
364
403
|
### Auto-reconnecting SSE: `streamSSE`
|
|
365
404
|
|
|
366
405
|
`readSSE` parses a stream you already have; **`streamSSE` opens it for you and keeps it open.** When the connection drops (or the server closes it) it reconnects by itself and sends `Last-Event-ID`, so the server can resume where you left off — what the browser's `EventSource` does, but built on `fetch`: custom headers, POST bodies, `AbortSignal`, your own client.
|
|
@@ -463,6 +502,7 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
|
|
|
463
502
|
| `retry` | see below | `false` disables; otherwise `{ retries, statusCodes, methods, baseDelayMs, maxDelayMs, factor, jitter, maxRetryTime, shouldRetry, calculateDelay, onRetry }` |
|
|
464
503
|
| `rateLimit` | — | `true` just learns from `X-RateLimit-*` / `Retry-After`; `{ requests, perMs }` also enforces a budget; or pass a shared [`createRateLimiter`](#createratelimiteropts) |
|
|
465
504
|
| `circuitBreaker` | — | `true` / options → one breaker **per origin**; or pass a shared [`createCircuitBreaker`](#createcircuitbreakeropts), or `(url) => breaker`. An open circuit stops retries immediately. |
|
|
505
|
+
| `auth` | — | attach a token and refresh it on 401 — see [Auth token refresh](#auth-token-refresh) |
|
|
466
506
|
| `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
|
|
467
507
|
| `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
|
|
468
508
|
| `throwHttpErrors` | `true` | throw `HTTPError` for non-2xx (`err.status`, `err.response`, `err.url`, `err.method`) |
|
|
@@ -470,6 +510,28 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
|
|
|
470
510
|
| `onRequest(ctx)` | — | once per call; may mutate `ctx.url` / `ctx.init` (e.g. add a header) |
|
|
471
511
|
| `onResponse(ctx)` | — | once per call, with the final response, before `HTTPError` is thrown |
|
|
472
512
|
|
|
513
|
+
#### Auth token refresh
|
|
514
|
+
|
|
515
|
+
```js
|
|
516
|
+
let token = await loadToken();
|
|
517
|
+
|
|
518
|
+
const api = createFetch({
|
|
519
|
+
baseUrl: "https://api.example.com",
|
|
520
|
+
auth: {
|
|
521
|
+
getToken: () => token, // attached as `Authorization: Bearer <token>`
|
|
522
|
+
refresh: async () => { token = await loginAgain(); }, // called on 401 — ONCE, however many requests are waiting
|
|
523
|
+
},
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
await Promise.all(urls.map((u) => api.json(u))); // 20 requests, all 401 → one refresh, all 20 replayed
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
- **Single-flight:** concurrent 401s share one `refresh()` call. A request rejected *after* someone else already refreshed notices the token changed and just replays with the new one — no second refresh.
|
|
530
|
+
- **Replay once:** if the replay is rejected too, you get the `401` (as `HTTPError`) — it never loops. Request bodies (JSON, strings…) are re-sent; a stream body can't be replayed, so its 401 is surfaced as is.
|
|
531
|
+
- **Your header wins:** if a request sets its own `Authorization`, reqkit leaves it alone and won't refresh it.
|
|
532
|
+
- **Options:** `header` (default `"authorization"`), `scheme` (default `"Bearer"`, `""` for a bare token such as an `X-API-Key`), `shouldRefresh(response)` (default: status 401). `refresh({ token, response })` may return the new token or just update your store. If `refresh` throws, every waiting request rejects with that error — and the next request tries again.
|
|
533
|
+
- `extend()` children that share the same `auth` object share one refresh; the cache keys entries per token.
|
|
534
|
+
|
|
473
535
|
#### Caching
|
|
474
536
|
|
|
475
537
|
```js
|
package/dist/reqkit.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @boy-offi9-inc/reqkit v0.
|
|
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
|
-
|
|
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();
|
|
@@ -1414,6 +1482,7 @@ const { createMockFetch } = require("./createMockFetch");
|
|
|
1414
1482
|
const { downloadResumable } = require("./downloadResumable");
|
|
1415
1483
|
const { readLines, readNDJSON, readSSE } = require("./streams");
|
|
1416
1484
|
const { streamSSE } = require("./streamSSE");
|
|
1485
|
+
const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
|
|
1417
1486
|
|
|
1418
1487
|
module.exports = {
|
|
1419
1488
|
withRetry,
|
|
@@ -1443,6 +1512,10 @@ module.exports = {
|
|
|
1443
1512
|
readNDJSON,
|
|
1444
1513
|
readSSE,
|
|
1445
1514
|
streamSSE,
|
|
1515
|
+
paginate,
|
|
1516
|
+
paginatePages,
|
|
1517
|
+
paginateLinks,
|
|
1518
|
+
parseLinkHeader,
|
|
1446
1519
|
};
|
|
1447
1520
|
|
|
1448
1521
|
},
|
|
@@ -1572,6 +1645,175 @@ function normalizeError(err) {
|
|
|
1572
1645
|
|
|
1573
1646
|
module.exports = { normalizeError };
|
|
1574
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
|
+
|
|
1575
1817
|
},
|
|
1576
1818
|
"./parseJsonSafe": function (module, exports, require) {
|
|
1577
1819
|
/**
|
|
@@ -2492,7 +2734,11 @@ export const {
|
|
|
2492
2734
|
downloadResumable,
|
|
2493
2735
|
getWaitMsFromHeaders,
|
|
2494
2736
|
normalizeError,
|
|
2737
|
+
paginate,
|
|
2738
|
+
paginateLinks,
|
|
2739
|
+
paginatePages,
|
|
2495
2740
|
parseJsonSafe,
|
|
2741
|
+
parseLinkHeader,
|
|
2496
2742
|
parseRetryAfter,
|
|
2497
2743
|
readLines,
|
|
2498
2744
|
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.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",
|
package/src/createFetch.js
CHANGED
|
@@ -54,6 +54,18 @@ function originOf(url) {
|
|
|
54
54
|
}
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
// One in-flight token refresh per `auth` config object, so concurrent 401s —
|
|
58
|
+
// even across extend() children — trigger exactly one refresh.
|
|
59
|
+
const refreshStates = new WeakMap();
|
|
60
|
+
function refreshStateFor(auth) {
|
|
61
|
+
let state = refreshStates.get(auth);
|
|
62
|
+
if (!state) {
|
|
63
|
+
state = { inflight: null };
|
|
64
|
+
refreshStates.set(auth, state);
|
|
65
|
+
}
|
|
66
|
+
return state;
|
|
67
|
+
}
|
|
68
|
+
|
|
57
69
|
function mergeDefaults(base, extra) {
|
|
58
70
|
const merged = { ...base, ...extra };
|
|
59
71
|
|
|
@@ -87,6 +99,8 @@ function mergeDefaults(base, extra) {
|
|
|
87
99
|
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
|
|
88
100
|
* ETag / Last-Modified revalidation (304s)
|
|
89
101
|
* - concurrent GET de-duplication (withDedupe)
|
|
102
|
+
* - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
|
|
103
|
+
* concurrent requests) and replay the request
|
|
90
104
|
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
91
105
|
* - `onRequest` / `onResponse` hooks
|
|
92
106
|
*
|
|
@@ -104,6 +118,11 @@ function mergeDefaults(base, extra) {
|
|
|
104
118
|
* a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
|
|
105
119
|
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
|
|
106
120
|
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
|
|
121
|
+
* @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
|
|
122
|
+
* `getToken()` supplies the current token (sync or async); on a 401 (or
|
|
123
|
+
* whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
|
|
124
|
+
* called once for all concurrent callers and the request is replayed with the
|
|
125
|
+
* new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
|
|
107
126
|
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
108
127
|
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
109
128
|
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
@@ -120,6 +139,7 @@ function createFetch(defaults = {}) {
|
|
|
120
139
|
rateLimit,
|
|
121
140
|
circuitBreaker,
|
|
122
141
|
cache,
|
|
142
|
+
auth,
|
|
123
143
|
dedupe = false,
|
|
124
144
|
throwHttpErrors: defaultThrow = true,
|
|
125
145
|
fetch: customFetch,
|
|
@@ -190,6 +210,27 @@ function createFetch(defaults = {}) {
|
|
|
190
210
|
pendingStores.set(key, p);
|
|
191
211
|
}
|
|
192
212
|
|
|
213
|
+
// Returns a token newer than `usedToken`, refreshing at most once no matter
|
|
214
|
+
// how many requests were rejected at the same time. If someone else already
|
|
215
|
+
// refreshed (the current token isn't the one we used), just use that.
|
|
216
|
+
async function obtainFreshToken(authCfg, tokenUsed, response) {
|
|
217
|
+
const current = await authCfg.getToken();
|
|
218
|
+
if (current && current !== tokenUsed) return current;
|
|
219
|
+
|
|
220
|
+
const state = refreshStateFor(authCfg);
|
|
221
|
+
if (!state.inflight) {
|
|
222
|
+
state.inflight = (async () => {
|
|
223
|
+
try {
|
|
224
|
+
const out = await authCfg.refresh({ token: tokenUsed, response });
|
|
225
|
+
return typeof out === "string" && out ? out : await authCfg.getToken();
|
|
226
|
+
} finally {
|
|
227
|
+
state.inflight = null;
|
|
228
|
+
}
|
|
229
|
+
})();
|
|
230
|
+
}
|
|
231
|
+
return state.inflight;
|
|
232
|
+
}
|
|
233
|
+
|
|
193
234
|
async function request(input, init = {}) {
|
|
194
235
|
const {
|
|
195
236
|
query,
|
|
@@ -217,6 +258,15 @@ function createFetch(defaults = {}) {
|
|
|
217
258
|
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
218
259
|
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
219
260
|
|
|
261
|
+
// --- auth: attach the current token (unless the caller set the header) ----
|
|
262
|
+
const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
|
|
263
|
+
const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
|
|
264
|
+
let usedToken = null;
|
|
265
|
+
if (auth && !finalHeaders.has(authHeader)) {
|
|
266
|
+
usedToken = (await auth.getToken()) || null;
|
|
267
|
+
if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
|
|
268
|
+
}
|
|
269
|
+
|
|
220
270
|
// --- retry policy ------------------------------------------------------
|
|
221
271
|
const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
|
|
222
272
|
const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
|
|
@@ -292,7 +342,7 @@ function createFetch(defaults = {}) {
|
|
|
292
342
|
!ctx.init.body &&
|
|
293
343
|
cacheMode !== false &&
|
|
294
344
|
cacheMode !== "no-store";
|
|
295
|
-
|
|
345
|
+
let key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
|
|
296
346
|
let entry = null;
|
|
297
347
|
|
|
298
348
|
const finish = async (response) => {
|
|
@@ -353,6 +403,24 @@ function createFetch(defaults = {}) {
|
|
|
353
403
|
throw err;
|
|
354
404
|
}
|
|
355
405
|
|
|
406
|
+
// --- auth: a rejected token is refreshed once, then the request replays ----
|
|
407
|
+
const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
|
|
408
|
+
if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
|
|
409
|
+
const fresh = await obtainFreshToken(auth, usedToken, response);
|
|
410
|
+
if (fresh) {
|
|
411
|
+
await discardBody(response);
|
|
412
|
+
finalHeaders.set(authHeader, formatToken(fresh));
|
|
413
|
+
entry = null; // belongs to the old token's cache key
|
|
414
|
+
if (useCache) key = cacheKey(finalMethod, ctx.url, finalHeaders);
|
|
415
|
+
try {
|
|
416
|
+
response = await network(finalHeaders, signal);
|
|
417
|
+
} catch (err) {
|
|
418
|
+
if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
|
|
419
|
+
throw err;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
356
424
|
if (useCache) {
|
|
357
425
|
if (response.status === 304 && entry) {
|
|
358
426
|
entry.storedAt = Date.now();
|
package/src/index.js
CHANGED
|
@@ -15,6 +15,7 @@ const { createMockFetch } = require("./createMockFetch");
|
|
|
15
15
|
const { downloadResumable } = require("./downloadResumable");
|
|
16
16
|
const { readLines, readNDJSON, readSSE } = require("./streams");
|
|
17
17
|
const { streamSSE } = require("./streamSSE");
|
|
18
|
+
const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
|
|
18
19
|
|
|
19
20
|
module.exports = {
|
|
20
21
|
withRetry,
|
|
@@ -44,4 +45,8 @@ module.exports = {
|
|
|
44
45
|
readNDJSON,
|
|
45
46
|
readSSE,
|
|
46
47
|
streamSSE,
|
|
48
|
+
paginate,
|
|
49
|
+
paginatePages,
|
|
50
|
+
paginateLinks,
|
|
51
|
+
parseLinkHeader,
|
|
47
52
|
};
|
package/src/index.mjs
CHANGED
package/src/paginate.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
const { HTTPError } = require("./HTTPError");
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
|
|
5
|
+
* pagination) into `{ rel: url }`. Pass `base` to resolve relative URLs.
|
|
6
|
+
*
|
|
7
|
+
* ```js
|
|
8
|
+
* parseLinkHeader('<https://api.x/items?page=2>; rel="next", <https://api.x/items?page=9>; rel="last"');
|
|
9
|
+
* // { next: "https://api.x/items?page=2", last: "https://api.x/items?page=9" }
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* 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.
|
|
14
|
+
*
|
|
15
|
+
* @param {string | null | undefined} header
|
|
16
|
+
* @param {string} [base]
|
|
17
|
+
* @returns {Record<string, string>}
|
|
18
|
+
*/
|
|
19
|
+
function parseLinkHeader(header, base) {
|
|
20
|
+
const links = {};
|
|
21
|
+
if (!header) return links;
|
|
22
|
+
|
|
23
|
+
// <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
|
|
24
|
+
const linkRe = /<([^>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
|
|
25
|
+
let match;
|
|
26
|
+
while ((match = linkRe.exec(header)) !== null) {
|
|
27
|
+
const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
|
|
28
|
+
if (!rel) continue;
|
|
29
|
+
|
|
30
|
+
let url = match[1].trim();
|
|
31
|
+
if (base) {
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(url, base).href;
|
|
34
|
+
} catch {
|
|
35
|
+
// leave it as written
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
for (const name of (rel[1] ?? rel[2]).toLowerCase().split(/\s+/).filter(Boolean)) {
|
|
39
|
+
if (!(name in links)) links[name] = url;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return links;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function stuckError(message) {
|
|
46
|
+
return Object.assign(new Error(message), { code: "PAGINATE_STUCK" });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Walks a paginated API page by page. You write `fetchPage(cursor, ctx)` —
|
|
51
|
+
* it returns `{ items, next }`, where `next` is whatever identifies the next
|
|
52
|
+
* page (a cursor token, an offset, a URL...) or `undefined`/`null` on the
|
|
53
|
+
* last page — and you get an async iterator of pages.
|
|
54
|
+
*
|
|
55
|
+
* ```js
|
|
56
|
+
* for await (const { items, index } of paginatePages(async (cursor) => {
|
|
57
|
+
* const r = await api.json("/users", { query: { cursor, limit: 100 } });
|
|
58
|
+
* return { items: r.data, next: r.next_cursor };
|
|
59
|
+
* })) { ... }
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* A `next` that equals the cursor just used throws `code: "PAGINATE_STUCK"`
|
|
63
|
+
* instead of looping forever.
|
|
64
|
+
*
|
|
65
|
+
* @template T
|
|
66
|
+
* @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
|
|
67
|
+
* @param {object} [opts]
|
|
68
|
+
* @param {any} [opts.start] - the cursor for the first page (default undefined)
|
|
69
|
+
* @param {number} [opts.maxPages=Infinity]
|
|
70
|
+
* @param {AbortSignal} [opts.signal]
|
|
71
|
+
* @returns {AsyncGenerator<{ items: T[], index: number, cursor: any }>}
|
|
72
|
+
*/
|
|
73
|
+
async function* paginatePages(fetchPage, opts = {}) {
|
|
74
|
+
const { start, maxPages = Infinity, signal } = opts;
|
|
75
|
+
let cursor = start;
|
|
76
|
+
let index = 0;
|
|
77
|
+
|
|
78
|
+
for (;;) {
|
|
79
|
+
if (index >= maxPages) return;
|
|
80
|
+
signal?.throwIfAborted?.();
|
|
81
|
+
|
|
82
|
+
const result = await fetchPage(cursor, { index, signal });
|
|
83
|
+
if (!result || !Array.isArray(result.items)) {
|
|
84
|
+
throw new TypeError("paginate: fetchPage must return { items: array, next?: cursor }");
|
|
85
|
+
}
|
|
86
|
+
yield { items: result.items, index, cursor };
|
|
87
|
+
|
|
88
|
+
const next = result.next;
|
|
89
|
+
if (next === undefined || next === null) return;
|
|
90
|
+
if (next === cursor) {
|
|
91
|
+
throw stuckError("paginate: `next` equals the cursor just used, so the page didn't advance — stopping to avoid an infinite loop");
|
|
92
|
+
}
|
|
93
|
+
cursor = next;
|
|
94
|
+
index++;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Like `paginatePages`, but yields the items one by one. `maxItems` stops
|
|
100
|
+
* early (mid-page) — and since nothing is fetched until you ask for it,
|
|
101
|
+
* `break`-ing out of the loop never fetches a page you didn't need.
|
|
102
|
+
*
|
|
103
|
+
* ```js
|
|
104
|
+
* for await (const user of paginate(fetchUsersPage, { maxItems: 250 })) { ... }
|
|
105
|
+
* ```
|
|
106
|
+
*
|
|
107
|
+
* @template T
|
|
108
|
+
* @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
|
|
109
|
+
* @param {object} [opts] - same as paginatePages, plus `maxItems`
|
|
110
|
+
* @param {number} [opts.maxItems=Infinity]
|
|
111
|
+
* @returns {AsyncGenerator<T>}
|
|
112
|
+
*/
|
|
113
|
+
async function* paginate(fetchPage, opts = {}) {
|
|
114
|
+
const { maxItems = Infinity } = opts;
|
|
115
|
+
if (maxItems <= 0) return;
|
|
116
|
+
|
|
117
|
+
let count = 0;
|
|
118
|
+
for await (const page of paginatePages(fetchPage, opts)) {
|
|
119
|
+
for (const item of page.items) {
|
|
120
|
+
yield item;
|
|
121
|
+
if (++count >= maxItems) return;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function defaultGetItems(data) {
|
|
127
|
+
if (Array.isArray(data)) return data;
|
|
128
|
+
for (const key of ["items", "data", "results", "records"]) {
|
|
129
|
+
if (data && Array.isArray(data[key])) return data[key];
|
|
130
|
+
}
|
|
131
|
+
throw new TypeError("paginateLinks: couldn't find the items array in the response — pass `getItems(data)`");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Follows `Link: <...>; rel="next"` headers (GitHub-style) and yields the
|
|
136
|
+
* items of every page. `request` is anything fetch-like — a `createFetch`
|
|
137
|
+
* instance or plain `fetch` (a non-2xx response throws `HTTPError`).
|
|
138
|
+
*
|
|
139
|
+
* ```js
|
|
140
|
+
* for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) { ... }
|
|
141
|
+
* ```
|
|
142
|
+
*
|
|
143
|
+
* @param {(url: string, init?: object) => Promise<Response>} request
|
|
144
|
+
* @param {string} url - the first page
|
|
145
|
+
* @param {object} [opts]
|
|
146
|
+
* @param {(data: any, response: Response) => any[]} [opts.getItems] - default: the body if it's an array, else its `items` / `data` / `results` / `records`
|
|
147
|
+
* @param {number} [opts.maxPages=Infinity]
|
|
148
|
+
* @param {number} [opts.maxItems=Infinity]
|
|
149
|
+
* @param {AbortSignal} [opts.signal]
|
|
150
|
+
* @param {object} [opts.init] - extra options passed to every `request`
|
|
151
|
+
*/
|
|
152
|
+
async function* paginateLinks(request, url, opts = {}) {
|
|
153
|
+
const { getItems = defaultGetItems, init = {}, signal } = opts;
|
|
154
|
+
|
|
155
|
+
async function fetchPage(pageUrl) {
|
|
156
|
+
const res = await request(pageUrl, { ...init, signal });
|
|
157
|
+
if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
|
|
158
|
+
const items = getItems(await res.json(), res);
|
|
159
|
+
const next = parseLinkHeader(res.headers.get("link"), pageUrl).next;
|
|
160
|
+
return { items, next };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
yield* paginate(fetchPage, { ...opts, start: url });
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { paginate, paginatePages, paginateLinks, parseLinkHeader };
|
package/types/index.d.ts
CHANGED
|
@@ -210,6 +210,8 @@ 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
|
+
/** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
|
|
214
|
+
auth?: AuthOptions;
|
|
213
215
|
/** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
|
|
214
216
|
cache?: boolean | CacheOptions;
|
|
215
217
|
/** Merge identical concurrent GET/HEAD calls. Default false. */
|
|
@@ -441,3 +443,76 @@ export interface StreamSSEOptions {
|
|
|
441
443
|
|
|
442
444
|
/** Auto-reconnecting SSE: resumes with Last-Event-ID, honors `retry:` and Retry-After. */
|
|
443
445
|
export function streamSSE(url: string, opts?: StreamSSEOptions): AsyncGenerator<SSEEvent, void, undefined>;
|
|
446
|
+
|
|
447
|
+
export interface AuthOptions {
|
|
448
|
+
/** The current token (sync or async). Falsy = send no auth header. */
|
|
449
|
+
getToken(): string | null | undefined | Promise<string | null | undefined>;
|
|
450
|
+
/**
|
|
451
|
+
* Called ONCE when requests are rejected (401 by default), no matter how many
|
|
452
|
+
* are waiting. Return the new token, or update your store and return nothing
|
|
453
|
+
* (reqkit then calls getToken() again). Throwing rejects every waiting request.
|
|
454
|
+
*/
|
|
455
|
+
refresh(ctx: { token: string; response: Response }): string | void | undefined | Promise<string | void | undefined>;
|
|
456
|
+
/** Default "authorization". */
|
|
457
|
+
header?: string;
|
|
458
|
+
/** Default "Bearer". Use "" to send the bare token (e.g. for an X-API-Key header). */
|
|
459
|
+
scheme?: string;
|
|
460
|
+
/** Default: `res.status === 401`. */
|
|
461
|
+
shouldRefresh?: (response: Response) => boolean;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export interface PageContext {
|
|
465
|
+
index: number;
|
|
466
|
+
signal?: AbortSignal;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export interface PageResult<T, C = any> {
|
|
470
|
+
items: T[];
|
|
471
|
+
/** Whatever identifies the next page (token, offset, URL...). undefined/null = last page. */
|
|
472
|
+
next?: C | null;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export interface Page<T, C = any> {
|
|
476
|
+
items: T[];
|
|
477
|
+
index: number;
|
|
478
|
+
/** The cursor this page was fetched with (undefined for the first, unless `start` was given). */
|
|
479
|
+
cursor: C | undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export interface PaginateOptions<C = any> {
|
|
483
|
+
/** The cursor for the first page. */
|
|
484
|
+
start?: C;
|
|
485
|
+
maxPages?: number;
|
|
486
|
+
maxItems?: number;
|
|
487
|
+
signal?: AbortSignal;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export type FetchPage<T, C = any> = (cursor: C | undefined, ctx: PageContext) => Promise<PageResult<T, C>>;
|
|
491
|
+
|
|
492
|
+
/** Yields every item, fetching pages lazily. */
|
|
493
|
+
export function paginate<T, C = any>(fetchPage: FetchPage<T, C>, opts?: PaginateOptions<C>): AsyncGenerator<T, void, undefined>;
|
|
494
|
+
/** Yields whole pages. */
|
|
495
|
+
export function paginatePages<T, C = any>(
|
|
496
|
+
fetchPage: FetchPage<T, C>,
|
|
497
|
+
opts?: Omit<PaginateOptions<C>, "maxItems">
|
|
498
|
+
): AsyncGenerator<Page<T, C>, void, undefined>;
|
|
499
|
+
|
|
500
|
+
export interface PaginateLinksOptions<T = any> {
|
|
501
|
+
/** Default: the body if it's an array, else its `items` / `data` / `results` / `records`. */
|
|
502
|
+
getItems?: (data: any, response: Response) => T[];
|
|
503
|
+
maxPages?: number;
|
|
504
|
+
maxItems?: number;
|
|
505
|
+
signal?: AbortSignal;
|
|
506
|
+
/** Extra options passed to every request. */
|
|
507
|
+
init?: Record<string, any>;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Follows `Link: <...>; rel="next"` headers and yields the items of every page. */
|
|
511
|
+
export function paginateLinks<T = any>(
|
|
512
|
+
request: (url: string, init?: any) => Promise<Response>,
|
|
513
|
+
url: string,
|
|
514
|
+
opts?: PaginateLinksOptions<T>
|
|
515
|
+
): AsyncGenerator<T, void, undefined>;
|
|
516
|
+
|
|
517
|
+
/** Parses an RFC 8288 Link header into `{ rel: url }`. Pass `base` to resolve relative URLs. */
|
|
518
|
+
export function parseLinkHeader(header: string | null | undefined, base?: string): Record<string, string>;
|