@happy-ts/fetch-t 1.9.1 → 1.10.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/dist/main.mjs CHANGED
@@ -1,316 +1,513 @@
1
- import { Err, Ok } from 'happy-rusty';
2
-
1
+ import { Err, Ok } from "happy-rusty";
2
+ //#region src/fetch/constants.ts
3
+ /**
4
+ * Error name for aborted fetch requests.
5
+ *
6
+ * This matches the standard `AbortError` name used by the Fetch API when a request
7
+ * is cancelled via `AbortController.abort()`.
8
+ *
9
+ * @since 1.0.0
10
+ * @example
11
+ * ```typescript
12
+ * import { fetchT, ABORT_ERROR } from '@happy-ts/fetch-t';
13
+ *
14
+ * const task = fetchT('https://api.example.com/data', { abortable: true });
15
+ * task.abort();
16
+ *
17
+ * const result = await task.result;
18
+ * result.inspectErr((err) => {
19
+ * if (err.name === ABORT_ERROR) {
20
+ * console.log('Request was aborted');
21
+ * }
22
+ * });
23
+ * ```
24
+ */
3
25
  const ABORT_ERROR = "AbortError";
26
+ /**
27
+ * Error name for timed out fetch requests.
28
+ *
29
+ * This is set on the `Error.name` property when a request exceeds the specified
30
+ * `timeout` duration and is automatically aborted.
31
+ *
32
+ * @since 1.0.0
33
+ * @example
34
+ * ```typescript
35
+ * import { fetchT, TIMEOUT_ERROR } from '@happy-ts/fetch-t';
36
+ *
37
+ * const result = await fetchT('https://api.example.com/slow-endpoint', {
38
+ * timeout: 5000, // 5 seconds
39
+ * });
40
+ *
41
+ * result.inspectErr((err) => {
42
+ * if (err.name === TIMEOUT_ERROR) {
43
+ * console.log('Request timed out after 5 seconds');
44
+ * }
45
+ * });
46
+ * ```
47
+ */
4
48
  const TIMEOUT_ERROR = "TimeoutError";
