@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/README.md CHANGED
@@ -48,13 +48,14 @@ const pages = await limit.map(urls, (url) =>
48
48
  )
49
49
  );
50
50
  ```
51
+
51
52
  ---
52
53
 
53
54
  ## Playground
54
55
 
55
- Try everything in your browser — retries obeying `Retry-After`, rate limiting, the circuit breaker, caching, SSE streaming 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.
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.
56
57
 
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
+ 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.)
58
59
 
59
60
  ## Works with what you already use
60
61
 
@@ -90,7 +91,10 @@ 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) |
97
+ | Stay connected to an SSE endpoint (auto-reconnect, `Last-Event-ID`) | [`streamSSE`](#auto-reconnecting-sse-streamsse) |
94
98
  | Read SSE / NDJSON / lines from a streamed body (LLM tokens!) | [`readSSE` / `readNDJSON` / `readLines`](#streaming-readsse-readndjson-readlines) |
95
99
  | Test retry/timeout code without a network | [`createMockFetch`](#createmockfetch) |
96
100
  | All of the above, wired up | [`createFetch`](#createfetchdefaults) |
@@ -360,6 +364,69 @@ for await (const line of readLines(response)) console.log(line);
360
364
 
361
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.
362
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
+
403
+ ### Auto-reconnecting SSE: `streamSSE`
404
+
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.
406
+
407
+ ```js
408
+ import { streamSSE } from "@boy-offi9-inc/reqkit";
409
+
410
+ for await (const { id, data } of streamSSE("https://api.example.com/events", {
411
+ headers: () => ({ authorization: `Bearer ${getToken()}` }), // re-evaluated on every reconnect
412
+ onReconnect: ({ reason, delayMs, lastEventId }) => console.log(`reconnecting in ${delayMs}ms from id ${lastEventId}`),
413
+ signal,
414
+ })) {
415
+ handle(data);
416
+ }
417
+ ```
418
+
419
+ | Behavior | Details |
420
+ |---|---|
421
+ | Resume | `Last-Event-ID` (the last `id:` seen) is sent on every reconnect; `lastEventId` option resumes on the first connect too |
422
+ | Delay | the server's `retry: <ms>` field sets the base delay (like `EventSource`); waits then back off exponentially up to `maxDelayMs` while connecting keeps failing; `Retry-After` on 429/503 is honored |
423
+ | Giving up | `retries` (default 10) counts consecutive attempts **without progress** — a connection that delivered an event resets it. Then it throws the last error |
424
+ | Stopping | HTTP `204` ends the stream (the standard "don't reconnect"); `stopWhen: e => e.data === "[DONE]"`; `break`ing out of the loop or aborting `signal` closes the connection |
425
+ | Fatal | a non-retryable status (401, 404…) throws `HTTPError` right away; a response that isn't `text/event-stream` throws (`code: "SSE_BAD_CONTENT_TYPE"`) |
426
+ | POST streams | **only GET reconnects by default** — re-sending a POST (say, an LLM prompt) could repeat side effects, so POST streams just end or throw unless you pass `reconnect: true` |
427
+
428
+ It takes any fetch: `streamSSE(url, { fetch: api })` with a `createFetch` instance works too.
429
+
363
430
  ### `downloadResumable(url, opts?)`
364
431
 
365
432
  Downloads into memory and, if the connection drops mid-body, **resumes with an HTTP `Range` request** instead of starting over. It sends `If-Range` with the first response's strong ETag (or `Last-Modified`), so a file that changed on the server is restarted rather than stitched together wrongly; if the server ignores `Range`, it restarts from zero.
@@ -435,6 +502,7 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
435
502
  | `retry` | see below | `false` disables; otherwise `{ retries, statusCodes, methods, baseDelayMs, maxDelayMs, factor, jitter, maxRetryTime, shouldRetry, calculateDelay, onRetry }` |
436
503
  | `rateLimit` | — | `true` just learns from `X-RateLimit-*` / `Retry-After`; `{ requests, perMs }` also enforces a budget; or pass a shared [`createRateLimiter`](#createratelimiteropts) |
437
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) |
438
506
  | `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
439
507
  | `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
440
508
  | `throwHttpErrors` | `true` | throw `HTTPError` for non-2xx (`err.status`, `err.response`, `err.url`, `err.method`) |
@@ -442,6 +510,28 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
442
510
  | `onRequest(ctx)` | — | once per call; may mutate `ctx.url` / `ctx.init` (e.g. add a header) |
443
511
  | `onResponse(ctx)` | — | once per call, with the final response, before `HTTPError` is thrown |
444
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
+
445
535
  #### Caching
446
536
 
447
537
  ```js