@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 CHANGED
@@ -48,14 +48,14 @@ const pages = await limit.map(urls, (url) =>
48
48
  )
49
49
  );
50
50
  ```
51
-
52
- ---
51
+ __
53
52
 
54
53
  ## Playground
55
54
 
56
55
  Try everything in your browser — retries obeying `Retry-After`, rate limiting, the circuit breaker, caching, SSE streaming, an SSE stream that reconnects itself, and a resumable download that survives a dropped connection. It runs against a fake in-memory server, so there's nothing to install and no network involved.
57
56
 
58
- Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.)
57
+ Run it locally: `npm run build && python3 -m http.server -d docs` → open `http://localhost:8000`. (ES modules don't load from `file://`, so it needs a server.) The repo's `pages.yml` workflow publishes the same folder to GitHub Pages — enable it once under Settings → Pages → Source: "GitHub Actions".
58
+
59
59
  ## Works with what you already use
60
60
 
61
61
  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).
@@ -75,6 +75,18 @@ Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming li
75
75
 
76
76
  ---
77
77
 
78
+ ## Security
79
+
80
+ reqkit handles data a server controls (headers, streams, redirects), so the defaults are conservative:
81
+
82
+ - **Credentials stay home.** With a `baseUrl`, the default `auth` token and default `Authorization` / `Proxy-Authorization` / `Cookie` / `X-API-Key` / `X-Auth-Token` headers are only sent to that origin (plus `trustedOrigins`). Pass an absolute URL to another host and those headers are dropped — the classic "absolute URL leaks the token" bug (the one axios had) can't happen. Headers you pass *for a specific call* are deliberate and always sent. (With neither `baseUrl` nor `trustedOrigins` there's nothing to compare against, so set at least one.)
83
+ - **A server can't steer pagination.** `paginateLinks` refuses a `Link: rel="next"` that points at another origin (`code: "PAGINATE_CROSS_ORIGIN"`) — otherwise a hostile API could send your requests, and any credentials you pass in `init`, to a third party. Allow extra origins with `origins: [...]`.
84
+ - **Streams are bounded.** `readLines` / `readNDJSON` / `readSSE` run in linear time and throw `code: "LINE_TOO_LONG"` past `maxLineLength` (default 16 Mi characters) instead of buffering a never-ending line forever; `withProgress` and `downloadResumable` have `maxBytes`.
85
+ - **Hostile headers are cheap.** `parseLinkHeader` is linear-time and ignores headers over 64 KB.
86
+ - **Safe retries.** `createFetch` only retries idempotent methods (or requests with an `Idempotency-Key`), and a cross-origin redirect is left to `fetch`, which strips `Authorization` itself.
87
+
88
+ What reqkit does **not** do: it doesn't block requests to private/internal addresses (SSRF protection for server-side use needs DNS-level control). If you fetch user-supplied URLs on a server, validate them first.
89
+
78
90
  ## Pick what you need
79
91
 
80
92
  | You want to… | Use |
