@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.
@@ -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,19 @@ function isStreamBody(body) {
47
49
  }
48
50
 
49
51
  function originOf(url) {
50
- try {
51
- return new URL(url).origin;
52
- } catch {
53
- return url;
52
+ return getOrigin(url) || url;
53
+ }
54
+
55
+ // One in-flight token refresh per `auth` config object, so concurrent 401s —
56
+ // even across extend() children — trigger exactly one refresh.
57
+ const refreshStates = new WeakMap();
58
+ function refreshStateFor(auth) {
59
+ let state = refreshStates.get(auth);
60
+ if (!state) {
61
+ state = { inflight: null };
62
+ refreshStates.set(auth, state);
54
63
  }
64
+ return state;
55
65
  }
56
66
 
57
67
  function mergeDefaults(base, extra) {
@@ -87,6 +97,11 @@ function mergeDefaults(base, extra) {
87
97
  * - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
88
98
  * ETag / Last-Modified revalidation (304s)
89
99
  * - concurrent GET de-duplication (withDedupe)
100
+ * - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
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
90
105
  * - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
91
106
  * - `onRequest` / `onResponse` hooks
92
107
  *
@@ -104,6 +119,14 @@ function mergeDefaults(base, extra) {
104
119
  * a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
105
120
  * @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
106
121
  * maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
122
+ * @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
123
+ * `getToken()` supplies the current token (sync or async); on a 401 (or
124
+ * whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
125
+ * called once for all concurrent callers and the request is replayed with the
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.
107
130
  * @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
108
131
  * @param {boolean} [defaults.throwHttpErrors=true]
109
132
  * @param {typeof fetch} [defaults.fetch=globalThis.fetch]
@@ -120,6 +143,8 @@ function createFetch(defaults = {}) {
120
143
  rateLimit,
121
144
  circuitBreaker,
122
145
  cache,
146
+ auth,
147
+ trustedOrigins,
123
148
  dedupe = false,
124
149
  throwHttpErrors: defaultThrow = true,
125
150
  fetch: customFetch,
@@ -129,6 +154,18 @@ function createFetch(defaults = {}) {
129
154
 
130
155
  const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
131
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
+
132
169
  // --- stateful pieces, built once so `extend()` children share them -------
133
170
  const limiter = !rateLimit
134
171
  ? null
@@ -190,6 +227,27 @@ function createFetch(defaults = {}) {
190
227
  pendingStores.set(key, p);
191
228
  }
192
229
 
230
+ // Returns a token newer than `usedToken`, refreshing at most once no matter
231
+ // how many requests were rejected at the same time. If someone else already
232
+ // refreshed (the current token isn't the one we used), just use that.
233
+ async function obtainFreshToken(authCfg, tokenUsed, response) {
234
+ const current = await authCfg.getToken();
235
+ if (current && current !== tokenUsed) return current;
236
+
237
+ const state = refreshStateFor(authCfg);
238
+ if (!state.inflight) {
239
+ state.inflight = (async () => {
240
+ try {
241
+ const out = await authCfg.refresh({ token: tokenUsed, response });
242
+ return typeof out === "string" && out ? out : await authCfg.getToken();
243
+ } finally {
244
+ state.inflight = null;
245
+ }
246
+ })();
247
+ }
248
+ return state.inflight;
249
+ }
250
+
193
251
  async function request(input, init = {}) {
194
252
  const {
195
253
  query,
@@ -204,7 +262,18 @@ function createFetch(defaults = {}) {
204
262
  } = init;
205
263
 
206
264
  const method = String(fetchInit.method || "GET").toUpperCase();
207
- const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
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));
208
277
 
209
278
  let body = fetchInit.body;
210
279
  if (json !== undefined) {
@@ -212,11 +281,20 @@ function createFetch(defaults = {}) {
212
281
  if (!headers.has("content-type")) headers.set("content-type", "application/json");
213
282
  }
214
283
 
215
- const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
284
+ const ctx = { url, init: { ...fetchInit, method, headers, body } };
216
285
  if (onRequest) await onRequest(ctx);
217
286
  const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
218
287
  const finalMethod = String(ctx.init.method || method).toUpperCase();
219
288
 
289
+ // --- auth: attach the current token (unless the caller set the header) ----
290
+ const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
291
+ const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
292
+ let usedToken = null;
293
+ if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
294
+ usedToken = (await auth.getToken()) || null;
295
+ if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
296
+ }
297
+
220
298
  // --- retry policy ------------------------------------------------------
221
299
  const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
222
300
  const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
@@ -292,7 +370,7 @@ function createFetch(defaults = {}) {
292
370
  !ctx.init.body &&
293
371
  cacheMode !== false &&
294
372
  cacheMode !== "no-store";
295
- const key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
373
+ let key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
296
374
  let entry = null;
297
375
 
298
376
  const finish = async (response) => {
@@ -353,6 +431,24 @@ function createFetch(defaults = {}) {
353
431
  throw err;
354
432
  }
355
433
 
434
+ // --- auth: a rejected token is refreshed once, then the request replays ----
435
+ const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
436
+ if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
437
+ const fresh = await obtainFreshToken(auth, usedToken, response);
438
+ if (fresh) {
439
+ await discardBody(response);
440
+ finalHeaders.set(authHeader, formatToken(fresh));
441
+ entry = null; // belongs to the old token's cache key
442
+ if (useCache) key = cacheKey(finalMethod, ctx.url, finalHeaders);
443
+ try {
444
+ response = await network(finalHeaders, signal);
445
+ } catch (err) {
446
+ if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
447
+ throw err;
448
+ }
449
+ }
450
+ }
451
+
356
452
  if (useCache) {
357
453
  if (response.status === 304 && entry) {
358
454
  entry.storedAt = Date.now();
@@ -369,6 +465,9 @@ function createFetch(defaults = {}) {
369
465
  return finish(response);
370
466
  }
371
467
 
468
+ /** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
469
+ request.baseUrl = baseUrl;
470
+
372
471
  /** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
373
472
  request.json = async (input, init) => {
374
473
  const response = await request(input, init);
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
@@ -28,6 +28,10 @@ export const {
28
28
  readNDJSON,
29
29
  readSSE,
30
30
  streamSSE,
31
+ paginate,
32
+ paginatePages,
33
+ paginateLinks,
34
+ parseLinkHeader,
31
35
  } = reqkit;
32
36
 
33
37
  export default reqkit;
@@ -0,0 +1,195 @@
1
+ const { HTTPError } = require("./HTTPError");
2
+ const { getOrigin } = require("./_http");
3
+
4
+ const MAX_LINK_HEADER_LENGTH = 64 * 1024;
5
+
6
+ /**
7
+ * Parses an RFC 8288 `Link` header (GitHub, GitLab, WordPress... use it for
8
+ * pagination) into `{ rel: url }`. Pass `base` to resolve relative URLs.
9
+ *
10
+ * ```js
11
+ * parseLinkHeader('<https://api.x/items?page=2>; rel="next", <https://api.x/items?page=9>; rel="last"');
12
+ * // { next: "https://api.x/items?page=2", last: "https://api.x/items?page=9" }
13
+ * ```
14
+ *
15
+ * A link with several rels (`rel="next last"`) is listed under each; the first
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.
18
+ *
19
+ * @param {string | null | undefined} header
20
+ * @param {string} [base]
21
+ * @returns {Record<string, string>}
22
+ */
23
+ function parseLinkHeader(header, base) {
24
+ const links = {};
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;
27
+
28
+ // <url> followed by any number of `; name` / `; name=token` / `; name="quoted, value"` params
29
+ const linkRe = /<([^<>]*)>((?:\s*;\s*[^=;,<\s]+(?:\s*=\s*(?:"[^"]*"|[^;,<\s]*))?)*)/g;
30
+ let match;
31
+ while ((match = linkRe.exec(header)) !== null) {
32
+ const rel = /;\s*rel\s*=\s*(?:"([^"]*)"|([^\s;,]+))/i.exec(match[2]);
33
+ if (!rel) continue;
34
+
35
+ let url = match[1].trim();
36
+ if (base) {
37
+ try {
38
+ url = new URL(url, base).href;
39
+ } catch {
40
+ // leave it as written
41
+ }
42
+ }
43
+ for (const name of (rel[1] ?? rel[2]).toLowerCase().split(/\s+/).filter(Boolean)) {
44
+ if (!(name in links)) links[name] = url;
45
+ }
46
+ }
47
+ return links;
48
+ }
49
+
50
+ function stuckError(message) {
51
+ return Object.assign(new Error(message), { code: "PAGINATE_STUCK" });
52
+ }
53
+
54
+ /**
55
+ * Walks a paginated API page by page. You write `fetchPage(cursor, ctx)` —
56
+ * it returns `{ items, next }`, where `next` is whatever identifies the next
57
+ * page (a cursor token, an offset, a URL...) or `undefined`/`null` on the
58
+ * last page — and you get an async iterator of pages.
59
+ *
60
+ * ```js
61
+ * for await (const { items, index } of paginatePages(async (cursor) => {
62
+ * const r = await api.json("/users", { query: { cursor, limit: 100 } });
63
+ * return { items: r.data, next: r.next_cursor };
64
+ * })) { ... }
65
+ * ```
66
+ *
67
+ * A `next` that equals the cursor just used throws `code: "PAGINATE_STUCK"`
68
+ * instead of looping forever.
69
+ *
70
+ * @template T
71
+ * @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
72
+ * @param {object} [opts]
73
+ * @param {any} [opts.start] - the cursor for the first page (default undefined)
74
+ * @param {number} [opts.maxPages=Infinity]
75
+ * @param {AbortSignal} [opts.signal]
76
+ * @returns {AsyncGenerator<{ items: T[], index: number, cursor: any }>}
77
+ */
78
+ async function* paginatePages(fetchPage, opts = {}) {
79
+ const { start, maxPages = Infinity, signal } = opts;
80
+ let cursor = start;
81
+ let index = 0;
82
+
83
+ for (;;) {
84
+ if (index >= maxPages) return;
85
+ signal?.throwIfAborted?.();
86
+
87
+ const result = await fetchPage(cursor, { index, signal });
88
+ if (!result || !Array.isArray(result.items)) {
89
+ throw new TypeError("paginate: fetchPage must return { items: array, next?: cursor }");
90
+ }
91
+ yield { items: result.items, index, cursor };
92
+
93
+ const next = result.next;
94
+ if (next === undefined || next === null) return;
95
+ if (next === cursor) {
96
+ throw stuckError("paginate: `next` equals the cursor just used, so the page didn't advance — stopping to avoid an infinite loop");
97
+ }
98
+ cursor = next;
99
+ index++;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Like `paginatePages`, but yields the items one by one. `maxItems` stops
105
+ * early (mid-page) — and since nothing is fetched until you ask for it,
106
+ * `break`-ing out of the loop never fetches a page you didn't need.
107
+ *
108
+ * ```js
109
+ * for await (const user of paginate(fetchUsersPage, { maxItems: 250 })) { ... }
110
+ * ```
111
+ *
112
+ * @template T
113
+ * @param {(cursor: any, ctx: { index: number, signal?: AbortSignal }) => Promise<{ items: T[], next?: any }>} fetchPage
114
+ * @param {object} [opts] - same as paginatePages, plus `maxItems`
115
+ * @param {number} [opts.maxItems=Infinity]
116
+ * @returns {AsyncGenerator<T>}
117
+ */
118
+ async function* paginate(fetchPage, opts = {}) {
119
+ const { maxItems = Infinity } = opts;
120
+ if (maxItems <= 0) return;
121
+
122
+ let count = 0;
123
+ for await (const page of paginatePages(fetchPage, opts)) {
124
+ for (const item of page.items) {
125
+ yield item;
126
+ if (++count >= maxItems) return;
127
+ }
128
+ }
129
+ }
130
+
131
+ function defaultGetItems(data) {
132
+ if (Array.isArray(data)) return data;
133
+ for (const key of ["items", "data", "results", "records"]) {
134
+ if (data && Array.isArray(data[key])) return data[key];
135
+ }
136
+ throw new TypeError("paginateLinks: couldn't find the items array in the response — pass `getItems(data)`");
137
+ }
138
+
139
+ /**
140
+ * Follows `Link: <...>; rel="next"` headers (GitHub-style) and yields the
141
+ * items of every page. `request` is anything fetch-like — a `createFetch`
142
+ * instance or plain `fetch` (a non-2xx response throws `HTTPError`).
143
+ *
144
+ * ```js
145
+ * for await (const repo of paginateLinks(api, "/orgs/acme/repos?per_page=100")) { ... }
146
+ * ```
147
+ *
148
+ * @param {(url: string, init?: object) => Promise<Response>} request
149
+ * @param {string} url - the first page
150
+ * @param {object} [opts]
151
+ * @param {(data: any, response: Response) => any[]} [opts.getItems] - default: the body if it's an array, else its `items` / `data` / `results` / `records`
152
+ * @param {number} [opts.maxPages=Infinity]
153
+ * @param {number} [opts.maxItems=Infinity]
154
+ * @param {AbortSignal} [opts.signal]
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.
160
+ */
161
+ async function* paginateLinks(request, url, opts = {}) {
162
+ const { getItems = defaultGetItems, init = {}, signal } = opts;
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
+
175
+ async function fetchPage(pageUrl) {
176
+ const res = await request(pageUrl, { ...init, signal });
177
+ if (!res.ok) throw new HTTPError(res, { method: init.method || "GET", url: pageUrl });
178
+ const items = getItems(await res.json(), res);
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
+ }
189
+ return { items, next };
190
+ }
191
+
192
+ yield* paginate(fetchPage, { ...opts, start: url });
193
+ }
194
+
195
+ module.exports = { paginate, paginatePages, paginateLinks, parseLinkHeader };
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
- let buffer = "";
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
- buffer += decoder.decode(chunk, { stream: true });
56
- if (first && buffer.length > 0) {
57
- buffer = buffer.replace(/^\uFEFF/, "");
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 pos = 0;
62
- for (;;) {
63
- const rest = buffer.slice(pos);
64
- const i = rest.search(/\r\n|\n|\r/);
65
- if (i === -1) break;
66
- // A trailing "\r" might be the first half of "\r\n" — wait for more data.
67
- if (rest[i] === "\r" && i === rest.length - 1) break;
68
- yield rest.slice(0, i);
69
- pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
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
- buffer += decoder.decode();
75
- if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
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") data.push(value);
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,15 @@ 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[];
220
+ /** Attach a token and, on a 401, refresh it once (shared by all concurrent requests) and replay. */
221
+ auth?: AuthOptions;
213
222
  /** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
214
223
  cache?: boolean | CacheOptions;
215
224
  /** Merge identical concurrent GET/HEAD calls. Default false. */
@@ -235,6 +244,8 @@ export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"
235
244
 
236
245
  export interface ReqkitFetch {
237
246
  (input: string, init?: ReqkitRequestInit): Promise<Response>;
247
+ /** The `baseUrl` this instance was created with. */
248
+ readonly baseUrl: string | undefined;
238
249
  /** Resolves to the parsed JSON body (null for an empty body). */
239
250
  json<T = unknown>(input: string, init?: ReqkitRequestInit): Promise<T>;
240
251
  /** New instance with these defaults merged over the current ones. */
@@ -390,6 +401,12 @@ export type StreamSource = Response | ReadableStream<Uint8Array> | AsyncIterable
390
401
 
391
402
  export interface StreamOptions {
392
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;
393
410
  }
394
411
 
395
412
  export interface SSEEvent {
@@ -441,3 +458,81 @@ export interface StreamSSEOptions {
441
458
 
442
459
  /** Auto-reconnecting SSE: resumes with Last-Event-ID, honors `retry:` and Retry-After. */
443
460
  export function streamSSE(url: string, opts?: StreamSSEOptions): AsyncGenerator<SSEEvent, void, undefined>;
461
+
462
+ export interface AuthOptions {
463
+ /** The current token (sync or async). Falsy = send no auth header. */
464
+ getToken(): string | null | undefined | Promise<string | null | undefined>;
465
+ /**
466
+ * Called ONCE when requests are rejected (401 by default), no matter how many
467
+ * are waiting. Return the new token, or update your store and return nothing
468
+ * (reqkit then calls getToken() again). Throwing rejects every waiting request.
469
+ */
470
+ refresh(ctx: { token: string; response: Response }): string | void | undefined | Promise<string | void | undefined>;
471
+ /** Default "authorization". */
472
+ header?: string;
473
+ /** Default "Bearer". Use "" to send the bare token (e.g. for an X-API-Key header). */
474
+ scheme?: string;
475
+ /** Default: `res.status === 401`. */
476
+ shouldRefresh?: (response: Response) => boolean;
477
+ }
478
+
479
+ export interface PageContext {
480
+ index: number;
481
+ signal?: AbortSignal;
482
+ }
483
+
484
+ export interface PageResult<T, C = any> {
485
+ items: T[];
486
+ /** Whatever identifies the next page (token, offset, URL...). undefined/null = last page. */
487
+ next?: C | null;
488
+ }
489
+
490
+ export interface Page<T, C = any> {
491
+ items: T[];
492
+ index: number;
493
+ /** The cursor this page was fetched with (undefined for the first, unless `start` was given). */
494
+ cursor: C | undefined;
495
+ }
496
+
497
+ export interface PaginateOptions<C = any> {
498
+ /** The cursor for the first page. */
499
+ start?: C;
500
+ maxPages?: number;
501
+ maxItems?: number;
502
+ signal?: AbortSignal;
503
+ }
504
+
505
+ export type FetchPage<T, C = any> = (cursor: C | undefined, ctx: PageContext) => Promise<PageResult<T, C>>;
506
+
507
+ /** Yields every item, fetching pages lazily. */
508
+ export function paginate<T, C = any>(fetchPage: FetchPage<T, C>, opts?: PaginateOptions<C>): AsyncGenerator<T, void, undefined>;
509
+ /** Yields whole pages. */
510
+ export function paginatePages<T, C = any>(
511
+ fetchPage: FetchPage<T, C>,
512
+ opts?: Omit<PaginateOptions<C>, "maxItems">
513
+ ): AsyncGenerator<Page<T, C>, void, undefined>;
514
+
515
+ export interface PaginateLinksOptions<T = any> {
516
+ /** Default: the body if it's an array, else its `items` / `data` / `results` / `records`. */
517
+ getItems?: (data: any, response: Response) => T[];
518
+ maxPages?: number;
519
+ maxItems?: number;
520
+ signal?: AbortSignal;
521
+ /** Extra options passed to every request. */
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[];
528
+ }
529
+
530
+ /** Follows `Link: <...>; rel="next"` headers and yields the items of every page. */
531
+ export function paginateLinks<T = any>(
532
+ request: (url: string, init?: any) => Promise<Response>,
533
+ url: string,
534
+ opts?: PaginateLinksOptions<T>
535
+ ): AsyncGenerator<T, void, undefined>;
536
+
537
+ /** Parses an RFC 8288 Link header into `{ rel: url }`. Pass `base` to resolve relative URLs. */
538
+ export function parseLinkHeader(header: string | null | undefined, base?: string): Record<string, string>;