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