@microlink/mql 0.13.20 → 0.14.1

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.
@@ -9,11 +9,15 @@
9
9
  }
10
10
 
11
11
  function getAugmentedNamespace(n) {
12
- if (n.__esModule) return n;
12
+ if (Object.prototype.hasOwnProperty.call(n, '__esModule')) return n;
13
13
  var f = n.default;
14
14
  if (typeof f == "function") {
15
15
  var a = function a () {
16
- if (this instanceof a) {
16
+ var isInstance = false;
17
+ try {
18
+ isInstance = this instanceof a;
19
+ } catch {}
20
+ if (isInstance) {
17
21
  return Reflect.construct(f, arguments, this.constructor);
18
22
  }
19
23
  return f.apply(this, arguments);
@@ -33,7 +37,7 @@
33
37
  return a;
34
38
  }
35
39
 
36
- var lightweight$1 = {exports: {}};
40
+ var lightweight = {exports: {}};
37
41
 
38
42
  var dist = {};
39
43
 
@@ -71,7 +75,7 @@
71
75
  options;
72
76
  constructor(response, request, options) {
73
77
  const code = (response.status || response.status === 0) ? response.status : '';
74
- const title = response.statusText || '';
78
+ const title = response.statusText ?? '';
75
79
  const status = `${code} ${title}`.trim();
76
80
  const reason = status ? `status code ${status}` : 'an unknown error';
77
81
  super(`Request failed with ${reason}: ${request.method} ${request.url}`);
@@ -82,82 +86,54 @@
82
86
  }
83
87
  }
84
88
 
85
- class TimeoutError extends Error {
86
- request;
87
- constructor(request) {
88
- super(`Request timed out: ${request.method} ${request.url}`);
89
- this.name = 'TimeoutError';
90
- this.request = request;
91
- }
92
- }
93
-
94
- // eslint-disable-next-line @typescript-eslint/ban-types
95
- const isObject$1 = (value) => value !== null && typeof value === 'object';
89
+ /**
90
+ Wrapper for non-Error values that were thrown.
96
91
 
97
- const validateAndMerge = (...sources) => {
98
- for (const source of sources) {
99
- if ((!isObject$1(source) || Array.isArray(source)) && source !== undefined) {
100
- throw new TypeError('The `options` argument must be an object');
101
- }
102
- }
103
- return deepMerge({}, ...sources);
104
- };
105
- const mergeHeaders = (source1 = {}, source2 = {}) => {
106
- const result = new globalThis.Headers(source1);
107
- const isHeadersInstance = source2 instanceof globalThis.Headers;
108
- const source = new globalThis.Headers(source2);
109
- for (const [key, value] of source.entries()) {
110
- if ((isHeadersInstance && value === 'undefined') || value === undefined) {
111
- result.delete(key);
92
+ In JavaScript, any value can be thrown (not just Error instances). This class wraps such values to ensure consistent error handling.
93
+ */
94
+ class NonError extends Error {
95
+ name = 'NonError';
96
+ value;
97
+ constructor(value) {
98
+ let message = 'Non-error value was thrown';
99
+ // Intentionally minimal as this error is just an edge-case.
100
+ try {
101
+ if (typeof value === 'string') {
102
+ message = value;
103
+ }
104
+ else if (value && typeof value === 'object' && 'message' in value && typeof value.message === 'string') {
105
+ message = value.message;
106
+ }
112
107
  }
113
- else {
114
- result.set(key, value);
108
+ catch {
109
+ // Use default message if accessing properties throws
115
110
  }
111
+ super(message);
112
+ this.value = value;
116
113
  }
117
- return result;
118
- };
119
- function newHookValue(original, incoming, property) {
120
- return (Object.hasOwn(incoming, property) && incoming[property] === undefined)
121
- ? []
122
- : deepMerge(original[property] ?? [], incoming[property] ?? []);
123
114
  }
124
- const mergeHooks = (original = {}, incoming = {}) => ({
125
- beforeRequest: newHookValue(original, incoming, 'beforeRequest'),
126
- beforeRetry: newHookValue(original, incoming, 'beforeRetry'),
127
- afterResponse: newHookValue(original, incoming, 'afterResponse'),
128
- beforeError: newHookValue(original, incoming, 'beforeError'),
129
- });
130
- // TODO: Make this strongly-typed (no `any`).
131
- const deepMerge = (...sources) => {
132
- let returnValue = {};
133
- let headers = {};
134
- let hooks = {};
135
- for (const source of sources) {
136
- if (Array.isArray(source)) {
137
- if (!Array.isArray(returnValue)) {
138
- returnValue = [];
139
- }
140
- returnValue = [...returnValue, ...source];
141
- }
142
- else if (isObject$1(source)) {
143
- for (let [key, value] of Object.entries(source)) {
144
- if (isObject$1(value) && key in returnValue) {
145
- value = deepMerge(returnValue[key], value);
146
- }
147
- returnValue = { ...returnValue, [key]: value };
148
- }
149
- if (isObject$1(source.hooks)) {
150
- hooks = mergeHooks(hooks, source.hooks);
151
- returnValue.hooks = hooks;
152
- }
153
- if (isObject$1(source.headers)) {
154
- headers = mergeHeaders(headers, source.headers);
155
- returnValue.headers = headers;
156
- }
157
- }
115
+
116
+ /**
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.
119
+ */
120
+ class ForceRetryError extends Error {
121
+ name = 'ForceRetryError';
122
+ customDelay;
123
+ code;
124
+ customRequest;
125
+ constructor(options) {
126
+ // Runtime protection: wrap non-Error causes in NonError
127
+ // TypeScript type is Error for guidance, but JS users can pass anything
128
+ const cause = options?.cause
129
+ ? (options.cause instanceof Error ? options.cause : new NonError(options.cause))
130
+ : undefined;
131
+ super(options?.code ? `Forced retry: ${options.code}` : 'Forced retry', cause ? { cause } : undefined);
132
+ this.customDelay = options?.delay;
133
+ this.code = options?.code;
134
+ this.customRequest = options?.request;
158
135
  }
159
- return returnValue;
160
- };
136
+ }
161
137
 
162
138
  const supportsRequestStreams = (() => {
163
139
  let duplexAccessed = false;
@@ -187,6 +163,7 @@
187
163
  return duplexAccessed && !hasContentType;
188
164
  })();
189
165
  const supportsAbortController = typeof globalThis.AbortController === 'function';
166
+ const supportsAbortSignal = typeof globalThis.AbortSignal === 'function' && typeof globalThis.AbortSignal.any === 'function';
190
167
  const supportsResponseStreams = typeof globalThis.ReadableStream === 'function';
191
168
  const supportsFormData = typeof globalThis.FormData === 'function';
192
169
  const requestMethods = ['get', 'post', 'put', 'patch', 'head', 'delete'];
@@ -196,10 +173,108 @@
196
173
  formData: 'multipart/form-data',
197
174
  arrayBuffer: '*/*',
198
175
  blob: '*/*',
176
+ // Supported in modern Fetch implementations (for example, browsers and recent Node.js/undici).
177
+ // We still feature-check at runtime before exposing the shortcut.
178
+ bytes: '*/*',
199
179
  };
200
180
  // The maximum value of a 32bit int (see issue #117)
201
181
  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;
202
184
  const stop = Symbol('stop');
185
+ /**
186
+ Marker returned by ky.retry() to signal a forced retry from afterResponse hooks.
187
+ */
188
+ class RetryMarker {
189
+ options;
190
+ constructor(options) {
191
+ this.options = options;
192
+ }
193
+ }
194
+ /**
195
+ Force a retry from an `afterResponse` hook.
196
+
197
+ This allows you to retry a request based on the response content, even if the response has a successful status code. The retry will respect the `retry.limit` option and skip the `shouldRetry` check. The forced retry is observable in `beforeRetry` hooks, where the error will be a `ForceRetryError`.
198
+
199
+ @param options - Optional configuration for the retry.
200
+
201
+ @example
202
+ ```
203
+ import ky, {isForceRetryError} from 'ky';
204
+
205
+ const api = ky.extend({
206
+ hooks: {
207
+ afterResponse: [
208
+ async (request, options, response) => {
209
+ // Retry based on response body content
210
+ if (response.status === 200) {
211
+ const data = await response.clone().json();
212
+
213
+ // Simple retry with default delay
214
+ if (data.error?.code === 'TEMPORARY_ERROR') {
215
+ return ky.retry();
216
+ }
217
+
218
+ // Retry with custom delay from API response
219
+ if (data.error?.code === 'RATE_LIMIT') {
220
+ return ky.retry({
221
+ delay: data.error.retryAfter * 1000,
222
+ code: 'RATE_LIMIT'
223
+ });
224
+ }
225
+
226
+ // Retry with a modified request (e.g., fallback endpoint)
227
+ if (data.error?.code === 'FALLBACK_TO_BACKUP') {
228
+ return ky.retry({
229
+ request: new Request('https://backup-api.com/endpoint', {
230
+ method: request.method,
231
+ headers: request.headers,
232
+ }),
233
+ code: 'BACKUP_ENDPOINT'
234
+ });
235
+ }
236
+
237
+ // Retry with refreshed authentication
238
+ if (data.error?.code === 'TOKEN_REFRESH' && data.newToken) {
239
+ return ky.retry({
240
+ request: new Request(request, {
241
+ headers: {
242
+ ...Object.fromEntries(request.headers),
243
+ 'Authorization': `Bearer ${data.newToken}`
244
+ }
245
+ }),
246
+ code: 'TOKEN_REFRESHED'
247
+ });
248
+ }
249
+
250
+ // Retry with cause to preserve error chain
251
+ try {
252
+ validateResponse(data);
253
+ } catch (error) {
254
+ return ky.retry({
255
+ code: 'VALIDATION_FAILED',
256
+ cause: error
257
+ });
258
+ }
259
+ }
260
+ }
261
+ ],
262
+ beforeRetry: [
263
+ ({error, retryCount}) => {
264
+ // Observable in beforeRetry hooks
265
+ if (isForceRetryError(error)) {
266
+ console.log(`Forced retry #${retryCount}: ${error.message}`);
267
+ // Example output: "Forced retry #1: Forced retry: RATE_LIMIT"
268
+ }
269
+ }
270
+ ]
271
+ }
272
+ });
273
+
274
+ const response = await api.get('https://example.com/api');
275
+ ```
276
+ */
277
+ const retry = (options) => new RetryMarker(options);
203
278
  const kyOptionKeys = {
204
279
  json: true,
205
280
  parseJson: true,
@@ -211,8 +286,21 @@
211
286
  hooks: true,
212
287
  throwHttpErrors: true,
213
288
  onDownloadProgress: true,
289
+ onUploadProgress: true,
214
290
  fetch: true,
291
+ context: true,
292
+ };
293
+ // Vendor-specific fetch options that should always be passed to fetch()
294
+ // even if they appear on the Request object due to vendor patching.
295
+ // See: https://github.com/sindresorhus/ky/issues/541
296
+ const vendorSpecificOptions = {
297
+ next: true, // Next.js cache revalidation (revalidate, tags)
215
298
  };
299
+ // Standard RequestInit options that should NOT be passed separately to fetch()
300
+ // because they're already applied to the Request object.
301
+ // Note: `dispatcher` and `priority` are NOT included here - they're fetch-only
302
+ // options that the Request constructor doesn't accept, so they need to be passed
303
+ // separately to fetch().
216
304
  const requestOptionsRegistry = {
217
305
  method: true,
218
306
  headers: true,
@@ -227,9 +315,267 @@
227
315
  keepalive: true,
228
316
  signal: true,
229
317
  window: true,
230
- dispatcher: true,
231
318
  duplex: true,
232
- priority: true,
319
+ };
320
+
321
+ // eslint-disable-next-line @typescript-eslint/ban-types
322
+ const getBodySize = (body) => {
323
+ if (!body) {
324
+ return 0;
325
+ }
326
+ if (body instanceof FormData) {
327
+ // This is an approximation, as FormData size calculation is not straightforward
328
+ let size = 0;
329
+ for (const [key, value] of body) {
330
+ size += usualFormBoundarySize;
331
+ size += new TextEncoder().encode(`Content-Disposition: form-data; name="${key}"`).length;
332
+ size += typeof value === 'string'
333
+ ? new TextEncoder().encode(value).length
334
+ : value.size;
335
+ }
336
+ return size;
337
+ }
338
+ if (body instanceof Blob) {
339
+ return body.size;
340
+ }
341
+ if (body instanceof ArrayBuffer) {
342
+ return body.byteLength;
343
+ }
344
+ if (typeof body === 'string') {
345
+ return new TextEncoder().encode(body).length;
346
+ }
347
+ if (body instanceof URLSearchParams) {
348
+ return new TextEncoder().encode(body.toString()).length;
349
+ }
350
+ if ('byteLength' in body) {
351
+ return (body).byteLength;
352
+ }
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
363
+ };
364
+ const withProgress = (stream, totalBytes, onProgress) => {
365
+ let previousChunk;
366
+ let transferredBytes = 0;
367
+ return stream.pipeThrough(new TransformStream({
368
+ transform(currentChunk, controller) {
369
+ controller.enqueue(currentChunk);
370
+ if (previousChunk) {
371
+ transferredBytes += previousChunk.byteLength;
372
+ let percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
373
+ // Avoid reporting 100% progress before the stream is actually finished (in case totalBytes is inaccurate)
374
+ if (percent >= 1) {
375
+ // Epsilon is used here to get as close as possible to 100% without reaching it.
376
+ // If we were to use 0.99 here, percent could potentially go backwards.
377
+ percent = 1 - Number.EPSILON;
378
+ }
379
+ onProgress?.({ percent, totalBytes: Math.max(totalBytes, transferredBytes), transferredBytes }, previousChunk);
380
+ }
381
+ previousChunk = currentChunk;
382
+ },
383
+ flush() {
384
+ if (previousChunk) {
385
+ transferredBytes += previousChunk.byteLength;
386
+ onProgress?.({ percent: 1, totalBytes: Math.max(totalBytes, transferredBytes), transferredBytes }, previousChunk);
387
+ }
388
+ },
389
+ }));
390
+ };
391
+ const streamResponse = (response, onDownloadProgress) => {
392
+ if (!response.body) {
393
+ return response;
394
+ }
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), {
404
+ status: response.status,
405
+ statusText: response.statusText,
406
+ headers: response.headers,
407
+ });
408
+ };
409
+ // eslint-disable-next-line @typescript-eslint/ban-types
410
+ const streamRequest = (request, onUploadProgress, originalBody) => {
411
+ if (!request.body) {
412
+ return request;
413
+ }
414
+ // Use original body for size calculation since request.body is already a stream
415
+ const totalBytes = getBodySize(originalBody ?? request.body);
416
+ return new Request(request, {
417
+ // @ts-expect-error - Types are outdated.
418
+ duplex: 'half',
419
+ body: withProgress(request.body, totalBytes, onUploadProgress),
420
+ });
421
+ };
422
+
423
+ // eslint-disable-next-line @typescript-eslint/ban-types
424
+ const isObject$1 = (value) => value !== null && typeof value === 'object';
425
+
426
+ const validateAndMerge = (...sources) => {
427
+ for (const source of sources) {
428
+ if ((!isObject$1(source) || Array.isArray(source)) && source !== undefined) {
429
+ throw new TypeError('The `options` argument must be an object');
430
+ }
431
+ }
432
+ return deepMerge({}, ...sources);
433
+ };
434
+ const mergeHeaders = (source1 = {}, source2 = {}) => {
435
+ const result = new globalThis.Headers(source1);
436
+ const isHeadersInstance = source2 instanceof globalThis.Headers;
437
+ const source = new globalThis.Headers(source2);
438
+ for (const [key, value] of source.entries()) {
439
+ if ((isHeadersInstance && value === 'undefined') || value === undefined) {
440
+ result.delete(key);
441
+ }
442
+ else {
443
+ result.set(key, value);
444
+ }
445
+ }
446
+ return result;
447
+ };
448
+ function newHookValue(original, incoming, property) {
449
+ return (Object.hasOwn(incoming, property) && incoming[property] === undefined)
450
+ ? []
451
+ : deepMerge(original[property] ?? [], incoming[property] ?? []);
452
+ }
453
+ const mergeHooks = (original = {}, incoming = {}) => ({
454
+ beforeRequest: newHookValue(original, incoming, 'beforeRequest'),
455
+ beforeRetry: newHookValue(original, incoming, 'beforeRetry'),
456
+ afterResponse: newHookValue(original, incoming, 'afterResponse'),
457
+ beforeError: newHookValue(original, incoming, 'beforeError'),
458
+ });
459
+ const appendSearchParameters = (target, source) => {
460
+ const result = new URLSearchParams();
461
+ for (const input of [target, source]) {
462
+ if (input === undefined) {
463
+ continue;
464
+ }
465
+ if (input instanceof URLSearchParams) {
466
+ for (const [key, value] of input.entries()) {
467
+ result.append(key, value);
468
+ }
469
+ }
470
+ else if (Array.isArray(input)) {
471
+ for (const pair of input) {
472
+ if (!Array.isArray(pair) || pair.length !== 2) {
473
+ throw new TypeError('Array search parameters must be provided in [[key, value], ...] format');
474
+ }
475
+ result.append(String(pair[0]), String(pair[1]));
476
+ }
477
+ }
478
+ else if (isObject$1(input)) {
479
+ for (const [key, value] of Object.entries(input)) {
480
+ if (value !== undefined) {
481
+ result.append(key, String(value));
482
+ }
483
+ }
484
+ }
485
+ else {
486
+ // String
487
+ const parameters = new URLSearchParams(input);
488
+ for (const [key, value] of parameters.entries()) {
489
+ result.append(key, value);
490
+ }
491
+ }
492
+ }
493
+ return result;
494
+ };
495
+ // TODO: Make this strongly-typed (no `any`).
496
+ const deepMerge = (...sources) => {
497
+ let returnValue = {};
498
+ let headers = {};
499
+ let hooks = {};
500
+ let searchParameters;
501
+ const signals = [];
502
+ for (const source of sources) {
503
+ if (Array.isArray(source)) {
504
+ if (!Array.isArray(returnValue)) {
505
+ returnValue = [];
506
+ }
507
+ returnValue = [...returnValue, ...source];
508
+ }
509
+ else if (isObject$1(source)) {
510
+ for (let [key, value] of Object.entries(source)) {
511
+ // Special handling for AbortSignal instances
512
+ if (key === 'signal' && value instanceof globalThis.AbortSignal) {
513
+ signals.push(value);
514
+ continue;
515
+ }
516
+ // Special handling for context - shallow merge only
517
+ if (key === 'context') {
518
+ if (value !== undefined && value !== null && (!isObject$1(value) || Array.isArray(value))) {
519
+ throw new TypeError('The `context` option must be an object');
520
+ }
521
+ // Shallow merge: always create a new object to prevent mutation bugs
522
+ returnValue = {
523
+ ...returnValue,
524
+ context: (value === undefined || value === null)
525
+ ? {}
526
+ : { ...returnValue.context, ...value },
527
+ };
528
+ continue;
529
+ }
530
+ // Special handling for searchParams
531
+ if (key === 'searchParams') {
532
+ if (value === undefined || value === null) {
533
+ // Explicit undefined or null removes searchParams
534
+ searchParameters = undefined;
535
+ }
536
+ else {
537
+ // First source: keep as-is to preserve type (string/object/URLSearchParams)
538
+ // Subsequent sources: merge and convert to URLSearchParams
539
+ searchParameters = searchParameters === undefined ? value : appendSearchParameters(searchParameters, value);
540
+ }
541
+ continue;
542
+ }
543
+ if (isObject$1(value) && key in returnValue) {
544
+ value = deepMerge(returnValue[key], value);
545
+ }
546
+ returnValue = { ...returnValue, [key]: value };
547
+ }
548
+ if (isObject$1(source.hooks)) {
549
+ hooks = mergeHooks(hooks, source.hooks);
550
+ returnValue.hooks = hooks;
551
+ }
552
+ if (isObject$1(source.headers)) {
553
+ headers = mergeHeaders(headers, source.headers);
554
+ returnValue.headers = headers;
555
+ }
556
+ }
557
+ }
558
+ if (searchParameters !== undefined) {
559
+ returnValue.searchParams = searchParameters;
560
+ }
561
+ if (signals.length > 0) {
562
+ if (signals.length === 1) {
563
+ returnValue.signal = signals[0];
564
+ }
565
+ else if (supportsAbortSignal) {
566
+ returnValue.signal = AbortSignal.any(signals);
567
+ }
568
+ else {
569
+ // When AbortSignal.any is not available, use the last signal
570
+ // This maintains the previous behavior before signal merging was added
571
+ // This can be remove when the `supportsAbortSignal` check is removed.`
572
+ returnValue.signal = signals.at(-1);
573
+ }
574
+ }
575
+ if (returnValue.context === undefined) {
576
+ returnValue.context = {};
577
+ }
578
+ return returnValue;
233
579
  };
234
580
 
235
581
  const normalizeRequestMethod = (input) => requestMethods.includes(input) ? input.toUpperCase() : input;
@@ -244,6 +590,8 @@
244
590
  maxRetryAfter: Number.POSITIVE_INFINITY,
245
591
  backoffLimit: Number.POSITIVE_INFINITY,
246
592
  delay: attemptCount => 0.3 * (2 ** (attemptCount - 1)) * 1000,
593
+ jitter: undefined,
594
+ retryOnTimeout: false,
247
595
  };