5
-
6
- class FetchError extends Error {
7
- /**
8
- * The error name, always `'FetchError'`.
9
- */
10
- name = "FetchError";
11
- /**
12
- * The HTTP status code of the response (e.g., 404, 500).
13
- */
14
- status;
15
- /**
16
- * Creates a new FetchError instance.
17
- *
18
- * @param message - The status text from the HTTP response (e.g., "Not Found").
19
- * @param status - The HTTP status code (e.g., 404).
20
- */
21
- constructor(message, status) {
22
- super(message);
23
- this.status = status;
24
- }
49
+ //#endregion
50
+ //#region src/fetch/defines.ts
51
+ /**
52
+ * Custom error class for HTTP error responses (non-2xx status codes).
53
+ *
54
+ * Thrown when `Response.ok` is `false`. Contains the HTTP status code
55
+ * for programmatic error handling.
56
+ *
57
+ * @since 1.0.0
58
+ * @example
59
+ * ```typescript
60
+ * import { fetchT, FetchError } from '@happy-ts/fetch-t';
61
+ *
62
+ * const result = await fetchT('https://api.example.com/not-found', {
63
+ * responseType: 'json',
64
+ * });
65
+ *
66
+ * result.inspectErr((err) => {
67
+ * if (err instanceof FetchError) {
68
+ * console.log('HTTP Status:', err.status); // e.g., 404
69
+ * console.log('Status Text:', err.message); // e.g., "Not Found"
70
+ *
71
+ * // Handle specific status codes
72
+ * switch (err.status) {
73
+ * case 401:
74
+ * console.log('Unauthorized - please login');
75
+ * break;
76
+ * case 404:
77
+ * console.log('Resource not found');
78
+ * break;
79
+ * case 500:
80
+ * console.log('Server error');
81
+ * break;
82
+ * }
83
+ * }
84
+ * });
85
+ * ```
86
+ */
87
+ var FetchError = class extends Error {
88
+ /**
89
+ * The error name, always `'FetchError'`.
90
+ */
91
+ name = "FetchError";
92
+ /**
93
+ * The HTTP status code of the response (e.g., 404, 500).
94
+ */
95
+ status;
96
+ /**
97
+ * Creates a new FetchError instance.
98
+ *
99
+ * @param message - The status text from the HTTP response (e.g., "Not Found").
100
+ * @param status - The HTTP status code (e.g., 404).
101
+ */
102
+ constructor(message, status) {
103
+ super(message);
104
+ this.status = status;
105
+ }
106
+ };
107
+ //#endregion
108
+ //#region src/fetch/polyfills.ts
109
+ /**
110
+ * @internal
111
+ * Polyfills for AbortSignal and related APIs.
112
+ */
113
+ /**
114
+ * Creates an AbortSignal that aborts after the specified timeout.
115
+ * Uses native `AbortSignal.timeout` when available, otherwise falls back to a manual implementation.
116
+ */
117
+ function signalTimeout(ms) {
118
+ if (typeof AbortSignal.timeout === "function") return AbortSignal.timeout(ms);
119
+ const controller = new AbortController();
120
+ const timeoutError = createTimeoutError();
121
+ const timerId = setTimeout(() => {
122
+ controller.abort(timeoutError);
123
+ }, ms);
124
+ if (typeof timerId?.unref === "function") timerId.unref();
125
+ return controller.signal;
25
126
  }
26
-
127
+ /**
128
+ * Creates an AbortSignal that aborts when any of the provided signals abort.
129
+ * Uses native `AbortSignal.any` when available, otherwise falls back to a manual implementation.
130
+ *
131
+ * The polyfill properly cleans up event listeners on all signals when one fires,
132
+ * preventing memory leaks from long-lived signals.
133
+ */
134
+ function signalAny(signals) {
135
+ if (typeof AbortSignal.any === "function") return AbortSignal.any(signals);
136
+ const controller = new AbortController();
137
+ for (const signal of signals) if (signal.aborted) {
138
+ controller.abort(signal.reason);
139
+ return controller.signal;
140
+ }
141
+ const onAbort = (event) => {
142
+ controller.abort(event.target.reason);
143
+ };
144
+ const cleanup = () => {
145
+ for (const signal of signals) signal.removeEventListener("abort", onAbort);
146
+ };
147
+ for (const signal of signals) signal.addEventListener("abort", onAbort);
148
+ controller.signal.addEventListener("abort", cleanup);
149
+ return controller.signal;
150
+ }
151
+ /**
152
+ * Creates a DOMException with TimeoutError name.
153
+ * Falls back to a plain Error in environments where DOMException constructor is unavailable.
154
+ */
155
+ function createTimeoutError() {
156
+ const message = "The operation timed out.";
157
+ try {
158
+ return new DOMException(message, TIMEOUT_ERROR);
159
+ } catch {
160
+ const error = new Error(message);
161
+ error.name = TIMEOUT_ERROR;
162
+ return error;
163
+ }
164
+ }
165
+ //#endregion
166
+ //#region src/fetch/fetch.ts
167
+ /**
168
+ * Enhanced fetch function that wraps the native Fetch API with additional capabilities.
169
+ *
170
+ * Features:
171
+ * - **Abortable requests**: Set `abortable: true` to get a `FetchTask` with `abort()` method.
172
+ * - **Type-safe responses**: Use `responseType` to automatically parse responses as text, JSON, ArrayBuffer, Blob, bytes, or stream.
173
+ * - **Timeout support**: Set `timeout` in milliseconds to auto-abort long-running requests.
174
+ * - **Progress tracking**: Use `onProgress` callback to track download progress (requires Content-Length header).
175
+ * - **Chunk streaming**: Use `onChunk` callback to receive raw data chunks as they arrive.
176
+ * - **Retry support**: Use `retry` to automatically retry failed requests with configurable delay and conditions.
177
+ * - **Result type error handling**: Returns `Result<T, Error>` instead of throwing exceptions for runtime errors.
178
+ *
179
+ * **Note**: Invalid parameters throw synchronously (fail-fast) rather than returning rejected Promises.
180
+ * This differs from native `fetch` behavior and helps catch programming errors during development.
181
+ *
182
+ * @typeParam T - The expected type of the response data.
183
+ * @param url - The resource to fetch. Can be a URL object or a string representing a URL.
184
+ * @param init - Additional options for the fetch operation, extending standard `RequestInit` with custom properties.
185
+ * @returns A `FetchTask<T>` if `abortable: true`, otherwise a `FetchResult<T>` (which is `AsyncIOResult<T>`).
186
+ * @throws {TypeError} If `url` is invalid or a relative URL in non-browser environment.
187
+ * @throws {TypeError} If `responseType` is not a valid response type.
188
+ * @throws {TypeError} If `timeout` is not a number.
189
+ * @throws {Error} If `timeout` is not greater than 0.
190
+ * @throws {TypeError} If `onProgress` or `onChunk` is provided but not a function.
191
+ * @throws {TypeError} If `retry.retries` is not an integer.
192
+ * @throws {Error} If `retry.retries` is negative.
193
+ * @throws {TypeError} If `retry.delay` is not a number or function.
194
+ * @throws {Error} If `retry.delay` is a negative number.
195
+ * @throws {TypeError} If `retry.when` is not an array or function.
196
+ * @throws {TypeError} If `retry.onRetry` is provided but not a function.
197
+ * @since 1.0.0
198
+ * @example
199
+ * // Basic GET request - returns Response object wrapped in Result
200
+ * const result = await fetchT('https://api.example.com/data');
201
+ * result
202
+ * .inspect((res) => console.log('Status:', res.status))
203
+ * .inspectErr((err) => console.error('Error:', err));
204
+ *
205
+ * @example
206
+ * // GET JSON with type safety
207
+ * interface User {
208
+ * id: number;
209
+ * name: string;
210
+ * }
211
+ * const result = await fetchT<User>('https://api.example.com/user/1', {
212
+ * responseType: 'json',
213
+ * });
214
+ * result.inspect((user) => console.log(user.name));
215
+ *
216
+ * @example
217
+ * // POST request with JSON body
218
+ * const result = await fetchT<User>('https://api.example.com/users', {
219
+ * method: 'POST',
220
+ * headers: { 'Content-Type': 'application/json' },
221
+ * body: JSON.stringify({ name: 'John' }),
222
+ * responseType: 'json',
223
+ * });
224
+ *
225
+ * @example
226
+ * // Abortable request with timeout
227
+ * const task = fetchT('https://api.example.com/data', {
228
+ * abortable: true,
229
+ * timeout: 5000, // 5 seconds
230
+ * });
231
+ *
232
+ * // Cancel the request if needed
233
+ * task.abort('User cancelled');
234
+ *
235
+ * // Check if aborted
236
+ * console.log('Aborted:', task.aborted);
237
+ *
238
+ * // Wait for result
239
+ * const result = await task.result;
240
+ *
241
+ * @example
242
+ * // Track download progress
243
+ * const result = await fetchT('https://example.com/large-file.zip', {
244
+ * responseType: 'blob',
245
+ * onProgress: (progressResult) => {
246
+ * progressResult
247
+ * .inspect(({ completedByteLength, totalByteLength }) => {
248
+ * const percent = ((completedByteLength / totalByteLength) * 100).toFixed(1);
249
+ * console.log(`Progress: ${percent}%`);
250
+ * })
251
+ * .inspectErr((err) => console.warn('Progress unavailable:', err.message));
252
+ * },
253
+ * });
254
+ *
255
+ * @example
256
+ * // Stream data chunks
257
+ * const chunks: Uint8Array[] = [];
258
+ * const result = await fetchT('https://example.com/stream', {
259
+ * onChunk: (chunk) => chunks.push(chunk),
260
+ * });
261
+ *
262
+ * @example
263
+ * // Retry with exponential backoff
264
+ * const result = await fetchT('https://api.example.com/data', {
265
+ * retry: {
266
+ * retries: 3,
267
+ * delay: (attempt) => Math.min(1000 * Math.pow(2, attempt - 1), 10000),
268
+ * when: [500, 502, 503, 504],
269
+ * onRetry: (error, attempt) => console.log(`Retry ${attempt}: ${error.message}`),
270
+ * },
271
+ * responseType: 'json',
272
+ * });
273
+ */
27
274
  function fetchT(url, init) {
28
- const parsedUrl = validateUrl(url);
29
- const fetchInit = init ?? {};
30
- const {
31
- retries,
32
- delay: retryDelay,
33
- when: retryWhen,
34
- onRetry
35
- } = validateOptions(fetchInit);
36
- const {
37
- // default not abortable
38
- abortable = false,
39
- responseType,
40
- timeout,
41
- onProgress,
42
- onChunk,
43
- ...rest
44
- } = fetchInit;
45
- const userSignal = rest.signal;
46
- let userController;
47
- if (abortable) {
48
- userController = new AbortController();
49
- }
50
- const shouldRetry = (error, attempt) => {
51
- if (error.name === ABORT_ERROR) {
52
- return false;
53
- }
54
- if (!retryWhen) {
55
- return !(error instanceof FetchError);
56
- }
57
- if (Array.isArray(retryWhen)) {
58
- return error instanceof FetchError && retryWhen.includes(error.status);
59
- }
60
- return retryWhen(error, attempt);
61
- };
62
- const getRetryDelay = (attempt) => {
63
- return typeof retryDelay === "function" ? retryDelay(attempt) : retryDelay;
64
- };
65
- const configureSignal = () => {
66
- const signals = [];
67
- if (userSignal) {
68
- signals.push(userSignal);
69
- }
70
- if (userController) {
71
- signals.push(userController.signal);
72
- }
73
- if (typeof timeout === "number") {
74
- signals.push(AbortSignal.timeout(timeout));
75
- }
76
- if (signals.length > 0) {
77
- rest.signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals);
78
- }
79
- };
80
- const doFetch = async () => {
81
- configureSignal();
82
- try {
83
- const response = await fetch(parsedUrl, rest);
84
- if (!response.ok) {
85
- response.body?.cancel().catch(() => {
86
- });
87
- return Err(new FetchError(response.statusText, response.status));
88
- }
89
- return await processResponse(response);
90
- } catch (err) {
91
- return Err(
92
- err instanceof Error ? err : wrapAbortReason(err)
93
- );
94
- }
95
- };
96
- const setupProgressCallbacks = async (response) => {
97
- let totalByteLength;
98
- let completedByteLength = 0;
99
- if (onProgress) {
100
- const contentLength = response.headers.get("content-length");
101
- if (contentLength == null) {
102
- try {
103
- onProgress(Err(new Error("No content-length in response headers")));
104
- } catch {
105
- }
106
- } else {
107
- totalByteLength = Number.parseInt(contentLength, 10);
108
- }
109
- }
110
- const body = response.clone().body;
111
- try {
112
- for await (const chunk of body) {
113
- if (onChunk) {
114
- try {
115
- onChunk(chunk);
116
- } catch {
117
- }
118
- }
119
- if (onProgress && totalByteLength != null) {
120
- completedByteLength += chunk.byteLength;
121
- try {
122
- onProgress(Ok({
123
- totalByteLength,
124
- completedByteLength
125
- }));
126
- } catch {
127
- }
128
- }
129
- }
130
- } catch {
131
- }
132
- };
133
- const processResponse = async (response) => {
134
- if (response.body && (onProgress || onChunk)) {
135
- setupProgressCallbacks(response);
136
- }
137
- switch (responseType) {
138
- case "json": {
139
- if (response.body == null) {
140
- return Ok(null);
141
- }
142
- try {
143
- return Ok(await response.json());
144
- } catch {
145
- return Err(new Error("Response is invalid json while responseType is json"));
146
- }
147
- }
148
- case "text": {
149
- return Ok(await response.text());
150
- }
151
- case "bytes": {
152
- if (typeof response.bytes === "function") {
153
- return Ok(await response.bytes());
154
- }
155
- return Ok(new Uint8Array(await response.arrayBuffer()));
156
- }
157
- case "arraybuffer": {
158
- return Ok(await response.arrayBuffer());
159
- }
160
- case "blob": {
161
- return Ok(await response.blob());
162
- }
163
- case "stream": {
164
- return Ok(response.body);
165
- }
166
- default: {
167
- return Ok(response);
168
- }
169
- }
170
- };
171
- const fetchWithRetry = async () => {
172
- let lastError;
173
- let attempt = 0;
174
- do {
175
- if (attempt > 0) {
176
- if (userController?.signal.aborted) {
177
- return Err(userController.signal.reason);
178
- }
179
- const delayMs = getRetryDelay(attempt);
180
- if (delayMs > 0) {
181
- await delay(delayMs);
182
- if (userController?.signal.aborted) {
183
- return Err(userController.signal.reason);
184
- }
185
- }
186
- try {
187
- onRetry?.(lastError, attempt);
188
- } catch {
189
- }
190
- }
191
- const result2 = await doFetch();
192
- if (result2.isOk()) {
193
- return result2;
194
- }
195
- lastError = result2.unwrapErr();
196
- attempt++;
197
- } while (attempt <= retries && shouldRetry(lastError, attempt));
198
- return Err(lastError);
199
- };
200
- const result = fetchWithRetry();
201
- if (abortable && userController) {
202
- return {
203
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
204
- abort(reason) {
205
- if (reason instanceof Error) {
206
- userController.abort(reason);
207
- } else if (reason != null) {
208
- userController.abort(wrapAbortReason(reason));
209
- } else {
210
- userController.abort();
211
- }
212
- },
213
- get aborted() {
214
- return userController.signal.aborted;
215
- },
216
- get result() {
217
- return result;
218
- }
219
- };
220
- }
221
- return result;
275
+ const parsedUrl = validateUrl(url);
276
+ const fetchInit = init ?? {};
277
+ const { retries, delay: retryDelay, when: retryWhen, onRetry } = validateOptions(fetchInit);
278
+ const { abortable = false, responseType, timeout, onProgress, onChunk, ...rest } = fetchInit;
279
+ const userSignal = rest.signal;
280
+ let userController;
281
+ if (abortable) userController = new AbortController();
282
+ /**
283
+ * Determines if the error should trigger a retry.
284
+ * By default, only network errors (not FetchError) trigger retries.
285
+ */
286
+ const shouldRetry = (error, attempt) => {
287
+ if (error.name === "AbortError") return false;
288
+ if (!retryWhen) return !(error instanceof FetchError);
289
+ if (Array.isArray(retryWhen)) return error instanceof FetchError && retryWhen.includes(error.status);
290
+ return retryWhen(error, attempt);
291
+ };
292
+ /**
293
+ * Calculates the delay before the next retry attempt.
294
+ */
295
+ const getRetryDelay = (attempt) => {
296
+ return typeof retryDelay === "function" ? retryDelay(attempt) : retryDelay;
297
+ };
298
+ /**
299
+ * Configures the abort signal for a fetch attempt.
300
+ *
301
+ * Combines multiple signals:
302
+ * - User's external signal (from init.signal)
303
+ * - Internal abort controller signal (for abortable requests)
304
+ * - Timeout signal (creates a new one each call for per-attempt timeout)
305
+ *
306
+ * Must be called before each fetch attempt to ensure fresh timeout signal on retries.
307
+ */
308
+ const configureSignal = () => {
309
+ const signals = [];
310
+ if (userSignal) signals.push(userSignal);
311
+ if (userController) signals.push(userController.signal);
312
+ if (typeof timeout === "number") signals.push(signalTimeout(timeout));
313
+ if (signals.length > 0) rest.signal = signals.length === 1 ? signals[0] : signalAny(signals);
314
+ };
315
+ /**
316
+ * Performs a single fetch attempt with optional timeout.
317
+ */
318
+ const doFetch = async () => {
319
+ configureSignal();
320
+ try {
321
+ const response = await fetch(parsedUrl, rest);
322
+ if (!response.ok) {
323
+ response.body?.cancel().catch(() => {});
324
+ return Err(new FetchError(response.statusText, response.status));
325
+ }
326
+ return await processResponse(response);
327
+ } catch (err) {
328
+ return Err(err instanceof Error ? err : wrapAbortReason(err));
329
+ }
330
+ };
331
+ /**
332
+ * Sets up progress tracking and chunk callbacks using a cloned response.
333
+ * The original response is returned unchanged for further processing.
334
+ */
335
+ const setupProgressCallbacks = async (response) => {
336
+ let totalByteLength;
337
+ let completedByteLength = 0;
338
+ if (onProgress) {
339
+ const contentLength = response.headers.get("content-length");
340
+ if (contentLength == null) try {
341
+ onProgress(Err(/* @__PURE__ */ new Error("No content-length in response headers")));
342
+ } catch {}
343
+ else totalByteLength = Number.parseInt(contentLength, 10);
344
+ }
345
+ const body = response.clone().body;
346
+ try {
347
+ for await (const chunk of body) {
348
+ if (onChunk) try {
349
+ onChunk(chunk);
350
+ } catch {}
351
+ if (onProgress && totalByteLength != null) {
352
+ completedByteLength += chunk.byteLength;
353
+ try {
354
+ onProgress(Ok({
355
+ totalByteLength,
356
+ completedByteLength
357
+ }));
358
+ } catch {}
359
+ }
360
+ }
361
+ } catch {}
362
+ };
363
+ /**
364
+ * Processes the response based on responseType and callbacks.
365
+ */
366
+ const processResponse = async (response) => {
367
+ if (response.body && (onProgress || onChunk)) setupProgressCallbacks(response);
368
+ switch (responseType) {
369
+ case "json":
370
+ if (response.body == null) return Ok(null);
371
+ try {
372
+ return Ok(await response.json());
373
+ } catch {
374
+ return Err(/* @__PURE__ */ new Error("Response is invalid json while responseType is json"));
375
+ }
376
+ case "text": return Ok(await response.text());
377
+ case "bytes":
378
+ if (typeof response.bytes === "function") return Ok(await response.bytes());
379
+ return Ok(new Uint8Array(await response.arrayBuffer()));
380
+ case "arraybuffer": return Ok(await response.arrayBuffer());
381
+ case "blob": return Ok(await response.blob());
382
+ case "stream": return Ok(response.body);
383
+ default: return Ok(response);
384
+ }
385
+ };
386
+ /**
387
+ * Performs fetch with retry logic.
388
+ */
389
+ const fetchWithRetry = async () => {
390
+ let lastError;
391
+ let attempt = 0;
392
+ do {
393
+ if (attempt > 0) {
394
+ if (userController?.signal.aborted) return Err(userController.signal.reason);
395
+ const delayMs = getRetryDelay(attempt);
396
+ if (delayMs > 0) {
397
+ await delay(delayMs);
398
+ if (userController?.signal.aborted) return Err(userController.signal.reason);
399
+ }
400
+ try {
401
+ onRetry?.(lastError, attempt);
402
+ } catch {}
403
+ }
404
+ const result = await doFetch();
405
+ if (result.isOk()) return result;
406
+ lastError = result.unwrapErr();
407
+ attempt++;
408
+ } while (attempt <= retries && shouldRetry(lastError, attempt));
409
+ return Err(lastError);
410
+ };
411
+ const result = fetchWithRetry();
412
+ if (abortable && userController) return {
413
+ abort(reason) {
414
+ if (reason instanceof Error) userController.abort(reason);
415
+ else if (reason != null) userController.abort(wrapAbortReason(reason));
416
+ else userController.abort();
417
+ },
418
+ get aborted() {
419
+ return userController.signal.aborted;
420
+ },
421
+ get result() {
422
+ return result;
423
+ }
424
+ };
425
+ return result;
222
426
  }
