@boy-offi9-inc/reqkit 0.3.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,193 @@
1
+ const { HTTPError } = require("./HTTPError");
2
+ const { BodyTooLargeError } = require("./withProgress");
3
+ const { normalizeError } = require("./normalizeError");
4
+ const { getWaitMsFromHeaders } = require("./_http");
5
+ const { abortableSleep } = require("./_sleep");
6
+
7
+ const RETRYABLE_STATUS = new Set([408, 425, 429, 500, 502, 503, 504]);
8
+
9
+ function parseContentRange(header) {
10
+ // "bytes 100-199/1000" (total may be "*")
11
+ const m = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(String(header || "").trim());
12
+ if (!m) return null;
13
+ return { start: Number(m[1]), end: Number(m[2]), total: m[3] === "*" ? null : Number(m[3]) };
14
+ }
15
+
16
+ function isRetryable(err) {
17
+ if (err instanceof BodyTooLargeError) return false;
18
+ if (err instanceof HTTPError) return RETRYABLE_STATUS.has(err.status);
19
+ const n = normalizeError(err);
20
+ if (n.isAborted) return false;
21
+ return n.isNetworkError || n.isTimeout;
22
+ }
23
+
24
+ /**
25
+ * Downloads a URL into memory and, if the connection drops mid-body, resumes
26
+ * from where it stopped with an HTTP `Range` request instead of starting
27
+ * over. Uses `If-Range` with the first response's strong ETag (or
28
+ * Last-Modified) so a file that changed on the server is restarted rather
29
+ * than stitched together wrongly. If the server ignores Range, it simply
30
+ * restarts from zero.
31
+ *
32
+ * `retries` counts consecutive attempts *without progress* — a flaky link
33
+ * that keeps making headway keeps going. Honors Retry-After on 429/503.
34
+ *
35
+ * ```js
36
+ * const bytes = await downloadResumable(url, {
37
+ * onProgress: ({ percent }) => console.log(percent),
38
+ * maxBytes: 200 * 1024 * 1024,
39
+ * });
40
+ * ```
41
+ *
42
+ * @param {string} url
43
+ * @param {object} [opts]
44
+ * @param {typeof fetch} [opts.fetch=globalThis.fetch] - e.g. `createFetch(...)` or a mock
45
+ * @param {HeadersInit} [opts.headers]
46
+ * @param {number} [opts.retries=5]
47
+ * @param {number} [opts.baseDelayMs=500]
48
+ * @param {number} [opts.maxDelayMs=10000]
49
+ * @param {(p: {loaded: number, total: number | null, percent: number | null}) => void} [opts.onProgress]
50
+ * @param {number} [opts.maxBytes] - throws BodyTooLargeError past this
51
+ * @param {AbortSignal} [opts.signal]
52
+ * @param {(err: any, attempt: number, delayMs: number, info: {resumeFrom: number}) => void} [opts.onRetry]
53
+ * @returns {Promise<Uint8Array>}
54
+ */
55
+ async function downloadResumable(url, opts = {}) {
56
+ const {
57
+ fetch: fetchFn = globalThis.fetch,
58
+ headers,
59
+ retries = 5,
60
+ baseDelayMs = 500,
61
+ maxDelayMs = 10000,
62
+ onProgress,
63
+ maxBytes,
64
+ signal,
65
+ onRetry,
66
+ } = opts;
67
+
68
+ const chunks = [];
69
+ let loaded = 0;
70
+ let total = null;
71
+ let validator = null;
72
+ let attempt = 0; // consecutive attempts without progress
73
+
74
+ const report = () => {
75
+ if (onProgress) {
76
+ onProgress({ loaded, total, percent: total ? Math.min(100, Math.round((loaded / total) * 100)) : null });
77
+ }
78
+ };
79
+
80
+ for (;;) {
81
+ signal?.throwIfAborted?.();
82
+ const loadedBefore = loaded;
83
+
84
+ try {
85
+ const reqHeaders = new Headers(headers || undefined);
86
+ // Range offsets count encoded bytes, so ask for an uncompressed body.
87
+ if (!reqHeaders.has("accept-encoding")) reqHeaders.set("accept-encoding", "identity");
88
+ if (loaded > 0) {
89
+ reqHeaders.set("range", `bytes=${loaded}-`);
90
+ if (validator) reqHeaders.set("if-range", validator);
91
+ }
92
+
93
+ const res = await fetchFn(url, { headers: reqHeaders, signal });
94
+
95
+ if (res.status === 416 && loaded > 0) {
96
+ // Our offset no longer fits the file — start over, without burning a retry.
97
+ await res.body?.cancel?.().catch(() => {});
98
+ chunks.length = 0;
99
+ loaded = 0;
100
+ continue;
101
+ }
102
+ if (!res.ok) {
103
+ await res.body?.cancel?.().catch(() => {});
104
+ throw new HTTPError(res, { method: "GET", url });
105
+ }
106
+
107
+ const encoding = res.headers.get("content-encoding");
108
+ const encoded = Boolean(encoding) && encoding.trim().toLowerCase() !== "identity";
109
+
110
+ if (loaded > 0) {
111
+ const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
112
+ if (!range || range.start !== loaded || encoded) {
113
+ // Server ignored Range, the file changed (If-Range failed), or the
114
+ // bytes don't line up: discard and restart from the beginning.
115
+ chunks.length = 0;
116
+ loaded = 0;
117
+ }
118
+ }
119
+
120
+ if (loaded === 0) {
121
+ const range = res.status === 206 ? parseContentRange(res.headers.get("content-range")) : null;
122
+ const length = Number(res.headers.get("content-length"));
123
+ total = encoded ? null : range ? range.total : Number.isFinite(length) && res.headers.has("content-length") ? length : null;
124
+ const etag = res.headers.get("etag");
125
+ validator = etag && !etag.startsWith("W/") ? etag : res.headers.get("last-modified");
126
+ }
127
+
128
+ if (maxBytes !== undefined && total !== null && total > maxBytes) {
129
+ await res.body?.cancel?.().catch(() => {});
130
+ throw new BodyTooLargeError(maxBytes);
131
+ }
132
+
133
+ if (!res.body || typeof res.body.getReader !== "function") {
134
+ const buf = new Uint8Array(await res.arrayBuffer());
135
+ if (maxBytes !== undefined && buf.byteLength > maxBytes) throw new BodyTooLargeError(maxBytes);
136
+ chunks.push(buf);
137
+ loaded += buf.byteLength;
138
+ total = total ?? loaded;
139
+ report();
140
+ } else {
141
+ const reader = res.body.getReader();
142
+ const onAbort = () => reader.cancel(signal.reason).catch(() => {});
143
+ signal?.addEventListener("abort", onAbort, { once: true });
144
+ let finished = false;
145
+ try {
146
+ for (;;) {
147
+ const { done, value } = await reader.read();
148
+ signal?.throwIfAborted?.();
149
+ if (done) break;
150
+ chunks.push(value);
151
+ loaded += value.byteLength;
152
+ if (maxBytes !== undefined && loaded > maxBytes) throw new BodyTooLargeError(maxBytes);
153
+ report();
154
+ }
155
+ finished = true;
156
+ } finally {
157
+ signal?.removeEventListener("abort", onAbort);
158
+ if (!finished) reader.cancel().catch(() => {});
159
+ }
160
+ }
161
+
162
+ if (total !== null && loaded < total) {
163
+ // Stream ended early without an error (some servers just close the socket).
164
+ throw Object.assign(new Error(`Incomplete download: got ${loaded} of ${total} bytes`), { code: "ECONNRESET" });
165
+ }
166
+
167
+ const out = new Uint8Array(loaded);
168
+ let offset = 0;
169
+ for (const chunk of chunks) {
170
+ out.set(chunk, offset);
171
+ offset += chunk.byteLength;
172
+ }
173
+ return out;
174
+ } catch (err) {
175
+ if (signal?.aborted) throw signal.reason ?? err;
176
+ if (!isRetryable(err)) throw err;
177
+
178
+ if (loaded > loadedBefore) attempt = 0; // it made progress — doesn't count against the budget
179
+ if (attempt >= retries) throw err;
180
+
181
+ let delayMs = Math.min(baseDelayMs * 2 ** attempt, maxDelayMs);
182
+ if (err instanceof HTTPError) {
183
+ const headerMs = getWaitMsFromHeaders(err.response && err.response.headers);
184
+ if (headerMs !== null) delayMs = Math.min(headerMs, 60000);
185
+ }
186
+ attempt++;
187
+ if (onRetry) onRetry(err, attempt, delayMs, { resumeFrom: loaded });
188
+ await abortableSleep(delayMs, signal);
189
+ }
190
+ }
191
+ }
192
+
193
+ module.exports = { downloadResumable };
package/src/index.js CHANGED
@@ -8,6 +8,12 @@ const { withProgress, BodyTooLargeError } = require("./withProgress");
8
8
  const { withDedupe } = require("./withDedupe");