248
596
  const normalizeRetryOptions = (retry = {}) => {
249
597
  if (typeof retry === 'number') {
@@ -255,15 +603,26 @@
255
603
  if (retry.methods && !Array.isArray(retry.methods)) {
256
604
  throw new Error('retry.methods must be an array');
257
605
  }
606
+ retry.methods &&= retry.methods.map(method => method.toLowerCase());
258
607
  if (retry.statusCodes && !Array.isArray(retry.statusCodes)) {
259
608
  throw new Error('retry.statusCodes must be an array');
260
609
  }
610
+ const normalizedRetry = Object.fromEntries(Object.entries(retry).filter(([, value]) => value !== undefined));
261
611
  return {
262
612
  ...defaultRetryOptions,
263
- ...retry,
613
+ ...normalizedRetry,
264
614
  };
265
615
  };
266
616
 
617
+ class TimeoutError extends Error {
618
+ request;
619
+ constructor(request) {
620
+ super(`Request timed out: ${request.method} ${request.url}`);
621
+ this.name = 'TimeoutError';
622
+ this.request = request;
623
+ }
624
+ }
625
+
267
626
  // `Promise.race()` workaround (#91)
268
627
  async function timeout(request, init, abortController, options) {
269
628
  return new Promise((resolve, reject) => {
@@ -304,163 +663,396 @@
304
663
  const findUnknownOptions = (request, options) => {
305
664
  const unknownOptions = {};
306
665
  for (const key in options) {
307
- if (!(key in requestOptionsRegistry) && !(key in kyOptionKeys) && !(key in request)) {
666
+ // Skip inherited properties
667
+ if (!Object.hasOwn(options, key)) {
668
+ continue;
669
+ }
670
+ // An option is passed to fetch() if:
671
+ // 1. It's not a standard RequestInit option (not in requestOptionsRegistry)
672
+ // 2. It's not a ky-specific option (not in kyOptionKeys)
673
+ // 3. Either:
674
+ // a. It's not on the Request object, OR
675
+ // b. It's a vendor-specific option that should always be passed (in vendorSpecificOptions)
676
+ if (!(key in requestOptionsRegistry) && !(key in kyOptionKeys) && (!(key in request) || key in vendorSpecificOptions)) {
308
677
  unknownOptions[key] = options[key];
309
678
  }
310
679
  }
311
680
  return unknownOptions;
312
681
  };
682
+ const hasSearchParameters = (search) => {
683
+ if (search === undefined) {
684
+ return false;
685
+ }
686
+ // The `typeof array` still gives "object", so we need different checking for array.
687
+ if (Array.isArray(search)) {
688
+ return search.length > 0;
689
+ }
690
+ if (search instanceof URLSearchParams) {
691
+ return search.size > 0;
692
+ }
693
+ // Record
694
+ if (typeof search === 'object') {
695
+ return Object.keys(search).length > 0;
696
+ }
697
+ if (typeof search === 'string') {
698
+ return search.trim().length > 0;
699
+ }
700
+ return Boolean(search);
701
+ };
702
+
703
+ /**
704
+ Type guard to check if an error is a Ky error.
705
+
706
+ @param error - The error to check
707
+ @returns `true` if the error is a Ky error, `false` otherwise
708
+
709
+ @example
710
+ ```
711
+ import ky, {isKyError} from 'ky';
712
+ try {
713
+ const response = await ky.get('/api/data');
714
+ } catch (error) {
715
+ if (isKyError(error)) {
716
+ // Handle Ky-specific errors
717
+ console.log('Ky error occurred:', error.message);
718
+ } else {
719
+ // Handle other errors
720
+ console.log('Unknown error:', error);
721
+ }
722
+ }
723
+ ```
724
+ */
725
+ function isKyError(error) {
726
+ return isHTTPError(error) || isTimeoutError(error) || isForceRetryError(error);
727
+ }
728
+ /**
729
+ Type guard to check if an error is an HTTPError.
730
+
731
+ @param error - The error to check
732
+ @returns `true` if the error is an HTTPError, `false` otherwise
733
+
734
+ @example
735
+ ```
736
+ import ky, {isHTTPError} from 'ky';
737
+ try {
738
+ const response = await ky.get('/api/data');
739
+ } catch (error) {
740
+ if (isHTTPError(error)) {
741
+ console.log('HTTP error status:', error.response.status);
742
+ }
743
+ }
744
+ ```
745
+ */
746
+ function isHTTPError(error) {
747
+ return error instanceof HTTPError || (error?.name === HTTPError.name);
748
+ }
749
+ /**
750
+ Type guard to check if an error is a TimeoutError.
751
+
752
+ @param error - The error to check
753
+ @returns `true` if the error is a TimeoutError, `false` otherwise
754
+
755
+ @example
756
+ ```
757
+ import ky, {isTimeoutError} from 'ky';
758
+ try {
759
+ const response = await ky.get('/api/data', { timeout: 1000 });
760
+ } catch (error) {
761
+ if (isTimeoutError(error)) {
762
+ console.log('Request timed out:', error.request.url);
763
+ }
764
+ }
765
+ ```
766
+ */
767
+ function isTimeoutError(error) {
768
+ return error instanceof TimeoutError || (error?.name === TimeoutError.name);
769
+ }
770
+ /**
771
+ Type guard to check if an error is a ForceRetryError.
772
+
773
+ @param error - The error to check
774
+ @returns `true` if the error is a ForceRetryError, `false` otherwise
775
+
776
+ @example
777
+ ```
778
+ import ky, {isForceRetryError} from 'ky';
779
+
780
+ const api = ky.extend({
781
+ hooks: {
782
+ beforeRetry: [
783
+ ({error, retryCount}) => {
784
+ if (isForceRetryError(error)) {
785
+ console.log(`Forced retry #${retryCount}: ${error.code}`);
786
+ }
787
+ }
788
+ ]
789
+ }
790
+ });
791
+ ```
792
+ */
793
+ function isForceRetryError(error) {
794
+ return error instanceof ForceRetryError || (error?.name === ForceRetryError.name);
795
+ }
313
796
 
314
797
  class Ky {
315
798
  static create(input, options) {
316
799
  const ky = new Ky(input, options);
317
800
  const function_ = async () => {
318
- if (typeof ky._options.timeout === 'number' && ky._options.timeout > maxSafeTimeout) {
801
+ if (typeof ky.#options.timeout === 'number' && ky.#options.timeout > maxSafeTimeout) {
319
802
  throw new RangeError(`The \`timeout\` option cannot be greater than ${maxSafeTimeout}`);
320
803
  }
321
804
  // Delay the fetch so that body method shortcuts can set the Accept header
322
805
  await Promise.resolve();
323
- let response = await ky._fetch();
324
- for (const hook of ky._options.hooks.afterResponse) {
325
- // eslint-disable-next-line no-await-in-loop
326
- const modifiedResponse = await hook(ky.request, ky._options, ky._decorateResponse(response.clone()));
327
- if (modifiedResponse instanceof globalThis.Response) {
328
- response = modifiedResponse;
806
+ // Before using ky.request, _fetch clones it and saves the clone for future retries to use.
807
+ // If retry is not needed, close the cloned request's ReadableStream for memory safety.
808
+ let response = await ky.#fetch();
809
+ for (const hook of ky.#options.hooks.afterResponse) {
810
+ // Clone the response before passing to hook so we can cancel it if needed
811
+ const clonedResponse = ky.#decorateResponse(response.clone());
812
+ let modifiedResponse;
813
+ try {
814
+ // eslint-disable-next-line no-await-in-loop
815
+ modifiedResponse = await hook(ky.request, ky.#getNormalizedOptions(), clonedResponse, { retryCount: ky.#retryCount });
329
816
  }
817
+ catch (error) {
818
+ // Cancel both responses to prevent memory leaks when hook throws
819
+ ky.#cancelResponseBody(clonedResponse);
820
+ ky.#cancelResponseBody(response);
821
+ throw error;
822
+ }
823
+ if (modifiedResponse instanceof RetryMarker) {
824
+ // Cancel both the cloned response passed to the hook and the current response to prevent resource leaks (especially important in Deno/Bun).
825
+ // Do not await cancellation since hooks can clone the response, leaving extra tee branches that keep cancel promises pending per the Streams spec.
826
+ ky.#cancelResponseBody(clonedResponse);
827
+ ky.#cancelResponseBody(response);
828
+ throw new ForceRetryError(modifiedResponse.options);
829
+ }
830
+ // Determine which response to use going forward
831
+ const nextResponse = modifiedResponse instanceof globalThis.Response ? modifiedResponse : response;
832
+ // Cancel any response bodies we won't use to prevent memory leaks.
833
+ // Uses fire-and-forget since hooks may have cloned the response, creating tee branches that block cancellation.
834
+ if (clonedResponse !== nextResponse) {
835
+ ky.#cancelResponseBody(clonedResponse);
836
+ }
837
+ if (response !== nextResponse) {
838
+ ky.#cancelResponseBody(response);
839
+ }
840
+ response = nextResponse;
330
841
  }
331
- ky._decorateResponse(response);
332
- if (!response.ok && ky._options.throwHttpErrors) {
333
- let error = new HTTPError(response, ky.request, ky._options);
334
- for (const hook of ky._options.hooks.beforeError) {
842
+ ky.#decorateResponse(response);
843
+ if (!response.ok && (typeof ky.#options.throwHttpErrors === 'function'
844
+ ? ky.#options.throwHttpErrors(response.status)
845
+ : ky.#options.throwHttpErrors)) {
846
+ let error = new HTTPError(response, ky.request, ky.#getNormalizedOptions());
847
+ for (const hook of ky.#options.hooks.beforeError) {
335
848
  // eslint-disable-next-line no-await-in-loop
336
- error = await hook(error);
849
+ error = await hook(error, { retryCount: ky.#retryCount });
337
850
  }
338
851
  throw error;
339
852
  }
340
853
  // If `onDownloadProgress` is passed, it uses the stream API internally
341
- /* istanbul ignore next */
342
- if (ky._options.onDownloadProgress) {
343
- if (typeof ky._options.onDownloadProgress !== 'function') {
854
+ if (ky.#options.onDownloadProgress) {
855
+ if (typeof ky.#options.onDownloadProgress !== 'function') {
344
856
  throw new TypeError('The `onDownloadProgress` option must be a function');
345
857
  }
346
858
  if (!supportsResponseStreams) {
347
859
  throw new Error('Streams are not supported in your environment. `ReadableStream` is missing.');
348
860
  }
349
- return ky._stream(response.clone(), ky._options.onDownloadProgress);
861
+ const progressResponse = response.clone();
862
+ ky.#cancelResponseBody(response);
863
+ return streamResponse(progressResponse, ky.#options.onDownloadProgress);
350
864
  }
351
865
  return response;
352
866
  };
353
- const isRetriableMethod = ky._options.retry.methods.includes(ky.request.method.toLowerCase());
354
- const result = (isRetriableMethod ? ky._retry(function_) : function_());
867
+ // Always wrap in #retry to catch forced retries from afterResponse hooks
868
+ // Method retriability is checked in #calculateRetryDelay for non-forced retries
869
+ const result = ky.#retry(function_)
870
+ .finally(() => {
871
+ const originalRequest = ky.#originalRequest;
872
+ // Ignore cancellation errors from already-locked or already-consumed streams.
873
+ ky.#cancelBody(originalRequest?.body ?? undefined);
874
+ ky.#cancelBody(ky.request.body ?? undefined);
875
+ });
355
876
  for (const [type, mimeType] of Object.entries(responseTypes)) {
877
+ // Only expose `.bytes()` when the environment implements it.
878
+ if (type === 'bytes'
879
+ && typeof globalThis.Response?.prototype?.bytes !== 'function') {
880
+ continue;
881
+ }
356
882
  result[type] = async () => {
357
883
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
358
884
  ky.request.headers.set('accept', ky.request.headers.get('accept') || mimeType);
359
- const awaitedResult = await result;
360
- const response = awaitedResult.clone();
885
+ const response = await result;
361
886
  if (type === 'json') {
362
887
  if (response.status === 204) {
363
888
  return '';
364
889
  }
365
- const arrayBuffer = await response.clone().arrayBuffer();
366
- const responseSize = arrayBuffer.byteLength;
367
- if (responseSize === 0) {
890
+ const text = await response.text();
891
+ if (text === '') {
368
892
  return '';
369
893
  }
370
894
  if (options.parseJson) {
371
- return options.parseJson(await response.text());
895
+ return options.parseJson(text);
372
896
  }
897
+ return JSON.parse(text);
373
898
  }
374
899
  return response[type]();
375
900
  };
376
901
  }
377
902
  return result;
378
903
  }
904
+ // eslint-disable-next-line unicorn/prevent-abbreviations
905
+ static #normalizeSearchParams(searchParams) {
906
+ // Filter out undefined values from plain objects
907
+ if (searchParams && typeof searchParams === 'object' && !Array.isArray(searchParams) && !(searchParams instanceof URLSearchParams)) {
908
+ return Object.fromEntries(Object.entries(searchParams).filter(([, value]) => value !== undefined));
909
+ }
910
+ return searchParams;
911
+ }
379
912
  request;
380
- abortController;
381
- _retryCount = 0;
382
- _input;
383
- _options;
913
+ #abortController;
914
+ #retryCount = 0;
915
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly -- False positive: #input is reassigned on line 202
916
+ #input;
917
+ #options;
918
+ #originalRequest;
919
+ #userProvidedAbortSignal;
920
+ #cachedNormalizedOptions;
384
921
  // eslint-disable-next-line complexity
385
922
  constructor(input, options = {}) {
386
- this._input = input;
387
- this._options = {
923
+ this.#input = input;
924
+ this.#options = {
388
925
  ...options,
389
- headers: mergeHeaders(this._input.headers, options.headers),
926
+ headers: mergeHeaders(this.#input.headers, options.headers),
390
927
  hooks: mergeHooks({
391
928
  beforeRequest: [],
392
929
  beforeRetry: [],
393
930
  beforeError: [],
394
931
  afterResponse: [],
395
932
  }, options.hooks),
396
- method: normalizeRequestMethod(options.method ?? this._input.method),
933
+ method: normalizeRequestMethod(options.method ?? this.#input.method ?? 'GET'),
397
934
  // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
398
935
  prefixUrl: String(options.prefixUrl || ''),
399
936
  retry: normalizeRetryOptions(options.retry),
400
- throwHttpErrors: options.throwHttpErrors !== false,
937
+ throwHttpErrors: options.throwHttpErrors ?? true,
401
938
  timeout: options.timeout ?? 10_000,
402
939
  fetch: options.fetch ?? globalThis.fetch.bind(globalThis),
940
+ context: options.context ?? {},
403
941
  };
404
- if (typeof this._input !== 'string' && !(this._input instanceof URL || this._input instanceof globalThis.Request)) {
942
+ if (typeof this.#input !== 'string' && !(this.#input instanceof URL || this.#input instanceof globalThis.Request)) {
405
943
  throw new TypeError('`input` must be a string, URL, or Request');
406
944
  }
407
- if (this._options.prefixUrl && typeof this._input === 'string') {
408
- if (this._input.startsWith('/')) {
945
+ if (this.#options.prefixUrl && typeof this.#input === 'string') {
946
+ if (this.#input.startsWith('/')) {
409
947
  throw new Error('`input` must not begin with a slash when using `prefixUrl`');
410
948
  }
411
- if (!this._options.prefixUrl.endsWith('/')) {
412
- this._options.prefixUrl += '/';
949
+ if (!this.#options.prefixUrl.endsWith('/')) {
950
+ this.#options.prefixUrl += '/';
413
951
  }
414
- this._input = this._options.prefixUrl + this._input;
952
+ this.#input = this.#options.prefixUrl + this.#input;
415
953
  }
416
- if (supportsAbortController) {
417
- this.abortController = new globalThis.AbortController();
418
- const originalSignal = this._options.signal ?? this._input.signal;
419
- originalSignal?.addEventListener('abort', () => {
420
- this.abortController.abort(originalSignal.reason);
421
- });
422
- this._options.signal = this.abortController.signal;
954
+ if (supportsAbortController && supportsAbortSignal) {
955
+ this.#userProvidedAbortSignal = this.#options.signal ?? this.#input.signal;
956
+ this.#abortController = new globalThis.AbortController();
957
+ this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal;
423
958
  }
424
959
  if (supportsRequestStreams) {
425
960
  // @ts-expect-error - Types are outdated.
426
- this._options.duplex = 'half';
961
+ this.#options.duplex = 'half';
427
962
  }
428
- if (this._options.json !== undefined) {
429
- this._options.body = this._options.stringifyJson?.(this._options.json) ?? JSON.stringify(this._options.json);
430
- this._options.headers.set('content-type', this._options.headers.get('content-type') ?? 'application/json');
963
+ if (this.#options.json !== undefined) {
964
+ this.#options.body = this.#options.stringifyJson?.(this.#options.json) ?? JSON.stringify(this.#options.json);
965
+ this.#options.headers.set('content-type', this.#options.headers.get('content-type') ?? 'application/json');
431
966
  }
432
- this.request = new globalThis.Request(this._input, this._options);
433
- if (this._options.searchParams) {
967
+ // To provide correct form boundary, Content-Type header should be deleted when creating Request from another Request with FormData/URLSearchParams body
968
+ // Only delete if user didn't explicitly provide a custom content-type
969
+ const userProvidedContentType = options.headers && new globalThis.Headers(options.headers).has('content-type');
970
+ if (this.#input instanceof globalThis.Request
971
+ && ((supportsFormData && this.#options.body instanceof globalThis.FormData) || this.#options.body instanceof URLSearchParams)
972
+ && !userProvidedContentType) {
973
+ this.#options.headers.delete('content-type');
974
+ }
975
+ this.request = new globalThis.Request(this.#input, this.#options);
976
+ if (hasSearchParameters(this.#options.searchParams)) {
434
977
  // eslint-disable-next-line unicorn/prevent-abbreviations
435
- const textSearchParams = typeof this._options.searchParams === 'string'
436
- ? this._options.searchParams.replace(/^\?/, '')
437
- : new URLSearchParams(this._options.searchParams).toString();
978
+ const textSearchParams = typeof this.#options.searchParams === 'string'
979
+ ? this.#options.searchParams.replace(/^\?/, '')
980
+ : new URLSearchParams(Ky.#normalizeSearchParams(this.#options.searchParams)).toString();
438
981
  // eslint-disable-next-line unicorn/prevent-abbreviations
439
982
  const searchParams = '?' + textSearchParams;
440
983
  const url = this.request.url.replace(/(?:\?.*?)?(?=#|$)/, searchParams);
441
- // To provide correct form boundary, Content-Type header should be deleted each time when new Request instantiated from another one
442
- if (((supportsFormData && this._options.body instanceof globalThis.FormData)
443
- || this._options.body instanceof URLSearchParams) && !(this._options.headers && this._options.headers['content-type'])) {
444
- this.request.headers.delete('content-type');
984
+ // Recreate request with the updated URL. We already have all options in this.#options, including duplex.
985
+ this.request = new globalThis.Request(url, this.#options);
986
+ }
987
+ // If `onUploadProgress` is passed, it uses the stream API internally
988
+ if (this.#options.onUploadProgress) {
989
+ if (typeof this.#options.onUploadProgress !== 'function') {
990
+ throw new TypeError('The `onUploadProgress` option must be a function');
991
+ }
992
+ if (!supportsRequestStreams) {
993
+ throw new Error('Request streams are not supported in your environment. The `duplex` option for `Request` is not available.');
994
+ }
995
+ this.request = this.#wrapRequestWithUploadProgress(this.request, this.#options.body ?? undefined);
996
+ }
997
+ }
998
+ #calculateDelay() {
999
+ const retryDelay = this.#options.retry.delay(this.#retryCount);
1000
+ let jitteredDelay = retryDelay;
1001
+ if (this.#options.retry.jitter === true) {
1002
+ jitteredDelay = Math.random() * retryDelay;
1003
+ }
1004
+ else if (typeof this.#options.retry.jitter === 'function') {
1005
+ jitteredDelay = this.#options.retry.jitter(retryDelay);
1006
+ if (!Number.isFinite(jitteredDelay) || jitteredDelay < 0) {
1007
+ jitteredDelay = retryDelay;
445
1008
  }
446
- // The spread of `this.request` is required as otherwise it misses the `duplex` option for some reason and throws.
447
- this.request = new globalThis.Request(new globalThis.Request(url, { ...this.request }), this._options);
448
1009
  }
1010
+ // Handle undefined backoffLimit by treating it as no limit (Infinity)
1011
+ const backoffLimit = this.#options.retry.backoffLimit ?? Number.POSITIVE_INFINITY;
1012
+ return Math.min(backoffLimit, jitteredDelay);
449
1013
  }
450
- _calculateRetryDelay(error) {
451
- this._retryCount++;
452
- if (this._retryCount > this._options.retry.limit || error instanceof TimeoutError) {
1014
+ async #calculateRetryDelay(error) {
1015
+ this.#retryCount++;
1016
+ if (this.#retryCount > this.#options.retry.limit) {
453
1017
  throw error;
454
1018
  }
455
- if (error instanceof HTTPError) {
456
- if (!this._options.retry.statusCodes.includes(error.response.status)) {
1019
+ // Wrap non-Error throws to ensure consistent error handling
1020
+ const errorObject = error instanceof Error ? error : new NonError(error);
1021
+ // Handle forced retry from afterResponse hook - skip method check and shouldRetry
1022
+ if (errorObject instanceof ForceRetryError) {
1023
+ return errorObject.customDelay ?? this.#calculateDelay();
1024
+ }
1025
+ // Check if method is retriable for non-forced retries
1026
+ if (!this.#options.retry.methods.includes(this.request.method.toLowerCase())) {
1027
+ throw error;
1028
+ }
1029
+ // User-provided shouldRetry function takes precedence over all other checks
1030
+ if (this.#options.retry.shouldRetry !== undefined) {
1031
+ const result = await this.#options.retry.shouldRetry({ error: errorObject, retryCount: this.#retryCount });
1032
+ // Strict boolean checking - only exact true/false are handled specially
1033
+ if (result === false) {
1034
+ throw error;
1035
+ }
1036
+ if (result === true) {
1037
+ // Force retry - skip all other validation and return delay
1038
+ return this.#calculateDelay();
1039
+ }
1040
+ // If undefined or any other value, fall through to default behavior
1041
+ }
1042
+ // Default timeout behavior
1043
+ if (isTimeoutError(error) && !this.#options.retry.retryOnTimeout) {
1044
+ throw error;
1045
+ }
1046
+ if (isHTTPError(error)) {
1047
+ if (!this.#options.retry.statusCodes.includes(error.response.status)) {
457
1048
  throw error;
458
1049
  }
459
1050
  const retryAfter = error.response.headers.get('Retry-After')
460
1051
  ?? error.response.headers.get('RateLimit-Reset')
1052
+ ?? error.response.headers.get('X-RateLimit-Retry-After') // Symfony-based services
461
1053
  ?? error.response.headers.get('X-RateLimit-Reset') // GitHub
462
1054
  ?? error.response.headers.get('X-Rate-Limit-Reset'); // Twitter
463
- if (retryAfter && this._options.retry.afterStatusCodes.includes(error.response.status)) {
1055
+ if (retryAfter && this.#options.retry.afterStatusCodes.includes(error.response.status)) {
464
1056
  let after = Number(retryAfter) * 1000;
465
1057
  if (Number.isNaN(after)) {
466
1058
  after = Date.parse(retryAfter) - Date.now();
@@ -469,110 +1061,120 @@
469
1061
  // A large number is treated as a timestamp (fixed threshold protects against clock skew)
470
1062
  after -= Date.now();
471
1063
  }
472
- const max = this._options.retry.maxRetryAfter ?? after;
1064
+ const max = this.#options.retry.maxRetryAfter ?? after;
1065
+ // Don't apply jitter when server provides explicit retry timing
473
1066
  return after < max ? after : max;
474
1067
  }
475
1068
  if (error.response.status === 413) {
476
1069
  throw error;
477
1070
  }
478
1071
  }
479
- const retryDelay = this._options.retry.delay(this._retryCount);
480
- return Math.min(this._options.retry.backoffLimit, retryDelay);
1072
+ return this.#calculateDelay();
481
1073
  }
482
- _decorateResponse(response) {
483
- if (this._options.parseJson) {
484
- response.json = async () => this._options.parseJson(await response.text());
1074
+ #decorateResponse(response) {
1075
+ if (this.#options.parseJson) {
1076
+ response.json = async () => this.#options.parseJson(await response.text());
485
1077
  }
486
1078
  return response;
487
1079
  }
488
- async _retry(function_) {
1080
+ #cancelBody(body) {
1081
+ if (!body) {
1082
+ return;
1083
+ }
1084
+ // Ignore cancellation failures from already-locked or already-consumed streams.
1085
+ void body.cancel().catch(() => undefined);
1086
+ }
1087
+ #cancelResponseBody(response) {
1088
+ // Ignore cancellation failures from already-locked or already-consumed streams.
1089
+ this.#cancelBody(response.body ?? undefined);
1090
+ }
1091
+ async #retry(function_) {
489
1092
  try {
490
1093
  return await function_();
491
1094
  }
492
1095
  catch (error) {
493
- const ms = Math.min(this._calculateRetryDelay(error), maxSafeTimeout);
494
- if (this._retryCount < 1) {
1096
+ const ms = Math.min(await this.#calculateRetryDelay(error), maxSafeTimeout);
1097
+ if (this.#retryCount < 1) {
495
1098
  throw error;
496
1099
  }
497
- await delay(ms, { signal: this._options.signal });
498
- for (const hook of this._options.hooks.beforeRetry) {
1100
+ // Only use user-provided signal for delay, not our internal abortController
1101
+ await delay(ms, this.#userProvidedAbortSignal ? { signal: this.#userProvidedAbortSignal } : {});
1102
+ // Apply custom request from forced retry before beforeRetry hooks
1103
+ // Ensure the custom request has the correct managed signal for timeouts and user aborts
1104
+ if (error instanceof ForceRetryError && error.customRequest) {
1105
+ const managedRequest = this.#options.signal
1106
+ ? new globalThis.Request(error.customRequest, { signal: this.#options.signal })
1107
+ : new globalThis.Request(error.customRequest);
1108
+ this.#assignRequest(managedRequest);
1109
+ }
1110
+ for (const hook of this.#options.hooks.beforeRetry) {
499
1111
  // eslint-disable-next-line no-await-in-loop
500
1112
  const hookResult = await hook({
501
1113
  request: this.request,
502
- options: this._options,
1114
+ options: this.#getNormalizedOptions(),
503
1115
  error: error,
504
- retryCount: this._retryCount,
1116
+ retryCount: this.#retryCount,
505
1117
  });
1118
+ if (hookResult instanceof globalThis.Request) {
1119
+ this.#assignRequest(hookResult);
1120
+ break;
1121
+ }
1122
+ // If a Response is returned, use it and skip the retry
1123
+ if (hookResult instanceof globalThis.Response) {
1124
+ return hookResult;
1125
+ }
506
1126
  // If `stop` is returned from the hook, the retry process is stopped
507
1127
  if (hookResult === stop) {
508
1128
  return;
509
1129
  }
510
1130
  }
511
- return this._retry(function_);
1131
+ return this.#retry(function_);
512
1132
  }
513
1133
  }
514
- async _fetch() {
515
- for (const hook of this._options.hooks.beforeRequest) {
1134
+ async #fetch() {
1135
+ // Reset abortController if it was aborted (happens on timeout retry)
1136
+ if (this.#abortController?.signal.aborted) {
1137
+ this.#abortController = new globalThis.AbortController();
1138
+ this.#options.signal = this.#userProvidedAbortSignal ? AbortSignal.any([this.#userProvidedAbortSignal, this.#abortController.signal]) : this.#abortController.signal;
1139
+ // Recreate request with new signal
1140
+ this.request = new globalThis.Request(this.request, { signal: this.#options.signal });
1141
+ }
1142
+ for (const hook of this.#options.hooks.beforeRequest) {
516
1143
  // eslint-disable-next-line no-await-in-loop
517
- const result = await hook(this.request, this._options);
518
- if (result instanceof Request) {
519
- this.request = result;
520
- break;
521
- }
1144
+ const result = await hook(this.request, this.#getNormalizedOptions(), { retryCount: this.#retryCount });
522
1145
  if (result instanceof Response) {
523
1146
  return result;
524
1147
  }
1148
+ if (result instanceof globalThis.Request) {
1149
+ this.#assignRequest(result);
1150
+ break;
1151
+ }
525
1152
  }
526
- const nonRequestOptions = findUnknownOptions(this.request, this._options);
1153
+ const nonRequestOptions = findUnknownOptions(this.request, this.#options);
527
1154
  // Cloning is done here to prepare in advance for retries
528
- const mainRequest = this.request;
529
- this.request = mainRequest.clone();
530
- if (this._options.timeout === false) {
531
- return this._options.fetch(mainRequest, nonRequestOptions);
532
- }
533
- return timeout(mainRequest, nonRequestOptions, this.abortController, this._options);
534
- }
535
- /* istanbul ignore next */
536
- _stream(response, onDownloadProgress) {
537
- const totalBytes = Number(response.headers.get('content-length')) || 0;
538
- let transferredBytes = 0;
539
- if (response.status === 204) {
540
- if (onDownloadProgress) {
541
- onDownloadProgress({ percent: 1, totalBytes, transferredBytes }, new Uint8Array());
542
- }
543
- return new globalThis.Response(null, {
544
- status: response.status,
545
- statusText: response.statusText,
546
- headers: response.headers,
547
- });
548
- }
549
- return new globalThis.Response(new globalThis.ReadableStream({
550
- async start(controller) {
551
- const reader = response.body.getReader();
552
- if (onDownloadProgress) {
553
- onDownloadProgress({ percent: 0, transferredBytes: 0, totalBytes }, new Uint8Array());
554
- }
555
- async function read() {
556
- const { done, value } = await reader.read();
557
- if (done) {
558
- controller.close();
559
- return;
560
- }
561
- if (onDownloadProgress) {
562
- transferredBytes += value.byteLength;
563
- const percent = totalBytes === 0 ? 0 : transferredBytes / totalBytes;
564
- onDownloadProgress({ percent, transferredBytes, totalBytes }, value);
565
- }
566
- controller.enqueue(value);
567
- await read();
568
- }
569
- await read();
570
- },
571
- }), {
572
- status: response.status,
573
- statusText: response.statusText,
574
- headers: response.headers,
575
- });
1155
+ this.#originalRequest = this.request;
1156
+ this.request = this.#originalRequest.clone();
1157
+ if (this.#options.timeout === false) {
1158
+ return this.#options.fetch(this.#originalRequest, nonRequestOptions);
1159
+ }
1160
+ return timeout(this.#originalRequest, nonRequestOptions, this.#abortController, this.#options);
1161
+ }
1162
+ #getNormalizedOptions() {
1163
+ if (!this.#cachedNormalizedOptions) {
1164
+ const { hooks, ...normalizedOptions } = this.#options;
1165
+ this.#cachedNormalizedOptions = Object.freeze(normalizedOptions);
1166
+ }
1167
+ return this.#cachedNormalizedOptions;
1168
+ }
1169
+ #assignRequest(request) {
1170
+ this.#cachedNormalizedOptions = undefined;
1171
+ this.request = this.#wrapRequestWithUploadProgress(request);
1172
+ }
1173
+ #wrapRequestWithUploadProgress(request, originalBody) {
1174
+ if (!this.#options.onUploadProgress || !request.body) {
1175
+ return request;
1176
+ }
1177
+ return streamRequest(request, this.#options.onUploadProgress, originalBody ?? this.#options.body ?? undefined);
576
1178
  }
577
1179
  }
578
1180
 
@@ -592,19 +1194,39 @@
592
1194
  return createInstance(validateAndMerge(defaults, newDefaults));
593
1195
  };
594
1196
  ky.stop = stop;
1197
+ ky.retry = retry;
595
1198
  return ky;
596
1199
  };
597
1200
  const ky$1 = createInstance();
1201
+ // Intentionally not exporting this for now as it's just an implementation detail and we don't want to commit to a certain API yet at least.
1202
+ // export {NonError} from './errors/NonError.js';
598
1203
 
599
1204
  var distribution = /*#__PURE__*/Object.freeze({
600
1205
  __proto__: null,
1206
+ ForceRetryError: ForceRetryError,
601
1207
  HTTPError: HTTPError,
602
1208
  TimeoutError: TimeoutError,
603
- default: ky$1
1209
+ default: ky$1,
1210
+ isForceRetryError: isForceRetryError,
1211
+ isHTTPError: isHTTPError,
1212
+ isKyError: isKyError,
1213
+ isTimeoutError: isTimeoutError
604
1214
  });
605
1215
 
606
1216
  var require$$1 = /*@__PURE__*/getAugmentedNamespace(distribution);
607
1217
 
1218
+ const VERSION$1 = '0.14.1';
1219
+
1220
+ var constants = {
1221
+ VERSION: VERSION$1,
1222
+ USER_AGENT: `mql/${VERSION$1}`,
1223
+ /**
1224
+ * Based on require('got').defaults.options.retry.statusCodes
1225
+ * but without 429 (too many requests)
1226
+ */
1227
+ RETRY_STATUS_CODES: [408, 413, 500, 502, 503, 504, 521, 522, 524]
1228
+ };
1229
+
608
1230
  const ENDPOINT = {
609
1231
  FREE: 'https://api.microlink.io/',
610
1232
  PRO: 'https://pro.microlink.io/'
@@ -705,7 +1327,7 @@
705
1327
 
706
1328
  const getApiUrl = (
707
1329
  url,
708
- { data, apiKey, endpoint, retry, cache, ...opts } = {},
1330
+ { data, apiKey, endpoint, ...opts } = {},
709
1331
  { responseType = 'json', headers: gotHeaders, ...gotOpts } = {}
710
1332
  ) => {
711
1333
  const isPro = !!apiKey;
@@ -724,7 +1346,7 @@
724
1346
  if (opts.stream) {
725
1347
  responseType = streamResponseType;
726
1348
  }
727
- return [apiUrl, { ...gotOpts, responseType, cache, retry, headers }]
1349
+ return [apiUrl, { ...gotOpts, responseType, headers }]
728
1350
  };
729
1351
 
730
1352
  const createMql = defaultOpts => async (url, opts, gotOpts) => {
@@ -753,6 +1375,13 @@
753
1375
  const { flattie: flatten } = dist;
754
1376
  const { default: ky } = require$$1;
755
1377
 
1378
+ const { VERSION, USER_AGENT, RETRY_STATUS_CODES } = constants;
1379
+
1380
+ const kyInstance = ky.extend({
1381
+ headers: { 'user-agent': USER_AGENT },
1382
+ retry: { statusCodes: RETRY_STATUS_CODES }
1383
+ });
1384
+
756
1385
  const factory = factory_1('arrayBuffer');
757
1386
 
758
1387
  class MicrolinkError extends Error {
@@ -770,7 +1399,7 @@
770
1399
  const got = async (url, { responseType, ...opts }) => {
771
1400
  try {
772
1401
  if (opts.timeout === undefined) opts.timeout = false;
773
- const response = await ky(url, opts);
1402
+ const response = await kyInstance(url, opts);
774
1403
  const body = await response[responseType]();
775
1404
  const { headers, status: statusCode } = response;
776
1405
  return { url: response.url, body, headers, statusCode }
@@ -794,30 +1423,30 @@
794
1423
  }
795
1424
  };
796
1425
 
797
- got.stream = (...args) => ky(...args).then(res => res.body);
1426
+ got.stream = (...args) => kyInstance(...args).then(res => res.body);
798
1427
 
799
1428
  const mql = factory({
800
1429
  MicrolinkError,
801
1430
  got,
802
1431
  flatten,
803
- VERSION: '0.13.20'
1432
+ VERSION
804
1433
  });
805
1434
 
806
- lightweight$1.exports = mql;
807
- var arrayBuffer = lightweight$1.exports.arrayBuffer = mql.extend({ responseType: 'arrayBuffer' });
808
- var extend = lightweight$1.exports.extend = mql.extend;
809
- var fetchFromApi = lightweight$1.exports.fetchFromApi = mql.fetchFromApi;
810
- var getApiUrl = lightweight$1.exports.getApiUrl = mql.getApiUrl;
811
- var mapRules = lightweight$1.exports.mapRules = mql.mapRules;
812
- var MicrolinkError_1 = lightweight$1.exports.MicrolinkError = mql.MicrolinkError;
813
- var version = lightweight$1.exports.version = mql.version;
1435
+ lightweight.exports = mql;
1436
+ var arrayBuffer = lightweight.exports.arrayBuffer = mql.extend({ responseType: 'arrayBuffer' });
1437
+ var extend = lightweight.exports.extend = mql.extend;
1438
+ var fetchFromApi = lightweight.exports.fetchFromApi = mql.fetchFromApi;
1439
+ var getApiUrl = lightweight.exports.getApiUrl = mql.getApiUrl;
1440
+ var mapRules = lightweight.exports.mapRules = mql.mapRules;
1441
+ var MicrolinkError_1 = lightweight.exports.MicrolinkError = mql.MicrolinkError;
1442
+ var version = lightweight.exports.version = mql.version;
814
1443
 
815
- var lightweightExports = lightweight$1.exports;
816
- var lightweight = /*@__PURE__*/getDefaultExportFromCjs(lightweightExports);
1444
+ var lightweightExports = lightweight.exports;
1445
+ var lightweight_default = /*@__PURE__*/getDefaultExportFromCjs(lightweightExports);
817
1446
 
818
1447
  exports.MicrolinkError = MicrolinkError_1;
819
1448
  exports.arrayBuffer = arrayBuffer;
820
- exports.default = lightweight;
1449
+ exports.default = lightweight_default;
821
1450
  exports.extend = extend;
822
1451
  exports.fetchFromApi = fetchFromApi;
823
1452
  exports.getApiUrl = getApiUrl;