@boy-offi9-inc/reqkit 0.7.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 +17 -3
- package/dist/reqkit.mjs +148 -34
- package/package.json +1 -1
- package/src/_http.js +11 -1
- package/src/createFetch.js +40 -9
- package/src/paginate.js +32 -3
- package/src/streams.js +64 -20
- package/types/index.d.ts +20 -0
package/README.md
CHANGED
|
@@ -48,14 +48,13 @@ 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".
|
|
59
58
|
|
|
60
59
|
## Works with what you already use
|
|
61
60
|
|
|
@@ -76,6 +75,18 @@ Recipes for **axios, ky, got, undici/node-fetch**, testing, and LLM streaming li
|
|
|
76
75
|
|
|
77
76
|
---
|
|
78
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
|
+
|
|
79
90
|
## Pick what you need
|
|
80
91
|
|
|
81
92
|
| You want to… | Use |
|
|
@@ -362,6 +373,8 @@ for await (const row of readNDJSON(response)) console.log(row); // newline-delim
|
|
|
362
373
|
for await (const line of readLines(response)) console.log(line);
|
|
363
374
|
```
|
|
364
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
|
+
|
|
365
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.
|
|
366
379
|
|
|
367
380
|
### Pagination: `paginate`, `paginatePages`, `paginateLinks`
|
|
@@ -502,6 +515,7 @@ const admin = api.extend({ headers: { "x-role": "admin" } }); // merges over the
|
|
|
502
515
|
| `retry` | see below | `false` disables; otherwise `{ retries, statusCodes, methods, baseDelayMs, maxDelayMs, factor, jitter, maxRetryTime, shouldRetry, calculateDelay, onRetry }` |
|
|
503
516
|
| `rateLimit` | — | `true` just learns from `X-RateLimit-*` / `Retry-After`; `{ requests, perMs }` also enforces a budget; or pass a shared [`createRateLimiter`](#createratelimiteropts) |
|
|
504
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) |
|
|
505
519
|
| `auth` | — | attach a token and refresh it on 401 — see [Auth token refresh](#auth-token-refresh) |
|
|
506
520
|
| `cache` | — | in-memory GET cache — see [Caching](#caching). `true` = 60s TTL. Per request: `cache: false` bypasses, `"reload"` refreshes |
|
|
507
521
|
| `dedupe` | `false` | merge identical concurrent `GET`/`HEAD` calls; every caller gets its own readable clone. Requests with their own `signal` are never merged. |
|
package/dist/reqkit.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/*! @boy-offi9-inc/reqkit v0.7.
|
|
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,7 @@ function isStreamBody(body) {
|
|
|
447
459
|
}
|
|
448
460
|
|
|
449
461
|
function originOf(url) {
|
|
450
|
-
|
|
451
|
-
return new URL(url).origin;
|
|
452
|
-
} catch {
|
|
453
|
-
return url;
|
|
454
|
-
}
|
|
462
|
+
return getOrigin(url) || url;
|
|
455
463
|
}
|
|
456
464
|
|
|
457
465
|
// One in-flight token refresh per `auth` config object, so concurrent 401s —
|
|
@@ -501,6 +509,9 @@ function mergeDefaults(base, extra) {
|
|
|
501
509
|
* - concurrent GET de-duplication (withDedupe)
|
|
502
510
|
* - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
|
|
503
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
|
|
504
515
|
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
505
516
|
* - `onRequest` / `onResponse` hooks
|
|
506
517
|
*
|
|
@@ -523,6 +534,9 @@ function mergeDefaults(base, extra) {
|
|
|
523
534
|
* whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
|
|
524
535
|
* called once for all concurrent callers and the request is replayed with the
|
|
525
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.
|
|
526
540
|
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
527
541
|
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
528
542
|
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
@@ -540,6 +554,7 @@ function createFetch(defaults = {}) {
|
|
|
540
554
|
circuitBreaker,
|
|
541
555
|
cache,
|
|
542
556
|
auth,
|
|
557
|
+
trustedOrigins,
|
|
543
558
|
dedupe = false,
|
|
544
559
|
throwHttpErrors: defaultThrow = true,
|
|
545
560
|
fetch: customFetch,
|
|
@@ -549,6 +564,18 @@ function createFetch(defaults = {}) {
|
|
|
549
564
|
|
|
550
565
|
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
551
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
|
+
|
|
552
579
|
// --- stateful pieces, built once so `extend()` children share them -------
|
|
553
580
|
const limiter = !rateLimit
|
|
554
581
|
? null
|
|
@@ -645,7 +672,18 @@ function createFetch(defaults = {}) {
|
|
|
645
672
|
} = init;
|
|
646
673
|
|
|
647
674
|
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
648
|
-
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));
|
|
649
687
|
|
|
650
688
|
let body = fetchInit.body;
|
|
651
689
|
if (json !== undefined) {
|
|
@@ -653,7 +691,7 @@ function createFetch(defaults = {}) {
|
|
|
653
691
|
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
654
692
|
}
|
|
655
693
|
|
|
656
|
-
const ctx = { url
|
|
694
|
+
const ctx = { url, init: { ...fetchInit, method, headers, body } };
|
|
657
695
|
if (onRequest) await onRequest(ctx);
|
|
658
696
|
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
659
697
|
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
@@ -662,7 +700,7 @@ function createFetch(defaults = {}) {
|
|
|
662
700
|
const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
|
|
663
701
|
const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
|
|
664
702
|
let usedToken = null;
|
|
665
|
-
if (auth && !finalHeaders.has(authHeader)) {
|
|
703
|
+
if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
|
|
666
704
|
usedToken = (await auth.getToken()) || null;
|
|
667
705
|
if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
|
|
668
706
|
}
|
|
@@ -837,6 +875,9 @@ function createFetch(defaults = {}) {
|
|
|
837
875
|
return finish(response);
|
|
838
876
|
}
|
|
839
877
|
|
|
878
|
+
/** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
|
|
879
|
+
request.baseUrl = baseUrl;
|
|
880
|
+
|
|
840
881
|
/** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
|
|
841
882
|
request.json = async (input, init) => {
|
|
842
883
|
const response = await request(input, init);
|
|
@@ -1648,6 +1689,9 @@ module.exports = { normalizeError };
|
|
|
1648
1689
|
},
|
|
1649
1690
|
"./paginate": function (module, exports, require) {
|
|
1650
1691
|
const { HTTPError } = require("./HTTPError");
|
|
1692
|
+
const { getOrigin } = require("./_http");
|
|
1693
|
+
|
|
1694
|
+
const MAX_LINK_HEADER_LENGTH = 64 * 1024;
|
|
1651
1695
|
|
|
1652
1696
|
/**
|
|
1653
1697
|
* Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
|
|
@@ -1659,7 +1703,8 @@ const { HTTPError } = require("./HTTPError");
|
|
|
1659
1703
|
* ```
|
|
1660
1704
|
*
|
|
1661
1705
|
* 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.
|
|
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.
|
|
1663
1708
|
*
|
|
1664
1709
|
* @param {string | null | undefined} header
|
|
1665
1710
|
* @param {string} [base]
|
|
@@ -1667,10 +1712,11 @@ const { HTTPError } = require("./HTTPError");
|
|
|
1667
1712
|
*/
|
|
1668
1713
|
function parseLinkHeader(header, base) {
|
|
1669
1714
|
const links = {};
|
|
1670
|
-
|
|
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;
|
|
1671
1717
|
|
|
1672
1718
|
// <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
|
|
1673
|
-
const linkRe = /<([
|
|
1719
|
+
const linkRe = /<([^<>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
|
|
1674
1720
|
let match;
|
|
1675
1721
|
while ((match = linkRe.exec(header)) !== null) {
|
|
1676
1722
|
const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
|
|
@@ -1797,15 +1843,39 @@ function defaultGetItems(data) {
|
|
|
1797
1843
|
* @param {number} [opts.maxItems=Infinity]
|
|
1798
1844
|
* @param {AbortSignal} [opts.signal]
|
|
1799
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.
|
|
1800
1850
|
*/
|
|
1801
1851
|
async function* paginateLinks(request, url, opts = {}) {
|
|
1802
1852
|
const { getItems = defaultGetItems, init = {}, signal } = opts;
|
|
1803
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
|
+
|
|
1804
1865
|
async function fetchPage(pageUrl) {
|
|
1805
1866
|
const res = await request(pageUrl, { ...init, signal });
|
|
1806
1867
|
if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
|
|
1807
1868
|
const items = getItems(await res.json(), res);
|
|
1808
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
|
+
}
|
|
1809
1879
|
return { items, next };
|
|
1810
1880
|
}
|
|
1811
1881
|
|
|
@@ -2159,46 +2229,83 @@ async function* chunks(source, signal) {
|
|
|
2159
2229
|
}
|
|
2160
2230
|
}
|
|
2161
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
|
+
|
|
2162
2241
|
/**
|
|
2163
2242
|
* Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
|
|
2164
2243
|
* multi-byte characters split across chunks, and a leading BOM.
|
|
2165
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
|
+
*
|
|
2166
2250
|
* ```js
|
|
2167
2251
|
* for await (const line of readLines(response)) console.log(line);
|
|
2168
2252
|
* ```
|
|
2169
2253
|
*
|
|
2170
2254
|
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
2171
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
2255
|
+
* @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts]
|
|
2172
2256
|
* @returns {AsyncGenerator<string>}
|
|
2173
2257
|
*/
|
|
2174
2258
|
async function* readLines(source, opts = {}) {
|
|
2259
|
+
const { maxLineLength = DEFAULT_MAX_LINE_LENGTH } = opts;
|
|
2175
2260
|
const decoder = new TextDecoder("utf-8");
|
|
2176
|
-
|
|
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
|
|
2177
2266
|
let first = true;
|
|
2178
2267
|
|
|
2179
2268
|
for await (const chunk of chunks(source, opts.signal)) {
|
|
2180
|
-
|
|
2181
|
-
if (first &&
|
|
2182
|
-
|
|
2269
|
+
let text = decoder.decode(chunk, { stream: true });
|
|
2270
|
+
if (first && text.length > 0) {
|
|
2271
|
+
text = text.replace(/^\uFEFF/, "");
|
|
2183
2272
|
first = false;
|
|
2184
2273
|
}
|
|
2185
2274
|
|
|
2186
|
-
let
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
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;
|
|
2195
2304
|
}
|
|
2196
|
-
buffer = buffer.slice(pos);
|
|
2197
2305
|
}
|
|
2198
2306
|
|
|
2199
|
-
|
|
2200
|
-
if (
|
|
2201
|
-
if (buffer.length > 0) yield buffer;
|
|
2307
|
+
const tail = parts.join("") + decoder.decode();
|
|
2308
|
+
if (tail.length > 0) yield tail;
|
|
2202
2309
|
}
|
|
2203
2310
|
|
|
2204
2311
|
/**
|
|
@@ -2238,11 +2345,13 @@ async function* readNDJSON(source, opts = {}) {
|
|
|
2238
2345
|
* ```
|
|
2239
2346
|
*
|
|
2240
2347
|
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
2241
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
2348
|
+
* @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts] - `maxLineLength` also caps the data of a single event
|
|
2242
2349
|
*/
|
|
2243
2350
|
async function* readSSE(source, opts = {}) {
|
|
2351
|
+
const maxEventLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
|
|
2244
2352
|
let event = "";
|
|
2245
2353
|
let data = [];
|
|
2354
|
+
let dataLength = 0;
|
|
2246
2355
|
let retry;
|
|
2247
2356
|
let lastId = "";
|
|
2248
2357
|
|
|
@@ -2251,6 +2360,7 @@ async function* readSSE(source, opts = {}) {
|
|
|
2251
2360
|
if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
|
|
2252
2361
|
event = "";
|
|
2253
2362
|
data = [];
|
|
2363
|
+
dataLength = 0;
|
|
2254
2364
|
retry = undefined;
|
|
2255
2365
|
continue;
|
|
2256
2366
|
}
|
|
@@ -2262,7 +2372,11 @@ async function* readSSE(source, opts = {}) {
|
|
|
2262
2372
|
if (value.startsWith(" ")) value = value.slice(1);
|
|
2263
2373
|
|
|
2264
2374
|
if (field === "event") event = value;
|
|
2265
|
-
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
|
+
}
|
|
2266
2380
|
else if (field === "id") {
|
|
2267
2381
|
if (!value.includes("\0")) lastId = value;
|
|
2268
2382
|
} else if (field === "retry") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boy-offi9-inc/reqkit",
|
|
3
|
-
"version": "0.7.
|
|
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",
|
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 };
|
package/src/createFetch.js
CHANGED
|
@@ -9,11 +9,13 @@ const { createRateLimiter } = require("./createRateLimiter");
|
|
|
9
9
|
const { createCircuitBreaker } = require("./createCircuitBreaker");
|
|
10
10
|
const { createMemoryCache } = require("./createMemoryCache");
|
|
11
11
|
const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
|
|
12
|
-
const { discardBody } = require("./_http");
|
|
12
|
+
const { discardBody, getOrigin } = require("./_http");
|
|
13
13
|
|
|
14
14
|
const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
|
|
15
15
|
// Safe to repeat without side effects. POST/PATCH are deliberately absent.
|
|
16
16
|
const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
17
|
+
// Default headers that carry credentials. They're only sent to trusted origins.
|
|
18
|
+
const SENSITIVE_HEADERS = ["authorization", "proxy-authorization", "cookie", "x-api-key", "x-auth-token"];
|
|
17
19
|
|
|
18
20
|
function isAbsoluteUrl(url) {
|
|
19
21
|
return /^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
@@ -47,11 +49,7 @@ function isStreamBody(body) {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function originOf(url) {
|
|
50
|
-
|
|
51
|
-
return new URL(url).origin;
|
|
52
|
-
} catch {
|
|
53
|
-
return url;
|
|
54
|
-
}
|
|
52
|
+
return getOrigin(url) || url;
|
|
55
53
|
}
|
|
56
54
|
|
|
57
55
|
// One in-flight token refresh per `auth` config object, so concurrent 401s —
|
|
@@ -101,6 +99,9 @@ function mergeDefaults(base, extra) {
|
|
|
101
99
|
* - concurrent GET de-duplication (withDedupe)
|
|
102
100
|
* - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
|
|
103
101
|
* concurrent requests) and replay the request
|
|
102
|
+
* - credential safety: default credentials (`auth`, and default headers like
|
|
103
|
+
* Authorization/Cookie/X-API-Key) are only sent to `baseUrl`'s origin and
|
|
104
|
+
* `trustedOrigins` — pass an absolute URL to another host and they stay home
|
|
104
105
|
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
105
106
|
* - `onRequest` / `onResponse` hooks
|
|
106
107
|
*
|
|
@@ -123,6 +124,9 @@ function mergeDefaults(base, extra) {
|
|
|
123
124
|
* whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
|
|
124
125
|
* called once for all concurrent callers and the request is replayed with the
|
|
125
126
|
* new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
|
|
127
|
+
* @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
|
|
128
|
+
* receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
|
|
129
|
+
* `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
|
|
126
130
|
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
127
131
|
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
128
132
|
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
@@ -140,6 +144,7 @@ function createFetch(defaults = {}) {
|
|
|
140
144
|
circuitBreaker,
|
|
141
145
|
cache,
|
|
142
146
|
auth,
|
|
147
|
+
trustedOrigins,
|
|
143
148
|
dedupe = false,
|
|
144
149
|
throwHttpErrors: defaultThrow = true,
|
|
145
150
|
fetch: customFetch,
|
|
@@ -149,6 +154,18 @@ function createFetch(defaults = {}) {
|
|
|
149
154
|
|
|
150
155
|
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
151
156
|
|
|
157
|
+
// Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
|
|
158
|
+
const trusted = new Set();
|
|
159
|
+
for (const o of [baseUrl, ...(trustedOrigins || [])]) {
|
|
160
|
+
const origin = o && getOrigin(o);
|
|
161
|
+
if (origin) trusted.add(origin);
|
|
162
|
+
}
|
|
163
|
+
const isTrustedTarget = (url) => {
|
|
164
|
+
if (trusted.size === 0 || !isAbsoluteUrl(url)) return true; // relative URLs resolve against our own base
|
|
165
|
+
const origin = getOrigin(url);
|
|
166
|
+
return origin !== null && trusted.has(origin);
|
|
167
|
+
};
|
|
168
|
+
|
|
152
169
|
// --- stateful pieces, built once so `extend()` children share them -------
|
|
153
170
|
const limiter = !rateLimit
|
|
154
171
|
? null
|
|
@@ -245,7 +262,18 @@ function createFetch(defaults = {}) {
|
|
|
245
262
|
} = init;
|
|
246
263
|
|
|
247
264
|
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
248
|
-
const
|
|
265
|
+
const url = joinUrl(baseUrl, input, query);
|
|
266
|
+
const credentialsAllowed = isTrustedTarget(url);
|
|
267
|
+
|
|
268
|
+
const defaultHeaderSet = await resolveHeaders(defaultHeaders);
|
|
269
|
+
if (!credentialsAllowed) {
|
|
270
|
+
// An absolute URL to some other host: don't hand it our default credentials.
|
|
271
|
+
for (const name of [...SENSITIVE_HEADERS, ...(auth && auth.header ? [String(auth.header).toLowerCase()] : [])]) {
|
|
272
|
+
defaultHeaderSet.delete(name);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
// Headers the caller passes for *this* call are deliberate, so they always go through.
|
|
276
|
+
const headers = mergeHeaders(defaultHeaderSet, new Headers(initHeaders || undefined));
|
|
249
277
|
|
|
250
278
|
let body = fetchInit.body;
|
|
251
279
|
if (json !== undefined) {
|
|
@@ -253,7 +281,7 @@ function createFetch(defaults = {}) {
|
|
|
253
281
|
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
254
282
|
}
|
|
255
283
|
|
|
256
|
-
const ctx = { url
|
|
284
|
+
const ctx = { url, init: { ...fetchInit, method, headers, body } };
|
|
257
285
|
if (onRequest) await onRequest(ctx);
|
|
258
286
|
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
259
287
|
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
@@ -262,7 +290,7 @@ function createFetch(defaults = {}) {
|
|
|
262
290
|
const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
|
|
263
291
|
const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
|
|
264
292
|
let usedToken = null;
|
|
265
|
-
if (auth && !finalHeaders.has(authHeader)) {
|
|
293
|
+
if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
|
|
266
294
|
usedToken = (await auth.getToken()) || null;
|
|
267
295
|
if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
|
|
268
296
|
}
|
|
@@ -437,6 +465,9 @@ function createFetch(defaults = {}) {
|
|
|
437
465
|
return finish(response);
|
|
438
466
|
}
|
|
439
467
|
|
|
468
|
+
/** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
|
|
469
|
+
request.baseUrl = baseUrl;
|
|
470
|
+
|
|
440
471
|
/** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
|
|
441
472
|
request.json = async (input, init) => {
|
|
442
473
|
const response = await request(input, init);
|
package/src/paginate.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
const { HTTPError } = require("./HTTPError");
|
|
2
|
+
const { getOrigin } = require("./_http");
|
|
3
|
+
|
|
4
|
+
const MAX_LINK_HEADER_LENGTH = 64 * 1024;
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
|
|
@@ -10,7 +13,8 @@ const { HTTPError } = require("./HTTPError");
|
|
|
10
13
|
* ```
|
|
11
14
|
*
|
|
12
15
|
* 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.
|
|
16
|
+
* link wins when a rel appears twice. Commas inside `<...>` are handled. Input
|
|
17
|
+
* is treated as untrusted: headers over 64 KB are ignored and parsing is linear-time.
|
|
14
18
|
*
|
|
15
19
|
* @param {string | null | undefined} header
|
|
16
20
|
* @param {string} [base]
|
|
@@ -18,10 +22,11 @@ const { HTTPError } = require("./HTTPError");
|
|
|
18
22
|
*/
|
|
19
23
|
function parseLinkHeader(header, base) {
|
|
20
24
|
const links = {};
|
|
21
|
-
|
|
25
|
+
// Link headers are server-controlled; real ones are a few hundred bytes (HTTP stacks cap headers at ~16 KB).
|
|
26
|
+
if (!header || header.length > MAX_LINK_HEADER_LENGTH) return links;
|
|
22
27
|
|
|
23
28
|
// <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
|
|
24
|
-
const linkRe = /<([
|
|
29
|
+
const linkRe = /<([^<>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
|
|
25
30
|
let match;
|
|
26
31
|
while ((match = linkRe.exec(header)) !== null) {
|
|
27
32
|
const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
|
|
@@ -148,15 +153,39 @@ function defaultGetItems(data) {
|
|
|
148
153
|
* @param {number} [opts.maxItems=Infinity]
|
|
149
154
|
* @param {AbortSignal} [opts.signal]
|
|
150
155
|
* @param {object} [opts.init] - extra options passed to every `request`
|
|
156
|
+
* @param {string[]} [opts.origins] - origins a `Link` may point to. Default: the origin of `url`
|
|
157
|
+
* (or of the `createFetch` instance's `baseUrl`). A `Link` to anywhere else throws
|
|
158
|
+
* `code: "PAGINATE_CROSS_ORIGIN"` — a server could otherwise steer your requests (and any
|
|
159
|
+
* credentials in `init`) to a third party. The check is skipped only when no origin can be determined.
|
|
151
160
|
*/
|
|
152
161
|
async function* paginateLinks(request, url, opts = {}) {
|
|
153
162
|
const { getItems = defaultGetItems, init = {}, signal } = opts;
|
|
154
163
|
|
|
164
|
+
const allowed = new Set();
|
|
165
|
+
if (opts.origins) {
|
|
166
|
+
for (const o of opts.origins) {
|
|
167
|
+
const origin = getOrigin(o);
|
|
168
|
+
if (origin) allowed.add(origin);
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
const origin = getOrigin(url) || getOrigin(request && request.baseUrl) || getOrigin(globalThis.location && globalThis.location.href);
|
|
172
|
+
if (origin) allowed.add(origin);
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
async function fetchPage(pageUrl) {
|
|
156
176
|
const res = await request(pageUrl, { ...init, signal });
|
|
157
177
|
if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
|
|
158
178
|
const items = getItems(await res.json(), res);
|
|
159
179
|
const next = parseLinkHeader(res.headers.get("link"), pageUrl).next;
|
|
180
|
+
if (next && allowed.size > 0) {
|
|
181
|
+
const nextOrigin = getOrigin(next);
|
|
182
|
+
if (nextOrigin !== null && !allowed.has(nextOrigin)) {
|
|
183
|
+
throw Object.assign(
|
|
184
|
+
new Error(`paginateLinks: refusing to follow a Link to another origin (${nextOrigin}). Pass { origins: [...] } if that's intended.`),
|
|
185
|
+
{ code: "PAGINATE_CROSS_ORIGIN" }
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
160
189
|
return { items, next };
|
|
161
190
|
}
|
|
162
191
|
|
package/src/streams.js
CHANGED
|
@@ -34,46 +34,83 @@ async function* chunks(source, signal) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
const DEFAULT_MAX_LINE_LENGTH = 16 * 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
function lineTooLong(limit) {
|
|
40
|
+
return Object.assign(
|
|
41
|
+
new Error(`reqkit streams: a line exceeded ${limit} characters without a line break (raise \`maxLineLength\` if that's expected)`),
|
|
42
|
+
{ code: "LINE_TOO_LONG" }
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
/**
|
|
38
47
|
* Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
|
|
39
48
|
* multi-byte characters split across chunks, and a leading BOM.
|
|
40
49
|
*
|
|
50
|
+
* Runs in linear time and bounded memory: only newly arrived text is scanned,
|
|
51
|
+
* and a "line" longer than `maxLineLength` characters (default 16 Mi — a
|
|
52
|
+
* server that never sends a newline) throws `code: "LINE_TOO_LONG"` instead
|
|
53
|
+
* of buffering forever.
|
|
54
|
+
*
|
|
41
55
|
* ```js
|
|
42
56
|
* for await (const line of readLines(response)) console.log(line);
|
|
43
57
|
* ```
|
|
44
58
|
*
|
|
45
59
|
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
46
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
60
|
+
* @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts]
|
|
47
61
|
* @returns {AsyncGenerator<string>}
|
|
48
62
|
*/
|
|
49
63
|
async function* readLines(source, opts = {}) {
|
|
64
|
+
const { maxLineLength = DEFAULT_MAX_LINE_LENGTH } = opts;
|
|
50
65
|
const decoder = new TextDecoder("utf-8");
|
|
51
|
-
|
|
66
|
+
const terminator = /[\r\n]/g;
|
|
67
|
+
|
|
68
|
+
let parts = []; // pieces of the line being assembled (no terminators in them)
|
|
69
|
+
let partsLength = 0;
|
|
70
|
+
let skipLF = false; // the previous line ended with "\r", so a leading "\n" belongs to it
|
|
52
71
|
let first = true;
|
|
53
72
|
|
|
54
73
|
for await (const chunk of chunks(source, opts.signal)) {
|
|
55
|
-
|
|
56
|
-
if (first &&
|
|
57
|
-
|
|
74
|
+
let text = decoder.decode(chunk, { stream: true });
|
|
75
|
+
if (first && text.length > 0) {
|
|
76
|
+
text = text.replace(/^\uFEFF/, "");
|
|
58
77
|
first = false;
|
|
59
78
|
}
|
|
60
79
|
|
|
61
|
-
let
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
80
|
+
let start = 0;
|
|
81
|
+
terminator.lastIndex = 0;
|
|
82
|
+
let match;
|
|
83
|
+
while ((match = terminator.exec(text)) !== null) {
|
|
84
|
+
const ch = match[0];
|
|
85
|
+
if (ch === "\n" && skipLF && match.index === start) {
|
|
86
|
+
skipLF = false; // the "\n" of a "\r\n" pair — swallow it
|
|
87
|
+
start = match.index + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
skipLF = false;
|
|
91
|
+
|
|
92
|
+
const piece = text.slice(start, match.index);
|
|
93
|
+
if (partsLength + piece.length > maxLineLength) throw lineTooLong(maxLineLength);
|
|
94
|
+
const line = parts.length > 0 ? parts.join("") + piece : piece;
|
|
95
|
+
parts = [];
|
|
96
|
+
partsLength = 0;
|
|
97
|
+
yield line;
|
|
98
|
+
|
|
99
|
+
start = match.index + 1;
|
|
100
|
+
skipLF = ch === "\r";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (start < text.length) {
|
|
104
|
+
const rest = text.slice(start);
|
|
105
|
+
partsLength += rest.length;
|
|
106
|
+
if (partsLength > maxLineLength) throw lineTooLong(maxLineLength);
|
|
107
|
+
parts.push(rest);
|
|
108
|
+
skipLF = false;
|
|
70
109
|
}
|
|
71
|
-
buffer = buffer.slice(pos);
|
|
72
110
|
}
|
|
73
111
|
|
|
74
|
-
|
|
75
|
-
if (
|
|
76
|
-
if (buffer.length > 0) yield buffer;
|
|
112
|
+
const tail = parts.join("") + decoder.decode();
|
|
113
|
+
if (tail.length > 0) yield tail;
|
|
77
114
|
}
|
|
78
115
|
|
|
79
116
|
/**
|
|
@@ -113,11 +150,13 @@ async function* readNDJSON(source, opts = {}) {
|
|
|
113
150
|
* ```
|
|
114
151
|
*
|
|
115
152
|
* @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
|
|
116
|
-
* @param {{ signal?: AbortSignal }} [opts]
|
|
153
|
+
* @param {{ signal?: AbortSignal, maxLineLength?: number }} [opts] - `maxLineLength` also caps the data of a single event
|
|
117
154
|
*/
|
|
118
155
|
async function* readSSE(source, opts = {}) {
|
|
156
|
+
const maxEventLength = opts.maxLineLength ?? DEFAULT_MAX_LINE_LENGTH;
|
|
119
157
|
let event = "";
|
|
120
158
|
let data = [];
|
|
159
|
+
let dataLength = 0;
|
|
121
160
|
let retry;
|
|
122
161
|
let lastId = "";
|
|
123
162
|
|
|
@@ -126,6 +165,7 @@ async function* readSSE(source, opts = {}) {
|
|
|
126
165
|
if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
|
|
127
166
|
event = "";
|
|
128
167
|
data = [];
|
|
168
|
+
dataLength = 0;
|
|
129
169
|
retry = undefined;
|
|
130
170
|
continue;
|
|
131
171
|
}
|
|
@@ -137,7 +177,11 @@ async function* readSSE(source, opts = {}) {
|
|
|
137
177
|
if (value.startsWith(" ")) value = value.slice(1);
|
|
138
178
|
|
|
139
179
|
if (field === "event") event = value;
|
|
140
|
-
else if (field === "data")
|
|
180
|
+
else if (field === "data") {
|
|
181
|
+
dataLength += value.length + 1;
|
|
182
|
+
if (dataLength > maxEventLength) throw lineTooLong(maxEventLength);
|
|
183
|
+
data.push(value);
|
|
184
|
+
}
|
|
141
185
|
else if (field === "id") {
|
|
142
186
|
if (!value.includes("\0")) lastId = value;
|
|
143
187
|
} else if (field === "retry") {
|
package/types/index.d.ts
CHANGED
|
@@ -210,6 +210,13 @@ 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
|
+
/**
|
|
214
|
+
* Extra origins ("https://cdn.example.com") that may receive the default credentials
|
|
215
|
+
* (`auth` and default Authorization/Cookie/X-API-Key headers), besides `baseUrl`'s.
|
|
216
|
+
* Absolute URLs to any other origin don't get them. With neither `baseUrl` nor
|
|
217
|
+
* `trustedOrigins` there's nothing to compare against and credentials go everywhere.
|
|
218
|
+
*/
|
|
219
|
+
trustedOrigins?: string[];
|
|
213
220
|
/** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
|
|
214
221
|
auth?: AuthOptions;
|
|
215
222
|
/** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
|
|
@@ -237,6 +244,8 @@ export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"
|
|
|
237
244
|
|
|
238
245
|
export interface ReqkitFetch {
|
|
239
246
|
(input: string, init?: ReqkitRequestInit): Promise<Response>;
|
|
247
|
+
/** The `baseUrl` this instance was created with. */
|
|
248
|
+
readonly baseUrl: string | undefined;
|
|
240
249
|
/** Resolves to the parsed JSON body (null for an empty body). */
|
|
241
250
|
json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
|
|
242
251
|
/** New instance with these defaults merged over the current ones. */
|
|
@@ -392,6 +401,12 @@ export type StreamSource = Response | ReadableStream<Uint8Array> | AsyncIterable
|
|
|
392
401
|
|
|
393
402
|
export interface StreamOptions {
|
|
394
403
|
signal?: AbortSignal;
|
|
404
|
+
/**
|
|
405
|
+
* Longest line (or, for readSSE, one event's data) in characters before throwing
|
|
406
|
+
* `code: "LINE_TOO_LONG"` — protects against a server that never sends a newline.
|
|
407
|
+
* Default 16 Mi (16 * 1024 * 1024); pass Infinity to disable.
|
|
408
|
+
*/
|
|
409
|
+
maxLineLength?: number;
|
|
395
410
|
}
|
|
396
411
|
|
|
397
412
|
export interface SSEEvent {
|
|
@@ -505,6 +520,11 @@ export interface PaginateLinksOptions<T = any> {
|
|
|
505
520
|
signal?: AbortSignal;
|
|
506
521
|
/** Extra options passed to every request. */
|
|
507
522
|
init?: Record<string, any>;
|
|
523
|
+
/**
|
|
524
|
+
* Origins a `Link` may point to. Default: the origin of `url` (or of the createFetch
|
|
525
|
+
* instance's `baseUrl`). Any other origin throws `code: "PAGINATE_CROSS_ORIGIN"`.
|
|
526
|
+
*/
|
|
527
|
+
origins?: string[];
|
|
508
528
|
}
|
|
509
529
|
|
|
510
530
|
/** Follows `Link: <...>; rel="next"` headers and yields the items of every page. */
|