@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.
@@ -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 };