got 14.4.8 → 14.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/source/as-promise/index.js +5 -1
- package/dist/source/core/index.d.ts +2 -3
- package/dist/source/core/index.js +144 -26
- package/dist/source/core/options.d.ts +95 -7
- package/dist/source/core/options.js +78 -4
- package/dist/source/core/utils/get-body-size.js +14 -1
- package/package.json +5 -3
|
@@ -68,6 +68,7 @@ export default function asPromise(firstRequest) {
|
|
|
68
68
|
// @ts-expect-error TS doesn't notice that CancelableRequest is a Promise
|
|
69
69
|
// eslint-disable-next-line no-await-in-loop
|
|
70
70
|
response = await hook(response, async (updatedOptions) => {
|
|
71
|
+
const preserveHooks = updatedOptions.preserveHooks ?? false;
|
|
71
72
|
options.merge(updatedOptions);
|
|
72
73
|
options.prefixUrl = '';
|
|
73
74
|
if (updatedOptions.url) {
|
|
@@ -75,7 +76,10 @@ export default function asPromise(firstRequest) {
|
|
|
75
76
|
}
|
|
76
77
|
// Remove any further hooks for that request, because we'll call them anyway.
|
|
77
78
|
// The loop continues. We don't want duplicates (asPromise recursion).
|
|
78
|
-
|
|
79
|
+
// Unless preserveHooks is true, in which case we keep the remaining hooks.
|
|
80
|
+
if (!preserveHooks) {
|
|
81
|
+
options.hooks.afterResponse = options.hooks.afterResponse.slice(0, index);
|
|
82
|
+
}
|
|
79
83
|
throw new RetryError(request);
|
|
80
84
|
});
|
|
81
85
|
if (!(is.object(response) && is.number(response.statusCode) && !is.nullOrUndefined(response.body))) {
|
|
@@ -2,7 +2,7 @@ import { Duplex } from 'node:stream';
|
|
|
2
2
|
import { type ClientRequest } from 'node:http';
|
|
3
3
|
import type { Socket } from 'node:net';
|
|
4
4
|
import { type Timings } from '@szmarczak/http-timer';
|
|
5
|
-
import Options from './options.js';
|
|
5
|
+
import Options, { type OptionsInit } from './options.js';
|
|
6
6
|
import { type PlainResponse, type Response } from './response.js';
|
|
7
7
|
import { RequestError } from './errors.js';
|
|
8
8
|
type Error = NodeJS.ErrnoException;
|
|
@@ -71,7 +71,7 @@ When this event is emitted, you should reset the stream you were writing to and
|
|
|
71
71
|
|
|
72
72
|
See `got.options.retry` for more information.
|
|
73
73
|
*/
|
|
74
|
-
& ((name: 'retry', listener: (retryCount: number, error: RequestError) => void) => T);
|
|
74
|
+
& ((name: 'retry', listener: (retryCount: number, error: RequestError, createRetryStream: (options?: OptionsInit) => Request) => void) => T);
|
|
75
75
|
export type RequestEvents<T> = {
|
|
76
76
|
on: GotEventFunction<T>;
|
|
77
77
|
once: GotEventFunction<T>;
|
|
@@ -99,7 +99,6 @@ export default class Request extends Duplex implements RequestEvents<Request> {
|
|
|
99
99
|
private _bodySize?;
|
|
100
100
|
private _unproxyEvents;
|
|
101
101
|
private _isFromCache?;
|
|
102
|
-
private _cannotHaveBody;
|
|
103
102
|
private _triggerRead;
|
|
104
103
|
private readonly _jobs;
|
|
105
104
|
private _cancelTimeouts;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
3
|
import { Duplex } from 'node:stream';
|
|
4
|
+
import { gunzip, inflate, brotliDecompress } from 'node:zlib';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
4
6
|
import http, { ServerResponse } from 'node:http';
|
|
5
7
|
import timer from '@szmarczak/http-timer';
|
|
6
8
|
import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request';
|
|
@@ -21,6 +23,8 @@ import isUnixSocketURL from './utils/is-unix-socket-url.js';
|
|
|
21
23
|
import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
|
|
22
24
|
const supportsBrotli = is.string(process.versions.brotli);
|
|
23
25
|
const methodsWithoutBody = new Set(['GET', 'HEAD']);
|
|
26
|
+
// Methods that should auto-end streams when no body is provided
|
|
27
|
+
const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
|
|
24
28
|
const cacheableStore = new WeakableMap();
|
|
25
29
|
const redirectCodes = new Set([300, 301, 302, 303, 304, 307, 308]);
|
|
26
30
|
const proxiedRequestEvents = [
|
|
@@ -51,7 +55,6 @@ export default class Request extends Duplex {
|
|
|
51
55
|
_bodySize;
|
|
52
56
|
_unproxyEvents;
|
|
53
57
|
_isFromCache;
|
|
54
|
-
_cannotHaveBody;
|
|
55
58
|
_triggerRead;
|
|
56
59
|
_cancelTimeouts;
|
|
57
60
|
_removeListeners;
|
|
@@ -71,7 +74,6 @@ export default class Request extends Duplex {
|
|
|
71
74
|
this._uploadedSize = 0;
|
|
72
75
|
this._stopReading = false;
|
|
73
76
|
this._pipedServerResponses = new Set();
|
|
74
|
-
this._cannotHaveBody = false;
|
|
75
77
|
this._unproxyEvents = noop;
|
|
76
78
|
this._triggerRead = false;
|
|
77
79
|
this._cancelTimeouts = noop;
|
|
@@ -221,19 +223,29 @@ export default class Request extends Duplex {
|
|
|
221
223
|
}
|
|
222
224
|
}
|
|
223
225
|
const retryOptions = options.retry;
|
|
224
|
-
|
|
226
|
+
const computedValue = calculateRetryDelay({
|
|
225
227
|
attemptCount,
|
|
226
228
|
retryOptions,
|
|
227
229
|
error: typedError,
|
|
228
230
|
retryAfter,
|
|
229
|
-
computedValue:
|
|
231
|
+
computedValue: retryOptions.maxRetryAfter ?? options.timeout.request ?? Number.POSITIVE_INFINITY,
|
|
232
|
+
});
|
|
233
|
+
// When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
|
|
234
|
+
// before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
|
|
235
|
+
// based on these rules), skip calling calculateDelay entirely.
|
|
236
|
+
// When false (default), always call calculateDelay, allowing it to override retry decisions.
|
|
237
|
+
if (retryOptions.enforceRetryRules && computedValue === 0) {
|
|
238
|
+
backoff = 0;
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
backoff = await retryOptions.calculateDelay({
|
|
230
242
|
attemptCount,
|
|
231
243
|
retryOptions,
|
|
232
244
|
error: typedError,
|
|
233
245
|
retryAfter,
|
|
234
|
-
computedValue
|
|
235
|
-
})
|
|
236
|
-
}
|
|
246
|
+
computedValue,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
237
249
|
}
|
|
238
250
|
catch (error_) {
|
|
239
251
|
void this._error(new RequestError(error_.message, error_, this));
|
|
@@ -382,7 +394,6 @@ export default class Request extends Duplex {
|
|
|
382
394
|
const isJSON = !is.undefined(options.json);
|
|
383
395
|
const isBody = !is.undefined(options.body);
|
|
384
396
|
const cannotHaveBody = methodsWithoutBody.has(options.method) && !(options.method === 'GET' && options.allowGetBody);
|
|
385
|
-
this._cannotHaveBody = cannotHaveBody;
|
|
386
397
|
if (isForm || isJSON || isBody) {
|
|
387
398
|
if (cannotHaveBody) {
|
|
388
399
|
throw new TypeError(`The \`${options.method}\` method cannot be used with a body`);
|
|
@@ -449,10 +460,97 @@ export default class Request extends Duplex {
|
|
|
449
460
|
const { options } = this;
|
|
450
461
|
const { url } = options;
|
|
451
462
|
this._nativeResponse = response;
|
|
452
|
-
|
|
463
|
+
const statusCode = response.statusCode;
|
|
464
|
+
const { method } = options;
|
|
465
|
+
// Skip decompression for responses that must not have bodies per RFC 9110:
|
|
466
|
+
// - HEAD responses (any status code)
|
|
467
|
+
// - 1xx (Informational): 100, 101, 102, 103, etc.
|
|
468
|
+
// - 204 (No Content)
|
|
469
|
+
// - 205 (Reset Content)
|
|
470
|
+
// - 304 (Not Modified)
|
|
471
|
+
const hasNoBody = method === 'HEAD'
|
|
472
|
+
|| (statusCode >= 100 && statusCode < 200)
|
|
473
|
+
|| statusCode === 204
|
|
474
|
+
|| statusCode === 205
|
|
475
|
+
|| statusCode === 304;
|
|
476
|
+
const nativeResponse = response;
|
|
477
|
+
if (options.decompress && !hasNoBody) {
|
|
453
478
|
response = decompressResponse(response);
|
|
454
479
|
}
|
|
455
|
-
|
|
480
|
+
// For revalidated cached responses (304 Not Modified), cacheable-request may return
|
|
481
|
+
// a ResponseLike object with the body stored in a .body property but not pushed into
|
|
482
|
+
// the stream. The stream is created and may be ended without data, and the 'end' event is
|
|
483
|
+
// never emitted, causing Got to hang waiting for it. Detect and fix this.
|
|
484
|
+
const nativeAsAny = nativeResponse;
|
|
485
|
+
if (nativeAsAny.body) {
|
|
486
|
+
// Mark the response as complete immediately (ResponseLike doesn't have req.res.complete)
|
|
487
|
+
nativeResponse.complete = true;
|
|
488
|
+
response.complete = true;
|
|
489
|
+
// Use setImmediate to check if this is a stuck revalidated response
|
|
490
|
+
setImmediate(() => {
|
|
491
|
+
// Check if the stream ended with no data (revalidated response)
|
|
492
|
+
if (nativeResponse.readableEnded && !nativeResponse.readableLength) {
|
|
493
|
+
// The body is in nativeAsAny.body but was never pushed to the stream.
|
|
494
|
+
// We need to push it to the native response so it flows through decompression if needed.
|
|
495
|
+
try {
|
|
496
|
+
// Push the body to the native stream
|
|
497
|
+
nativeResponse.push(nativeAsAny.body);
|
|
498
|
+
// eslint-disable-next-line unicorn/no-array-push-push
|
|
499
|
+
nativeResponse.push(null);
|
|
500
|
+
// Update download size with the cached body length
|
|
501
|
+
let bodyLength = 0;
|
|
502
|
+
if (Buffer.isBuffer(nativeAsAny.body)) {
|
|
503
|
+
bodyLength = nativeAsAny.body.length;
|
|
504
|
+
}
|
|
505
|
+
else if (typeof nativeAsAny.body === 'string') {
|
|
506
|
+
bodyLength = Buffer.byteLength(nativeAsAny.body);
|
|
507
|
+
}
|
|
508
|
+
this._downloadedSize += bodyLength;
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
// If push fails (stream already ended), we need to decompress manually for compressed responses
|
|
512
|
+
const encoding = nativeResponse.headers['content-encoding'];
|
|
513
|
+
if (encoding && Buffer.isBuffer(nativeAsAny.body)) {
|
|
514
|
+
// Decompress the body based on the encoding
|
|
515
|
+
let decompressAsync;
|
|
516
|
+
switch (encoding) {
|
|
517
|
+
case 'gzip': {
|
|
518
|
+
decompressAsync = promisify(gunzip);
|
|
519
|
+
break;
|
|
520
|
+
}
|
|
521
|
+
case 'deflate': {
|
|
522
|
+
decompressAsync = promisify(inflate);
|
|
523
|
+
break;
|
|
524
|
+
}
|
|
525
|
+
case 'br': {
|
|
526
|
+
decompressAsync = promisify(brotliDecompress);
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
default: {
|
|
530
|
+
break;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (decompressAsync) {
|
|
534
|
+
// Decompress asynchronously and set rawBody
|
|
535
|
+
void decompressAsync(nativeAsAny.body).then((decompressed) => {
|
|
536
|
+
response.rawBody = decompressed;
|
|
537
|
+
this._downloadedSize += nativeAsAny.body.length;
|
|
538
|
+
response.emit('end');
|
|
539
|
+
}).catch(() => {
|
|
540
|
+
// Decompression failed, use compressed body as-is
|
|
541
|
+
response.rawBody = nativeAsAny.body;
|
|
542
|
+
response.emit('end');
|
|
543
|
+
});
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Not compressed or decompression not needed, set rawBody directly
|
|
548
|
+
response.rawBody = nativeAsAny.body;
|
|
549
|
+
response.emit('end');
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
});
|
|
553
|
+
}
|
|
456
554
|
const typedResponse = response;
|
|
457
555
|
typedResponse.statusMessage = typedResponse.statusMessage ?? http.STATUS_CODES[statusCode];
|
|
458
556
|
typedResponse.url = options.url.toString();
|
|
@@ -466,10 +564,6 @@ export default class Request extends Duplex {
|
|
|
466
564
|
this._isFromCache = typedResponse.isFromCache;
|
|
467
565
|
this._responseSize = Number(response.headers['content-length']) || undefined;
|
|
468
566
|
this.response = typedResponse;
|
|
469
|
-
response.once('end', () => {
|
|
470
|
-
this._responseSize = this._downloadedSize;
|
|
471
|
-
this.emit('downloadProgress', this.downloadProgress);
|
|
472
|
-
});
|
|
473
567
|
response.once('error', (error) => {
|
|
474
568
|
this._aborted = true;
|
|
475
569
|
// Force clean-up, because some packages don't do this.
|
|
@@ -485,7 +579,6 @@ export default class Request extends Duplex {
|
|
|
485
579
|
code: 'ECONNRESET',
|
|
486
580
|
}, this));
|
|
487
581
|
});
|
|
488
|
-
this.emit('downloadProgress', this.downloadProgress);
|
|
489
582
|
const rawCookies = response.headers['set-cookie'];
|
|
490
583
|
if (is.object(options.cookieJar) && rawCookies) {
|
|
491
584
|
let promises = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString()));
|
|
@@ -524,6 +617,8 @@ export default class Request extends Duplex {
|
|
|
524
617
|
return;
|
|
525
618
|
}
|
|
526
619
|
this._request = undefined;
|
|
620
|
+
// Reset download progress for the new request
|
|
621
|
+
this._downloadedSize = 0;
|
|
527
622
|
const updatedOptions = new Options(undefined, undefined, this.options);
|
|
528
623
|
const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD';
|
|
529
624
|
const canRewrite = statusCode !== 307 && statusCode !== 308;
|
|
@@ -589,6 +684,14 @@ export default class Request extends Duplex {
|
|
|
589
684
|
this._beforeError(new HTTPError(typedResponse));
|
|
590
685
|
return;
|
|
591
686
|
}
|
|
687
|
+
// Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
|
|
688
|
+
const endStream = () => {
|
|
689
|
+
this._responseSize = this._downloadedSize;
|
|
690
|
+
this.emit('downloadProgress', this.downloadProgress);
|
|
691
|
+
this.push(null);
|
|
692
|
+
};
|
|
693
|
+
response.once('end', endStream);
|
|
694
|
+
this.emit('downloadProgress', this.downloadProgress);
|
|
592
695
|
response.on('readable', () => {
|
|
593
696
|
if (this._triggerRead) {
|
|
594
697
|
this._read();
|
|
@@ -600,9 +703,6 @@ export default class Request extends Duplex {
|
|
|
600
703
|
this.on('pause', () => {
|
|
601
704
|
response.pause();
|
|
602
705
|
});
|
|
603
|
-
response.once('end', () => {
|
|
604
|
-
this.push(null);
|
|
605
|
-
});
|
|
606
706
|
if (this._noPipe) {
|
|
607
707
|
const success = await this._setRawBody();
|
|
608
708
|
if (success) {
|
|
@@ -657,11 +757,19 @@ export default class Request extends Duplex {
|
|
|
657
757
|
const { options } = this;
|
|
658
758
|
const { timeout, url } = options;
|
|
659
759
|
timer(request);
|
|
760
|
+
this._cancelTimeouts = timedOut(request, timeout, url);
|
|
660
761
|
if (this.options.http2) {
|
|
661
762
|
// Unset stream timeout, as the `timeout` option was used only for connection timeout.
|
|
662
|
-
|
|
763
|
+
// We remove all 'timeout' listeners instead of calling setTimeout(0) because:
|
|
764
|
+
// 1. setTimeout(0) causes a memory leak (see https://github.com/sindresorhus/got/issues/690)
|
|
765
|
+
// 2. With HTTP/2 connection reuse, setTimeout(0) accumulates listeners on the socket
|
|
766
|
+
// 3. removeAllListeners('timeout') properly cleans up without the memory leak
|
|
767
|
+
request.removeAllListeners('timeout');
|
|
768
|
+
// For HTTP/2, wait for socket and remove timeout listeners from it
|
|
769
|
+
request.once('socket', (socket) => {
|
|
770
|
+
socket.removeAllListeners('timeout');
|
|
771
|
+
});
|
|
663
772
|
}
|
|
664
|
-
this._cancelTimeouts = timedOut(request, timeout, url);
|
|
665
773
|
const responseEventName = options.cache ? 'cacheableResponse' : 'response';
|
|
666
774
|
request.once(responseEventName, (response) => {
|
|
667
775
|
void this._onResponse(response);
|
|
@@ -697,7 +805,7 @@ export default class Request extends Duplex {
|
|
|
697
805
|
if (is.nodeStream(body)) {
|
|
698
806
|
body.pipe(currentRequest);
|
|
699
807
|
}
|
|
700
|
-
else if (is.
|
|
808
|
+
else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
|
|
701
809
|
(async () => {
|
|
702
810
|
try {
|
|
703
811
|
for await (const chunk of body) {
|
|
@@ -710,11 +818,16 @@ export default class Request extends Duplex {
|
|
|
710
818
|
}
|
|
711
819
|
})();
|
|
712
820
|
}
|
|
713
|
-
else if (
|
|
714
|
-
|
|
715
|
-
|
|
821
|
+
else if (is.undefined(body)) {
|
|
822
|
+
// No body to send, end the request
|
|
823
|
+
const cannotHaveBody = methodsWithoutBody.has(this.options.method) && !(this.options.method === 'GET' && this.options.allowGetBody);
|
|
824
|
+
const shouldAutoEndStream = methodsWithoutBodyStream.has(this.options.method);
|
|
825
|
+
if ((this._noPipe ?? false) || cannotHaveBody || currentRequest !== this || shouldAutoEndStream) {
|
|
826
|
+
currentRequest.end();
|
|
827
|
+
}
|
|
716
828
|
}
|
|
717
|
-
else
|
|
829
|
+
else {
|
|
830
|
+
this._writeRequest(body, undefined, () => { });
|
|
718
831
|
currentRequest.end();
|
|
719
832
|
}
|
|
720
833
|
}
|
|
@@ -834,7 +947,12 @@ export default class Request extends Duplex {
|
|
|
834
947
|
this._requestOptions._request = request;
|
|
835
948
|
this._requestOptions.cache = options.cache;
|
|
836
949
|
this._requestOptions.body = options.body;
|
|
837
|
-
|
|
950
|
+
try {
|
|
951
|
+
this._prepareCache(options.cache);
|
|
952
|
+
}
|
|
953
|
+
catch (error) {
|
|
954
|
+
throw new CacheError(error, this);
|
|
955
|
+
}
|
|
838
956
|
}
|
|
839
957
|
// Cache support
|
|
840
958
|
const function_ = options.cache ? this._createCacheableRequest : request;
|
|
@@ -7,13 +7,15 @@ import type { Socket } from 'node:net';
|
|
|
7
7
|
import CacheableLookup from 'cacheable-lookup';
|
|
8
8
|
import http2wrapper, { type ClientHttp2Session } from 'http2-wrapper';
|
|
9
9
|
import { type FormDataLike } from 'form-data-encoder';
|
|
10
|
-
import type {
|
|
10
|
+
import type { KeyvStoreAdapter } from 'keyv';
|
|
11
|
+
import type KeyvType from 'keyv';
|
|
11
12
|
import type ResponseLike from 'responselike';
|
|
12
13
|
import type { IncomingMessageWithTimings } from '@szmarczak/http-timer';
|
|
13
14
|
import type { CancelableRequest } from '../as-promise/types.js';
|
|
14
15
|
import type { PlainResponse, Response } from './response.js';
|
|
15
16
|
import type { RequestError } from './errors.js';
|
|
16
17
|
import type { Delays } from './timed-out.js';
|
|
18
|
+
type StorageAdapter = KeyvStoreAdapter | KeyvType | Map<any, any>;
|
|
17
19
|
type Promisable<T> = T | Promise<T>;
|
|
18
20
|
export type DnsLookupIpVersion = undefined | 4 | 6;
|
|
19
21
|
type Except<ObjectType, KeysType extends keyof ObjectType> = Pick<ObjectType, Exclude<keyof ObjectType, KeysType>>;
|
|
@@ -272,9 +274,15 @@ export type Hooks = {
|
|
|
272
274
|
> - When using the Stream API, this hook is ignored.
|
|
273
275
|
|
|
274
276
|
**Note:**
|
|
275
|
-
> - Calling the `retryWithMergedOptions` function will trigger `beforeRetry` hooks.
|
|
277
|
+
> - Calling the `retryWithMergedOptions` function will trigger `beforeRetry` hooks. By default, remaining `afterResponse` hooks are removed to prevent duplicate execution. To preserve remaining hooks on retry, set `preserveHooks: true` in the options passed to `retryWithMergedOptions`. In case of an error, `beforeRetry` hooks will be called instead.
|
|
276
278
|
Meanwhile the `init`, `beforeRequest` , `beforeRedirect` as well as already executed `afterResponse` hooks will be skipped.
|
|
277
279
|
|
|
280
|
+
**Note:**
|
|
281
|
+
> - To preserve remaining `afterResponse` hooks after calling `retryWithMergedOptions`, set `preserveHooks: true` in the options passed to `retryWithMergedOptions`. This is useful when you want hooks to run on retried requests.
|
|
282
|
+
|
|
283
|
+
**Warning:**
|
|
284
|
+
> - Be cautious when using `preserveHooks: true`. If a hook unconditionally calls `retryWithMergedOptions` with `preserveHooks: true`, it will create an infinite retry loop. Always ensure hooks have proper conditional logic to avoid infinite retries.
|
|
285
|
+
|
|
278
286
|
@example
|
|
279
287
|
```
|
|
280
288
|
import got from 'got';
|
|
@@ -312,6 +320,37 @@ export type Hooks = {
|
|
|
312
320
|
mutableDefaults: true
|
|
313
321
|
});
|
|
314
322
|
```
|
|
323
|
+
|
|
324
|
+
@example
|
|
325
|
+
```
|
|
326
|
+
// Example with preserveHooks
|
|
327
|
+
import got from 'got';
|
|
328
|
+
|
|
329
|
+
const instance = got.extend({
|
|
330
|
+
hooks: {
|
|
331
|
+
afterResponse: [
|
|
332
|
+
(response, retryWithMergedOptions) => {
|
|
333
|
+
if (response.statusCode === 401) {
|
|
334
|
+
return retryWithMergedOptions({
|
|
335
|
+
headers: {
|
|
336
|
+
authorization: getNewToken()
|
|
337
|
+
},
|
|
338
|
+
preserveHooks: true // Keep remaining hooks
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return response;
|
|
343
|
+
},
|
|
344
|
+
(response) => {
|
|
345
|
+
// This hook will run on the retried request
|
|
346
|
+
// (the original request is interrupted when the first hook triggers a retry)
|
|
347
|
+
console.log('Response received:', response.statusCode);
|
|
348
|
+
return response;
|
|
349
|
+
}
|
|
350
|
+
]
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
```
|
|
315
354
|
*/
|
|
316
355
|
afterResponse: AfterResponseHook[];
|
|
317
356
|
};
|
|
@@ -337,6 +376,10 @@ Delays between retries counts with function `1000 * Math.pow(2, retry) + Math.ra
|
|
|
337
376
|
The `calculateDelay` property is a `function` that receives an object with `attemptCount`, `retryOptions`, `error` and `computedValue` properties for current retry count, the retry options, error and default computed value.
|
|
338
377
|
The function must return a delay in milliseconds (or a Promise resolving with it) (`0` return value cancels retry).
|
|
339
378
|
|
|
379
|
+
The `enforceRetryRules` property is a `boolean` that, when set to `true`, enforces the `limit`, `methods`, `statusCodes`, and `errorCodes` options before calling `calculateDelay`. Your `calculateDelay` function is only invoked when a retry is allowed based on these criteria. When `false` (default), `calculateDelay` receives the computed value but can override all retry logic.
|
|
380
|
+
|
|
381
|
+
__Note:__ When `enforceRetryRules` is `false`, you must check `computedValue` in your `calculateDelay` function to respect the default retry logic. When `true`, the retry rules are enforced automatically.
|
|
382
|
+
|
|
340
383
|
By default, it retries *only* on the specified methods, status codes, and on these network errors:
|
|
341
384
|
- `ETIMEDOUT`: One of the [timeout](#timeout) limits were reached.
|
|
342
385
|
- `ECONNRESET`: Connection was forcibly closed by a peer.
|
|
@@ -360,6 +403,7 @@ export type RetryOptions = {
|
|
|
360
403
|
backoffLimit: number;
|
|
361
404
|
noise: number;
|
|
362
405
|
maxRetryAfter?: number;
|
|
406
|
+
enforceRetryRules?: boolean;
|
|
363
407
|
};
|
|
364
408
|
export type CreateConnectionFunction = (options: NativeRequestOptions, oncreate: (error: NodeJS.ErrnoException, socket: Socket) => void) => Socket;
|
|
365
409
|
export type CheckServerIdentityFunction = (hostname: string, certificate: DetailedPeerCertificate) => NodeJS.ErrnoException | void;
|
|
@@ -379,6 +423,24 @@ export type HttpsOptions = {
|
|
|
379
423
|
rejectUnauthorized?: NativeRequestOptions['rejectUnauthorized'];
|
|
380
424
|
checkServerIdentity?: CheckServerIdentityFunction;
|
|
381
425
|
/**
|
|
426
|
+
Server name for the [Server Name Indication (SNI)](https://en.wikipedia.org/wiki/Server_Name_Indication) TLS extension.
|
|
427
|
+
|
|
428
|
+
This is useful when requesting to servers that don't have a proper domain name but use a certificate with a known CN/SAN.
|
|
429
|
+
|
|
430
|
+
@example
|
|
431
|
+
```
|
|
432
|
+
import got from 'got';
|
|
433
|
+
|
|
434
|
+
// Request to IP address with specific servername for TLS
|
|
435
|
+
await got('https://192.168.1.100', {
|
|
436
|
+
https: {
|
|
437
|
+
serverName: 'example.com'
|
|
438
|
+
}
|
|
439
|
+
});
|
|
440
|
+
```
|
|
441
|
+
*/
|
|
442
|
+
serverName?: string;
|
|
443
|
+
/**
|
|
382
444
|
Override the default Certificate Authorities ([from Mozilla](https://ccadb-public.secure.force.com/mozilla/IncludedCACertificateReport)).
|
|
383
445
|
|
|
384
446
|
@example
|
|
@@ -551,6 +613,7 @@ export type OptionsError = NodeJS.ErrnoException & {
|
|
|
551
613
|
export type OptionsInit = Except<Partial<InternalsType>, 'hooks' | 'retry'> & {
|
|
552
614
|
hooks?: Partial<Hooks>;
|
|
553
615
|
retry?: Partial<RetryOptions>;
|
|
616
|
+
preserveHooks?: boolean;
|
|
554
617
|
};
|
|
555
618
|
export default class Options {
|
|
556
619
|
private _unixOptions?;
|
|
@@ -675,9 +738,26 @@ export default class Options {
|
|
|
675
738
|
The `content-length` header will be automatically set if `body` is a `string` / `Buffer` / [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) / [`form-data` instance](https://github.com/form-data/form-data), and `content-length` and `transfer-encoding` are not manually set in `options.headers`.
|
|
676
739
|
|
|
677
740
|
Since Got 12, the `content-length` is not automatically set when `body` is a `fs.createReadStream`.
|
|
741
|
+
|
|
742
|
+
You can use `Iterable` and `AsyncIterable` objects as request body, including Web [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream):
|
|
743
|
+
|
|
744
|
+
@example
|
|
745
|
+
```
|
|
746
|
+
import got from 'got';
|
|
747
|
+
|
|
748
|
+
// Using an async generator
|
|
749
|
+
async function* generateData() {
|
|
750
|
+
yield 'Hello, ';
|
|
751
|
+
yield 'world!';
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
await got.post('https://httpbin.org/anything', {
|
|
755
|
+
body: generateData()
|
|
756
|
+
});
|
|
757
|
+
```
|
|
678
758
|
*/
|
|
679
|
-
get body(): string | Buffer | Readable | Generator | AsyncGenerator | FormDataLike | undefined;
|
|
680
|
-
set body(value: string | Buffer | Readable | Generator | AsyncGenerator | FormDataLike | undefined);
|
|
759
|
+
get body(): string | Buffer | Readable | Generator | AsyncGenerator | Iterable<unknown> | AsyncIterable<unknown> | FormDataLike | undefined;
|
|
760
|
+
set body(value: string | Buffer | Readable | Generator | AsyncGenerator | Iterable<unknown> | AsyncIterable<unknown> | FormDataLike | undefined);
|
|
681
761
|
/**
|
|
682
762
|
The form body is converted to a query string using [`(new URLSearchParams(object)).toString()`](https://nodejs.org/api/url.html#url_constructor_new_urlsearchparams_obj).
|
|
683
763
|
|
|
@@ -1005,6 +1085,10 @@ export default class Options {
|
|
|
1005
1085
|
The `calculateDelay` property is a `function` that receives an object with `attemptCount`, `retryOptions`, `error` and `computedValue` properties for current retry count, the retry options, error and default computed value.
|
|
1006
1086
|
The function must return a delay in milliseconds (or a Promise resolving with it) (`0` return value cancels retry).
|
|
1007
1087
|
|
|
1088
|
+
The `enforceRetryRules` property is a `boolean` that, when set to `true`, enforces the `limit`, `methods`, `statusCodes`, and `errorCodes` options before calling `calculateDelay`. Your `calculateDelay` function is only invoked when a retry is allowed based on these criteria. When `false` (default), `calculateDelay` receives the computed value but can override all retry logic.
|
|
1089
|
+
|
|
1090
|
+
__Note:__ When `enforceRetryRules` is `false`, you must check `computedValue` in your `calculateDelay` function to respect the default retry logic. When `true`, the retry rules are enforced automatically.
|
|
1091
|
+
|
|
1008
1092
|
By default, it retries *only* on the specified methods, status codes, and on these network errors:
|
|
1009
1093
|
|
|
1010
1094
|
- `ETIMEDOUT`: One of the [timeout](#timeout) limits were reached.
|
|
@@ -1131,7 +1215,7 @@ export default class Options {
|
|
|
1131
1215
|
h2session: ClientHttp2Session | undefined;
|
|
1132
1216
|
decompress: boolean;
|
|
1133
1217
|
prefixUrl: string | URL;
|
|
1134
|
-
body: string | Buffer | Readable | Generator | AsyncGenerator | FormDataLike | undefined;
|
|
1218
|
+
body: string | Buffer | Readable | Generator | AsyncGenerator | Iterable<unknown> | AsyncIterable<unknown> | FormDataLike | undefined;
|
|
1135
1219
|
form: Record<string, any> | undefined;
|
|
1136
1220
|
url: string | URL | undefined;
|
|
1137
1221
|
cookieJar: PromiseCookieJar | ToughCookieJar | undefined;
|
|
@@ -1174,6 +1258,7 @@ export default class Options {
|
|
|
1174
1258
|
pfx: PfxType;
|
|
1175
1259
|
rejectUnauthorized: boolean | undefined;
|
|
1176
1260
|
checkServerIdentity: CheckServerIdentityFunction | typeof checkServerIdentity;
|
|
1261
|
+
servername: string | undefined;
|
|
1177
1262
|
ciphers: string | undefined;
|
|
1178
1263
|
honorCipherOrder: boolean | undefined;
|
|
1179
1264
|
minVersion: import("tls").SecureVersion | undefined;
|
|
@@ -1192,7 +1277,11 @@ export default class Options {
|
|
|
1192
1277
|
(hostname: string, options: import("cacheable-lookup").LookupOptions, callback: (error: NodeJS.ErrnoException | null, address: string, family: import("cacheable-lookup").IPFamily) => void): void;
|
|
1193
1278
|
} | undefined;
|
|
1194
1279
|
family: DnsLookupIpVersion;
|
|
1195
|
-
agent: false | http.Agent |
|
|
1280
|
+
agent: false | http.Agent | {
|
|
1281
|
+
http2: {};
|
|
1282
|
+
http?: HttpAgent | false;
|
|
1283
|
+
https?: HttpsAgent | false;
|
|
1284
|
+
} | undefined;
|
|
1196
1285
|
setHost: boolean;
|
|
1197
1286
|
method: Method;
|
|
1198
1287
|
maxHeaderSize: number | undefined;
|
|
@@ -1229,7 +1318,6 @@ export default class Options {
|
|
|
1229
1318
|
secureProtocol?: string | undefined;
|
|
1230
1319
|
sessionIdContext?: string | undefined;
|
|
1231
1320
|
ticketKeys?: Buffer | undefined;
|
|
1232
|
-
servername?: string | undefined;
|
|
1233
1321
|
shared?: boolean;
|
|
1234
1322
|
cacheHeuristic?: number;
|
|
1235
1323
|
immutableMinTimeToLive?: number;
|
|
@@ -27,6 +27,39 @@ const getGlobalDnsCache = () => {
|
|
|
27
27
|
globalDnsCache = new CacheableLookup();
|
|
28
28
|
return globalDnsCache;
|
|
29
29
|
};
|
|
30
|
+
// Detects and wraps QuickLRU v7+ instances to make them compatible with the StorageAdapter interface
|
|
31
|
+
const wrapQuickLruIfNeeded = (value) => {
|
|
32
|
+
// Check if this is QuickLRU v7+ using Symbol.toStringTag and the evict method (added in v7)
|
|
33
|
+
if (value?.[Symbol.toStringTag] === 'QuickLRU' && typeof value.evict === 'function') {
|
|
34
|
+
// QuickLRU v7+ uses set(key, value, {maxAge: number}) but StorageAdapter expects set(key, value, ttl)
|
|
35
|
+
// Wrap it to translate the interface
|
|
36
|
+
return {
|
|
37
|
+
get(key) {
|
|
38
|
+
return value.get(key);
|
|
39
|
+
},
|
|
40
|
+
set(key, cacheValue, ttl) {
|
|
41
|
+
if (ttl === undefined) {
|
|
42
|
+
value.set(key, cacheValue);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
value.set(key, cacheValue, { maxAge: ttl });
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
},
|
|
49
|
+
delete(key) {
|
|
50
|
+
return value.delete(key);
|
|
51
|
+
},
|
|
52
|
+
clear() {
|
|
53
|
+
return value.clear();
|
|
54
|
+
},
|
|
55
|
+
has(key) {
|
|
56
|
+
return value.has(key);
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
// QuickLRU v5 and other caches work as-is
|
|
61
|
+
return value;
|
|
62
|
+
};
|
|
30
63
|
const defaultInternals = {
|
|
31
64
|
request: undefined,
|
|
32
65
|
agent: {
|
|
@@ -115,6 +148,8 @@ const defaultInternals = {
|
|
|
115
148
|
calculateDelay: ({ computedValue }) => computedValue,
|
|
116
149
|
backoffLimit: Number.POSITIVE_INFINITY,
|
|
117
150
|
noise: 100,
|
|
151
|
+
// TODO: Change default to `true` in the next major version to fix https://github.com/sindresorhus/got/issues/2243
|
|
152
|
+
enforceRetryRules: false,
|
|
118
153
|
},
|
|
119
154
|
localAddress: undefined,
|
|
120
155
|
method: 'GET',
|
|
@@ -129,6 +164,7 @@ const defaultInternals = {
|
|
|
129
164
|
alpnProtocols: undefined,
|
|
130
165
|
rejectUnauthorized: undefined,
|
|
131
166
|
checkServerIdentity: undefined,
|
|
167
|
+
serverName: undefined,
|
|
132
168
|
certificateAuthority: undefined,
|
|
133
169
|
key: undefined,
|
|
134
170
|
certificate: undefined,
|
|
@@ -386,6 +422,10 @@ export default class Options {
|
|
|
386
422
|
if (key === 'url') {
|
|
387
423
|
continue;
|
|
388
424
|
}
|
|
425
|
+
// Never merge `preserveHooks` - it's a control flag, not a persistent option
|
|
426
|
+
if (key === 'preserveHooks') {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
389
429
|
if (!(key in this)) {
|
|
390
430
|
throw new Error(`Unexpected option: ${key}`);
|
|
391
431
|
}
|
|
@@ -452,7 +492,7 @@ export default class Options {
|
|
|
452
492
|
throw new TypeError(`Unexpected agent option: ${key}`);
|
|
453
493
|
}
|
|
454
494
|
// @ts-expect-error - No idea why `value[key]` doesn't work here.
|
|
455
|
-
assert.any([is.object, is.undefined], value[key]);
|
|
495
|
+
assert.any([is.object, is.undefined, (v) => v === false], value[key]);
|
|
456
496
|
}
|
|
457
497
|
if (this._merging) {
|
|
458
498
|
Object.assign(this._internals.agent, value);
|
|
@@ -593,12 +633,29 @@ export default class Options {
|
|
|
593
633
|
The `content-length` header will be automatically set if `body` is a `string` / `Buffer` / [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) / [`form-data` instance](https://github.com/form-data/form-data), and `content-length` and `transfer-encoding` are not manually set in `options.headers`.
|
|
594
634
|
|
|
595
635
|
Since Got 12, the `content-length` is not automatically set when `body` is a `fs.createReadStream`.
|
|
636
|
+
|
|
637
|
+
You can use `Iterable` and `AsyncIterable` objects as request body, including Web [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream):
|
|
638
|
+
|
|
639
|
+
@example
|
|
640
|
+
```
|
|
641
|
+
import got from 'got';
|
|
642
|
+
|
|
643
|
+
// Using an async generator
|
|
644
|
+
async function* generateData() {
|
|
645
|
+
yield 'Hello, ';
|
|
646
|
+
yield 'world!';
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
await got.post('https://httpbin.org/anything', {
|
|
650
|
+
body: generateData()
|
|
651
|
+
});
|
|
652
|
+
```
|
|
596
653
|
*/
|
|
597
654
|
get body() {
|
|
598
655
|
return this._internals.body;
|
|
599
656
|
}
|
|
600
657
|
set body(value) {
|
|
601
|
-
assert.any([is.string, is.buffer, is.nodeStream, is.generator, is.asyncGenerator, isFormData, is.undefined], value);
|
|
658
|
+
assert.any([is.string, is.buffer, is.nodeStream, is.generator, is.asyncGenerator, is.iterable, is.asyncIterable, isFormData, is.undefined], value);
|
|
602
659
|
if (is.nodeStream(value)) {
|
|
603
660
|
assert.truthy(value.readable);
|
|
604
661
|
}
|
|
@@ -1034,7 +1091,7 @@ export default class Options {
|
|
|
1034
1091
|
this._internals.cache = undefined;
|
|
1035
1092
|
}
|
|
1036
1093
|
else {
|
|
1037
|
-
this._internals.cache = value;
|
|
1094
|
+
this._internals.cache = wrapQuickLruIfNeeded(value);
|
|
1038
1095
|
}
|
|
1039
1096
|
}
|
|
1040
1097
|
/**
|
|
@@ -1261,6 +1318,10 @@ export default class Options {
|
|
|
1261
1318
|
The `calculateDelay` property is a `function` that receives an object with `attemptCount`, `retryOptions`, `error` and `computedValue` properties for current retry count, the retry options, error and default computed value.
|
|
1262
1319
|
The function must return a delay in milliseconds (or a Promise resolving with it) (`0` return value cancels retry).
|
|
1263
1320
|
|
|
1321
|
+
The `enforceRetryRules` property is a `boolean` that, when set to `true`, enforces the `limit`, `methods`, `statusCodes`, and `errorCodes` options before calling `calculateDelay`. Your `calculateDelay` function is only invoked when a retry is allowed based on these criteria. When `false` (default), `calculateDelay` receives the computed value but can override all retry logic.
|
|
1322
|
+
|
|
1323
|
+
__Note:__ When `enforceRetryRules` is `false`, you must check `computedValue` in your `calculateDelay` function to respect the default retry logic. When `true`, the retry rules are enforced automatically.
|
|
1324
|
+
|
|
1264
1325
|
By default, it retries *only* on the specified methods, status codes, and on these network errors:
|
|
1265
1326
|
|
|
1266
1327
|
- `ETIMEDOUT`: One of the [timeout](#timeout) limits were reached.
|
|
@@ -1287,6 +1348,7 @@ export default class Options {
|
|
|
1287
1348
|
assert.any([is.array, is.undefined], value.statusCodes);
|
|
1288
1349
|
assert.any([is.array, is.undefined], value.errorCodes);
|
|
1289
1350
|
assert.any([is.number, is.undefined], value.noise);
|
|
1351
|
+
assert.any([is.boolean, is.undefined], value.enforceRetryRules);
|
|
1290
1352
|
if (value.noise && Math.abs(value.noise) > 100) {
|
|
1291
1353
|
throw new Error(`The maximum acceptable retry noise is +/- 100ms, got ${value.noise}`);
|
|
1292
1354
|
}
|
|
@@ -1373,6 +1435,7 @@ export default class Options {
|
|
|
1373
1435
|
assert.plainObject(value);
|
|
1374
1436
|
assert.any([is.boolean, is.undefined], value.rejectUnauthorized);
|
|
1375
1437
|
assert.any([is.function, is.undefined], value.checkServerIdentity);
|
|
1438
|
+
assert.any([is.string, is.undefined], value.serverName);
|
|
1376
1439
|
assert.any([is.string, is.object, is.array, is.undefined], value.certificateAuthority);
|
|
1377
1440
|
assert.any([is.string, is.object, is.array, is.undefined], value.key);
|
|
1378
1441
|
assert.any([is.string, is.object, is.array, is.undefined], value.certificate);
|
|
@@ -1539,7 +1602,17 @@ export default class Options {
|
|
|
1539
1602
|
const url = internals.url;
|
|
1540
1603
|
let agent;
|
|
1541
1604
|
if (url.protocol === 'https:') {
|
|
1542
|
-
|
|
1605
|
+
if (internals.http2) {
|
|
1606
|
+
// Ensure HTTP/2 agent is configured for connection reuse
|
|
1607
|
+
// If no custom agent.http2 is provided, use the global agent for connection pooling
|
|
1608
|
+
agent = {
|
|
1609
|
+
...internals.agent,
|
|
1610
|
+
http2: internals.agent.http2 ?? http2wrapper.globalAgent,
|
|
1611
|
+
};
|
|
1612
|
+
}
|
|
1613
|
+
else {
|
|
1614
|
+
agent = internals.agent.https;
|
|
1615
|
+
}
|
|
1543
1616
|
}
|
|
1544
1617
|
else {
|
|
1545
1618
|
agent = internals.agent.http;
|
|
@@ -1565,6 +1638,7 @@ export default class Options {
|
|
|
1565
1638
|
pfx: https.pfx,
|
|
1566
1639
|
rejectUnauthorized: https.rejectUnauthorized,
|
|
1567
1640
|
checkServerIdentity: https.checkServerIdentity ?? checkServerIdentity,
|
|
1641
|
+
servername: https.serverName,
|
|
1568
1642
|
ciphers: https.ciphers,
|
|
1569
1643
|
honorCipherOrder: https.honorCipherOrder,
|
|
1570
1644
|
minVersion: https.minVersion,
|
|
@@ -16,7 +16,20 @@ export default async function getBodySize(body, headers) {
|
|
|
16
16
|
return body.length;
|
|
17
17
|
}
|
|
18
18
|
if (isFormData(body)) {
|
|
19
|
-
|
|
19
|
+
try {
|
|
20
|
+
return await promisify(body.getLength.bind(body))();
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
const typedError = error;
|
|
24
|
+
throw new Error('Cannot determine content-length for form-data with stream(s) of unknown length. '
|
|
25
|
+
+ 'This is a limitation of the `form-data` package. '
|
|
26
|
+
+ 'To fix this, either:\n'
|
|
27
|
+
+ '1. Use the `knownLength` option when appending streams:\n'
|
|
28
|
+
+ ' form.append(\'file\', stream, {knownLength: 12345});\n'
|
|
29
|
+
+ '2. Switch to spec-compliant FormData (formdata-node package)\n'
|
|
30
|
+
+ 'See: https://github.com/form-data/form-data#alternative-submission-methods\n'
|
|
31
|
+
+ `Original error: ${typedError.message}`);
|
|
32
|
+
}
|
|
20
33
|
}
|
|
21
34
|
return undefined;
|
|
22
35
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "got",
|
|
3
|
-
"version": "14.
|
|
3
|
+
"version": "14.5.0",
|
|
4
4
|
"description": "Human-friendly and powerful HTTP request library for Node.js",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "sindresorhus/got",
|
|
@@ -51,13 +51,14 @@
|
|
|
51
51
|
"@sindresorhus/is": "^7.0.1",
|
|
52
52
|
"@szmarczak/http-timer": "^5.0.1",
|
|
53
53
|
"cacheable-lookup": "^7.0.0",
|
|
54
|
-
"cacheable-request": "^
|
|
54
|
+
"cacheable-request": "^13.0.12",
|
|
55
55
|
"decompress-response": "^6.0.0",
|
|
56
56
|
"form-data-encoder": "^4.0.2",
|
|
57
57
|
"http2-wrapper": "^2.2.1",
|
|
58
|
+
"keyv": "^5.5.3",
|
|
58
59
|
"lowercase-keys": "^3.0.0",
|
|
59
60
|
"p-cancelable": "^4.0.1",
|
|
60
|
-
"responselike": "^
|
|
61
|
+
"responselike": "^4.0.2",
|
|
61
62
|
"type-fest": "^4.26.1"
|
|
62
63
|
},
|
|
63
64
|
"devDependencies": {
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
"p-event": "^6.0.1",
|
|
94
95
|
"pem": "^1.14.8",
|
|
95
96
|
"pify": "^6.1.0",
|
|
97
|
+
"quick-lru": "^7.2.0",
|
|
96
98
|
"readable-stream": "^4.4.2",
|
|
97
99
|
"request": "^2.88.2",
|
|
98
100
|
"sinon": "^19.0.2",
|