@@ -90,6 +102,8 @@ Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming li
90
102
  | Slow down *before* the server says 429 (obey `X-RateLimit-*`) | [`createRateLimiter`](#createratelimiteropts) |
91
103
  | Stop hammering a service that keeps failing | [`createCircuitBreaker`](#createcircuitbreakeropts) |
92
104
  | Cache GETs (TTL, stale-while-revalidate, ETag/304) | [`createFetch` → `cache`](#caching) |
105
+ | Attach a token and refresh it on 401 — once, even with many requests in flight | [`createFetch` → `auth`](#auth-token-refresh) |
106
+ | Walk a paginated API (cursor, offset, `Link` headers) | [`paginate` / `paginateLinks`](#pagination-paginate-paginatepages-paginatelinks) |
93
107
  | Resume a download after the connection drops | [`downloadResumable`](#downloadresumableurl-opts) |
94
108
  | Stay connected to an SSE endpoint (auto-reconnect, `Last-Event-ID`) | [`streamSSE`](#auto-reconnecting-sse-streamsse) |
95
109
  | Read SSE / NDJSON / lines from a streamed body (LLM tokens!) | [`readSSE` / `readNDJSON` / `readLines`](#streaming-readsse-readndjson-readlines) |
@@ -359,8 +373,46 @@ for await (const row of readNDJSON(response)) console.log(row); // newline-delim
359
373
  for await (const line of readLines(response)) console.log(line);
360
374
  ```
361
375
 
376
+ All three take `{ signal, maxLineLength }` — a line (or one SSE event's data) longer than `maxLineLength` characters (default 16 Mi) throws `code: "LINE_TOO_LONG"` rather than buffering forever.
377
+
362
378
  `readSSE` follows the spec: multi-line `data:` is joined with `\n`, comments are ignored, `event` defaults to `"message"`, `id` carries over, `retry` is parsed, and an event cut off by the end of the stream is dropped. `readNDJSON` skips blank lines and names the bad line on a parse error (or pass `skipInvalid: true`). Line splitting handles `\n`, `\r\n`, `\r`, a BOM, and multi-byte characters split across chunks.
363
379
 
380
+ ### Pagination: `paginate`, `paginatePages`, `paginateLinks`
381
+
382
+ 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.
383
+
384
+ ```js
385
+ import { paginate, paginateLinks } from "@boy-offi9-inc/reqkit";
386
+
387
+ // Cursor-based: you say how to fetch a page and what the next cursor is
388
+ for await (const user of paginate(async (cursor) => {
389
+ const r = await api.json("/users", { query: { cursor, limit: 100 } });
390
+ return { items: r.data, next: r.next_cursor }; // undefined/null on the last page
391
+ }, { maxItems: 500 })) {
392
+ console.log(user.name);
393
+ }
394
+
395
+ // Offset-based is the same idea: the cursor is just a number
396
+ for await (const row of paginate(async (offset = 0) => {
397
+ const rows = await api.json("/rows", { query: { offset, limit: 50 } });
398
+ return { items: rows, next: rows.length === 50 ? offset + 50 : undefined };
399
+ })) { /* ... */ }
400
+
401
+ // GitHub-style `Link: <...>; rel="next"` headers — nothing to write
402
+ for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) {
403
+ console.log(repo.full_name);
404
+ }
405
+ ```
406
+
407
+ | API | What it does |
408
+ |---|---|
409
+ | `paginate(fetchPage, opts?)` | yields **items**; `opts`: `start`, `maxPages`, `maxItems`, `signal` |
410
+ | `paginatePages(fetchPage, opts?)` | yields **pages** `{ items, index, cursor }` |
411
+ | `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` |
412
+ | `parseLinkHeader(header, base?)` | RFC 8288 `Link` parser → `{ next, prev, first, last, ... }`; handles commas in URLs and quoted params |
413
+
414
+ 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.
415
+
364
416
  ### Auto-reconnecting SSE: `streamSSE`
365
417
 
366
418
  `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 +515,8 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
463
515
  | `retry` | see below | `false` disables; otherwise `{ retries, statusCodes, methods, baseDelayMs, maxDelayMs, factor, jitter, maxRetryTime, shouldRetry, calculateDelay, onRetry }` |
464
516
  | `rateLimit` | — | `true` just learns from `X-RateLimit-*` / `Retry-After`; `{ requests, perMs }` also enforces a budget; or pass a shared [`createRateLimiter`](#createratelimiteropts) |
465
517
  | `circuitBreaker` | — | `true` / options → one breaker **per origin**; or pass a shared [`createCircuitBreaker`](#createcircuitbreakeropts), or `(url) => breaker`. An open circuit stops retries immediately. |
518
+ | `trustedOrigins` | — | extra origins that may receive the default credentials — see [Security](#security) |
519
+ | `auth` | — | attach a token and refresh it on 401 — see [Auth token refresh](#auth-token-refresh) |
466
520
  | `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
467
521
  | `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
468
522
  | `throwHttpErrors` | `true` | throw `HTTPError` for non-2xx (`err.status`, `err.response`, `err.url`, `err.method`) |
@@ -470,6 +524,28 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
470
524
  | `onRequest(ctx)` | — | once per call; may mutate `ctx.url` / `ctx.init` (e.g. add a header) |
471
525
  | `onResponse(ctx)` | — | once per call, with the final response, before `HTTPError` is thrown |
472
526
 
527
+ #### Auth token refresh
528
+
529
+ ```js
530
+ let token = await loadToken();
531
+
532
+ const api = createFetch({
533
+ baseUrl: "https://api.example.com",
534
+ auth: {
535
+ getToken: () => token, // attached as `Authorization: Bearer <token>`
536
+ refresh: async () => { token = await loginAgain(); }, // called on 401 — ONCE, however many requests are waiting
537
+ },
538
+ });
539
+
540
+ await Promise.all(urls.map((u) => api.json(u))); // 20 requests, all 401 → one refresh, all 20 replayed
541
+ ```
542
+
543
+ - **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.
544
+ - **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.
545
+ - **Your header wins:** if a request sets its own `Authorization`, reqkit leaves it alone and won't refresh it.
546
+ - **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.
547
+ - `extend()` children that share the same `auth` object share one refresh; the cache keys entries per token.
548
+
473
549
  #### Caching
474
550
 
475
551
  ```js