9
9
  const { createLimiter } = require("./createLimiter");
10
10
  const { createFetch, HTTPError } = require("./createFetch");
11
+ const { createRateLimiter } = require("./createRateLimiter");
12
+ const { createCircuitBreaker, CircuitOpenError } = require("./createCircuitBreaker");
13
+ const { createMemoryCache } = require("./createMemoryCache");
14
+ const { createMockFetch } = require("./createMockFetch");
15
+ const { downloadResumable } = require("./downloadResumable");
16
+ const { readLines, readNDJSON, readSSE } = require("./streams");
11
17
 
12
18
  module.exports = {
13
19
  withRetry,
@@ -27,4 +33,13 @@ module.exports = {
27
33
  createLimiter,
28
34
  createFetch,
29
35
  HTTPError,
36
+ createRateLimiter,
37
+ createCircuitBreaker,
38
+ CircuitOpenError,
39
+ createMemoryCache,
40
+ createMockFetch,
41
+ downloadResumable,
42
+ readLines,
43
+ readNDJSON,
44
+ readSSE,
30
45
  };
package/src/index.mjs CHANGED
@@ -18,6 +18,15 @@ export const {
18
18
  createLimiter,
19
19
  createFetch,
20
20
  HTTPError,
21
+ createRateLimiter,
22
+ createCircuitBreaker,
23
+ CircuitOpenError,
24
+ createMemoryCache,
25
+ createMockFetch,
26
+ downloadResumable,
27
+ readLines,
28
+ readNDJSON,
29
+ readSSE,
21
30
  } = reqkit;
22
31
 
23
32
  export default reqkit;
package/src/streams.js ADDED
@@ -0,0 +1,149 @@
1
+ const encoder = new TextEncoder();
2
+
3
+ // Yields Uint8Array chunks from a fetch Response, a web ReadableStream, or any
4
+ // async iterable (e.g. a Node Readable). Cancels the underlying stream when
5
+ // the consumer stops early or the signal aborts, so the connection is freed.
6
+ async function* chunks(source, signal) {
7
+ let body = source;
8
+ if (body && typeof body.getReader !== "function" && "body" in body) body = body.body; // a Response
9
+ if (body === null || body === undefined) return;
10
+ signal?.throwIfAborted?.();
11
+
12
+ if (typeof body.getReader === "function") {
13
+ const reader = body.getReader();
14
+ const onAbort = () => reader.cancel(signal.reason).catch(() => {});
15
+ signal?.addEventListener("abort", onAbort, { once: true });
16
+ try {
17
+ for (;;) {
18
+ const { done, value } = await reader.read();
19
+ signal?.throwIfAborted?.(); // cancel() makes read() resolve as done — don't mistake that for the end
20
+ if (done) return;
21
+ yield value;
22
+ }
23
+ } finally {
24
+ signal?.removeEventListener("abort", onAbort);
25
+ reader.cancel().catch(() => {});
26
+ }
27
+ } else if (typeof body[Symbol.asyncIterator] === "function") {
28
+ for await (const chunk of body) {
29
+ signal?.throwIfAborted?.();
30
+ yield typeof chunk === "string" ? encoder.encode(chunk) : chunk;
31
+ }
32
+ } else {
33
+ throw new TypeError("reqkit streams: expected a Response, a ReadableStream or an async iterable");
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Async-iterates the text lines of a streamed body. Handles \n, \r\n and \r,
39
+ * multi-byte characters split across chunks, and a leading BOM.
40
+ *
41
+ * ```js
42
+ * for await (const line of readLines(response)) console.log(line);
43
+ * ```
44
+ *
45
+ * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
46
+ * @param {{ signal?: AbortSignal }} [opts]
47
+ * @returns {AsyncGenerator<string>}
48
+ */
49
+ async function* readLines(source, opts = {}) {
50
+ const decoder = new TextDecoder("utf-8");
51
+ let buffer = "";
52
+ let first = true;
53
+
54
+ for await (const chunk of chunks(source, opts.signal)) {
55
+ buffer += decoder.decode(chunk, { stream: true });
56
+ if (first && buffer.length > 0) {
57
+ buffer = buffer.replace(/^\uFEFF/, "");
58
+ first = false;
59
+ }
60
+
61
+ let pos = 0;
62
+ for (;;) {
63
+ const rest = buffer.slice(pos);
64
+ const i = rest.search(/\r\n|\n|\r/);
65
+ if (i === -1) break;
66
+ // A trailing "\r" might be the first half of "\r\n" — wait for more data.
67
+ if (rest[i] === "\r" && i === rest.length - 1) break;
68
+ yield rest.slice(0, i);
69
+ pos += i + (rest.startsWith("\r\n", i) ? 2 : 1);
70
+ }
71
+ buffer = buffer.slice(pos);
72
+ }
73
+
74
+ buffer += decoder.decode();
75
+ if (buffer.endsWith("\r")) buffer = buffer.slice(0, -1);
76
+ if (buffer.length > 0) yield buffer;
77
+ }
78
+
79
+ /**
80
+ * Async-iterates parsed JSON values from a newline-delimited JSON stream
81
+ * (NDJSON / JSON Lines). Blank lines are skipped. A malformed line throws a
82
+ * SyntaxError naming the line, unless `skipInvalid` is true.
83
+ *
84
+ * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
85
+ * @param {{ signal?: AbortSignal, skipInvalid?: boolean }} [opts]
86
+ */
87
+ async function* readNDJSON(source, opts = {}) {
88
+ let lineNo = 0;
89
+ for await (const line of readLines(source, opts)) {
90
+ lineNo++;
91
+ if (line.trim() === "") continue;
92
+ try {
93
+ yield JSON.parse(line);
94
+ } catch (err) {
95
+ if (opts.skipInvalid) continue;
96
+ throw new SyntaxError(`readNDJSON: invalid JSON on line ${lineNo}: ${err.message}`);
97
+ }
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Async-iterates Server-Sent Events (text/event-stream) as
103
+ * `{ event, data, id, retry }`, following the spec: multi-line `data:` is
104
+ * joined with "\n", comments (`:`) are ignored, `event` defaults to
105
+ * "message", `id` carries over between events, and an event cut off by the end
106
+ * of the stream is discarded. Great for LLM token streams.
107
+ *
108
+ * ```js
109
+ * for await (const { data } of readSSE(response)) {
110
+ * if (data === "[DONE]") break;
111
+ * console.log(JSON.parse(data));
112
+ * }
113
+ * ```
114
+ *
115
+ * @param {Response | ReadableStream | AsyncIterable<Uint8Array>} source
116
+ * @param {{ signal?: AbortSignal }} [opts]
117
+ */
118
+ async function* readSSE(source, opts = {}) {
119
+ let event = "";
120
+ let data = [];
121
+ let retry;
122
+ let lastId = "";
123
+
124
+ for await (const line of readLines(source, opts)) {
125
+ if (line === "") {
126
+ if (data.length > 0) yield { event: event || "message", data: data.join("\n"), id: lastId, retry };
127
+ event = "";
128
+ data = [];
129
+ retry = undefined;
130
+ continue;
131
+ }
132
+ if (line.startsWith(":")) continue;
133
+
134
+ const idx = line.indexOf(":");
135
+ const field = idx === -1 ? line : line.slice(0, idx);
136
+ let value = idx === -1 ? "" : line.slice(idx + 1);
137
+ if (value.startsWith(" ")) value = value.slice(1);
138
+
139
+ if (field === "event") event = value;
140
+ else if (field === "data") data.push(value);
141
+ else if (field === "id") {
142
+ if (!value.includes("\0")) lastId = value;
143
+ } else if (field === "retry") {
144
+ if (/^\d+$/.test(value)) retry = Number(value);
145
+ }
146
+ }
147
+ }
148
+
149
+ module.exports = { readLines, readNDJSON, readSSE };
package/types/index.d.ts CHANGED
@@ -203,6 +203,15 @@ export interface CreateFetchDefaults {
203
203
  timeout?: number | false;
204
204
  /** false disables retrying. */
205
205
  retry?: false | CreateFetchRetryOptions;
206
+ /**
207
+ * `true` only learns from X-RateLimit-* / Retry-After headers; `{ requests, perMs }`
208
+ * also enforces a budget; or share a createRateLimiter() instance.
209
+ */
210
+ rateLimit?: boolean | RateLimiterOptions | RateLimiter;
211
+ /** Options for a per-origin breaker, a shared breaker instance, or `(url) => breaker`. */
212
+ circuitBreaker?: boolean | CircuitBreakerOptions | CircuitBreaker | ((url: string) => CircuitBreaker);
213
+ /** In-memory GET cache with TTL, stale-while-revalidate and ETag/Last-Modified revalidation. `true` = 60s TTL. */
214
+ cache?: boolean | CacheOptions;
206
215
  /** Merge identical concurrent GET/HEAD calls. Default false. */
207
216
  dedupe?: boolean;
208
217
  /** Throw HTTPError on non-2xx. Default true. */
@@ -212,7 +221,7 @@ export interface CreateFetchDefaults {
212
221
  onResponse?: (ctx: FetchHookContext & { response: Response }) => void | Promise<void>;
213
222
  }
214
223
 
215
- export interface ReqkitRequestInit extends Omit<RequestInit, "headers"> {
224
+ export interface ReqkitRequestInit extends Omit<RequestInit, "headers" | "cache"> {
216
225
  headers?: HeadersInit;
217
226
  query?: Record<string, unknown>;
218
227
  /** Serialized as the body with a JSON content-type. */
@@ -220,6 +229,8 @@ export interface ReqkitRequestInit extends Omit<RequestInit, "headers"> {
220
229
  timeout?: number | false;
221
230
  retry?: false | CreateFetchRetryOptions;
222
231
  throwHttpErrors?: boolean;
232
+ /** `false`/"no-store" bypass the cache; "reload" skips the read but refreshes the entry. */
233
+ cache?: false | "no-store" | "reload";
223
234
  }
224
235
 
225
236
  export interface ReqkitFetch {
@@ -231,3 +242,166 @@ export interface ReqkitFetch {
231
242
  }
232
243
 
233
244
  export function createFetch(defaults?: CreateFetchDefaults): ReqkitFetch;
245
+
246
+ export interface RateLimiterOptions {
247
+ /** Max requests per window. Default Infinity. */
248
+ requests?: number;
249
+ /** Window length in ms. Default 1000. */
250
+ perMs?: number;
251
+ }
252
+
253
+ export interface RateLimiter {
254
+ /** Resolves when it's your turn (FIFO). Rejects with the signal's reason if aborted while waiting. */
255
+ acquire(opts?: { signal?: AbortSignal }): Promise<void>;
256
+ /** Learn from a response's X-RateLimit-* / RateLimit-* / Retry-After headers. */
257
+ update(response: { status?: number; headers?: { get(name: string): string | null } } | null | undefined): void;
258
+ /** acquire → fn() → update(result). */
259
+ run<T>(fn: () => Promise<T>, opts?: { signal?: AbortSignal }): Promise<T>;
260
+ /** Ms until a server-imposed pause ends (0 if none). */
261
+ readonly blockedForMs: number;
262
+ reset(): void;
263
+ }
264
+
265
+ export function createRateLimiter(opts?: RateLimiterOptions): RateLimiter;
266
+
267
+ export class CircuitOpenError extends Error {
268
+ name: "CircuitOpenError";
269
+ code: "CIRCUIT_OPEN";
270
+ /** Ms until the breaker will let a probe through. */
271
+ retryAfterMs: number;
272
+ constructor(retryAfterMs: number);
273
+ }
274
+
275
+ export interface CircuitBreakerOptions {
276
+ /** Consecutive failures before opening. Default 5. */
277
+ failureThreshold?: number;
278
+ /** How long to stay open before allowing a probe. Default 30000. */
279
+ resetTimeoutMs?: number;
280
+ /** Concurrent probes allowed while half-open. Default 1. */
281
+ halfOpenMax?: number;
282
+ /** Default: any thrown error except AbortError, or a result with status >= 500. */
283
+ isFailure?: (valueOrError: any, isError: boolean) => boolean;
284
+ onStateChange?: (state: CircuitState, previous: CircuitState) => void;
285
+ }
286
+
287
+ export type CircuitState = "closed" | "open" | "half-open";
288
+
289
+ export interface CircuitBreaker {
290
+ run<T>(fn: () => Promise<T>): Promise<T>;
291
+ readonly state: CircuitState;
292
+ readonly failures: number;
293
+ reset(): void;
294
+ }
295
+
296
+ export function createCircuitBreaker(opts?: CircuitBreakerOptions): CircuitBreaker;
297
+
298
+ export interface CacheStore {
299
+ get(key: string): any | Promise<any>;
300
+ set(key: string, value: any): void | Promise<void>;
301
+ delete(key: string): any;
302
+ }
303
+
304
+ export interface MemoryCache extends CacheStore {
305
+ clear(): void;
306
+ readonly size: number;
307
+ }
308
+
309
+ export function createMemoryCache(opts?: { maxEntries?: number }): MemoryCache;
310
+
311
+ export interface CacheEvent {
312
+ type: "hit" | "stale" | "revalidated" | "miss" | "store";
313
+ url: string;
314
+ }
315
+
316
+ export interface CacheOptions {
317
+ /** How long an entry is served without asking the server, in ms. Default 60000. */
318
+ ttl?: number;
319
+ /** After the TTL, keep serving the stale copy for this long while refreshing in the background. Default 0. */
320
+ staleWhileRevalidate?: number;
321
+ /** For the built-in memory store. Default 100. */
322
+ maxEntries?: number;
323
+ /** Responses larger than this aren't cached. Default 1 MiB. */
324
+ maxBodyBytes?: number;
325
+ /** Bring your own store (Redis, IndexedDB, a Map...). */
326
+ store?: CacheStore;
327
+ onEvent?: (event: CacheEvent) => void;
328
+ }
329
+
330
+ export interface MockCall {
331
+ url: string;
332
+ method: string;
333
+ headers: Record<string, string>;
334
+ body: BodyInit | null | undefined;
335
+ signal?: AbortSignal;
336
+ /** The body parsed as JSON, if it was a JSON string. */
337
+ readonly json: any;
338
+ }
339
+
340
+ export interface MockReplyOptions {
341
+ headers?: HeadersInit;
342
+ /** Wait this long before answering; respects the request's AbortSignal. */
343
+ delayMs?: number;
344
+ }
345
+
346
+ export interface MockRoute {
347
+ reply(status?: number, body?: unknown, opts?: MockReplyOptions): MockRoute;
348
+ replyOnce(status?: number, body?: unknown, opts?: MockReplyOptions): MockRoute;
349
+ replyWith(fn: (call: MockCall) => Response | { status?: number; body?: unknown; headers?: HeadersInit } | Promise<Response | { status?: number; body?: unknown; headers?: HeadersInit }>): MockRoute;
350
+ replyWithOnce(fn: (call: MockCall) => Response | { status?: number; body?: unknown; headers?: HeadersInit } | Promise<Response | { status?: number; body?: unknown; headers?: HeadersInit }>): MockRoute;
351
+ fail(err?: unknown): MockRoute;
352
+ failOnce(err?: unknown): MockRoute;
353
+ }
354
+
355
+ export type MockMatcher = string | RegExp | ((url: string) => boolean);
356
+
357
+ export interface MockFetch {
358
+ fetch: typeof fetch;
359
+ on(method: string, matcher: MockMatcher): MockRoute;
360
+ get(matcher: MockMatcher): MockRoute;
361
+ post(matcher: MockMatcher): MockRoute;
362
+ put(matcher: MockMatcher): MockRoute;
363
+ patch(matcher: MockMatcher): MockRoute;
364
+ delete(matcher: MockMatcher): MockRoute;
365
+ /** A TypeError("fetch failed") whose `cause.code` is set, like Node's fetch throws. */
366
+ networkError(code?: string): TypeError;
367
+ readonly calls: MockCall[];
368
+ reset(): void;
369
+ }
370
+
371
+ export function createMockFetch(): MockFetch;
372
+
373
+ export interface DownloadResumableOptions {
374
+ fetch?: typeof fetch;
375
+ headers?: HeadersInit;
376
+ /** Consecutive attempts without progress before giving up. Default 5. */
377
+ retries?: number;
378
+ baseDelayMs?: number;
379
+ maxDelayMs?: number;
380
+ onProgress?: (progress: DownloadProgress) => void;
381
+ /** Throws BodyTooLargeError past this many bytes. */
382
+ maxBytes?: number;
383
+ signal?: AbortSignal;
384
+ onRetry?: (err: unknown, attempt: number, delayMs: number, info: { resumeFrom: number }) => void;
385
+ }
386
+
387
+ export function downloadResumable(url: string, opts?: DownloadResumableOptions): Promise<Uint8Array>;
388
+
389
+ export type StreamSource = Response | ReadableStream<Uint8Array> | AsyncIterable<Uint8Array | string>;
390
+
391
+ export interface StreamOptions {
392
+ signal?: AbortSignal;
393
+ }
394
+
395
+ export interface SSEEvent {
396
+ event: string;
397
+ data: string;
398
+ id: string;
399
+ retry: number | undefined;
400
+ }
401
+
402
+ export function readLines(source: StreamSource, opts?: StreamOptions): AsyncGenerator<string, void, undefined>;
403
+ export function readNDJSON<T = unknown>(
404
+ source: StreamSource,
405
+ opts?: StreamOptions & { skipInvalid?: boolean }
406
+ ): AsyncGenerator<T, void, undefined>;
407
+ export function readSSE(source: StreamSource, opts?: StreamOptions): AsyncGenerator<SSEEvent, void, undefined>;