@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 +79 -3
- package/dist/reqkit.mjs +390 -30
- package/package.json +6 -2
- package/src/_http.js +11 -1
- package/src/createFetch.js +107 -8
- package/src/index.js +5 -0
- package/src/index.mjs +4 -0
- package/src/paginate.js +195 -0
- package/src/streams.js +64 -20
- package/types/index.d.ts +95 -0
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,19 @@ function isStreamBody(body) {
|
|
|
47
49
|
}
|
|
48
50
|
|
|
49
51
|
function originOf(url) {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
package/src/paginate.js
ADDED
|
@@ -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
|
-
|
|
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,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>;
|