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