@microlink/mql 0.15.1 → 0.16.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/index.umd.js CHANGED
@@ -69,23 +69,53 @@
69
69
 
70
70
  dist.flattie = flattie;
71
71
 
72
- class HTTPError extends Error {
72
+ /**
73
+ Base class for all Ky-specific errors. `HTTPError`, `NetworkError`, `TimeoutError`, and `ForceRetryError` extend this class.
74
+ */
75
+ class KyError extends Error {
76
+ name = 'KyError';
77
+ get isKyError() {
78
+ return true;
79
+ }
80
+ }
81
+
82
+ /**
83
+ Error thrown when the response has a non-2xx status code and `throwHttpErrors` is enabled.
84
+
85
+ The error has a `response` property with the `Response` object, a `request` property with the `Request` object, an `options` property with the normalized options, and a `data` property with the pre-parsed response body. The response body is automatically consumed when populating `data`, so `response.json()` and other body methods will not work. Use `data` instead.
86
+ */
87
+ class HTTPError extends KyError {
88
+ name = 'HTTPError';
73
89
  response;
74
90
  request;
75
91
  options;
92
+ data;
76
93
  constructor(response, request, options) {
77
94
  const code = (response.status || response.status === 0) ? response.status : '';
78
95
  const title = response.statusText ?? '';
79
96
  const status = `${code} ${title}`.trim();
80
97
  const reason = status ? `status code ${status}` : 'an unknown error';
81
98
  super(`Request failed with ${reason}: ${request.method} ${request.url}`);
82
- this.name = 'HTTPError';
83
99
  this.response = response;
84
100
  this.request = request;
85
101
  this.options = options;
86
102
  }
87
103
  }
88
104
 
105
+ /**
106
+ Error thrown when a network error occurs during the request (e.g., DNS failure, connection refused, offline).
107
+
108
+ The error has a `request` property with the `Request` object. The original error is available via the standard `cause` property.
109
+ */
110
+ class NetworkError extends KyError {
111
+ name = 'NetworkError';
112
+ request;
113
+ constructor(request, options) {
114
+ super(`Request failed due to a network error: ${request.method} ${request.url}`, options);
115
+ this.request = request;
116
+ }
117
+ }
118
+
89
119
  /**
90
120
  Wrapper for non-Error values that were thrown.
91
121
 
@@ -114,10 +144,11 @@
114
144
  }
115
145
 
116
146
  /**
117
- Internal error used to signal a forced retry from afterResponse hooks.
118
- This is thrown when a user returns ky.retry() from an afterResponse hook.
147
+ Error used to signal a forced retry from `afterResponse` hooks.
148
+
149
+ This is thrown when `ky.retry()` is returned from an `afterResponse` hook. It is observable in `beforeRetry` and `beforeError` hooks via the `isForceRetryError()` type guard.
119
150
  */
120
- class ForceRetryError extends Error {
151
+ class ForceRetryError extends KyError {
121
152
  name = 'ForceRetryError';
122
153
  customDelay;
123
154
  code;
@@ -135,6 +166,51 @@
135
166
  }
136
167
  }
137
168
 
169
+ /**
170
+ Thrown when a response body fails validation against a user-provided Standard Schema.
171
+
172
+ This error intentionally does not extend `KyError` because it does not represent a failure in Ky's HTTP lifecycle. The request succeeded; the user's schema rejected the data. As such, it is not matched by `isKyError()`.
173
+
174
+ @example
175
+ ```
176
+ import ky, {SchemaValidationError} from 'ky';
177
+ import {z} from 'zod';
178
+
179
+ const userSchema = z.object({name: z.string()});
180
+
181
+ try {
182
+ const user = await ky('/api/user').json(userSchema);
183
+ console.log(user.name);
184
+ } catch (error) {
185
+ if (error instanceof SchemaValidationError) {
186
+ console.error(error.issues);
187
+ }
188
+ }
189
+ ```
190
+ */
191
+ class SchemaValidationError extends Error {
192
+ name = 'SchemaValidationError';
193
+ issues;
194
+ constructor(issues) {
195
+ super('Response schema validation failed');
196
+ this.issues = issues;
197
+ }
198
+ }
199
+
200
+ /**
201
+ Error thrown when the request times out.
202
+
203
+ The error has a `request` property with the `Request` object.
204
+ */
205
+ class TimeoutError extends KyError {
206
+ name = 'TimeoutError';
207
+ request;
208
+ constructor(request) {
209
+ super(`Request timed out: ${request.method} ${request.url}`);
210
+ this.request = request;
211
+ }
212
+ }
213
+
138
214
  const supportsRequestStreams = (() => {
139
215
  let duplexAccessed = false;
140
216
  let hasContentType = false;
@@ -179,11 +255,14 @@
179
255
  };
180
256
  // The maximum value of a 32bit int (see issue #117)
181
257
  const maxSafeTimeout = 2_147_483_647;
182
- // Size in bytes of a typical form boundary, used to help estimate upload size
183
- const usualFormBoundarySize = new TextEncoder().encode('------WebKitFormBoundaryaxpyiPgbbPti10Rw').length;
258
+ // Size in bytes of a typical form boundary (e.g., '------WebKitFormBoundaryaxpyiPgbbPti10Rw'), used to help estimate upload size
259
+ const usualFormBoundarySize = 40;
260
+ /**
261
+ Symbol that can be returned by a `beforeRetry` hook to stop retrying without throwing an error.
262
+ */
184
263
  const stop = Symbol('stop');
185
264
  /**
186
- Marker returned by ky.retry() to signal a forced retry from afterResponse hooks.
265
+ Marker returned by `ky.retry()` to signal a forced retry from `afterResponse` hooks.
187
266
  */
188
267
  class RetryMarker {
189
268
  options;
@@ -205,7 +284,7 @@
205
284
  const api = ky.extend({
206
285
  hooks: {
207
286
  afterResponse: [
208
- async (request, options, response) => {
287
+ async ({request, response}) => {
209
288
  // Retry based on response body content
210
289
  if (response.status === 200) {
211
290
  const data = await response.clone().json();
@@ -280,9 +359,11 @@
280
359
  parseJson: true,
281
360
  stringifyJson: true,
282
361
  searchParams: true,
283
- prefixUrl: true,
362
+ baseUrl: true,
363
+ prefix: true,
284
364
  retry: true,
285
365
  timeout: true,
366
+ totalTimeout: true,
286
367
  hooks: true,
287
368
  throwHttpErrors: true,
288
369
  onDownloadProgress: true,
@@ -318,7 +399,8 @@
318
399
  duplex: true,
319
400
  };
320
401
 
321
- // eslint-disable-next-line @typescript-eslint/ban-types
402
+ const encoder = new TextEncoder();
403
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types
322
404
  const getBodySize = (body) => {
323
405
  if (!body) {
324
406
  return 0;
@@ -328,9 +410,9 @@
328
410
  let size = 0;
329
411
  for (const [key, value] of body) {
330
412
  size += usualFormBoundarySize;
331
- size += new TextEncoder().encode(`Content-Disposition: form-data; name="${key}"`).length;
413
+ size += encoder.encode(`Content-Disposition: form-data; name="${key}"`).byteLength;
332
414
  size += typeof value === 'string'
333
- ? new TextEncoder().encode(value).length
415
+ ? encoder.encode(value).byteLength
334
416
  : value.size;
335
417
  }
336
418
  return size;
@@ -338,28 +420,16 @@
338
420
  if (body instanceof Blob) {
339
421
  return body.size;
340
422
  }
341
- if (body instanceof ArrayBuffer) {
423
+ if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) {
342
424
  return body.byteLength;
343
425
  }
344
426
  if (typeof body === 'string') {
345
- return new TextEncoder().encode(body).length;
427
+ return encoder.encode(body).byteLength;
346
428
  }
347
429
  if (body instanceof URLSearchParams) {
348
- return new TextEncoder().encode(body.toString()).length;
349
- }
350
- if ('byteLength' in body) {
351
- return (body).byteLength;
430
+ return encoder.encode(body.toString()).byteLength;
352
431
  }
353
- if (typeof body === 'object' && body !== null) {
354
- try {
355
- const jsonString = JSON.stringify(body);
356
- return new TextEncoder().encode(jsonString).length;
357
- }
358
- catch {
359
- return 0;
360
- }
361
- }
362
- return 0; // Default case, unable to determine size
432
+ return 0;
363
433
  };
364
434
  const withProgress = (stream, totalBytes, onProgress) => {
365
435
  let previousChunk;
@@ -392,21 +462,18 @@
392
462
  if (!response.body) {
393
463
  return response;
394
464
  }
395
- if (response.status === 204) {
396
- return new Response(null, {
397
- status: response.status,
398
- statusText: response.statusText,
399
- headers: response.headers,
400
- });
401
- }
402
- const totalBytes = Math.max(0, Number(response.headers.get('content-length')) || 0);
403
- return new Response(withProgress(response.body, totalBytes, onDownloadProgress), {
465
+ const responseInit = {
404
466
  status: response.status,
405
467
  statusText: response.statusText,
406
468
  headers: response.headers,
407
- });
469
+ };
470
+ if (response.status === 204) {
471
+ return new Response(null, responseInit);
472
+ }
473
+ const totalBytes = Math.max(0, Number(response.headers.get('content-length')) || 0);
474
+ return new Response(withProgress(response.body, totalBytes, onDownloadProgress), responseInit);
408
475
  };
409
- // eslint-disable-next-line @typescript-eslint/ban-types
476
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types
410
477
  const streamRequest = (request, onUploadProgress, originalBody) => {
411
478
  if (!request.body) {
412
479
  return request;
@@ -420,12 +487,46 @@
420
487
  });
421
488
  };
422
489
 
423
- // eslint-disable-next-line @typescript-eslint/ban-types
424
- const isObject$1 = (value) => value !== null && typeof value === 'object';
490
+ // eslint-disable-next-line @typescript-eslint/no-restricted-types
491
+ const isObject = (value) => value !== null && typeof value === 'object';
492
+
493
+ const replaceSymbol = Symbol('replaceOption');
494
+ const getReplaceState = (value) => isObject(value) && value[replaceSymbol] === true
495
+ ? {
496
+ isReplace: true,
497
+ value: value.value,
498
+ }
499
+ : {
500
+ isReplace: false,
501
+ value,
502
+ };
503
+ /**
504
+ Wraps a value so that `ky.extend()` will replace the parent value instead of merging with it.
505
+
506
+ By default, `.extend()` deep-merges options with the parent instance: hooks get appended, headers get merged, and search parameters get accumulated. Use `replaceOption` when you want to fully replace a merged property instead.
507
+
508
+ @example
509
+ ```
510
+ import ky, {replaceOption} from 'ky';
511
+
512
+ const base = ky.create({
513
+ hooks: {beforeRequest: [addAuth, addTracking]},
514
+ });
425
515
 
516
+ // Replaces instead of appending
517
+ const extended = base.extend({
518
+ hooks: replaceOption({beforeRequest: [onlyThis]}),
519
+ });
520
+ // hooks.beforeRequest is now [onlyThis], not [addAuth, addTracking, onlyThis]
521
+ ```
522
+ */
523
+ const replaceOption = (value) => {
524
+ const markedValue = { [replaceSymbol]: true, value };
525
+ return markedValue;
526
+ };
426
527
  const validateAndMerge = (...sources) => {
427
528
  for (const source of sources) {
428
- if ((!isObject$1(source) || Array.isArray(source)) && source !== undefined) {
529
+ if ((!isObject(source) || Array.isArray(source)) && source !== undefined) {
429
530
  throw new TypeError('The `options` argument must be an object');
430
531
  }
431
532
  }
@@ -445,19 +546,52 @@
445
546
  }
446
547
  return result;
447
548
  };
549
+ const isPlainObject = (value) => {
550
+ if (!isObject(value) || Array.isArray(value)) {
551
+ return false;
552
+ }
553
+ const prototype = Object.getPrototypeOf(value);
554
+ return prototype === Object.prototype || prototype === null;
555
+ };
556
+ const cloneShallow = (value) => {
557
+ if (value instanceof URLSearchParams) {
558
+ return new URLSearchParams(value);
559
+ }
560
+ if (value instanceof globalThis.Headers) {
561
+ return new globalThis.Headers(value);
562
+ }
563
+ if (Array.isArray(value)) {
564
+ return [...value];
565
+ }
566
+ if (isPlainObject(value)) {
567
+ const copy = { ...value };
568
+ return copy;
569
+ }
570
+ return value;
571
+ };
572
+ const normalizeHeaderObject = (headers) => Object.fromEntries(Object.entries(headers).filter((entry) => entry[1] !== undefined));
573
+ const mergeHeaderContainers = (source1, source2) => {
574
+ if (isPlainObject(source1) && isPlainObject(source2)) {
575
+ return normalizeHeaderObject({ ...source1, ...source2 });
576
+ }
577
+ return mergeHeaders(source1, source2);
578
+ };
448
579
  function newHookValue(original, incoming, property) {
449
580
  return (Object.hasOwn(incoming, property) && incoming[property] === undefined)
450
581
  ? []
451
582
  : deepMerge(original[property] ?? [], incoming[property] ?? []);
452
583
  }
453
584
  const mergeHooks = (original = {}, incoming = {}) => ({
585
+ init: newHookValue(original, incoming, 'init'),
454
586
  beforeRequest: newHookValue(original, incoming, 'beforeRequest'),
455
587
  beforeRetry: newHookValue(original, incoming, 'beforeRetry'),
456
- afterResponse: newHookValue(original, incoming, 'afterResponse'),
457
588
  beforeError: newHookValue(original, incoming, 'beforeError'),
589
+ afterResponse: newHookValue(original, incoming, 'afterResponse'),
458
590
  });
591
+ const deletedParametersSymbol = Symbol('deletedParameters');
459
592
  const appendSearchParameters = (target, source) => {
460
593
  const result = new URLSearchParams();
594
+ const deleted = new Set();
461
595
  for (const input of [target, source]) {
462
596
  if (input === undefined) {
463
597
  continue;
@@ -465,6 +599,14 @@
465
599
  if (input instanceof URLSearchParams) {
466
600
  for (const [key, value] of input.entries()) {
467
601
  result.append(key, value);
602
+ deleted.delete(key);
603
+ }
604
+ const inputDeleted = input[deletedParametersSymbol];
605
+ if (inputDeleted) {
606
+ for (const key of inputDeleted) {
607
+ result.delete(key);
608
+ deleted.add(key);
609
+ }
468
610
  }
469
611
  }
470
612
  else if (Array.isArray(input)) {
@@ -473,12 +615,18 @@
473
615
  throw new TypeError('Array search parameters must be provided in [[key, value], ...] format');
474
616
  }
475
617
  result.append(String(pair[0]), String(pair[1]));
618
+ deleted.delete(String(pair[0]));
476
619
  }
477
620
  }
478
- else if (isObject$1(input)) {
621
+ else if (isObject(input)) {
479
622
  for (const [key, value] of Object.entries(input)) {
480
- if (value !== undefined) {
623
+ if (value === undefined) {
624
+ result.delete(key);
625
+ deleted.add(key);
626
+ }
627
+ else {
481
628
  result.append(key, String(value));
629
+ deleted.delete(key);
482
630
  }
483
631
  }
484
632
  }
@@ -487,9 +635,13 @@
487
635
  const parameters = new URLSearchParams(input);
488
636
  for (const [key, value] of parameters.entries()) {
489
637
  result.append(key, value);
638
+ deleted.delete(key);
490
639
  }
491
640
  }
492
641
  }
642
+ if (deleted.size > 0) {
643
+ result[deletedParametersSymbol] = deleted;
644
+ }
493
645
  return result;
494
646
  };
495
647
  // TODO: Make this strongly-typed (no `any`).
@@ -506,16 +658,19 @@
506
658
  }
507
659
  returnValue = [...returnValue, ...source];
508
660
  }
509
- else if (isObject$1(source)) {
661
+ else if (isObject(source)) {
510
662
  for (let [key, value] of Object.entries(source)) {
511
663
  // Special handling for AbortSignal instances
512
664
  if (key === 'signal' && value instanceof globalThis.AbortSignal) {
513
665
  signals.push(value);
514
666
  continue;
515
667
  }
668
+ const replaceState = getReplaceState(value);
669
+ const { isReplace } = replaceState;
670
+ value = replaceState.value;
516
671
  // Special handling for context - shallow merge only
517
672
  if (key === 'context') {
518
- if (value !== undefined && value !== null && (!isObject$1(value) || Array.isArray(value))) {
673
+ if (value !== undefined && value !== null && (!isObject(value) || Array.isArray(value))) {
519
674
  throw new TypeError('The `context` option must be an object');
520
675
  }
521
676
  // Shallow merge: always create a new object to prevent mutation bugs
@@ -523,7 +678,9 @@
523
678
  ...returnValue,
524
679
  context: (value === undefined || value === null)
525
680
  ? {}
526
- : { ...returnValue.context, ...value },
681
+ : (isReplace
682
+ ? { ...value }
683
+ : { ...returnValue.context, ...value }),
527
684
  };
528
685
  continue;
529
686
  }
@@ -533,6 +690,9 @@
533
690
  // Explicit undefined or null removes searchParams
534
691
  searchParameters = undefined;
535
692
  }
693
+ else if (isReplace) {
694
+ searchParameters = value;
695
+ }
536
696
  else {
537
697
  // First source: keep as-is to preserve type (string/object/URLSearchParams)
538
698
  // Subsequent sources: merge and convert to URLSearchParams
@@ -540,17 +700,23 @@
540
700
  }
541
701
  continue;
542
702
  }
543
- if (isObject$1(value) && key in returnValue) {
703
+ if (isObject(value) && !isReplace && key in returnValue) {
544
704
  value = deepMerge(returnValue[key], value);
545
705
  }
546
706
  returnValue = { ...returnValue, [key]: value };
547
707
  }
548
- if (isObject$1(source.hooks)) {
549
- hooks = mergeHooks(hooks, source.hooks);
708
+ if (isObject(source.hooks)) {
709
+ const { value: hookValue, isReplace } = getReplaceState(source.hooks);
710
+ hooks = isReplace
711
+ ? mergeHooks({}, hookValue)
712
+ : mergeHooks(hooks, hookValue);
550
713
  returnValue.hooks = hooks;
551
714
  }
552
- if (isObject$1(source.headers)) {
553
- headers = mergeHeaders(headers, source.headers);
715
+ if (isObject(source.headers)) {
716
+ const { value: headerValue, isReplace } = getReplaceState(source.headers);
717
+ headers = isReplace
718
+ ? cloneShallow(headerValue)
719
+ : mergeHeaderContainers(headers, headerValue);
554
720
  returnValue.headers = headers;
555
721
  }
556
722
  }
@@ -611,15 +777,6 @@
611
777
  };
612
778
  };
613
779
 
614
- class TimeoutError extends Error {
615
- request;
616
- constructor(request) {
617
- super(`Request timed out: ${request.method} ${request.url}`);
618
- this.name = 'TimeoutError';
619
- this.request = request;
620
- }
621
- }
622
-
623
780
  // `Promise.race()` workaround (#91)
624
781
  async function timeout(request, init, abortController, options) {
625
782
  return new Promise((resolve, reject) => {
@@ -685,7 +842,7 @@
685
842
  return search.length > 0;
686
843
  }
687
844
  if (search instanceof URLSearchParams) {
688
- return search.size > 0;
845
+ return search.size > 0 || Boolean(search[deletedParametersSymbol]?.size);
689
846
  }
690
847
  // Record
691
848
  if (typeof search === 'object') {
@@ -697,8 +854,52 @@
697
854
  return Boolean(search);
698
855
  };
699
856
 
857
+ // Inlined from https://github.com/sindresorhus/is-network-error v1.3.1
858
+ const objectToString = Object.prototype.toString;
859
+ const isError = (value) => objectToString.call(value) === '[object Error]';
860
+ const errorMessages = new Set([
861
+ 'network error', // Chrome
862
+ 'NetworkError when attempting to fetch resource.', // Firefox
863
+ 'The Internet connection appears to be offline.', // Safari 16
864
+ 'Network request failed', // `cross-fetch`
865
+ 'fetch failed', // Undici (Node.js)
866
+ 'terminated', // Undici (Node.js)
867
+ ' A network error occurred.', // Bun (WebKit) - leading space is intentional
868
+ 'Network connection lost', // Cloudflare Workers (fetch)
869
+ ]);
870
+ function isRawNetworkError(error) {
871
+ const isValid = error
872
+ && isError(error)
873
+ && error.name === 'TypeError'
874
+ && typeof error.message === 'string';
875
+ if (!isValid) {
876
+ return false;
877
+ }
878
+ const { message, stack } = error;
879
+ // Safari 17+ has generic message but no stack for network errors
880
+ if (message === 'Load failed') {
881
+ return stack === undefined
882
+ // Sentry adds its own stack trace to the fetch error, so also check for that
883
+ || '__sentry_captured__' in error;
884
+ }
885
+ // Deno network errors start with specific text
886
+ if (message.startsWith('error sending request for url')) {
887
+ return true;
888
+ }
889
+ // Chrome: exact "Failed to fetch" or with hostname: "Failed to fetch (example.com)"
890
+ if (message === 'Failed to fetch' || (message.startsWith('Failed to fetch (') && message.endsWith(')'))) {
891
+ return true;
892
+ }
893
+ // Standard network error messages
894
+ return errorMessages.has(message);
895
+ }
896
+
897
+ // Handles cross-realm cases (e.g., iframes, different JS contexts) where `instanceof` fails.
898
+ const isErrorType = (error, cls) => error instanceof cls || error?.name === cls.name;
700
899
  /**
701
- Type guard to check if an error is a Ky error.
900
+ Type guard to check if an error is a `KyError`.
901
+
902
+ Note: `SchemaValidationError` is intentionally not considered a Ky error. `KyError` covers failures in Ky's HTTP lifecycle (bad status, timeout, retry), while schema validation errors originate from the user-provided schema, not from Ky itself.
702
903
 
703
904
  @param error - The error to check
704
905
  @returns `true` if the error is a Ky error, `false` otherwise
@@ -720,13 +921,13 @@
720
921
  ```
721
922
  */
722
923
  function isKyError(error) {
723
- return isHTTPError(error) || isTimeoutError(error) || isForceRetryError(error);
924
+ return error?.isKyError === true || isHTTPError(error) || isNetworkError(error) || isTimeoutError(error) || isForceRetryError(error);
724
925
  }
725
926
  /**
726
- Type guard to check if an error is an HTTPError.
927
+ Type guard to check if an error is an `HTTPError`.
727
928
 
728
929
  @param error - The error to check
729
- @returns `true` if the error is an HTTPError, `false` otherwise
930
+ @returns `true` if the error is an `HTTPError`, `false` otherwise
730
931
 
731
932
  @example
732
933
  ```
@@ -741,13 +942,34 @@
741
942
  ```
742
943
  */
743
944
  function isHTTPError(error) {
744
- return error instanceof HTTPError || (error?.name === HTTPError.name);
945
+ return isErrorType(error, HTTPError);
745
946
  }
746
947
  /**
747
- Type guard to check if an error is a TimeoutError.
948
+ Type guard to check if an error is a `NetworkError`.
748
949
 
749
950
  @param error - The error to check
750
- @returns `true` if the error is a TimeoutError, `false` otherwise
951
+ @returns `true` if the error is a `NetworkError`, `false` otherwise
952
+
953
+ @example
954
+ ```
955
+ import ky, {isNetworkError} from 'ky';
956
+ try {
957
+ const response = await ky.get('/api/data');
958
+ } catch (error) {
959
+ if (isNetworkError(error)) {
960
+ console.log('Network error:', error.request.url);
961
+ }
962
+ }
963
+ ```
964
+ */
965
+ function isNetworkError(error) {
966
+ return isErrorType(error, NetworkError);
967
+ }
968
+ /**
969
+ Type guard to check if an error is a `TimeoutError`.
970
+
971
+ @param error - The error to check
972
+ @returns `true` if the error is a `TimeoutError`, `false` otherwise
751
973
 
752
974
  @example
753
975
  ```
@@ -762,13 +984,13 @@
762
984
  ```
763
985
  */
764
986
  function isTimeoutError(error) {
765
- return error instanceof TimeoutError || (error?.name === TimeoutError.name);
987
+ return isErrorType(error, TimeoutError);
766
988
  }
767
989
  /**
768
- Type guard to check if an error is a ForceRetryError.
990
+ Type guard to check if an error is a `ForceRetryError`.
769
991
 
770
992
  @param error - The error to check
771
- @returns `true` if the error is a ForceRetryError, `false` otherwise
993
+ @returns `true` if the error is a `ForceRetryError`, `false` otherwise
772
994
 
773
995
  @example
774
996
  ```
@@ -788,65 +1010,120 @@
788
1010
  ```
789
1011
  */
790
1012
  function isForceRetryError(error) {
791
- return error instanceof ForceRetryError || (error?.name === ForceRetryError.name);
1013
+ return isErrorType(error, ForceRetryError);
792
1014
  }
793
1015
 
1016
+ const maxErrorResponseBodySize = 10 * 1024 * 1024;
1017
+ const prefixUrlRenamedErrorMessage = 'The `prefixUrl` option has been renamed `prefix` in v2 and enhanced to allow slashes in input. See also the new `baseUrl` option for improved flexibility with standard URL resolution: https://github.com/sindresorhus/ky#baseurl';
1018
+ const timedOutResponseData = Symbol('timedOutResponseData');
1019
+ const createTextDecoder = (contentType) => {
1020
+ const match = /;\s*charset\s*=\s*(?:"([^"]+)"|([^;,\s]+))/i.exec(contentType);
1021
+ const charset = match?.[1] ?? match?.[2];
1022
+ if (charset) {
1023
+ try {
1024
+ return new TextDecoder(charset);
1025
+ }
1026
+ catch { }
1027
+ }
1028
+ return new TextDecoder();
1029
+ };
1030
+ const invalidSchemaMessage = 'The `schema` argument must follow the Standard Schema specification';
1031
+ // Shallow-clone mutable option properties so init hook mutations don't leak across requests.
1032
+ function cloneInitHookOptions(options) {
1033
+ return {
1034
+ ...options,
1035
+ json: cloneShallow(options.json),
1036
+ retry: cloneShallow(options.retry),
1037
+ context: cloneShallow(options.context),
1038
+ headers: cloneShallow(options.headers),
1039
+ searchParams: cloneShallow(options.searchParams),
1040
+ };
1041
+ }
1042
+ const validateJsonWithSchema = async (jsonValue, schema) => {
1043
+ if ((typeof schema !== 'object'
1044
+ && typeof schema !== 'function')
1045
+ || schema === null) {
1046
+ throw new TypeError(invalidSchemaMessage);
1047
+ }
1048
+ const standardSchema = schema['~standard'];
1049
+ if (typeof standardSchema !== 'object'
1050
+ || standardSchema === null
1051
+ || typeof standardSchema.validate !== 'function') {
1052
+ throw new TypeError(invalidSchemaMessage);
1053
+ }
1054
+ const validationResult = await standardSchema.validate(jsonValue);
1055
+ if (validationResult.issues) {
1056
+ throw new SchemaValidationError(validationResult.issues);
1057
+ }
1058
+ return validationResult.value;
1059
+ };
794
1060
  class Ky {
795
1061
  static create(input, options) {
796
- const ky = new Ky(input, options);
1062
+ const initHooks = options.hooks?.init ?? [];
1063
+ const initHookOptions = initHooks.length > 0 ? cloneInitHookOptions(options) : options;
1064
+ for (const hook of initHooks) {
1065
+ hook(initHookOptions);
1066
+ }
1067
+ const ky = new Ky(input, initHookOptions);
797
1068
  const function_ = async () => {
798
1069
  if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) {
799
1070
  throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
800
1071
  }
1072
+ if (typeof ky.#options.totalTimeout === 'number' && ky.#options.totalTimeout > maxSafeTimeout) {
1073
+ throw new RangeError(`The \`totalTimeout\` option cannot be greater than ${maxSafeTimeout}`);
1074
+ }
801
1075
  // Delay the fetch so that body method shortcuts can set the Accept header
802
1076
  await Promise.resolve();
803
- // Before using ky.request, _fetch clones it and saves the clone for future retries to use.
804
- // If retry is not needed, close the cloned request's ReadableStream for memory safety.
805
- let response = await ky.#fetch();
806
- for (const hook of ky.#options.hooks.afterResponse) {
807
- // Clone the response before passing to hook so we can cancel it if needed
808
- const clonedResponse = ky.#decorateResponse(response.clone());
809
- let modifiedResponse;
1077
+ const beforeRequestResponse = await ky.#runBeforeRequestHooks();
1078
+ let response = beforeRequestResponse ?? await ky.#retry(async () => ky.#fetch());
1079
+ let responseFromHook = beforeRequestResponse !== undefined
1080
+ || ky.#consumeReturnedResponseFromBeforeRetryHook();
1081
+ if (!(response instanceof globalThis.Response)) {
1082
+ return response;
1083
+ }
1084
+ for (;;) {
810
1085
  try {
811
1086
  // eslint-disable-next-line no-await-in-loop
812
- modifiedResponse = await hook(ky.request, ky.#getNormalizedOptions(), clonedResponse, { retryCount: ky.#retryCount });
1087
+ response = await ky.#runAfterResponseHooks(response);
813
1088
  }
814
1089
  catch (error) {
815
- // Cancel both responses to prevent memory leaks when hook throws
816
- ky.#cancelResponseBody(clonedResponse);
817
- ky.#cancelResponseBody(response);
818
- throw error;
819
- }
820
- if (modifiedResponse instanceof RetryMarker) {
821
- // Cancel both the cloned response passed to the hook and the current response to prevent resource leaks (especially important in Deno/Bun).
822
- // Do not await cancellation since hooks can clone the response, leaving extra tee branches that keep cancel promises pending per the Streams spec.
823
- ky.#cancelResponseBody(clonedResponse);
824
- ky.#cancelResponseBody(response);
825
- throw new ForceRetryError(modifiedResponse.options);
826
- }
827
- // Determine which response to use going forward
828
- const nextResponse = modifiedResponse instanceof globalThis.Response ? modifiedResponse : response;
829
- // Cancel any response bodies we won't use to prevent memory leaks.
830
- // Uses fire-and-forget since hooks may have cloned the response, creating tee branches that block cancellation.
831
- if (clonedResponse !== nextResponse) {
832
- ky.#cancelResponseBody(clonedResponse);
833
- }
834
- if (response !== nextResponse) {
835
- ky.#cancelResponseBody(response);
1090
+ if (!(error instanceof ForceRetryError)) {
1091
+ throw error;
1092
+ }
1093
+ // eslint-disable-next-line no-await-in-loop
1094
+ const retriedResponse = await ky.#retryFromError(error, async () => ky.#fetch());
1095
+ if (!(retriedResponse instanceof globalThis.Response)) {
1096
+ return retriedResponse;
1097
+ }
1098
+ response = retriedResponse;
1099
+ responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook();
1100
+ continue;
836
1101
  }
837
- response = nextResponse;
838
- }
839
- ky.#decorateResponse(response);
840
- if (!response.ok && (typeof ky.#options.throwHttpErrors === 'function'
841
- ? ky.#options.throwHttpErrors(response.status)
842
- : ky.#options.throwHttpErrors)) {
843
- let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions());
844
- for (const hook of ky.#options.hooks.beforeError) {
1102
+ // Opaque responses (`response.type === 'opaque'`) from `no-cors` requests always have `status: 0` and `ok: false`, but this is not a failure - the actual status is hidden by the browser.
1103
+ if (!response.ok && response.type !== 'opaque' && (typeof ky.#options.throwHttpErrors === 'function'
1104
+ ? ky.#options.throwHttpErrors(response.status)
1105
+ : ky.#options.throwHttpErrors)) {
1106
+ // `request` must reflect the request that actually failed, but `options` stays as Ky's
1107
+ // normalized options snapshot. Replacement `Request` instances do not preserve the
1108
+ // original `BodyInit`, so trying to make `options` mirror arbitrary requests would be lossy.
1109
+ const error = new HTTPError(response, ky.#getResponseRequest(response), ky.#getNormalizedOptions());
1110
+ // eslint-disable-next-line no-await-in-loop
1111
+ error.data = await ky.#getResponseData(response);
1112
+ if (responseFromHook) {
1113
+ throw error;
1114
+ }
845
1115
  // eslint-disable-next-line no-await-in-loop
846
- error = await hook(error, { retryCount: ky.#retryCount });
1116
+ const retriedResponse = await ky.#retryFromError(error, async () => ky.#fetch());
1117
+ if (!(retriedResponse instanceof globalThis.Response)) {
1118
+ return retriedResponse;
1119
+ }
1120
+ response = retriedResponse;
1121
+ responseFromHook = ky.#consumeReturnedResponseFromBeforeRetryHook();
1122
+ continue;
847
1123
  }
848
- throw error;
1124
+ break;
849
1125
  }
1126
+ ky.#decorateResponse(response);
850
1127
  // If `onDownloadProgress` is passed, it uses the stream API internally
851
1128
  if (ky.#options.onDownloadProgress) {
852
1129
  if (typeof ky.#options.onDownloadProgress !== 'function') {
@@ -861,39 +1138,71 @@
861
1138
  }
862
1139
  return response;
863
1140
  };
864
- // Always wrap in #retry to catch forced retries from afterResponse hooks
865
- // Method retriability is checked in #calculateRetryDelay for non-forced retries
866
- const result = ky.#retry(function_)
867
- .finally(() => {
868
- const originalRequest = ky.#originalRequest;
869
- // Ignore cancellation errors from already-locked or already-consumed streams.
870
- ky.#cancelBody(originalRequest?.body ?? undefined);
871
- ky.#cancelBody(ky.request.body ?? undefined);
872
- });
1141
+ const result = (async () => {
1142
+ try {
1143
+ return await function_();
1144
+ }
1145
+ catch (error) {
1146
+ // Non-Error throws (e.g., thrown strings) pass through unchanged
1147
+ if (!(error instanceof Error)) {
1148
+ throw error;
1149
+ }
1150
+ // Errors thrown by beforeRetry hooks must propagate unchanged.
1151
+ if (ky.#beforeRetryHookErrors.has(error)) {
1152
+ throw error;
1153
+ }
1154
+ let processedError = error;
1155
+ for (const hook of ky.#options.hooks.beforeError) {
1156
+ // `request` is the current failing request. `options` intentionally remains the
1157
+ // stable normalized Ky options snapshot for the same reason as `HTTPError` above.
1158
+ // eslint-disable-next-line no-await-in-loop
1159
+ const hookResult = await hook({
1160
+ request: ky.request,
1161
+ options: ky.#getNormalizedOptions(),
1162
+ error: processedError,
1163
+ retryCount: ky.#retryCount,
1164
+ });
1165
+ // Only overwrite if the hook returns a valid Error instance.
1166
+ if (hookResult instanceof Error) {
1167
+ processedError = hookResult;
1168
+ }
1169
+ }
1170
+ throw processedError;
1171
+ }
1172
+ finally {
1173
+ const originalRequest = ky.#originalRequest;
1174
+ // Ignore cancellation errors from already-locked or already-consumed streams.
1175
+ ky.#cancelBody(originalRequest?.body ?? undefined);
1176
+ // Only cancel the current request body if it's distinct from the original (i.e. it was cloned for retries).
1177
+ if (ky.request !== originalRequest) {
1178
+ ky.#cancelBody(ky.request.body ?? undefined);
1179
+ }
1180
+ }
1181
+ })();
873
1182
  for (const [type, mimeType] of Object.entries(responseTypes)) {
874
1183
  // Only expose `.bytes()` when the environment implements it.
875
1184
  if (type === 'bytes'
876
1185
  && typeof globalThis.Response?.prototype?.bytes !== 'function') {
877
1186
  continue;
878
1187
  }
879
- result[type] = async () => {
1188
+ result[type] = async (schema) => {
880
1189
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
881
1190
  ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType);
882
1191
  const response = await result;
883
- if (type === 'json') {
884
- if (response.status === 204) {
885
- return '';
886
- }
887
- const text = await response.text();
888
- if (text === '') {
889
- return '';
890
- }
891
- if (options.parseJson) {
892
- return options.parseJson(text);
1192
+ if (type !== 'json') {
1193
+ return response[type]();
1194
+ }
1195
+ const text = await response.text();
1196
+ if (text === '') {
1197
+ if (schema !== undefined) {
1198
+ return validateJsonWithSchema(undefined, schema);
893
1199
  }
894
1200
  return JSON.parse(text);
895
1201
  }
896
- return response[type]();
1202
+ const jsonValue = initHookOptions.parseJson
1203
+ ? await initHookOptions.parseJson(text, { request: ky.#getResponseRequest(response), response })
1204
+ : JSON.parse(text);
1205
+ return schema === undefined ? jsonValue : validateJsonWithSchema(jsonValue, schema);
897
1206
  };
898
1207
  }
899
1208
  return result;
@@ -914,44 +1223,55 @@
914
1223
  #options;
915
1224
  #originalRequest;
916
1225
  #userProvidedAbortSignal;
1226
+ #beforeRetryHookErrors = new WeakSet();
917
1227
  #cachedNormalizedOptions;
1228
+ #startTime;
1229
+ #returnedResponseFromBeforeRetryHook = false;
1230
+ #responseRequests = new WeakMap();
918
1231
  // eslint-disable-next-line complexity
919
1232
  constructor(input, options = {}) {
920
1233
  this.#input = input;
1234
+ if (Object.hasOwn(options, 'prefixUrl')) {
1235
+ throw new Error(prefixUrlRenamedErrorMessage);
1236
+ }
921
1237
  this.#options = {
922
1238
  ...options,
923
1239
  headers: mergeHeaders(this.#input.headers, options.headers),
924
- hooks: mergeHooks({
925
- beforeRequest: [],
926
- beforeRetry: [],
927
- beforeError: [],
928
- afterResponse: [],
929
- }, options.hooks),
1240
+ hooks: mergeHooks({}, options.hooks),
930
1241
  method: normalizeRequestMethod(options.method ?? this.#input.method ?? 'GET'),
931
1242
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
932
- prefixUrl: String(options.prefixUrl || ''),
1243
+ prefix: String(options.prefix || ''),
933
1244
  retry: normalizeRetryOptions(options.retry),
934
1245
  throwHttpErrors: options.throwHttpErrors ?? true,
935
1246
  timeout: options.timeout ?? 10_000,
1247
+ totalTimeout: options.totalTimeout ?? false,
936
1248
  fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
937
1249
  context: options.context ?? {},
938
1250
  };
939
1251
  if (typeof this.#input !== 'string' && !(this.#input instanceof URL || this.#input instanceof globalThis.Request)) {
940
1252
  throw new TypeError('`input` must be a string, URL, or Request');
941
1253
  }
942
- if (this.#options.prefixUrl && typeof this.#input === 'string') {
943
- if (this.#input.startsWith('/')) {
944
- throw new Error('`input` must not begin with a slash when using `prefixUrl`');
1254
+ if (typeof this.#input === 'string') {
1255
+ if (this.#options.prefix) {
1256
+ const normalizedPrefix = this.#options.prefix.replace(/\/+$/, '');
1257
+ const normalizedInput = this.#input.replace(/^\/+/, '');
1258
+ this.#input = `${normalizedPrefix}/${normalizedInput}`;
945
1259
  }
946
- if (!this.#options.prefixUrl.endsWith('/')) {
947
- this.#options.prefixUrl += '/';
1260
+ if (this.#options.baseUrl) {
1261
+ let absoluteInput;
1262
+ try {
1263
+ absoluteInput = new URL(this.#input);
1264
+ }
1265
+ catch { }
1266
+ if (!absoluteInput) {
1267
+ this.#input = new URL(this.#input, (new Request(this.#options.baseUrl)).url);
1268
+ }
948
1269
  }
949
- this.#input = this.#options.prefixUrl + this.#input;
950
1270
  }
951
1271
  if (supportsAbortController && supportsAbortSignal) {
952
1272
  this.#userProvidedAbortSignal = this.#options.signal ?? this.#input.signal;
953
1273
  this.#abortController = new globalThis.AbortController();
954
- this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal;
1274
+ this.#options.signal = this.#createManagedSignal();
955
1275
  }
956
1276
  if (supportsRequestStreams) {
957
1277
  // @ts-expect-error - Types are outdated.
@@ -971,29 +1291,45 @@
971
1291
  }
972
1292
  this.request = new globalThis.Request(this.#input, this.#options);
973
1293
  if (hasSearchParameters(this.#options.searchParams)) {
974
- // eslint-disable-next-line unicorn/prevent-abbreviations
975
- const textSearchParams = typeof this.#options.searchParams === 'string'
976
- ? this.#options.searchParams.replace(/^\?/, '')
977
- : new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams)).toString();
978
- // eslint-disable-next-line unicorn/prevent-abbreviations
979
- const searchParams = '?' + textSearchParams;
980
- const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams);
1294
+ const url = new URL(this.request.url);
1295
+ if (typeof this.#options.searchParams === 'string') {
1296
+ const stringSearchParameters = this.#options.searchParams.replace(/^\?/, '');
1297
+ if (stringSearchParameters !== '') {
1298
+ url.search = url.search ? `${url.search}&${stringSearchParameters}` : `?${stringSearchParameters}`;
1299
+ }
1300
+ }
1301
+ else {
1302
+ const optionsSearchParameters = new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams));
1303
+ for (const [key, value] of optionsSearchParameters.entries()) {
1304
+ url.searchParams.append(key, value);
1305
+ }
1306
+ }
1307
+ if (this.#options.searchParams
1308
+ && typeof this.#options.searchParams === 'object'
1309
+ && !Array.isArray(this.#options.searchParams)
1310
+ && !(this.#options.searchParams instanceof URLSearchParams)) {
1311
+ for (const [key, value] of Object.entries(this.#options.searchParams)) {
1312
+ if (value === undefined) {
1313
+ url.searchParams.delete(key);
1314
+ }
1315
+ }
1316
+ }
1317
+ const deleted = this.#options.searchParams?.[deletedParametersSymbol];
1318
+ if (deleted) {
1319
+ for (const key of deleted) {
1320
+ url.searchParams.delete(key);
1321
+ }
1322
+ }
981
1323
  // Recreate request with the updated URL. We already have all options in this.#options, including duplex.
982
1324
  this.request = new globalThis.Request(url, this.#options);
983
1325
  }
984
- // If `onUploadProgress` is passed, it uses the stream API internally
985
- if (this.#options.onUploadProgress) {
986
- if (typeof this.#options.onUploadProgress !== 'function') {
987
- throw new TypeError('The `onUploadProgress` option must be a function');
988
- }
989
- if (!supportsRequestStreams) {
990
- throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
991
- }
992
- this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
1326
+ if (this.#options.onUploadProgress && typeof this.#options.onUploadProgress !== 'function') {
1327
+ throw new TypeError('The `onUploadProgress` option must be a function');
993
1328
  }
1329
+ this.#startTime = typeof this.#options.totalTimeout === 'number' ? this.#getCurrentTime() : undefined;
994
1330
  }
995
1331
  #calculateDelay() {
996
- const retryDelay = this.#options.retry.delay(this.#retryCount);
1332
+ const retryDelay = this.#options.retry.delay(this.#retryCount + 1);
997
1333
  let jitteredDelay = retryDelay;
998
1334
  if (this.#options.retry.jitter === true) {
999
1335
  jitteredDelay = Math.random() * retryDelay;
@@ -1004,13 +1340,10 @@
1004
1340
  jitteredDelay = retryDelay;
1005
1341
  }
1006
1342
  }
1007
- // Handle undefined backoffLimit by treating it as no limit (Infinity)
1008
- const backoffLimit = this.#options.retry.backoffLimit ?? Number.POSITIVE_INFINITY;
1009
- return Math.min(backoffLimit, jitteredDelay);
1343
+ return Math.min(this.#options.retry.backoffLimit, jitteredDelay);
1010
1344
  }
1011
1345
  async #calculateRetryDelay(error) {
1012
- this.#retryCount++;
1013
- if (this.#retryCount > this.#options.retry.limit) {
1346
+ if (this.#retryCount >= this.#options.retry.limit) {
1014
1347
  throw error;
1015
1348
  }
1016
1349
  // Wrap non-Error throws to ensure consistent error handling
@@ -1023,9 +1356,9 @@
1023
1356
  if (!this.#options.retry.methods.includes(this.request.method.toLowerCase())) {
1024
1357
  throw error;
1025
1358
  }
1026
- // User-provided shouldRetry function takes precedence over all other checks
1359
+ // User-provided shouldRetry function takes precedence over default checks (retryOnTimeout, status codes, etc.)
1027
1360
  if (this.#options.retry.shouldRetry !== undefined) {
1028
- const result = await this.#options.retry.shouldRetry({ error: errorObject, retryCount: this.#retryCount });
1361
+ const result = await this.#options.retry.shouldRetry({ error: errorObject, retryCount: this.#retryCount + 1 });
1029
1362
  // Strict boolean checking - only exact true/false are handled specially
1030
1363
  if (result === false) {
1031
1364
  throw error;
@@ -1037,8 +1370,11 @@
1037
1370
  // If undefined or any other value, fall through to default behavior
1038
1371
  }
1039
1372
  // Default timeout behavior
1040
- if (isTimeoutError(error) && !this.#options.retry.retryOnTimeout) {
1041
- throw error;
1373
+ if (isTimeoutError(error)) {
1374
+ if (!this.#options.retry.retryOnTimeout) {
1375
+ throw error;
1376
+ }
1377
+ return this.#calculateDelay();
1042
1378
  }
1043
1379
  if (isHTTPError(error)) {
1044
1380
  if (!this.#options.retry.statusCodes.includes(error.response.status)) {
@@ -1058,22 +1394,152 @@
1058
1394
  // A large number is treated as a timestamp (fixed threshold protects against clock skew)
1059
1395
  after -= Date.now();
1060
1396
  }
1061
- const max = this.#options.retry.maxRetryAfter ?? after;
1397
+ if (!Number.isFinite(after)) {
1398
+ return Math.min(this.#options.retry.maxRetryAfter, this.#calculateDelay());
1399
+ }
1400
+ after = Math.max(0, after);
1062
1401
  // Don't apply jitter when server provides explicit retry timing
1063
- return after < max ? after : max;
1402
+ return Math.min(this.#options.retry.maxRetryAfter, after);
1064
1403
  }
1065
1404
  if (error.response.status === 413) {
1066
1405
  throw error;
1067
1406
  }
1407
+ return this.#calculateDelay();
1408
+ }
1409
+ // Only retry known retriable error types. Unknown errors (e.g., programming bugs) are not retried.
1410
+ if (!isNetworkError(error)) {
1411
+ throw error;
1068
1412
  }
1069
1413
  return this.#calculateDelay();
1070
1414
  }
1071
1415
  #decorateResponse(response) {
1416
+ const request = this.#getResponseRequest(response);
1072
1417
  if (this.#options.parseJson) {
1073
- response.json = async () => this.#options.parseJson(await response.text());
1418
+ response.json = async () => {
1419
+ const text = await response.text();
1420
+ if (text === '') {
1421
+ return JSON.parse(text);
1422
+ }
1423
+ return this.#options.parseJson(text, { request, response });
1424
+ };
1074
1425
  }
1075
1426
  return response;
1076
1427
  }
1428
+ async #getResponseData(response) {
1429
+ // Even with request timeouts disabled, bound error-body reads so retries and error propagation
1430
+ // cannot be stalled indefinitely by never-ending response streams.
1431
+ const text = await this.#readResponseText(response, this.#getErrorDataTimeout());
1432
+ if (text === timedOutResponseData) {
1433
+ this.#throwIfTotalTimeoutExhausted();
1434
+ return undefined;
1435
+ }
1436
+ if (!text) {
1437
+ return undefined;
1438
+ }
1439
+ if (!this.#isJsonContentType(response.headers.get('content-type') ?? '')) {
1440
+ return text;
1441
+ }
1442
+ const data = await this.#parseJson(text, response, this.#getErrorDataTimeout(), this.#getResponseRequest(response));
1443
+ if (data === timedOutResponseData) {
1444
+ this.#throwIfTotalTimeoutExhausted();
1445
+ return undefined;
1446
+ }
1447
+ return data;
1448
+ }
1449
+ #getErrorDataTimeout() {
1450
+ const errorDataTimeout = this.#options.timeout === false ? 10_000 : this.#options.timeout;
1451
+ const remainingTotal = this.#getRemainingTotalTimeout();
1452
+ if (remainingTotal === undefined) {
1453
+ return errorDataTimeout;
1454
+ }
1455
+ if (remainingTotal <= 0) {
1456
+ throw new TimeoutError(this.request);
1457
+ }
1458
+ return Math.min(errorDataTimeout, remainingTotal);
1459
+ }
1460
+ #isJsonContentType(contentType) {
1461
+ // Match JSON subtypes like `json`, `problem+json`, and `vnd.api+json`.
1462
+ const mimeType = (contentType.split(';', 1)[0] ?? '').trim().toLowerCase();
1463
+ return /\/(?:.*[.+-])?json$/.test(mimeType);
1464
+ }
1465
+ async #readResponseText(response, timeoutMs) {
1466
+ const { body } = response;
1467
+ if (!body) {
1468
+ try {
1469
+ return await response.text();
1470
+ }
1471
+ catch {
1472
+ return undefined;
1473
+ }
1474
+ }
1475
+ let reader;
1476
+ try {
1477
+ reader = body.getReader();
1478
+ }
1479
+ catch {
1480
+ // Another consumer already locked the stream.
1481
+ return undefined;
1482
+ }
1483
+ const decoder = createTextDecoder(response.headers.get('content-type') ?? '');
1484
+ const chunks = [];
1485
+ let totalBytes = 0;
1486
+ const readAll = (async () => {
1487
+ try {
1488
+ for (;;) {
1489
+ // eslint-disable-next-line no-await-in-loop
1490
+ const { done, value } = await reader.read();
1491
+ if (done) {
1492
+ break;
1493
+ }
1494
+ totalBytes += value.byteLength;
1495
+ if (totalBytes > maxErrorResponseBodySize) {
1496
+ void reader.cancel().catch(() => undefined);
1497
+ return undefined;
1498
+ }
1499
+ chunks.push(decoder.decode(value, { stream: true }));
1500
+ }
1501
+ }
1502
+ catch {
1503
+ return undefined;
1504
+ }
1505
+ chunks.push(decoder.decode());
1506
+ return chunks.join('');
1507
+ })();
1508
+ const timeoutPromise = new Promise(resolve => {
1509
+ const timeoutId = setTimeout(() => {
1510
+ resolve(timedOutResponseData);
1511
+ }, timeoutMs);
1512
+ void readAll.finally(() => {
1513
+ clearTimeout(timeoutId);
1514
+ });
1515
+ });
1516
+ const result = await Promise.race([readAll, timeoutPromise]);
1517
+ if (result === timedOutResponseData) {
1518
+ void reader.cancel().catch(() => undefined);
1519
+ }
1520
+ return result;
1521
+ }
1522
+ async #parseJson(text, response, timeoutMs, request) {
1523
+ let timeoutId;
1524
+ try {
1525
+ return await Promise.race([
1526
+ Promise.resolve().then(() => this.#options.parseJson
1527
+ ? this.#options.parseJson(text, { request, response })
1528
+ : JSON.parse(text)),
1529
+ new Promise(resolve => {
1530
+ timeoutId = setTimeout(() => {
1531
+ resolve(timedOutResponseData);
1532
+ }, timeoutMs);
1533
+ }),
1534
+ ]);
1535
+ }
1536
+ catch {
1537
+ return undefined;
1538
+ }
1539
+ finally {
1540
+ clearTimeout(timeoutId);
1541
+ }
1542
+ }
1077
1543
  #cancelBody(body) {
1078
1544
  if (!body) {
1079
1545
  return;
@@ -1085,90 +1551,224 @@
1085
1551
  // Ignore cancellation failures from already-locked or already-consumed streams.
1086
1552
  this.#cancelBody(response.body ?? undefined);
1087
1553
  }
1554
+ #createManagedSignal() {
1555
+ return this.#userProvidedAbortSignal
1556
+ ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal])
1557
+ : this.#abortController.signal;
1558
+ }
1559
+ #throwIfTotalTimeoutExhausted() {
1560
+ const remaining = this.#getRemainingTotalTimeout();
1561
+ if (remaining !== undefined && remaining <= 0) {
1562
+ throw new TimeoutError(this.request);
1563
+ }
1564
+ }
1565
+ async #runBeforeRequestHooks() {
1566
+ for (const hook of this.#options.hooks.beforeRequest) {
1567
+ // eslint-disable-next-line no-await-in-loop
1568
+ const result = await hook({
1569
+ request: this.request,
1570
+ options: this.#getNormalizedOptions(),
1571
+ retryCount: 0,
1572
+ });
1573
+ if (result instanceof Response) {
1574
+ return result;
1575
+ }
1576
+ if (result instanceof globalThis.Request) {
1577
+ this.#assignRequest(result);
1578
+ }
1579
+ }
1580
+ return undefined;
1581
+ }
1582
+ async #runAfterResponseHooks(response) {
1583
+ const responseRequest = this.#getResponseRequest(response);
1584
+ for (const hook of this.#options.hooks.afterResponse) {
1585
+ // Clone the response before passing to hook so we can cancel it if needed
1586
+ const clonedResponse = this.#setResponseRequest(response.clone(), responseRequest);
1587
+ this.#decorateResponse(clonedResponse);
1588
+ let modifiedResponse;
1589
+ try {
1590
+ // eslint-disable-next-line no-await-in-loop
1591
+ modifiedResponse = await hook({
1592
+ request: this.request,
1593
+ options: this.#getNormalizedOptions(),
1594
+ response: clonedResponse,
1595
+ retryCount: this.#retryCount,
1596
+ });
1597
+ }
1598
+ catch (error) {
1599
+ // Cancel both responses to prevent memory leaks when hook throws
1600
+ this.#cancelResponseBody(clonedResponse);
1601
+ this.#cancelResponseBody(response);
1602
+ throw error;
1603
+ }
1604
+ if (modifiedResponse instanceof RetryMarker) {
1605
+ // Cancel both the cloned response passed to the hook and the current response to prevent resource leaks (especially important in Deno/Bun).
1606
+ // Do not await cancellation since hooks can clone the response, leaving extra tee branches that keep cancel promises pending per the Streams spec.
1607
+ this.#cancelResponseBody(clonedResponse);
1608
+ this.#cancelResponseBody(response);
1609
+ throw new ForceRetryError(modifiedResponse.options);
1610
+ }
1611
+ // Determine which response to use going forward
1612
+ const nextResponse = this.#setResponseRequest(modifiedResponse instanceof globalThis.Response ? modifiedResponse : response, responseRequest);
1613
+ // Cancel any response bodies we won't use to prevent memory leaks.
1614
+ // Uses fire-and-forget since hooks may have cloned the response, creating tee branches that block cancellation.
1615
+ if (clonedResponse !== nextResponse) {
1616
+ this.#cancelResponseBody(clonedResponse);
1617
+ }
1618
+ if (response !== nextResponse) {
1619
+ this.#cancelResponseBody(response);
1620
+ }
1621
+ response = nextResponse;
1622
+ }
1623
+ return response;
1624
+ }
1088
1625
  async #retry(function_) {
1089
1626
  try {
1090
1627
  return await function_();
1091
1628
  }
1092
1629
  catch (error) {
1093
- const ms = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
1094
- if (this.#retryCount < 1) {
1095
- throw error;
1630
+ return this.#retryFromError(error, function_);
1631
+ }
1632
+ }
1633
+ async #retryFromError(error, function_) {
1634
+ this.#returnedResponseFromBeforeRetryHook = false;
1635
+ const retryDelay = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
1636
+ const delayOptions = { signal: this.#userProvidedAbortSignal };
1637
+ const remainingTimeout = this.#getRemainingTotalTimeout();
1638
+ if (remainingTimeout !== undefined) {
1639
+ if (remainingTimeout <= 0) {
1640
+ throw new TimeoutError(this.request);
1096
1641
  }
1097
- // Only use user-provided signal for delay, not our internal abortController
1098
- await delay(ms, this.#userProvidedAbortSignal ? { signal: this.#userProvidedAbortSignal } : {});
1099
- // Apply custom request from forced retry before beforeRetry hooks
1100
- // Ensure the custom request has the correct managed signal for timeouts and user aborts
1101
- if (error instanceof ForceRetryError && error.customRequest) {
1102
- const managedRequest = this.#options.signal
1103
- ? new globalThis.Request(error.customRequest, { signal: this.#options.signal })
1104
- : new globalThis.Request(error.customRequest);
1105
- this.#assignRequest(managedRequest);
1642
+ // If waiting would consume all remaining budget, time out without starting another request.
1643
+ if (retryDelay >= remainingTimeout) {
1644
+ await delay(remainingTimeout, delayOptions);
1645
+ throw new TimeoutError(this.request);
1106
1646
  }
1107
- for (const hook of this.#options.hooks.beforeRetry) {
1647
+ }
1648
+ // Only use user-provided signal for delay, not our internal abortController
1649
+ await delay(retryDelay, delayOptions);
1650
+ this.#throwIfTotalTimeoutExhausted();
1651
+ // Apply custom request from forced retry before beforeRetry hooks
1652
+ // Ensure the custom request has the correct managed signal for timeouts and user aborts
1653
+ if (error instanceof ForceRetryError && error.customRequest) {
1654
+ this.#assignRequest(new globalThis.Request(error.customRequest, this.#options.signal ? { signal: this.#options.signal } : undefined));
1655
+ }
1656
+ for (const hook of this.#options.hooks.beforeRetry) {
1657
+ let hookResult;
1658
+ try {
1108
1659
  // eslint-disable-next-line no-await-in-loop
1109
- const hookResult = await hook({
1660
+ hookResult = await hook({
1110
1661
  request: this.request,
1111
1662
  options: this.#getNormalizedOptions(),
1112
1663
  error: error,
1113
- retryCount: this.#retryCount,
1664
+ retryCount: this.#retryCount + 1,
1114
1665
  });
1115
- if (hookResult instanceof globalThis.Request) {
1116
- this.#assignRequest(hookResult);
1117
- break;
1118
- }
1119
- // If a Response is returned, use it and skip the retry
1120
- if (hookResult instanceof globalThis.Response) {
1121
- return hookResult;
1122
- }
1123
- // If `stop` is returned from the hook, the retry process is stopped
1124
- if (hookResult === stop) {
1125
- return;
1666
+ }
1667
+ catch (hookError) {
1668
+ // Preserve the original request error path (`throw error`) so beforeError hooks can still run.
1669
+ if (hookError instanceof Error && hookError !== error) {
1670
+ this.#beforeRetryHookErrors.add(hookError);
1126
1671
  }
1672
+ throw hookError;
1673
+ }
1674
+ if (hookResult instanceof globalThis.Request) {
1675
+ this.#assignRequest(hookResult);
1676
+ break;
1677
+ }
1678
+ // If a Response is returned, use it and skip the retry
1679
+ if (hookResult instanceof globalThis.Response) {
1680
+ this.#returnedResponseFromBeforeRetryHook = true;
1681
+ this.#retryCount++;
1682
+ return hookResult;
1683
+ }
1684
+ // If `stop` is returned from the hook, the retry process is stopped
1685
+ if (hookResult === stop) {
1686
+ return;
1127
1687
  }
1128
- return this.#retry(function_);
1129
1688
  }
1689
+ this.#throwIfTotalTimeoutExhausted();
1690
+ this.#retryCount++;
1691
+ return this.#retry(function_);
1692
+ }
1693
+ #consumeReturnedResponseFromBeforeRetryHook() {
1694
+ const value = this.#returnedResponseFromBeforeRetryHook;
1695
+ this.#returnedResponseFromBeforeRetryHook = false;
1696
+ return value;
1130
1697
  }
1131
1698
  async #fetch() {
1132
1699
  // Reset abortController if it was aborted (happens on timeout retry)
1133
1700
  if (this.#abortController?.signal.aborted) {
1134
1701
  this.#abortController = new globalThis.AbortController();
1135
- this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal;
1702
+ this.#options.signal = this.#createManagedSignal();
1136
1703
  // Recreate request with new signal
1137
1704
  this.request = new globalThis.Request(this.request, { signal: this.#options.signal });
1138
1705
  }
1139
- for (const hook of this.#options.hooks.beforeRequest) {
1140
- // eslint-disable-next-line no-await-in-loop
1141
- const result = await hook(this.request, this.#getNormalizedOptions(), { retryCount: this.#retryCount });
1142
- if (result instanceof Response) {
1143
- return result;
1706
+ const nonRequestOptions = findUnknownOptions(this.request, this.#options);
1707
+ const retryRequest = this.#options.retry.limit > 0 ? this.request.clone() : undefined;
1708
+ const request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
1709
+ // Cloning is done here to prepare in advance for retries.
1710
+ // Skip cloning when retries are disabled - cloning a streaming body calls ReadableStream#tee()
1711
+ // which buffers the entire stream in memory, causing excessive memory usage for large uploads.
1712
+ this.#originalRequest = request;
1713
+ if (retryRequest) {
1714
+ this.request = retryRequest;
1715
+ }
1716
+ try {
1717
+ const remainingTotal = this.#getRemainingTotalTimeout();
1718
+ if (remainingTotal !== undefined && remainingTotal <= 0) {
1719
+ throw new TimeoutError(this.request);
1144
1720
  }
1145
- if (result instanceof globalThis.Request) {
1146
- this.#assignRequest(result);
1147
- break;
1721
+ const effectiveTimeout = this.#options.timeout === false
1722
+ ? remainingTotal
1723
+ : (remainingTotal === undefined
1724
+ ? this.#options.timeout
1725
+ : Math.min(this.#options.timeout, remainingTotal));
1726
+ const response = effectiveTimeout === undefined
1727
+ ? await this.#options.fetch(request, nonRequestOptions)
1728
+ : await timeout(request, nonRequestOptions, this.#abortController, {
1729
+ timeout: effectiveTimeout,
1730
+ fetch: this.#options.fetch,
1731
+ });
1732
+ return this.#setResponseRequest(response, request);
1733
+ }
1734
+ catch (error) {
1735
+ if (isRawNetworkError(error)) {
1736
+ throw new NetworkError(this.request, { cause: error });
1148
1737
  }
1738
+ throw error;
1149
1739
  }
1150
- const nonRequestOptions = findUnknownOptions(this.request, this.#options);
1151
- // Cloning is done here to prepare in advance for retries
1152
- this.#originalRequest = this.request;
1153
- this.request = this.#originalRequest.clone();
1154
- if (this.#options.timeout === false) {
1155
- return this.#options.fetch(this.#originalRequest, nonRequestOptions);
1740
+ }
1741
+ #getRemainingTotalTimeout() {
1742
+ if (this.#startTime === undefined) {
1743
+ return undefined;
1156
1744
  }
1157
- return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options);
1745
+ const elapsed = this.#getCurrentTime() - this.#startTime;
1746
+ return Math.max(0, this.#options.totalTimeout - elapsed);
1747
+ }
1748
+ #getCurrentTime() {
1749
+ return globalThis.performance?.now() ?? Date.now();
1158
1750
  }
1159
1751
  #getNormalizedOptions() {
1160
1752
  if (!this.#cachedNormalizedOptions) {
1161
- const { hooks, ...normalizedOptions } = this.#options;
1753
+ // Exclude Ky-specific options that are not part of `RequestInit`.
1754
+ const { hooks, json, parseJson, stringifyJson, searchParams, timeout, totalTimeout, throwHttpErrors, fetch, ...normalizedOptions } = this.#options;
1162
1755
  this.#cachedNormalizedOptions = Object.freeze(normalizedOptions);
1163
1756
  }
1164
1757
  return this.#cachedNormalizedOptions;
1165
1758
  }
1166
1759
  #assignRequest(request) {
1167
1760
  this.#cachedNormalizedOptions = undefined;
1168
- this.request = this.#wrapRequestWithUploadProgress(request);
1761
+ this.request = request;
1762
+ }
1763
+ #getResponseRequest(response) {
1764
+ return this.#responseRequests.get(response) ?? this.request;
1765
+ }
1766
+ #setResponseRequest(response, request) {
1767
+ this.#responseRequests.set(response, request);
1768
+ return response;
1169
1769
  }
1170
1770
  #wrapRequestWithUploadProgress(request, originalBody) {
1171
- if (!this.#options.onUploadProgress || !request.body) {
1771
+ if (!this.#options.onUploadProgress || !request.body || !supportsRequestStreams) {
1172
1772
  return request;
1173
1773
  }
1174
1774
  return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined);
@@ -1202,185 +1802,96 @@
1202
1802
  __proto__: null,
1203
1803
  ForceRetryError: ForceRetryError,
1204
1804
  HTTPError: HTTPError,
1805
+ KyError: KyError,
1806
+ NetworkError: NetworkError,
1807
+ SchemaValidationError: SchemaValidationError,
1205
1808
  TimeoutError: TimeoutError,
1206
1809
  default: ky,
1207
1810
  isForceRetryError: isForceRetryError,
1208
1811
  isHTTPError: isHTTPError,
1209
1812
  isKyError: isKyError,
1210
- isTimeoutError: isTimeoutError
1813
+ isNetworkError: isNetworkError,
1814
+ isTimeoutError: isTimeoutError,
1815
+ replaceOption: replaceOption
1211
1816
  });
1212
1817
 
1213
1818
  var require$$1 = /*@__PURE__*/getAugmentedNamespace(distribution);
1214
1819
 
1215
- const VERSION = '0.15.1';
1820
+ const VERSION = '0.16.0';
1216
1821
 
1217
1822
  var constants = {
1218
1823
  VERSION,
1219
1824
  USER_AGENT: `mql/${VERSION}`,
1220
1825
  /**
1221
- * Retry status codes, excluding 429 (too many requests).
1826
+ * Based on Ky default retry status codes, excluding 429:
1827
+ * https://github.com/sindresorhus/ky/blob/main/source/core/constants.ts
1222
1828
  */
1223
- RETRY_STATUS_CODES: [408, 413, 500, 502, 503, 504, 521, 522, 524]
1224
- };
1225
-
1226
- const ENDPOINT = {
1227
- FREE: 'https://api.microlink.io/',
1228
- PRO: 'https://pro.microlink.io/'
1229
- };
1230
-
1231
- const isObject = input => input !== null && typeof input === 'object';
1232
-
1233
- const isBuffer = input =>
1234
- input != null &&
1235
- input.constructor != null &&
1236
- typeof input.constructor.isBuffer === 'function' &&
1237
- input.constructor.isBuffer(input);
1238
-
1239
- const parseBody = (input, error, url) => {
1240
- try {
1241
- return JSON.parse(input)
1242
- } catch (_) {
1243
- const message = input || error.message;
1244
-
1245
- return {
1246
- status: 'error',
1247
- data: { url: message },
1248
- more: 'https://microlink.io/efatalclient',
1249
- code: 'EFATALCLIENT',
1250
- message,
1251
- url
1252
- }
1253
- }
1254
- };
1255
-
1256
- const isURL = url => {
1257
- try {
1258
- return /^https?:\/\//i.test(new URL(url).href)
1259
- } catch (_) {
1260
- return false
1261
- }
1262
- };
1263
-
1264
- const factory = streamResponseType => ({
1265
- VERSION,
1266
- MicrolinkError,
1267
- got,
1268
- flatten
1269
- }) => {
1270
- const assertUrl = (url = '') => {
1271
- if (!isURL(url)) {
1272
- const message = `The \`url\` as \`${url}\` is not valid. Ensure it has protocol (http or https) and hostname.`;
1273
- throw new MicrolinkError({
1274
- status: 'fail',
1275
- data: { url: message },
1276
- more: 'https://microlink.io/einvalurlclient',
1277
- code: 'EINVALURLCLIENT',
1278
- message,
1279
- url
1280
- })
1281
- }
1282
- };
1283
-
1284
- const mapRules = rules => {
1285
- if (!isObject(rules)) return
1286
- const flatRules = flatten(rules);
1287
- return Object.keys(flatRules).reduce((acc, key) => {
1288
- acc[`data.${key}`] = flatRules[key].toString();
1289
- return acc
1290
- }, {})
1291
- };
1292
-
1293
- const fetchFromApi = async (apiUrl, opts = {}) => {
1294
- try {
1295
- const response = await got(apiUrl, opts);
1296
- return opts.responseType === streamResponseType
1297
- ? response
1298
- : { ...response.body, response }
1299
- } catch (error) {
1300
- const { response = {} } = error;
1301
- const {
1302
- statusCode,
1303
- body: rawBody,
1304
- headers = {},
1305
- url: uri = apiUrl
1306
- } = response;
1307
- const isBodyBuffer = isBuffer(rawBody);
1308
-
1309
- const body =
1310
- isObject(rawBody) && !isBodyBuffer
1311
- ? rawBody
1312
- : parseBody(isBodyBuffer ? rawBody.toString() : rawBody, error, uri);
1313
-
1314
- throw new MicrolinkError({
1315
- ...body,
1316
- message: body.message,
1317
- url: uri,
1318
- statusCode,
1319
- headers
1320
- })
1321
- }
1322
- };
1323
-
1324
- const getApiUrl = (
1325
- url,
1326
- { data, apiKey, endpoint, ...opts } = {},
1327
- { responseType = 'json', headers: gotHeaders, ...gotOpts } = {}
1328
- ) => {
1329
- const isPro = !!apiKey;
1330
- const apiEndpoint = endpoint || ENDPOINT[isPro ? 'PRO' : 'FREE'];
1331
-
1332
- const apiUrl = `${apiEndpoint}?${new URLSearchParams({
1333
- url,
1334
- ...mapRules(data),
1335
- ...flatten(opts)
1336
- }).toString()}`;
1337
-
1338
- const headers = isPro
1339
- ? { ...gotHeaders, 'x-api-key': apiKey }
1340
- : { ...gotHeaders };
1341
-
1342
- if (opts.stream) {
1343
- responseType = streamResponseType;
1344
- }
1345
- return [apiUrl, { ...gotOpts, responseType, headers }]
1346
- };
1347
-
1348
- const createMql = defaultOpts => async (url, opts, gotOpts) => {
1349
- assertUrl(url);
1350
- const [apiUrl, fetchOpts] = getApiUrl(url, opts, {
1351
- ...defaultOpts,
1352
- ...gotOpts
1353
- });
1354
- return fetchFromApi(apiUrl, fetchOpts)
1355
- };
1356
-
1357
- const mql = createMql();
1358
- mql.extend = createMql;
1359
- mql.MicrolinkError = MicrolinkError;
1360
- mql.getApiUrl = getApiUrl;
1361
- mql.fetchFromApi = fetchFromApi;
1362
- mql.mapRules = mapRules;
1363
- mql.version = VERSION;
1364
- mql.stream = got.stream;
1365
-
1366
- return mql
1829
+ RETRY_STATUS_CODES: [408, 413, 500, 502, 503, 504, 521, 522, 524],
1830
+ /**
1831
+ * Based on Ky default Retry-After status codes, excluding 429:
1832
+ * https://github.com/sindresorhus/ky/blob/main/source/core/constants.ts
1833
+ */
1834
+ RETRY_AFTER_STATUS_CODES: [413, 503]
1367
1835
  };
1368
1836
 
1369
- var factory_1 = factory;
1370
-
1371
1837
  (function (module) {
1372
1838
 
1373
1839
  const { flattie: flatten } = dist;
1374
1840
  const { default: ky } = require$$1;
1375
1841
 
1376
- const { VERSION, USER_AGENT, RETRY_STATUS_CODES } = constants;
1842
+ const {
1843
+ VERSION,
1844
+ USER_AGENT,
1845
+ RETRY_STATUS_CODES,
1846
+ RETRY_AFTER_STATUS_CODES
1847
+ } = constants;
1848
+
1849
+ const ENDPOINT = {
1850
+ FREE: 'https://api.microlink.io/',
1851
+ PRO: 'https://pro.microlink.io/'
1852
+ };
1853
+
1854
+ const STREAM_RESPONSE_TYPE = 'arrayBuffer';
1377
1855
 
1378
1856
  const kyInstance = ky.extend({
1379
1857
  headers: { 'user-agent': USER_AGENT },
1380
- retry: { statusCodes: RETRY_STATUS_CODES }
1858
+ retry: {
1859
+ statusCodes: RETRY_STATUS_CODES,
1860
+ afterStatusCodes: RETRY_AFTER_STATUS_CODES
1861
+ }
1381
1862
  });
1382
1863
 
1383
- const factory = factory_1('arrayBuffer');
1864
+ const isObject = input => input !== null && typeof input === 'object';
1865
+
1866
+ const isBuffer = input =>
1867
+ typeof input?.constructor?.isBuffer === 'function' &&
1868
+ input.constructor.isBuffer(input);
1869
+
1870
+ const parseBody = (input, error, url) => {
1871
+ try {
1872
+ return JSON.parse(input)
1873
+ } catch (_) {
1874
+ const message = input || error.message;
1875
+
1876
+ return {
1877
+ status: 'error',
1878
+ data: { url: message },
1879
+ more: 'https://microlink.io/efatalclient',
1880
+ code: 'EFATALCLIENT',
1881
+ message,
1882
+ url
1883
+ }
1884
+ }
1885
+ };
1886
+
1887
+ const isURL = url => {
1888
+ try {
1889
+ const { protocol } = new URL(url);
1890
+ return protocol === 'http:' || protocol === 'https:'
1891
+ } catch (_) {
1892
+ return false
1893
+ }
1894
+ };
1384
1895
 
1385
1896
  class MicrolinkError extends Error {
1386
1897
  constructor (props) {
@@ -1394,44 +1905,132 @@
1394
1905
  }
1395
1906
  }
1396
1907
 
1397
- const got = async (url, { responseType, ...opts }) => {
1908
+ const assertUrl = (url = '') => {
1909
+ if (!isURL(url)) {
1910
+ const message = `The \`url\` as \`${url}\` is not valid. Ensure it has protocol (http or https) and hostname.`;
1911
+ throw new MicrolinkError({
1912
+ status: 'fail',
1913
+ data: { url: message },
1914
+ more: 'https://microlink.io/einvalurlclient',
1915
+ code: 'EINVALURLCLIENT',
1916
+ message,
1917
+ url
1918
+ })
1919
+ }
1920
+ };
1921
+
1922
+ const mapRules = rules => {
1923
+ if (!isObject(rules)) return
1924
+ return Object.fromEntries(
1925
+ Object.entries(flatten(rules)).map(([key, value]) => [
1926
+ `data.${key}`,
1927
+ value.toString()
1928
+ ])
1929
+ )
1930
+ };
1931
+
1932
+ const doFetch = async (apiUrl, { responseType, ...opts }) => {
1933
+ if (opts.timeout === undefined) opts.timeout = false;
1934
+ const response = await kyInstance(apiUrl, opts);
1935
+ const body = await response[responseType]();
1936
+ const { headers, status: statusCode } = response;
1937
+ return { url: response.url, body, headers, statusCode }
1938
+ };
1939
+
1940
+ const fetchFromApi = async (apiUrl, opts = {}) => {
1398
1941
  try {
1399
- if (opts.timeout === undefined) opts.timeout = false;
1400
- const response = await kyInstance(url, opts);
1401
- const body = await response[responseType]();
1402
- const { headers, status: statusCode } = response;
1403
- return { url: response.url, body, headers, statusCode }
1942
+ const response = await doFetch(apiUrl, opts);
1943
+ return opts.responseType === STREAM_RESPONSE_TYPE
1944
+ ? response
1945
+ : { ...response.body, response }
1404
1946
  } catch (error) {
1405
- if (error.response) {
1406
- const { response } = error;
1407
- error.response = {
1408
- ...response,
1409
- headers: Array.from(response.headers.entries()).reduce(
1410
- (acc, [key, value]) => {
1411
- acc[key] = value;
1412
- return acc
1413
- },
1414
- {}
1415
- ),
1416
- statusCode: response.status,
1417
- body: await response.text()
1418
- };
1947
+ const { response = {} } = error;
1948
+ const { statusCode: responseStatusCode, status } = response;
1949
+ const {
1950
+ body: rawBody,
1951
+ headers: responseHeaders,
1952
+ url: uri = apiUrl
1953
+ } = response;
1954
+
1955
+ const statusCode = responseStatusCode ?? status;
1956
+ const headers =
1957
+ typeof responseHeaders?.entries === 'function'
1958
+ ? Object.fromEntries(responseHeaders.entries())
1959
+ : responseHeaders || {};
1960
+
1961
+ let bodyInput = error.data ?? rawBody;
1962
+ const isBodyReadableStream = typeof bodyInput?.getReader === 'function';
1963
+
1964
+ if (
1965
+ (bodyInput === undefined || isBodyReadableStream) &&
1966
+ typeof response.text === 'function'
1967
+ ) {
1968
+ try {
1969
+ bodyInput = await response.text();
1970
+ } catch (_) {
1971
+ bodyInput = undefined;
1972
+ }
1419
1973
  }
1420
- throw error
1974
+
1975
+ const isBodyBuffer = isBuffer(bodyInput);
1976
+ const body =
1977
+ isObject(bodyInput) && !isBodyBuffer
1978
+ ? bodyInput
1979
+ : parseBody(isBodyBuffer ? bodyInput.toString() : bodyInput, error, uri);
1980
+
1981
+ throw new MicrolinkError({
1982
+ ...body,
1983
+ url: uri,
1984
+ statusCode,
1985
+ headers
1986
+ })
1421
1987
  }
1422
1988
  };
1423
1989
 
1424
- got.stream = (...args) => kyInstance(...args).then(res => res.body);
1990
+ const getApiUrl = (
1991
+ url,
1992
+ { data, apiKey, endpoint, ...opts } = {},
1993
+ { responseType = 'json', headers: responseHeaders = {}, ...gotOpts } = {}
1994
+ ) => {
1995
+ const isPro = !!apiKey;
1996
+ const apiEndpoint = endpoint || ENDPOINT[isPro ? 'PRO' : 'FREE'];
1425
1997
 
1426
- const mql = factory({
1427
- MicrolinkError,
1428
- got,
1429
- flatten,
1430
- VERSION
1431
- });
1998
+ const apiUrl = `${apiEndpoint}?${new URLSearchParams({
1999
+ url,
2000
+ ...mapRules(data),
2001
+ ...flatten(opts)
2002
+ })}`;
2003
+
2004
+ const headers = isPro
2005
+ ? { ...responseHeaders, 'x-api-key': apiKey }
2006
+ : responseHeaders;
2007
+
2008
+ if (opts.stream) responseType = STREAM_RESPONSE_TYPE;
2009
+
2010
+ return [apiUrl, { ...gotOpts, responseType, headers }]
2011
+ };
2012
+
2013
+ const createMql = defaultOpts => async (url, opts, gotOpts) => {
2014
+ assertUrl(url);
2015
+ const [apiUrl, fetchOpts] = getApiUrl(url, opts, {
2016
+ ...defaultOpts,
2017
+ ...gotOpts
2018
+ });
2019
+ return fetchFromApi(apiUrl, fetchOpts)
2020
+ };
2021
+
2022
+ const mql = createMql();
2023
+
2024
+ mql.extend = createMql;
2025
+ mql.MicrolinkError = MicrolinkError;
2026
+ mql.getApiUrl = getApiUrl;
2027
+ mql.fetchFromApi = fetchFromApi;
2028
+ mql.mapRules = mapRules;
2029
+ mql.version = VERSION;
2030
+ mql.stream = (...args) => kyInstance(...args).then(res => res.body);
1432
2031
 
1433
2032
  module.exports = mql;
1434
- module.exports.arrayBuffer = mql.extend({ responseType: 'arrayBuffer' });
2033
+ module.exports.arrayBuffer = mql.extend({ responseType: STREAM_RESPONSE_TYPE });
1435
2034
  module.exports.buffer = module.exports.arrayBuffer;
1436
2035
  module.exports.extend = mql.extend;
1437
2036
  module.exports.fetchFromApi = mql.fetchFromApi;