427
+ /**
428
+ * Delays execution for the specified number of milliseconds.
429
+ */
223
430
  function delay(ms) {
224
- return new Promise((resolve) => setTimeout(resolve, ms));
431
+ return new Promise((resolve) => setTimeout(resolve, ms));
225
432
  }
433
+ /**
434
+ * Wraps a non-Error abort reason into an Error with ABORT_ERROR name.
435
+ */
226
436
  function wrapAbortReason(reason) {
227
- const error = new Error(typeof reason === "string" ? reason : String(reason));
228
- error.name = ABORT_ERROR;
229
- error.cause = reason;
230
- return error;
437
+ const error = new Error(typeof reason === "string" ? reason : String(reason));
438
+ error.name = ABORT_ERROR;
439
+ error.cause = reason;
440
+ return error;
231
441
  }
442
+ /**
443
+ * Validates fetch options and parses retry configuration.
444
+ */
232
445
  function validateOptions(init) {
233
- const {
234
- responseType,
235
- timeout,
236
- retry: retryOptions = 0,
237
- onProgress,
238
- onChunk
239
- } = init;
240
- if (responseType != null) {
241
- const validTypes = ["text", "arraybuffer", "blob", "json", "bytes", "stream"];
242
- if (!validTypes.includes(responseType)) {
243
- throw new TypeError(`responseType must be one of ${validTypes.join(", ")} but received ${responseType}`);
244
- }
245
- }
246
- if (timeout != null) {
247
- if (typeof timeout !== "number") {
248
- throw new TypeError(`timeout must be a number but received ${typeof timeout}`);
249
- }
250
- if (timeout <= 0) {
251
- throw new Error(`timeout must be a number greater than 0 but received ${timeout}`);
252
- }
253
- }
254
- if (onProgress != null) {
255
- if (typeof onProgress !== "function") {
256
- throw new TypeError(`onProgress callback must be a function but received ${typeof onProgress}`);
257
- }
258
- }
259
- if (onChunk != null) {
260
- if (typeof onChunk !== "function") {
261
- throw new TypeError(`onChunk callback must be a function but received ${typeof onChunk}`);
262
- }
263
- }
264
- let retries = 0;
265
- let delay2 = 0;
266
- let when;
267
- let onRetry;
268
- if (typeof retryOptions === "number") {
269
- retries = retryOptions;
270
- } else if (retryOptions && typeof retryOptions === "object") {
271
- retries = retryOptions.retries ?? 0;
272
- delay2 = retryOptions.delay ?? 0;
273
- when = retryOptions.when;
274
- onRetry = retryOptions.onRetry;
275
- }
276
- if (!Number.isInteger(retries)) {
277
- throw new TypeError(`Retry count must be an integer but received ${retries}`);
278
- }
279
- if (retries < 0) {
280
- throw new Error(`Retry count must be non-negative but received ${retries}`);
281
- }
282
- if (typeof delay2 === "number") {
283
- if (delay2 < 0) {
284
- throw new Error(`Retry delay must be a non-negative number but received ${delay2}`);
285
- }
286
- } else {
287
- if (typeof delay2 !== "function") {
288
- throw new TypeError(`Retry delay must be a number or a function but received ${typeof delay2}`);
289
- }
290
- }
291
- if (when != null) {
292
- if (!Array.isArray(when) && typeof when !== "function") {
293
- throw new TypeError(`Retry when condition must be an array of status codes or a function but received ${typeof when}`);
294
- }
295
- }
296
- if (onRetry != null) {
297
- if (typeof onRetry !== "function") {
298
- throw new TypeError(`Retry onRetry callback must be a function but received ${typeof onRetry}`);
299
- }
300
- }
301
- return { retries, delay: delay2, when, onRetry };
446
+ const { responseType, timeout, retry: retryOptions = 0, onProgress, onChunk } = init;
447
+ if (responseType != null) {
448
+ const validTypes = [
449
+ "text",
450
+ "arraybuffer",
451
+ "blob",
452
+ "json",
453
+ "bytes",
454
+ "stream"
455
+ ];
456
+ if (!validTypes.includes(responseType)) throw new TypeError(`responseType must be one of ${validTypes.join(", ")} but received ${responseType}`);
457
+ }
458
+ if (timeout != null) {
459
+ if (typeof timeout !== "number") throw new TypeError(`timeout must be a number but received ${typeof timeout}`);
460
+ if (timeout <= 0) throw new Error(`timeout must be a number greater than 0 but received ${timeout}`);
461
+ }
462
+ if (onProgress != null) {
463
+ if (typeof onProgress !== "function") throw new TypeError(`onProgress callback must be a function but received ${typeof onProgress}`);
464
+ }
465
+ if (onChunk != null) {
466
+ if (typeof onChunk !== "function") throw new TypeError(`onChunk callback must be a function but received ${typeof onChunk}`);
467
+ }
468
+ let retries = 0;
469
+ let delay = 0;
470
+ let when;
471
+ let onRetry;
472
+ if (typeof retryOptions === "number") retries = retryOptions;
473
+ else if (retryOptions && typeof retryOptions === "object") {
474
+ retries = retryOptions.retries ?? 0;
475
+ delay = retryOptions.delay ?? 0;
476
+ when = retryOptions.when;
477
+ onRetry = retryOptions.onRetry;
478
+ }
479
+ if (!Number.isInteger(retries)) throw new TypeError(`Retry count must be an integer but received ${retries}`);
480
+ if (retries < 0) throw new Error(`Retry count must be non-negative but received ${retries}`);
481
+ if (typeof delay === "number") {
482
+ if (delay < 0) throw new Error(`Retry delay must be a non-negative number but received ${delay}`);
483
+ } else if (typeof delay !== "function") throw new TypeError(`Retry delay must be a number or a function but received ${typeof delay}`);
484
+ if (when != null) {
485
+ if (!Array.isArray(when) && typeof when !== "function") throw new TypeError(`Retry when condition must be an array of status codes or a function but received ${typeof when}`);
486
+ }
487
+ if (onRetry != null) {
488
+ if (typeof onRetry !== "function") throw new TypeError(`Retry onRetry callback must be a function but received ${typeof onRetry}`);
489
+ }
490
+ return {
491
+ retries,
492
+ delay,
493
+ when,
494
+ onRetry
495
+ };
302
496
  }
497
+ /**
498
+ * Validates and parses a URL string or URL object.
499
+ * In browser environments, relative URLs are resolved against `location.href`.
500
+ * In non-browser environments (Node/Deno/Bun), only absolute URLs are valid.
501
+ */
303
502
  function validateUrl(url) {
304
- if (url instanceof URL) {
305
- return url;
306
- }
307
- try {
308
- const base = typeof location !== "undefined" ? location.href : void 0;
309
- return new URL(url, base);
310
- } catch {
311
- throw new TypeError(`Invalid URL: ${url}`);
312
- }
503
+ if (url instanceof URL) return url;
504
+ try {
505
+ return new URL(url, typeof location !== "undefined" ? location.href : void 0);
506
+ } catch {
507
+ throw new TypeError(`Invalid URL: ${url}`);
508
+ }
313
509
  }
314
-
510
+ //#endregion
315
511
  export { ABORT_ERROR, FetchError, TIMEOUT_ERROR, fetchT };
316
- //# sourceMappingURL=main.mjs.map
512
+
513
+ //# sourceMappingURL=main.mjs.map