@boy-offi9-inc/reqkit 0.2.0 → 0.5.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 +305 -21
- package/RECIPES.md +243 -0
- package/dist/reqkit.mjs +2304 -0
- package/package.json +31 -4
- package/src/HTTPError.js +14 -0
- package/src/_cache.js +55 -0
- package/src/_http.js +90 -0
- package/src/_sleep.js +20 -1
- package/src/createCircuitBreaker.js +124 -0
- package/src/createFetch.js +398 -0
- package/src/createLimiter.js +55 -0
- package/src/createMemoryCache.js +40 -0
- package/src/createMockFetch.js +184 -0
- package/src/createRateLimiter.js +111 -0
- package/src/downloadResumable.js +193 -0
- package/src/index.js +22 -1
- package/src/index.mjs +13 -0
- package/src/retryAfterAware.js +26 -88
- package/src/streams.js +149 -0
- package/src/withRetry.js +112 -23
- package/src/withTimeout.js +43 -15
- package/types/index.d.ts +308 -2
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
const { withRetry, RetryAbortedError } = require("./withRetry");
|
|
2
|
+
const { withTimeout } = require("./withTimeout");
|
|
3
|
+
const { withDedupe } = require("./withDedupe");
|
|
4
|
+
const { buildQuery } = require("./buildQuery");
|
|
5
|
+
const { parseJsonSafe } = require("./parseJsonSafe");
|
|
6
|
+
const { normalizeError } = require("./normalizeError");
|
|
7
|
+
const { HTTPError } = require("./HTTPError");
|
|
8
|
+
const { createRateLimiter } = require("./createRateLimiter");
|
|
9
|
+
const { createCircuitBreaker } = require("./createCircuitBreaker");
|
|
10
|
+
const { createMemoryCache } = require("./createMemoryCache");
|
|
11
|
+
const { cacheKey, snapshotResponse, entryToResponse } = require("./_cache");
|
|
12
|
+
const { discardBody } = require("./_http");
|
|
13
|
+
|
|
14
|
+
const DEFAULT_RETRY_STATUS_CODES = [408, 425, 429, 500, 502, 503, 504];
|
|
15
|
+
// Safe to repeat without side effects. POST/PATCH are deliberately absent.
|
|
16
|
+
const DEFAULT_RETRY_METHODS = ["GET", "HEAD", "OPTIONS", "PUT", "DELETE"];
|
|
17
|
+
|
|
18
|
+
function isAbsoluteUrl(url) {
|
|
19
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(url);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function joinUrl(baseUrl, url, query) {
|
|
23
|
+
let full = String(url);
|
|
24
|
+
if (baseUrl && !isAbsoluteUrl(full)) {
|
|
25
|
+
full = `${String(baseUrl).replace(/\/+$/, "")}/${full.replace(/^\/+/, "")}`;
|
|
26
|
+
}
|
|
27
|
+
const qs = query ? buildQuery(query) : "";
|
|
28
|
+
if (qs) full += (full.includes("?") ? "&" : "?") + qs;
|
|
29
|
+
return full;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function resolveHeaders(source) {
|
|
33
|
+
const value = typeof source === "function" ? await source() : source;
|
|
34
|
+
return new Headers(value || undefined);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function mergeHeaders(...headerSets) {
|
|
38
|
+
const out = new Headers();
|
|
39
|
+
for (const set of headerSets) {
|
|
40
|
+
set.forEach((value, key) => out.set(key, value));
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isStreamBody(body) {
|
|
46
|
+
return Boolean(body) && typeof body === "object" && typeof body.getReader === "function";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function originOf(url) {
|
|
50
|
+
try {
|
|
51
|
+
return new URL(url).origin;
|
|
52
|
+
} catch {
|
|
53
|
+
return url;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mergeDefaults(base, extra) {
|
|
58
|
+
const merged = { ...base, ...extra };
|
|
59
|
+
|
|
60
|
+
if (base.headers || extra.headers) {
|
|
61
|
+
merged.headers = async () => {
|
|
62
|
+
const a = await resolveHeaders(base.headers);
|
|
63
|
+
const b = await resolveHeaders(extra.headers);
|
|
64
|
+
return mergeHeaders(a, b);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
if (base.retry !== false && extra.retry !== false && (base.retry || extra.retry)) {
|
|
68
|
+
merged.retry = { ...base.retry, ...extra.retry };
|
|
69
|
+
}
|
|
70
|
+
return merged;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds a fetch-like function from the other reqkit pieces — only if you
|
|
75
|
+
* want that. It's still plain `fetch` underneath: you get a standard
|
|
76
|
+
* `Response` back, and `fetch` itself stays swappable.
|
|
77
|
+
*
|
|
78
|
+
* What you get on top of fetch:
|
|
79
|
+
* - `baseUrl`, `query` (via buildQuery), `json` request bodies
|
|
80
|
+
* - default headers (an object, or a function for tokens that change)
|
|
81
|
+
* - per-attempt `timeout` (withTimeout) and external `signal`
|
|
82
|
+
* - retries (withRetry) on network errors/timeouts and retryable statuses,
|
|
83
|
+
* honoring Retry-After; only for idempotent methods unless you opt in
|
|
84
|
+
* - `rateLimit`: stay under a budget and obey X-RateLimit-* / Retry-After
|
|
85
|
+
* *before* the server has to say 429
|
|
86
|
+
* - `circuitBreaker`: fail fast against a service that keeps failing
|
|
87
|
+
* - `cache`: in-memory GET cache with TTL, stale-while-revalidate and
|
|
88
|
+
* ETag / Last-Modified revalidation (304s)
|
|
89
|
+
* - concurrent GET de-duplication (withDedupe)
|
|
90
|
+
* - `HTTPError` on non-2xx (turn off with `throwHttpErrors: false`)
|
|
91
|
+
* - `onRequest` / `onResponse` hooks
|
|
92
|
+
*
|
|
93
|
+
* @param {object} [defaults]
|
|
94
|
+
* @param {string} [defaults.baseUrl]
|
|
95
|
+
* @param {HeadersInit | (() => HeadersInit | Promise<HeadersInit>)} [defaults.headers]
|
|
96
|
+
* @param {number | false} [defaults.timeout=30000] - per attempt, ms
|
|
97
|
+
* @param {false | object} [defaults.retry] - false disables. Options:
|
|
98
|
+
* `retries`(2), `statusCodes`, `methods`, `baseDelayMs`, `maxDelayMs`,
|
|
99
|
+
* `factor`, `jitter`, `maxRetryTime`, `onRetry`, `calculateDelay`
|
|
100
|
+
* @param {boolean | object} [defaults.rateLimit] - `true` just learns from
|
|
101
|
+
* response headers; `{ requests, perMs }` also enforces a budget; or pass a
|
|
102
|
+
* createRateLimiter() instance to share one budget
|
|
103
|
+
* @param {boolean | object | Function} [defaults.circuitBreaker] - options for
|
|
104
|
+
* a per-origin breaker, a createCircuitBreaker() instance, or `(url) => breaker`
|
|
105
|
+
* @param {boolean | object} [defaults.cache] - `{ ttl, staleWhileRevalidate,
|
|
106
|
+
* maxEntries, maxBodyBytes, store, onEvent }`; `true` = 60s TTL
|
|
107
|
+
* @param {boolean} [defaults.dedupe=false] - merge identical concurrent GET/HEAD calls
|
|
108
|
+
* @param {boolean} [defaults.throwHttpErrors=true]
|
|
109
|
+
* @param {typeof fetch} [defaults.fetch=globalThis.fetch]
|
|
110
|
+
* @param {(ctx: {url: string, init: object}) => void | Promise<void>} [defaults.onRequest] -
|
|
111
|
+
* runs once per call; may mutate ctx.url / ctx.init (e.g. add a header)
|
|
112
|
+
* @param {(ctx: {url: string, init: object, response: Response}) => void | Promise<void>} [defaults.onResponse]
|
|
113
|
+
*/
|
|
114
|
+
function createFetch(defaults = {}) {
|
|
115
|
+
const {
|
|
116
|
+
baseUrl,
|
|
117
|
+
headers: defaultHeaders,
|
|
118
|
+
timeout: defaultTimeout = 30000,
|
|
119
|
+
retry: defaultRetry = {},
|
|
120
|
+
rateLimit,
|
|
121
|
+
circuitBreaker,
|
|
122
|
+
cache,
|
|
123
|
+
dedupe = false,
|
|
124
|
+
throwHttpErrors: defaultThrow = true,
|
|
125
|
+
fetch: customFetch,
|
|
126
|
+
onRequest,
|
|
127
|
+
onResponse,
|
|
128
|
+
} = defaults;
|
|
129
|
+
|
|
130
|
+
const doFetch = (...args) => (customFetch || globalThis.fetch)(...args);
|
|
131
|
+
|
|
132
|
+
// --- stateful pieces, built once so `extend()` children share them -------
|
|
133
|
+
const limiter = !rateLimit
|
|
134
|
+
? null
|
|
135
|
+
: typeof rateLimit.acquire === "function"
|
|
136
|
+
? rateLimit
|
|
137
|
+
: createRateLimiter(rateLimit === true ? {} : rateLimit);
|
|
138
|
+
|
|
139
|
+
const breakerRegistry = new Map();
|
|
140
|
+
const breakerFor = !circuitBreaker
|
|
141
|
+
? null
|
|
142
|
+
: typeof circuitBreaker === "function"
|
|
143
|
+
? circuitBreaker
|
|
144
|
+
: typeof circuitBreaker.run === "function"
|
|
145
|
+
? () => circuitBreaker
|
|
146
|
+
: (url) => {
|
|
147
|
+
const origin = originOf(url);
|
|
148
|
+
if (!breakerRegistry.has(origin)) {
|
|
149
|
+
breakerRegistry.set(origin, createCircuitBreaker(circuitBreaker === true ? {} : circuitBreaker));
|
|
150
|
+
}
|
|
151
|
+
return breakerRegistry.get(origin);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const cacheCfg = !cache
|
|
155
|
+
? null
|
|
156
|
+
: {
|
|
157
|
+
ttl: 60000,
|
|
158
|
+
staleWhileRevalidate: 0,
|
|
159
|
+
maxEntries: 100,
|
|
160
|
+
maxBodyBytes: undefined,
|
|
161
|
+
...(cache === true ? {} : cache),
|
|
162
|
+
};
|
|
163
|
+
if (cacheCfg && !cacheCfg.store) cacheCfg.store = createMemoryCache({ maxEntries: cacheCfg.maxEntries });
|
|
164
|
+
// In-flight bookkeeping lives on the (shared) cache config so `extend()`
|
|
165
|
+
// children see the parent's pending writes and revalidations.
|
|
166
|
+
if (cacheCfg) {
|
|
167
|
+
cacheCfg.pending = cacheCfg.pending || new Map();
|
|
168
|
+
cacheCfg.revalidating = cacheCfg.revalidating || new Set();
|
|
169
|
+
}
|
|
170
|
+
const pendingStores = cacheCfg ? cacheCfg.pending : null;
|
|
171
|
+
const revalidating = cacheCfg ? cacheCfg.revalidating : null;
|
|
172
|
+
const emit = (type, url) => cacheCfg && cacheCfg.onEvent && cacheCfg.onEvent({ type, url });
|
|
173
|
+
|
|
174
|
+
// Shared in-flight GETs. Every caller gets its own clone of the response so
|
|
175
|
+
// body reads don't fight each other.
|
|
176
|
+
const sharedFetch = withDedupe((key, url, init) => doFetch(url, init), { keyFn: (key) => key });
|
|
177
|
+
|
|
178
|
+
function scheduleStore(key, response, url) {
|
|
179
|
+
const p = snapshotResponse(response, cacheCfg.maxBodyBytes)
|
|
180
|
+
.then(async (entry) => {
|
|
181
|
+
if (entry) {
|
|
182
|
+
await cacheCfg.store.set(key, entry);
|
|
183
|
+
emit("store", url);
|
|
184
|
+
}
|
|
185
|
+
})
|
|
186
|
+
.catch(() => {})
|
|
187
|
+
.finally(() => {
|
|
188
|
+
if (pendingStores.get(key) === p) pendingStores.delete(key);
|
|
189
|
+
});
|
|
190
|
+
pendingStores.set(key, p);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function request(input, init = {}) {
|
|
194
|
+
const {
|
|
195
|
+
query,
|
|
196
|
+
json,
|
|
197
|
+
timeout = defaultTimeout,
|
|
198
|
+
retry: retryOpt,
|
|
199
|
+
throwHttpErrors = defaultThrow,
|
|
200
|
+
signal,
|
|
201
|
+
headers: initHeaders,
|
|
202
|
+
cache: cacheMode,
|
|
203
|
+
...fetchInit
|
|
204
|
+
} = init;
|
|
205
|
+
|
|
206
|
+
const method = String(fetchInit.method || "GET").toUpperCase();
|
|
207
|
+
const headers = mergeHeaders(await resolveHeaders(defaultHeaders), new Headers(initHeaders || undefined));
|
|
208
|
+
|
|
209
|
+
let body = fetchInit.body;
|
|
210
|
+
if (json !== undefined) {
|
|
211
|
+
body = JSON.stringify(json);
|
|
212
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const ctx = { url: joinUrl(baseUrl, input, query), init: { ...fetchInit, method, headers, body } };
|
|
216
|
+
if (onRequest) await onRequest(ctx);
|
|
217
|
+
const finalHeaders = ctx.init.headers instanceof Headers ? ctx.init.headers : new Headers(ctx.init.headers);
|
|
218
|
+
const finalMethod = String(ctx.init.method || method).toUpperCase();
|
|
219
|
+
|
|
220
|
+
// --- retry policy ------------------------------------------------------
|
|
221
|
+
const retryCfg = retryOpt === false || defaultRetry === false ? false : { ...defaultRetry, ...retryOpt };
|
|
222
|
+
const methods = (retryCfg && retryCfg.methods) || DEFAULT_RETRY_METHODS;
|
|
223
|
+
const hasIdempotencyKey = finalHeaders.has("idempotency-key");
|
|
224
|
+
const canRetry =
|
|
225
|
+
retryCfg !== false &&
|
|
226
|
+
!isStreamBody(ctx.init.body) && // a consumed stream can't be replayed
|
|
227
|
+
(methods.map((m) => m.toUpperCase()).includes(finalMethod) || hasIdempotencyKey);
|
|
228
|
+
const { statusCodes, methods: _methods, retries = 2, shouldRetry: customShouldRetry, ...retryRest } = retryCfg || {};
|
|
229
|
+
|
|
230
|
+
// One trip to the network: rate limit → circuit breaker → per-attempt
|
|
231
|
+
// timeout → fetch, retried per the policy above. `netHeaders` and
|
|
232
|
+
// `netSignal` are parameters so background revalidation can reuse this.
|
|
233
|
+
function network(netHeaders, netSignal) {
|
|
234
|
+
const attemptOnce = () => {
|
|
235
|
+
const attempt = async () => {
|
|
236
|
+
if (limiter) await limiter.acquire({ signal: netSignal });
|
|
237
|
+
const res = await withTimeout(
|
|
238
|
+
(attemptSignal) => {
|
|
239
|
+
const attemptInit = { ...ctx.init, method: finalMethod, headers: netHeaders, signal: attemptSignal };
|
|
240
|
+
const useDedupe =
|
|
241
|
+
dedupe && !netSignal && !ctx.init.body && (finalMethod === "GET" || finalMethod === "HEAD");
|
|
242
|
+
if (!useDedupe) return doFetch(ctx.url, attemptInit);
|
|
243
|
+
// No per-attempt signal on a shared request: aborting one caller
|
|
244
|
+
// must never cancel everyone else's. It still gets its own
|
|
245
|
+
// timeout so a hung server can't leak the request.
|
|
246
|
+
const sharedInit = {
|
|
247
|
+
...attemptInit,
|
|
248
|
+
signal: timeout === false ? undefined : AbortSignal.timeout(timeout),
|
|
249
|
+
};
|
|
250
|
+
const key = `${finalMethod} ${ctx.url} ${JSON.stringify([...netHeaders.entries()].sort())}`;
|
|
251
|
+
return sharedFetch(key, ctx.url, sharedInit).then((res) => res.clone());
|
|
252
|
+
},
|
|
253
|
+
timeout === false ? Infinity : timeout,
|
|
254
|
+
{ signal: netSignal }
|
|
255
|
+
);
|
|
256
|
+
if (limiter) limiter.update(res);
|
|
257
|
+
return res;
|
|
258
|
+
};
|
|
259
|
+
const breaker = breakerFor && breakerFor(ctx.url);
|
|
260
|
+
return breaker ? breaker.run(attempt) : attempt();
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
return canRetry
|
|
264
|
+
? withRetry(attemptOnce, {
|
|
265
|
+
...retryRest,
|
|
266
|
+
retries,
|
|
267
|
+
retryStatusCodes: statusCodes || DEFAULT_RETRY_STATUS_CODES,
|
|
268
|
+
shouldRetry:
|
|
269
|
+
customShouldRetry ||
|
|
270
|
+
((err) => {
|
|
271
|
+
const n = normalizeError(err);
|
|
272
|
+
return n.isNetworkError || n.isTimeout;
|
|
273
|
+
}),
|
|
274
|
+
signal: netSignal,
|
|
275
|
+
})
|
|
276
|
+
: attemptOnce();
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function conditionalHeaders(entry) {
|
|
280
|
+
const h = new Headers(finalHeaders);
|
|
281
|
+
if (entry) {
|
|
282
|
+
if (entry.etag && !h.has("if-none-match")) h.set("if-none-match", entry.etag);
|
|
283
|
+
if (entry.lastModified && !h.has("if-modified-since")) h.set("if-modified-since", entry.lastModified);
|
|
284
|
+
}
|
|
285
|
+
return h;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- cache lookup --------------------------------------------------------
|
|
289
|
+
const useCache =
|
|
290
|
+
Boolean(cacheCfg) &&
|
|
291
|
+
finalMethod === "GET" &&
|
|
292
|
+
!ctx.init.body &&
|
|
293
|
+
cacheMode !== false &&
|
|
294
|
+
cacheMode !== "no-store";
|
|
295
|
+
const key = useCache ? cacheKey(finalMethod, ctx.url, finalHeaders) : null;
|
|
296
|
+
let entry = null;
|
|
297
|
+
|
|
298
|
+
const finish = async (response) => {
|
|
299
|
+
if (onResponse) await onResponse({ url: ctx.url, init: ctx.init, response });
|
|
300
|
+
if (throwHttpErrors && !response.ok) {
|
|
301
|
+
throw new HTTPError(response, { method: finalMethod, url: ctx.url });
|
|
302
|
+
}
|
|
303
|
+
return response;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
if (useCache && cacheMode !== "reload") {
|
|
307
|
+
if (pendingStores.has(key)) await pendingStores.get(key);
|
|
308
|
+
entry = (await cacheCfg.store.get(key)) || null;
|
|
309
|
+
if (entry) {
|
|
310
|
+
const age = Date.now() - entry.storedAt;
|
|
311
|
+
if (age < cacheCfg.ttl) {
|
|
312
|
+
emit("hit", ctx.url);
|
|
313
|
+
return finish(entryToResponse(entry));
|
|
314
|
+
}
|
|
315
|
+
if (age < cacheCfg.ttl + cacheCfg.staleWhileRevalidate) {
|
|
316
|
+
emit("stale", ctx.url);
|
|
317
|
+
if (!revalidating.has(key)) {
|
|
318
|
+
revalidating.add(key);
|
|
319
|
+
const staleEntry = entry;
|
|
320
|
+
(async () => {
|
|
321
|
+
try {
|
|
322
|
+
const res = await network(conditionalHeaders(staleEntry), undefined);
|
|
323
|
+
if (res.status === 304) {
|
|
324
|
+
staleEntry.storedAt = Date.now();
|
|
325
|
+
await cacheCfg.store.set(key, staleEntry);
|
|
326
|
+
emit("revalidated", ctx.url);
|
|
327
|
+
await discardBody(res);
|
|
328
|
+
} else if (res.status === 200) {
|
|
329
|
+
scheduleStore(key, res, ctx.url);
|
|
330
|
+
await pendingStores.get(key);
|
|
331
|
+
} else {
|
|
332
|
+
await discardBody(res);
|
|
333
|
+
}
|
|
334
|
+
} catch {
|
|
335
|
+
// keep serving the stale copy; try again next time
|
|
336
|
+
} finally {
|
|
337
|
+
revalidating.delete(key);
|
|
338
|
+
}
|
|
339
|
+
})();
|
|
340
|
+
}
|
|
341
|
+
return finish(entryToResponse(entry));
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// --- network -------------------------------------------------------------
|
|
347
|
+
let response;
|
|
348
|
+
try {
|
|
349
|
+
response = await network(useCache ? conditionalHeaders(entry) : finalHeaders, signal);
|
|
350
|
+
} catch (err) {
|
|
351
|
+
// Present a user abort as the plain AbortError they caused, not as retry internals.
|
|
352
|
+
if (err instanceof RetryAbortedError && signal && signal.aborted) throw signal.reason ?? err;
|
|
353
|
+
throw err;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (useCache) {
|
|
357
|
+
if (response.status === 304 && entry) {
|
|
358
|
+
entry.storedAt = Date.now();
|
|
359
|
+
await cacheCfg.store.set(key, entry);
|
|
360
|
+
emit("revalidated", ctx.url);
|
|
361
|
+
await discardBody(response);
|
|
362
|
+
response = entryToResponse(entry);
|
|
363
|
+
} else {
|
|
364
|
+
emit("miss", ctx.url);
|
|
365
|
+
if (response.status === 200) scheduleStore(key, response, ctx.url);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return finish(response);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Like `request`, but resolves to the parsed JSON body (null for an empty body). */
|
|
373
|
+
request.json = async (input, init) => {
|
|
374
|
+
const response = await request(input, init);
|
|
375
|
+
const { data, error } = await parseJsonSafe(response);
|
|
376
|
+
if (error) throw error;
|
|
377
|
+
return data;
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/** A new fetch with these defaults merged over the current ones (headers and retry are merged, the rest replaced). */
|
|
381
|
+
request.extend = (overrides = {}) =>
|
|
382
|
+
createFetch(
|
|
383
|
+
mergeDefaults(
|
|
384
|
+
{
|
|
385
|
+
...defaults,
|
|
386
|
+
// children share the parent's budget, breakers and cache unless they bring their own
|
|
387
|
+
rateLimit: limiter || undefined,
|
|
388
|
+
circuitBreaker: breakerFor || undefined,
|
|
389
|
+
cache: cacheCfg || undefined,
|
|
390
|
+
},
|
|
391
|
+
overrides
|
|
392
|
+
)
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
return request;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
module.exports = { createFetch, HTTPError };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a concurrency limiter: at most `concurrency` calls run at once,
|
|
3
|
+
* the rest wait in a FIFO queue. Handy for "download these 200 files, 4 at a
|
|
4
|
+
* time" without pulling in a queue library. (Same idea as p-limit.)
|
|
5
|
+
*
|
|
6
|
+
* ```js
|
|
7
|
+
* const limit = createLimiter(4);
|
|
8
|
+
* const bytes = await Promise.all(urls.map((u) => limit(() => download(u))));
|
|
9
|
+
* // or: await limit.map(urls, (u) => download(u));
|
|
10
|
+
* ```
|
|
11
|
+
*
|
|
12
|
+
* @param {number} [concurrency=Infinity] - integer >= 1, or Infinity
|
|
13
|
+
*/
|
|
14
|
+
function createLimiter(concurrency = Infinity) {
|
|
15
|
+
if (concurrency !== Infinity && !(Number.isInteger(concurrency) && concurrency >= 1)) {
|
|
16
|
+
throw new TypeError("createLimiter: `concurrency` must be an integer >= 1 (or Infinity)");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const queue = [];
|
|
20
|
+
let active = 0;
|
|
21
|
+
|
|
22
|
+
function next() {
|
|
23
|
+
while (active < concurrency && queue.length > 0) {
|
|
24
|
+
active++;
|
|
25
|
+
queue.shift()();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function limit(fn, ...args) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
queue.push(() => {
|
|
32
|
+
Promise.resolve()
|
|
33
|
+
.then(() => fn(...args))
|
|
34
|
+
.then(resolve, reject)
|
|
35
|
+
.finally(() => {
|
|
36
|
+
active--;
|
|
37
|
+
next();
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
next();
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Object.defineProperties(limit, {
|
|
45
|
+
activeCount: { get: () => active },
|
|
46
|
+
pendingCount: { get: () => queue.length },
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
/** Runs `fn(item, index)` for every item through the limiter. Results keep input order. */
|
|
50
|
+
limit.map = (items, fn) => Promise.all(Array.from(items, (item, i) => limit(fn, item, i)));
|
|
51
|
+
|
|
52
|
+
return limit;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = { createLimiter };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small in-memory LRU store for createFetch's `cache` option. Anything with
|
|
3
|
+
* async-or-sync `get(key)`, `set(key, value)` and `delete(key)` can be used
|
|
4
|
+
* instead (Redis, IndexedDB, a Map...).
|
|
5
|
+
*
|
|
6
|
+
* @param {{ maxEntries?: number }} [opts]
|
|
7
|
+
*/
|
|
8
|
+
function createMemoryCache(opts = {}) {
|
|
9
|
+
const { maxEntries = 100 } = opts;
|
|
10
|
+
if (!(Number.isInteger(maxEntries) && maxEntries >= 1)) {
|
|
11
|
+
throw new TypeError("createMemoryCache: `maxEntries` must be an integer >= 1");
|
|
12
|
+
}
|
|
13
|
+
const map = new Map();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
get(key) {
|
|
17
|
+
if (!map.has(key)) return undefined;
|
|
18
|
+
const value = map.get(key);
|
|
19
|
+
map.delete(key); // re-insert so it counts as most recently used
|
|
20
|
+
map.set(key, value);
|
|
21
|
+
return value;
|
|
22
|
+
},
|
|
23
|
+
set(key, value) {
|
|
24
|
+
map.delete(key);
|
|
25
|
+
map.set(key, value);
|
|
26
|
+
while (map.size > maxEntries) map.delete(map.keys().next().value);
|
|
27
|
+
},
|
|
28
|
+
delete(key) {
|
|
29
|
+
return map.delete(key);
|
|
30
|
+
},
|
|
31
|
+
clear() {
|
|
32
|
+
map.clear();
|
|
33
|
+
},
|
|
34
|
+
get size() {
|
|
35
|
+
return map.size;
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { createMemoryCache };
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const { abortableSleep } = require("./_sleep");
|
|
2
|
+
|
|
3
|
+
const BODYLESS = new Set([101, 204, 205, 304]);
|
|
4
|
+
|
|
5
|
+
function toBody(body, headers) {
|
|
6
|
+
if (body === null || body === undefined) return null;
|
|
7
|
+
if (typeof body === "string") return body;
|
|
8
|
+
if (
|
|
9
|
+
body instanceof ArrayBuffer ||
|
|
10
|
+
ArrayBuffer.isView(body) ||
|
|
11
|
+
(typeof Blob !== "undefined" && body instanceof Blob) ||
|
|
12
|
+
(typeof ReadableStream !== "undefined" && body instanceof ReadableStream) ||
|
|
13
|
+
body instanceof URLSearchParams
|
|
14
|
+
) {
|
|
15
|
+
return body;
|
|
16
|
+
}
|
|
17
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
18
|
+
return JSON.stringify(body);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildResponse(status, body, extraHeaders) {
|
|
22
|
+
const headers = new Headers(extraHeaders || undefined);
|
|
23
|
+
const payload = BODYLESS.has(status) ? null : toBody(body, headers);
|
|
24
|
+
return new Response(payload, { status, headers });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function matchesUrl(matcher, url) {
|
|
28
|
+
if (typeof matcher === "function") return Boolean(matcher(url));
|
|
29
|
+
if (matcher instanceof RegExp) return matcher.test(url);
|
|
30
|
+
if (matcher === "*" || matcher === undefined) return true;
|
|
31
|
+
if (url === matcher) return true;
|
|
32
|
+
try {
|
|
33
|
+
const u = new URL(url);
|
|
34
|
+
if (matcher === u.pathname + u.search) return true;
|
|
35
|
+
if (!String(matcher).includes("?") && matcher === u.pathname) return true;
|
|
36
|
+
} catch {
|
|
37
|
+
// relative/odd URL — exact match only
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A scriptable, fetch-compatible test double — for testing code that uses
|
|
44
|
+
* retries, timeouts, rate limits and so on without a network.
|
|
45
|
+
*
|
|
46
|
+
* ```js
|
|
47
|
+
* const mock = createMockFetch();
|
|
48
|
+
* mock.on("GET", "/users/1")
|
|
49
|
+
* .replyOnce(503, "busy", { headers: { "retry-after": "0" } })
|
|
50
|
+
* .reply(200, { id: 1 });
|
|
51
|
+
* mock.on("POST", /\/orders/).fail(mock.networkError());
|
|
52
|
+
*
|
|
53
|
+
* const api = createFetch({ fetch: mock.fetch, baseUrl: "https://api.test" });
|
|
54
|
+
* await api.json("/users/1"); // → { id: 1 } after one retry
|
|
55
|
+
* mock.calls.length; // 2
|
|
56
|
+
* ```
|
|
57
|
+
*
|
|
58
|
+
* String matchers match the full URL, `pathname + search`, or the bare
|
|
59
|
+
* `pathname`; you can also pass a RegExp or a `(url) => boolean`. `"*"` as the
|
|
60
|
+
* method matches any method. Unmatched requests reject with a clear error.
|
|
61
|
+
*/
|
|
62
|
+
function createMockFetch() {
|
|
63
|
+
let routes = [];
|
|
64
|
+
const calls = [];
|
|
65
|
+
|
|
66
|
+
function on(method, matcher) {
|
|
67
|
+
const route = { method: String(method).toUpperCase(), matcher, once: [], fallback: null };
|
|
68
|
+
routes.push(route);
|
|
69
|
+
|
|
70
|
+
const chain = {
|
|
71
|
+
/** Always answer like this (once any `…Once` replies are used up). */
|
|
72
|
+
reply(status = 200, body, opts) {
|
|
73
|
+
route.fallback = replyHandler(status, body, opts);
|
|
74
|
+
return chain;
|
|
75
|
+
},
|
|
76
|
+
replyOnce(status = 200, body, opts) {
|
|
77
|
+
route.once.push(replyHandler(status, body, opts));
|
|
78
|
+
return chain;
|
|
79
|
+
},
|
|
80
|
+
/** `fn(call)` returns a Response, `{ status, body, headers }`, or throws/rejects. */
|
|
81
|
+
replyWith(fn) {
|
|
82
|
+
route.fallback = customHandler(fn);
|
|
83
|
+
return chain;
|
|
84
|
+
},
|
|
85
|
+
replyWithOnce(fn) {
|
|
86
|
+
route.once.push(customHandler(fn));
|
|
87
|
+
return chain;
|
|
88
|
+
},
|
|
89
|
+
/** Reject like fetch does on a network failure. */
|
|
90
|
+
fail(err = networkError()) {
|
|
91
|
+
route.fallback = async () => {
|
|
92
|
+
throw err;
|
|
93
|
+
};
|
|
94
|
+
return chain;
|
|
95
|
+
},
|
|
96
|
+
failOnce(err = networkError()) {
|
|
97
|
+
route.once.push(async () => {
|
|
98
|
+
throw err;
|
|
99
|
+
});
|
|
100
|
+
return chain;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
return chain;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function replyHandler(status, body, opts = {}) {
|
|
107
|
+
return async (call) => {
|
|
108
|
+
if (opts.delayMs) await abortableSleep(opts.delayMs, call.signal);
|
|
109
|
+
return buildResponse(status, body, opts.headers);
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function customHandler(fn) {
|
|
114
|
+
return async (call) => {
|
|
115
|
+
const out = await fn(call);
|
|
116
|
+
if (out instanceof Response) return out;
|
|
117
|
+
return buildResponse(out.status ?? 200, out.body, out.headers);
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function networkError(code = "ECONNREFUSED") {
|
|
122
|
+
const err = new TypeError("fetch failed");
|
|
123
|
+
err.cause = Object.assign(new Error(`connect ${code}`), { code });
|
|
124
|
+
return err;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function mockFetch(input, init = {}) {
|
|
128
|
+
const url = input instanceof URL ? input.href : typeof input === "string" ? input : input.url;
|
|
129
|
+
const method = String(init.method || (typeof input === "object" && input.method) || "GET").toUpperCase();
|
|
130
|
+
const signal = init.signal || (typeof input === "object" ? input.signal : undefined);
|
|
131
|
+
signal?.throwIfAborted?.();
|
|
132
|
+
|
|
133
|
+
const headers = {};
|
|
134
|
+
new Headers(init.headers || (typeof input === "object" ? input.headers : undefined)).forEach((v, k) => {
|
|
135
|
+
headers[k] = v;
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const call = {
|
|
139
|
+
url,
|
|
140
|
+
method,
|
|
141
|
+
headers,
|
|
142
|
+
body: init.body,
|
|
143
|
+
signal,
|
|
144
|
+
get json() {
|
|
145
|
+
try {
|
|
146
|
+
return typeof this.body === "string" ? JSON.parse(this.body) : undefined;
|
|
147
|
+
} catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
calls.push(call);
|
|
153
|
+
|
|
154
|
+
const route = routes.find(
|
|
155
|
+
(r) => (r.method === "*" || r.method === method) && matchesUrl(r.matcher, url) && (r.once.length > 0 || r.fallback)
|
|
156
|
+
);
|
|
157
|
+
if (!route) {
|
|
158
|
+
throw new Error(`createMockFetch: no mock registered for ${method} ${url}`);
|
|
159
|
+
}
|
|
160
|
+
const handler = route.once.length > 0 ? route.once.shift() : route.fallback;
|
|
161
|
+
return handler(call);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
fetch: mockFetch,
|
|
166
|
+
on,
|
|
167
|
+
get: (m) => on("GET", m),
|
|
168
|
+
post: (m) => on("POST", m),
|
|
169
|
+
put: (m) => on("PUT", m),
|
|
170
|
+
patch: (m) => on("PATCH", m),
|
|
171
|
+
delete: (m) => on("DELETE", m),
|
|
172
|
+
/** A TypeError("fetch failed") with `cause.code`, like Node's fetch throws. */
|
|
173
|
+
networkError,
|
|
174
|
+
/** Every request made so far, in order. */
|
|
175
|
+
calls,
|
|
176
|
+
/** Remove all routes and forget recorded calls. */
|
|
177
|
+
reset() {
|
|
178
|
+
routes = [];
|
|
179
|
+
calls.length = 0;
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { createMockFetch };
|