@boy-offi9-inc/reqkit 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +95 -55
- package/dist/reqkit.mjs +2143 -1907
- package/package.json +9 -5
- package/src/{createFetch.js → http/createFetch.js} +169 -30
- package/src/index.js +18 -18
- package/src/{_http.js → internal/http.js} +11 -1
- package/src/{paginate.js → pagination/paginate.js} +33 -4
- package/src/{createRateLimiter.js → resilience/rateLimiter.js} +7 -4
- package/src/{withRetry.js → resilience/retry.js} +2 -2
- package/src/{retryAfterAware.js → resilience/retryAfterAware.js} +3 -3
- package/src/{downloadResumable.js → streams/download.js} +5 -5
- package/src/{streams.js → streams/readers.js} +64 -20
- package/src/{streamSSE.js → streams/streamSSE.js} +5 -5
- package/src/{createMockFetch.js → testing/createMockFetch.js} +1 -1
- package/types/errors.d.ts +24 -0
- package/types/http.d.ts +180 -0
- package/types/index.d.ts +7 -518
- package/types/pagination.d.ts +62 -0
- package/types/resilience.d.ts +171 -0
- package/types/streams.d.ts +105 -0
- package/types/testing.d.ts +44 -0
- /package/{RECIPES.md → docs/recipes.md} +0 -0
- /package/src/{HTTPError.js → errors/HTTPError.js} +0 -0
- /package/src/{normalizeError.js → errors/normalizeError.js} +0 -0
- /package/src/{buildQuery.js → http/buildQuery.js} +0 -0
- /package/src/{_cache.js → http/cache/entry.js} +0 -0
- /package/src/{createMemoryCache.js → http/cache/memoryCache.js} +0 -0
- /package/src/{parseJsonSafe.js → http/parseJsonSafe.js} +0 -0
- /package/src/{_sleep.js → internal/sleep.js} +0 -0
- /package/src/{createCircuitBreaker.js → resilience/circuitBreaker.js} +0 -0
- /package/src/{withDedupe.js → resilience/dedupe.js} +0 -0
- /package/src/{createLimiter.js → resilience/limiter.js} +0 -0
- /package/src/{withTimeout.js → resilience/timeout.js} +0 -0
- /package/src/{withProgress.js → streams/progress.js} +0 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@boy-offi9-inc/reqkit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Small, composable HTTP helpers
|
|
3
|
+
"version": "0.8.0",
|
|
4
|
+
"description": "Small, composable HTTP helpers \u2014 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",
|
|
7
7
|
"exports": {
|
|
@@ -27,7 +27,8 @@
|
|
|
27
27
|
"test": "node --test",
|
|
28
28
|
"typecheck": "tsc -p tsconfig.json",
|
|
29
29
|
"smoke": "node smoke/run.mjs",
|
|
30
|
-
"
|
|
30
|
+
"check": "npm run build && npm test && npm run typecheck && npm run smoke",
|
|
31
|
+
"prepublishOnly": "npm run check"
|
|
31
32
|
},
|
|
32
33
|
"keywords": [
|
|
33
34
|
"http",
|
|
@@ -61,7 +62,10 @@
|
|
|
61
62
|
"pagination",
|
|
62
63
|
"auth",
|
|
63
64
|
"token-refresh",
|
|
64
|
-
"link-header"
|
|
65
|
+
"link-header",
|
|
66
|
+
"stale-if-error",
|
|
67
|
+
"observability",
|
|
68
|
+
"logging"
|
|
65
69
|
],
|
|
66
70
|
"author": "Boy Offi9",
|
|
67
71
|
"license": "MIT",
|
|
@@ -83,8 +87,8 @@
|
|
|
83
87
|
"src",
|
|
84
88
|
"types",
|
|
85
89
|
"dist",
|
|
90
|
+
"docs/recipes.md",
|
|
86
91
|
"README.md",
|
|
87
|
-
"RECIPES.md",
|
|
88
92
|
"LICENSE"
|
|
89
93
|
],
|
|
90
94
|
"devDependencies": {
|
|
@@ -1,19 +1,21 @@
|
|
|
1
|
-
const { withRetry, RetryAbortedError } = require("
|
|
2
|
-
const { withTimeout } = require("
|
|
3
|
-
const { withDedupe } = require("
|
|
1
|
+
const { withRetry, RetryAbortedError } = require("../resilience/retry");
|
|
2
|
+
const { withTimeout } = require("../resilience/timeout");
|
|
3
|
+
const { withDedupe } = require("../resilience/dedupe");
|
|
4
4
|
const { buildQuery } = require("./buildQuery");
|
|
5
5
|
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
6
|
-
const { normalizeError } = require("
|
|
7
|
-
const { HTTPError } = require("
|
|
8
|
-
const { createRateLimiter } = require("
|
|
9
|
-
const { createCircuitBreaker } = require("
|
|
10
|
-
const { createMemoryCache } = require("./
|
|
11
|
-
const { cacheKey, snapshotResponse, entryToResponse } = require("./
|
|
12
|
-
const { discardBody } = require("
|
|
6
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
7
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
8
|
+
const { createRateLimiter } = require("../resilience/rateLimiter");
|
|
9
|
+
const { createCircuitBreaker } = require("../resilience/circuitBreaker");
|
|
10
|
+
const { createMemoryCache } = require("./cache/memoryCache");
|
|
11
|
+
const { cacheKey, snapshotResponse, entryToResponse } = require("./cache/entry");
|
|
12
|
+
const { discardBody, getOrigin } = require("../internal/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 —
|
|
@@ -96,11 +94,17 @@ function mergeDefaults(base, extra) {
|
|
|
96
94
|
* - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
|
|
97
95
|
* *before* the server has to say 429
|
|
98
96
|
* - `circuitBreaker`: fail fast against a service that keeps failing
|
|
99
|
-
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate
|
|
100
|
-
* ETag / Last-Modified revalidation (304s)
|
|
97
|
+
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate,
|
|
98
|
+
* stale-if-error and ETag / Last-Modified revalidation (304s)
|
|
99
|
+
* - `onEvent`: one callback for everything that happens (request, attempt,
|
|
100
|
+
* retry, response, error, cache, rate-limit waits, circuit changes, token
|
|
101
|
+
* refreshes) — plug it into your logger or metrics
|
|
101
102
|
* - concurrent GET de-duplication (withDedupe)
|
|
102
103
|
* - `auth`: attach a token, and on a 401 refresh it ONCE (shared by all
|
|
103
104
|
* concurrent requests) and replay the request
|
|
105
|
+
* - credential safety: default credentials (`auth`, and default headers like
|
|
106
|
+
* Authorization/Cookie/X-API-Key) are only sent to `baseUrl`'s origin and
|
|
107
|
+
* `trustedOrigins` — pass an absolute URL to another host and they stay home
|
|
104
108
|
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
105
109
|
* - `onRequest` / `onResponse` hooks
|
|
106
110
|
*
|
|
@@ -116,19 +120,26 @@ function mergeDefaults(base, extra) {
|
|
|
116
120
|
* createRateLimiter() instance to share one budget
|
|
117
121
|
* @param {boolean | object | Function} [defaults.circuitBreaker] - options for
|
|
118
122
|
* a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
|
|
119
|
-
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
|
|
120
|
-
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
|
|
123
|
+
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate, staleIfError,
|
|
124
|
+
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL. `staleIfError` (ms) lets a
|
|
125
|
+
* cached copy be served for that long past its TTL when the network fails or the server answers 5xx.
|
|
121
126
|
* @param {object} [defaults.auth] - `{ getToken, refresh, header?, scheme?, shouldRefresh? }`.
|
|
122
127
|
* `getToken()` supplies the current token (sync or async); on a 401 (or
|
|
123
128
|
* whatever `shouldRefresh(response)` says) `refresh({ token, response })` is
|
|
124
129
|
* called once for all concurrent callers and the request is replayed with the
|
|
125
130
|
* new token. `header` defaults to "authorization", `scheme` to "Bearer" ("" for none).
|
|
131
|
+
* @param {string[]} [defaults.trustedOrigins] - extra origins ("https://cdn.example.com") that may
|
|
132
|
+
* receive the default credentials, besides `baseUrl`'s. With neither `baseUrl` nor
|
|
133
|
+
* `trustedOrigins` set there is nothing to compare against and credentials go everywhere.
|
|
126
134
|
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
127
135
|
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
128
136
|
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
129
137
|
* @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
|
|
130
138
|
* runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
|
|
131
139
|
* @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
|
|
140
|
+
* @param {(event: object) => void | Promise<void>} [defaults.onEvent] - receives `{ type, ... }` events:
|
|
141
|
+
* "request", "attempt", "retry", "response", "error", "cache", "ratelimit", "circuit", "auth".
|
|
142
|
+
* Errors thrown by the handler are swallowed — logging must never break a request.
|
|
132
143
|
*/
|
|
133
144
|
function createFetch(defaults = {}) {
|
|
134
145
|
const {
|
|
@@ -140,15 +151,40 @@ function createFetch(defaults = {}) {
|
|
|
140
151
|
circuitBreaker,
|
|
141
152
|
cache,
|
|
142
153
|
auth,
|
|
154
|
+
trustedOrigins,
|
|
143
155
|
dedupe = false,
|
|
144
156
|
throwHttpErrors: defaultThrow = true,
|
|
145
157
|
fetch: customFetch,
|
|
146
158
|
onRequest,
|
|
147
159
|
onResponse,
|
|
160
|
+
onEvent,
|
|
148
161
|
} = defaults;
|
|
149
162
|
|
|
150
163
|
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
151
164
|
|
|
165
|
+
// A throwing (or rejecting) handler must never break a request.
|
|
166
|
+
const emitEvent = (event) => {
|
|
167
|
+
if (!onEvent) return;
|
|
168
|
+
try {
|
|
169
|
+
const result = onEvent(event);
|
|
170
|
+
if (result && typeof result.catch === "function") result.catch(() => {});
|
|
171
|
+
} catch {
|
|
172
|
+
// ignore
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Origins allowed to receive default credentials. Empty = no baseUrl to compare against = unrestricted.
|
|
177
|
+
const trusted = new Set();
|
|
178
|
+
for (const o of [baseUrl, ...(trustedOrigins || [])]) {
|
|
179
|
+
const origin = o && getOrigin(o);
|
|
180
|
+
if (origin) trusted.add(origin);
|
|
181
|
+
}
|
|
182
|
+
const isTrustedTarget = (url) => {
|
|
183
|
+
if (trusted.size === 0 || !isAbsoluteUrl(url)) return true; // relative URLs resolve against our own base
|
|
184
|
+
const origin = getOrigin(url);
|
|
185
|
+
return origin !== null && trusted.has(origin);
|
|
186
|
+
};
|
|
187
|
+
|
|
152
188
|
// --- stateful pieces, built once so `extend()` children share them -------
|
|
153
189
|
const limiter = !rateLimit
|
|
154
190
|
? null
|
|
@@ -166,7 +202,13 @@ function createFetch(defaults = {}) {
|
|
|
166
202
|
: (url) => {
|
|
167
203
|
const origin = originOf(url);
|
|
168
204
|
if (!breakerRegistry.has(origin)) {
|
|
169
|
-
|
|
205
|
+
const options = circuitBreaker === true ? {} : { ...circuitBreaker };
|
|
206
|
+
const userOnStateChange = options.onStateChange;
|
|
207
|
+
options.onStateChange = (state, previous) => {
|
|
208
|
+
if (userOnStateChange) userOnStateChange(state, previous);
|
|
209
|
+
emitEvent({ type: "circuit", origin, state, previous });
|
|
210
|
+
};
|
|
211
|
+
breakerRegistry.set(origin, createCircuitBreaker(options));
|
|
170
212
|
}
|
|
171
213
|
return breakerRegistry.get(origin);
|
|
172
214
|
};
|
|
@@ -176,6 +218,7 @@ function createFetch(defaults = {}) {
|
|
|
176
218
|
: {
|
|
177
219
|
ttl: 60000,
|
|
178
220
|
staleWhileRevalidate: 0,
|
|
221
|
+
staleIfError: 0,
|
|
179
222
|
maxEntries: 100,
|
|
180
223
|
maxBodyBytes: undefined,
|
|
181
224
|
...(cache === true ? {} : cache),
|
|
@@ -189,7 +232,10 @@ function createFetch(defaults = {}) {
|
|
|
189
232
|
}
|
|
190
233
|
const pendingStores = cacheCfg ? cacheCfg.pending : null;
|
|
191
234
|
const revalidating = cacheCfg ? cacheCfg.revalidating : null;
|
|
192
|
-
const emit = (type, url) =>
|
|
235
|
+
const emit = (type, url) => {
|
|
236
|
+
if (cacheCfg && cacheCfg.onEvent) cacheCfg.onEvent({ type, url });
|
|
237
|
+
emitEvent({ type: "cache", url, method: "GET", event: type });
|
|
238
|
+
};
|
|
193
239
|
|
|
194
240
|
// Shared in-flight GETs. Every caller gets its own clone of the response so
|
|
195
241
|
// body reads don't fight each other.
|
|
@@ -213,9 +259,12 @@ function createFetch(defaults = {}) {
|
|
|
213
259
|
// Returns a token newer than `usedToken`, refreshing at most once no matter
|
|
214
260
|
// how many requests were rejected at the same time. If someone else already
|
|
215
261
|
// refreshed (the current token isn't the one we used), just use that.
|
|
216
|
-
async function obtainFreshToken(authCfg, tokenUsed, response) {
|
|
262
|
+
async function obtainFreshToken(authCfg, tokenUsed, response, info) {
|
|
217
263
|
const current = await authCfg.getToken();
|
|
218
|
-
if (current && current !== tokenUsed)
|
|
264
|
+
if (current && current !== tokenUsed) {
|
|
265
|
+
emitEvent({ type: "auth", ...info, outcome: "reused" });
|
|
266
|
+
return current;
|
|
267
|
+
}
|
|
219
268
|
|
|
220
269
|
const state = refreshStateFor(authCfg);
|
|
221
270
|
if (!state.inflight) {
|
|
@@ -228,10 +277,21 @@ function createFetch(defaults = {}) {
|
|
|
228
277
|
}
|
|
229
278
|
})();
|
|
230
279
|
}
|
|
231
|
-
|
|
280
|
+
try {
|
|
281
|
+
const token = await state.inflight;
|
|
282
|
+
emitEvent({ type: "auth", ...info, outcome: "refreshed" });
|
|
283
|
+
return token;
|
|
284
|
+
} catch (error) {
|
|
285
|
+
emitEvent({ type: "auth", ...info, outcome: "failed", error });
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
232
288
|
}
|
|
233
289
|
|
|
234
|
-
|
|
290
|
+
// May a cached copy be served because the origin is failing?
|
|
291
|
+
const withinStaleIfError = (entry) =>
|
|
292
|
+
cacheCfg && cacheCfg.staleIfError > 0 && Date.now() - entry.storedAt < cacheCfg.ttl + cacheCfg.staleIfError;
|
|
293
|
+
|
|
294
|
+
async function execute(input, init, state) {
|
|
235
295
|
const {
|
|
236
296
|
query,
|
|
237
297
|
json,
|
|
@@ -245,7 +305,20 @@ function createFetch(defaults = {}) {
|
|
|
245
305
|
} = init;
|
|
246
306
|
|
|
247
307
|
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
248
|
-
const
|
|
308
|
+
const url = joinUrl(baseUrl, input, query);
|
|
309
|
+
state.url = url;
|
|
310
|
+
state.method = method;
|
|
311
|
+
const credentialsAllowed = isTrustedTarget(url);
|
|
312
|
+
|
|
313
|
+
const defaultHeaderSet = await resolveHeaders(defaultHeaders);
|
|
314
|
+
if (!credentialsAllowed) {
|
|
315
|
+
// An absolute URL to some other host: don't hand it our default credentials.
|
|
316
|
+
for (const name of [...SENSITIVE_HEADERS, ...(auth && auth.header ? [String(auth.header).toLowerCase()] : [])]) {
|
|
317
|
+
defaultHeaderSet.delete(name);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Headers the caller passes for *this* call are deliberate, so they always go through.
|
|
321
|
+
const headers = mergeHeaders(defaultHeaderSet, new Headers(initHeaders || undefined));
|
|
249
322
|
|
|
250
323
|
let body = fetchInit.body;
|
|
251
324
|
if (json !== undefined) {
|
|
@@ -253,16 +326,19 @@ function createFetch(defaults = {}) {
|
|
|
253
326
|
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
254
327
|
}
|
|
255
328
|
|
|
256
|
-
const ctx = { url
|
|
329
|
+
const ctx = { url, init: { ...fetchInit, method, headers, body } };
|
|
257
330
|
if (onRequest) await onRequest(ctx);
|
|
258
331
|
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
259
332
|
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
333
|
+
state.url = ctx.url;
|
|
334
|
+
state.method = finalMethod;
|
|
335
|
+
emitEvent({ type: "request", url: ctx.url, method: finalMethod });
|
|
260
336
|
|
|
261
337
|
// --- auth: attach the current token (unless the caller set the header) ----
|
|
262
338
|
const authHeader = auth ? String(auth.header || "authorization").toLowerCase() : null;
|
|
263
339
|
const formatToken = (token) => (auth.scheme === "" ? String(token) : `${auth.scheme === undefined ? "Bearer" : auth.scheme} ${token}`);
|
|
264
340
|
let usedToken = null;
|
|
265
|
-
if (auth && !finalHeaders.has(authHeader)) {
|
|
341
|
+
if (auth && credentialsAllowed && !finalHeaders.has(authHeader)) {
|
|
266
342
|
usedToken = (await auth.getToken()) || null;
|
|
267
343
|
if (usedToken) finalHeaders.set(authHeader, formatToken(usedToken));
|
|
268
344
|
}
|
|
@@ -282,8 +358,13 @@ function createFetch(defaults = {}) {
|
|
|
282
358
|
// `netSignal` are parameters so background revalidation can reuse this.
|
|
283
359
|
function network(netHeaders, netSignal) {
|
|
284
360
|
const attemptOnce = () => {
|
|
361
|
+
state.attempts++;
|
|
362
|
+
emitEvent({ type: "attempt", url: ctx.url, method: finalMethod, attempt: state.attempts });
|
|
285
363
|
const attempt = async () => {
|
|
286
|
-
if (limiter)
|
|
364
|
+
if (limiter) {
|
|
365
|
+
const waitedMs = await limiter.acquire({ signal: netSignal });
|
|
366
|
+
if (waitedMs > 0) emitEvent({ type: "ratelimit", url: ctx.url, method: finalMethod, waitedMs });
|
|
367
|
+
}
|
|
287
368
|
const res = await withTimeout(
|
|
288
369
|
(attemptSignal) => {
|
|
289
370
|
const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
|
|
@@ -315,6 +396,19 @@ function createFetch(defaults = {}) {
|
|
|
315
396
|
...retryRest,
|
|
316
397
|
retries,
|
|
317
398
|
retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
|
|
399
|
+
onRetry: (err, attempt, delayMs, rctx) => {
|
|
400
|
+
if (retryRest.onRetry) retryRest.onRetry(err, attempt, delayMs, rctx);
|
|
401
|
+
emitEvent({
|
|
402
|
+
type: "retry",
|
|
403
|
+
url: ctx.url,
|
|
404
|
+
method: finalMethod,
|
|
405
|
+
attempt: attempt + 1,
|
|
406
|
+
delayMs,
|
|
407
|
+
reason: rctx.reason === "result" ? "status" : "error",
|
|
408
|
+
status: rctx.status,
|
|
409
|
+
error: err,
|
|
410
|
+
});
|
|
411
|
+
},
|
|
318
412
|
shouldRetry:
|
|
319
413
|
customShouldRetry ||
|
|
320
414
|
((err) => {
|
|
@@ -346,6 +440,17 @@ function createFetch(defaults = {}) {
|
|
|
346
440
|
let entry = null;
|
|
347
441
|
|
|
348
442
|
const finish = async (response) => {
|
|
443
|
+
state.responded = true;
|
|
444
|
+
emitEvent({
|
|
445
|
+
type: "response",
|
|
446
|
+
url: ctx.url,
|
|
447
|
+
method: finalMethod,
|
|
448
|
+
status: response.status,
|
|
449
|
+
ok: response.ok,
|
|
450
|
+
durationMs: Date.now() - state.startedAt,
|
|
451
|
+
attempts: state.attempts,
|
|
452
|
+
cache: state.cache,
|
|
453
|
+
});
|
|
349
454
|
if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
|
|
350
455
|
if (throwHttpErrors && !response.ok) {
|
|
351
456
|
throw new HTTPError(response, { method: finalMethod, url: ctx.url });
|
|
@@ -360,10 +465,12 @@ function createFetch(defaults = {}) {
|
|
|
360
465
|
const age = Date.now() - entry.storedAt;
|
|
361
466
|
if (age < cacheCfg.ttl) {
|
|
362
467
|
emit("hit", ctx.url);
|
|
468
|
+
state.cache = "hit";
|
|
363
469
|
return finish(entryToResponse(entry));
|
|
364
470
|
}
|
|
365
471
|
if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
|
|
366
472
|
emit("stale", ctx.url);
|
|
473
|
+
state.cache = "stale";
|
|
367
474
|
if (!revalidating.has(key)) {
|
|
368
475
|
revalidating.add(key);
|
|
369
476
|
const staleEntry = entry;
|
|
@@ -400,13 +507,19 @@ function createFetch(defaults = {}) {
|
|
|
400
507
|
} catch (err) {
|
|
401
508
|
// Present a user abort as the plain AbortError they caused, not as retry internals.
|
|
402
509
|
if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
|
|
510
|
+
// The network failed but we still hold a usable copy: serve it (never for a deliberate abort).
|
|
511
|
+
if (entry && withinStaleIfError(entry) && !(signal && signal.aborted) && !(err && err.name === "AbortError")) {
|
|
512
|
+
emit("stale-if-error", ctx.url);
|
|
513
|
+
state.cache = "stale-if-error";
|
|
514
|
+
return finish(entryToResponse(entry));
|
|
515
|
+
}
|
|
403
516
|
throw err;
|
|
404
517
|
}
|
|
405
518
|
|
|
406
519
|
// --- auth: a rejected token is refreshed once, then the request replays ----
|
|
407
520
|
const needsRefresh = auth && (auth.shouldRefresh ? auth.shouldRefresh(response) : response.status === 401);
|
|
408
521
|
if (usedToken && needsRefresh && !isStreamBody(ctx.init.body)) {
|
|
409
|
-
const fresh = await obtainFreshToken(auth, usedToken, response);
|
|
522
|
+
const fresh = await obtainFreshToken(auth, usedToken, response, { url: ctx.url, method: finalMethod });
|
|
410
523
|
if (fresh) {
|
|
411
524
|
await discardBody(response);
|
|
412
525
|
finalHeaders.set(authHeader, formatToken(fresh));
|
|
@@ -421,15 +534,25 @@ function createFetch(defaults = {}) {
|
|
|
421
534
|
}
|
|
422
535
|
}
|
|
423
536
|
|
|
537
|
+
// The server answered, but with an error, and we still hold a usable copy.
|
|
538
|
+
if (useCache && entry && response.status >= 500 && withinStaleIfError(entry)) {
|
|
539
|
+
await discardBody(response);
|
|
540
|
+
emit("stale-if-error", ctx.url);
|
|
541
|
+
state.cache = "stale-if-error";
|
|
542
|
+
return finish(entryToResponse(entry));
|
|
543
|
+
}
|
|
544
|
+
|
|
424
545
|
if (useCache) {
|
|
425
546
|
if (response.status === 304 && entry) {
|
|
426
547
|
entry.storedAt = Date.now();
|
|
427
548
|
await cacheCfg.store.set(key, entry);
|
|
428
549
|
emit("revalidated", ctx.url);
|
|
550
|
+
state.cache = "revalidated";
|
|
429
551
|
await discardBody(response);
|
|
430
552
|
response = entryToResponse(entry);
|
|
431
553
|
} else {
|
|
432
554
|
emit("miss", ctx.url);
|
|
555
|
+
state.cache = "miss";
|
|
433
556
|
if (response.status === 200) scheduleStore(key, response, ctx.url);
|
|
434
557
|
}
|
|
435
558
|
}
|
|
@@ -437,6 +560,22 @@ function createFetch(defaults = {}) {
|
|
|
437
560
|
return finish(response);
|
|
438
561
|
}
|
|
439
562
|
|
|
563
|
+
async function request(input, init = {}) {
|
|
564
|
+
const state = { startedAt: Date.now(), attempts: 0, responded: false, cache: undefined, url: undefined, method: undefined };
|
|
565
|
+
try {
|
|
566
|
+
return await execute(input, init, state);
|
|
567
|
+
} catch (error) {
|
|
568
|
+
// A non-2xx HTTPError was already reported as a "response" event.
|
|
569
|
+
if (!state.responded) {
|
|
570
|
+
emitEvent({ type: "error", url: state.url, method: state.method, error, durationMs: Date.now() - state.startedAt, attempts: state.attempts });
|
|
571
|
+
}
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** The `baseUrl` this instance was created with (paginateLinks uses it to stay on-origin). */
|
|
577
|
+
request.baseUrl = baseUrl;
|
|
578
|
+
|
|
440
579
|
/** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
|
|
441
580
|
request.json = async (input, init) => {
|
|
442
581
|
const response = await request(input, init);
|
package/src/index.js
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
const { withRetry, RetryAbortedError, StopRetry } = require("./
|
|
2
|
-
const { withTimeout, TimeoutError } = require("./
|
|
3
|
-
const { normalizeError } = require("./normalizeError");
|
|
4
|
-
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
5
|
-
const { buildQuery } = require("./buildQuery");
|
|
6
|
-
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./retryAfterAware");
|
|
7
|
-
const { withProgress, BodyTooLargeError } = require("./
|
|
8
|
-
const { withDedupe } = require("./
|
|
9
|
-
const { createLimiter } = require("./
|
|
10
|
-
const { createFetch, HTTPError } = require("./createFetch");
|
|
11
|
-
const { createRateLimiter } = require("./
|
|
12
|
-
const { createCircuitBreaker, CircuitOpenError } = require("./
|
|
13
|
-
const { createMemoryCache } = require("./
|
|
14
|
-
const { createMockFetch } = require("./createMockFetch");
|
|
15
|
-
const { downloadResumable } = require("./
|
|
16
|
-
const { readLines, readNDJSON, readSSE } = require("./streams");
|
|
17
|
-
const { streamSSE } = require("./streamSSE");
|
|
18
|
-
const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./paginate");
|
|
1
|
+
const { withRetry, RetryAbortedError, StopRetry } = require("./resilience/retry");
|
|
2
|
+
const { withTimeout, TimeoutError } = require("./resilience/timeout");
|
|
3
|
+
const { normalizeError } = require("./errors/normalizeError");
|
|
4
|
+
const { parseJsonSafe } = require("./http/parseJsonSafe");
|
|
5
|
+
const { buildQuery } = require("./http/buildQuery");
|
|
6
|
+
const { retryAfterAware, parseRetryAfter, getWaitMsFromHeaders } = require("./resilience/retryAfterAware");
|
|
7
|
+
const { withProgress, BodyTooLargeError } = require("./streams/progress");
|
|
8
|
+
const { withDedupe } = require("./resilience/dedupe");
|
|
9
|
+
const { createLimiter } = require("./resilience/limiter");
|
|
10
|
+
const { createFetch, HTTPError } = require("./http/createFetch");
|
|
11
|
+
const { createRateLimiter } = require("./resilience/rateLimiter");
|
|
12
|
+
const { createCircuitBreaker, CircuitOpenError } = require("./resilience/circuitBreaker");
|
|
13
|
+
const { createMemoryCache } = require("./http/cache/memoryCache");
|
|
14
|
+
const { createMockFetch } = require("./testing/createMockFetch");
|
|
15
|
+
const { downloadResumable } = require("./streams/download");
|
|
16
|
+
const { readLines, readNDJSON, readSSE } = require("./streams/readers");
|
|
17
|
+
const { streamSSE } = require("./streams/streamSSE");
|
|
18
|
+
const { paginate, paginatePages, paginateLinks, parseLinkHeader } = require("./pagination/paginate");
|
|
19
19
|
|
|
20
20
|
module.exports = {
|
|
21
21
|
withRetry,
|
|
@@ -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 };
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
const { HTTPError } = require("
|
|
1
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
2
|
+
const { getOrigin } = require("../internal/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
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const { abortableSleep } = require("
|
|
2
|
-
const { getRateLimitInfo } = require("
|
|
1
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
2
|
+
const { getRateLimitInfo } = require("../internal/http");
|
|
3
3
|
|
|
4
4
|
function abortable(promise, signal) {
|
|
5
5
|
if (!signal) return promise;
|
|
@@ -50,6 +50,7 @@ function createRateLimiter(opts = {}) {
|
|
|
50
50
|
let tail = Promise.resolve();
|
|
51
51
|
|
|
52
52
|
async function take(signal) {
|
|
53
|
+
let waitedMs = 0;
|
|
53
54
|
for (;;) {
|
|
54
55
|
signal?.throwIfAborted?.();
|
|
55
56
|
const now = Date.now();
|
|
@@ -61,13 +62,15 @@ function createRateLimiter(opts = {}) {
|
|
|
61
62
|
|
|
62
63
|
if (waitMs <= 0) {
|
|
63
64
|
stamps.push(now);
|
|
64
|
-
return;
|
|
65
|
+
return waitedMs;
|
|
65
66
|
}
|
|
67
|
+
const waitStartedAt = Date.now();
|
|
66
68
|
await abortableSleep(waitMs, signal);
|
|
69
|
+
waitedMs += Date.now() - waitStartedAt;
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
/** Resolves
|
|
73
|
+
/** Resolves with the time spent waiting. Rejects if aborted while waiting. */
|
|
71
74
|
function acquire({ signal } = {}) {
|
|
72
75
|
const turn = tail.then(() => take(signal));
|
|
73
76
|
tail = turn.catch(() => {});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
const { RetryAbortedError, sleep } = require("
|
|
2
|
-
const { getWaitMsFromHeaders, discardBody } = require("
|
|
1
|
+
const { RetryAbortedError, sleep } = require("../internal/sleep");
|
|
2
|
+
const { getWaitMsFromHeaders, discardBody } = require("../internal/http");
|
|
3
3
|
|
|
4
4
|
function defaultShouldRetry(err) {
|
|
5
5
|
// Retry thrown errors except deliberate aborts. Narrow it with `shouldRetry`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
const { withRetry } = require("./
|
|
2
|
-
const { normalizeError } = require("
|
|
3
|
-
const { parseRetryAfter, getWaitMsFromHeaders } = require("
|
|
1
|
+
const { withRetry } = require("./retry");
|
|
2
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
3
|
+
const { parseRetryAfter, getWaitMsFromHeaders } = require("../internal/http");
|
|
4
4
|
|
|
5
5
|
function defaultShouldRetryError(err) {
|
|
6
6
|
const normalized = normalizeError(err);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
const { HTTPError } = require("
|
|
2
|
-
const { BodyTooLargeError } = require("./
|
|
3
|
-
const { normalizeError } = require("
|
|
4
|
-
const { getWaitMsFromHeaders } = require("
|
|
5
|
-
const { abortableSleep } = require("
|
|
1
|
+
const { HTTPError } = require("../errors/HTTPError");
|
|
2
|
+
const { BodyTooLargeError } = require("./progress");
|
|
3
|
+
const { normalizeError } = require("../errors/normalizeError");
|
|
4
|
+
const { getWaitMsFromHeaders } = require("../internal/http");
|
|
5
|
+
const { abortableSleep } = require("../internal/sleep");
|
|
6
6
|
|
|
7
7
|
const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
|
|
8
8
|
|