got 14.4.9 → 14.6.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 +35 -3
- package/dist/source/core/index.d.ts +6 -4
- package/dist/source/core/index.js +213 -44
- package/dist/source/core/options.d.ts +184 -15
- package/dist/source/core/options.js +188 -61
- package/dist/source/core/utils/get-body-size.js +17 -1
- package/dist/source/core/utils/is-unix-socket-url.d.ts +16 -0
- package/dist/source/core/utils/is-unix-socket-url.js +21 -0
- package/dist/source/create.js +9 -1
- package/package.json +13 -10
- package/readme.md +1 -0
|
@@ -18,12 +18,14 @@ export default function asPromise(firstRequest) {
|
|
|
18
18
|
let globalResponse;
|
|
19
19
|
let normalizedOptions;
|
|
20
20
|
const emitter = new EventEmitter();
|
|
21
|
+
let promiseSettled = false;
|
|
21
22
|
const promise = new PCancelable((resolve, reject, onCancel) => {
|
|
22
23
|
onCancel(() => {
|
|
23
24
|
globalRequest.destroy();
|
|
24
25
|
});
|
|
25
26
|
onCancel.shouldReject = false;
|
|
26
27
|
onCancel(() => {
|
|
28
|
+
promiseSettled = true;
|
|
27
29
|
reject(new CancelError(globalRequest));
|
|
28
30
|
});
|
|
29
31
|
const makeRequest = (retryCount) => {
|
|
@@ -68,6 +70,7 @@ export default function asPromise(firstRequest) {
|
|
|
68
70
|
// @ts-expect-error TS doesn't notice that CancelableRequest is a Promise
|
|
69
71
|
// eslint-disable-next-line no-await-in-loop
|
|
70
72
|
response = await hook(response, async (updatedOptions) => {
|
|
73
|
+
const preserveHooks = updatedOptions.preserveHooks ?? false;
|
|
71
74
|
options.merge(updatedOptions);
|
|
72
75
|
options.prefixUrl = '';
|
|
73
76
|
if (updatedOptions.url) {
|
|
@@ -75,10 +78,13 @@ export default function asPromise(firstRequest) {
|
|
|
75
78
|
}
|
|
76
79
|
// Remove any further hooks for that request, because we'll call them anyway.
|
|
77
80
|
// The loop continues. We don't want duplicates (asPromise recursion).
|
|
78
|
-
|
|
81
|
+
// Unless preserveHooks is true, in which case we keep the remaining hooks.
|
|
82
|
+
if (!preserveHooks) {
|
|
83
|
+
options.hooks.afterResponse = options.hooks.afterResponse.slice(0, index);
|
|
84
|
+
}
|
|
79
85
|
throw new RetryError(request);
|
|
80
86
|
});
|
|
81
|
-
if (!(is.object(response) && is.number(response.statusCode) &&
|
|
87
|
+
if (!(is.object(response) && is.number(response.statusCode) && 'body' in response)) {
|
|
82
88
|
throw new TypeError('The `afterResponse` hook returned an invalid value');
|
|
83
89
|
}
|
|
84
90
|
}
|
|
@@ -93,12 +99,27 @@ export default function asPromise(firstRequest) {
|
|
|
93
99
|
return;
|
|
94
100
|
}
|
|
95
101
|
request.destroy();
|
|
102
|
+
promiseSettled = true;
|
|
96
103
|
resolve(request.options.resolveBodyOnly ? response.body : response);
|
|
97
104
|
});
|
|
105
|
+
let handledFinalError = false;
|
|
98
106
|
const onError = (error) => {
|
|
99
107
|
if (promise.isCanceled) {
|
|
100
108
|
return;
|
|
101
109
|
}
|
|
110
|
+
// Route errors emitted directly on the stream (e.g., EPIPE from Node.js)
|
|
111
|
+
// through retry logic first, then handle them here after retries are exhausted.
|
|
112
|
+
// See https://github.com/sindresorhus/got/issues/1995
|
|
113
|
+
if (!request._stopReading) {
|
|
114
|
+
request._beforeError(error);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
// Allow the manual re-emission from Request to land only once.
|
|
118
|
+
if (handledFinalError) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
handledFinalError = true;
|
|
122
|
+
promiseSettled = true;
|
|
102
123
|
const { options } = request;
|
|
103
124
|
if (error instanceof HTTPError && !options.throwHttpErrors) {
|
|
104
125
|
const { response } = error;
|
|
@@ -108,10 +129,21 @@ export default function asPromise(firstRequest) {
|
|
|
108
129
|
}
|
|
109
130
|
reject(error);
|
|
110
131
|
};
|
|
111
|
-
|
|
132
|
+
// Use .on() instead of .once() to keep the listener active across retries.
|
|
133
|
+
// When _stopReading is false, we return early and the error gets re-emitted
|
|
134
|
+
// after retry logic completes, so we need this listener to remain active.
|
|
135
|
+
// See https://github.com/sindresorhus/got/issues/1995
|
|
136
|
+
request.on('error', onError);
|
|
112
137
|
const previousBody = request.options?.body;
|
|
113
138
|
request.once('retry', (newRetryCount, error) => {
|
|
114
139
|
firstRequest = undefined;
|
|
140
|
+
// If promise already settled, don't retry
|
|
141
|
+
// This prevents the race condition in #1489 where a late error
|
|
142
|
+
// (e.g., ECONNRESET after successful response) triggers retry
|
|
143
|
+
// after the promise has already resolved/rejected
|
|
144
|
+
if (promiseSettled) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
115
147
|
const newBody = request.options.body;
|
|
116
148
|
if (previousBody === newBody && is.nodeStream(newBody)) {
|
|
117
149
|
error.message = 'Cannot retry with consumed body stream';
|
|
@@ -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>;
|
|
@@ -88,18 +88,17 @@ export default class Request extends Duplex implements RequestEvents<Request> {
|
|
|
88
88
|
requestUrl?: URL;
|
|
89
89
|
redirectUrls: URL[];
|
|
90
90
|
retryCount: number;
|
|
91
|
+
_stopReading: boolean;
|
|
91
92
|
private _requestOptions;
|
|
92
93
|
private _stopRetry;
|
|
93
94
|
private _downloadedSize;
|
|
94
95
|
private _uploadedSize;
|
|
95
|
-
private _stopReading;
|
|
96
96
|
private readonly _pipedServerResponses;
|
|
97
97
|
private _request?;
|
|
98
98
|
private _responseSize?;
|
|
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;
|
|
@@ -107,6 +106,8 @@ export default class Request extends Duplex implements RequestEvents<Request> {
|
|
|
107
106
|
private _nativeResponse?;
|
|
108
107
|
private _flushed;
|
|
109
108
|
private _aborted;
|
|
109
|
+
private _expectedContentLength?;
|
|
110
|
+
private _byteCounter?;
|
|
110
111
|
private _requestInitialized;
|
|
111
112
|
constructor(url: UrlType, options?: OptionsType, defaults?: DefaultsType);
|
|
112
113
|
flush(): Promise<void>;
|
|
@@ -119,6 +120,7 @@ export default class Request extends Duplex implements RequestEvents<Request> {
|
|
|
119
120
|
end?: boolean;
|
|
120
121
|
}): T;
|
|
121
122
|
unpipe<T extends NodeJS.WritableStream>(destination: T): this;
|
|
123
|
+
private _checkContentLengthMismatch;
|
|
122
124
|
private _finalizeBody;
|
|
123
125
|
private _onResponseBase;
|
|
124
126
|
private _setRawBody;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
|
-
import { Duplex } from 'node:stream';
|
|
3
|
+
import { Duplex, Transform } from 'node:stream';
|
|
4
4
|
import http, { ServerResponse } from 'node:http';
|
|
5
5
|
import timer from '@szmarczak/http-timer';
|
|
6
6
|
import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request';
|
|
@@ -17,10 +17,12 @@ import calculateRetryDelay from './calculate-retry-delay.js';
|
|
|
17
17
|
import Options from './options.js';
|
|
18
18
|
import { isResponseOk } from './response.js';
|
|
19
19
|
import isClientRequest from './utils/is-client-request.js';
|
|
20
|
-
import isUnixSocketURL from './utils/is-unix-socket-url.js';
|
|
20
|
+
import isUnixSocketURL, { getUnixSocketPath } from './utils/is-unix-socket-url.js';
|
|
21
21
|
import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
|
|
22
22
|
const supportsBrotli = is.string(process.versions.brotli);
|
|
23
23
|
const methodsWithoutBody = new Set(['GET', 'HEAD']);
|
|
24
|
+
// Methods that should auto-end streams when no body is provided
|
|
25
|
+
const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
|
|
24
26
|
const cacheableStore = new WeakableMap();
|
|
25
27
|
const redirectCodes = new Set([300, 301, 302, 303, 304, 307, 308]);
|
|
26
28
|
const proxiedRequestEvents = [
|
|
@@ -31,6 +33,17 @@ const proxiedRequestEvents = [
|
|
|
31
33
|
'upgrade',
|
|
32
34
|
];
|
|
33
35
|
const noop = () => { };
|
|
36
|
+
/**
|
|
37
|
+
Stream transform that counts bytes passing through.
|
|
38
|
+
Used to track compressed bytes before decompression for content-length validation.
|
|
39
|
+
*/
|
|
40
|
+
class ByteCounter extends Transform {
|
|
41
|
+
count = 0;
|
|
42
|
+
_transform(chunk, _encoding, callback) {
|
|
43
|
+
this.count += chunk.length;
|
|
44
|
+
callback(null, chunk);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
34
47
|
export default class Request extends Duplex {
|
|
35
48
|
// @ts-expect-error - Ignoring for now.
|
|
36
49
|
['constructor'];
|
|
@@ -41,23 +54,24 @@ export default class Request extends Duplex {
|
|
|
41
54
|
requestUrl;
|
|
42
55
|
redirectUrls;
|
|
43
56
|
retryCount;
|
|
57
|
+
_stopReading;
|
|
44
58
|
_stopRetry;
|
|
45
59
|
_downloadedSize;
|
|
46
60
|
_uploadedSize;
|
|
47
|
-
_stopReading;
|
|
48
61
|
_pipedServerResponses;
|
|
49
62
|
_request;
|
|
50
63
|
_responseSize;
|
|
51
64
|
_bodySize;
|
|
52
65
|
_unproxyEvents;
|
|
53
66
|
_isFromCache;
|
|
54
|
-
_cannotHaveBody;
|
|
55
67
|
_triggerRead;
|
|
56
68
|
_cancelTimeouts;
|
|
57
69
|
_removeListeners;
|
|
58
70
|
_nativeResponse;
|
|
59
71
|
_flushed;
|
|
60
72
|
_aborted;
|
|
73
|
+
_expectedContentLength;
|
|
74
|
+
_byteCounter;
|
|
61
75
|
// We need this because `this._request` if `undefined` when using cache
|
|
62
76
|
_requestInitialized;
|
|
63
77
|
constructor(url, options, defaults) {
|
|
@@ -71,7 +85,6 @@ export default class Request extends Duplex {
|
|
|
71
85
|
this._uploadedSize = 0;
|
|
72
86
|
this._stopReading = false;
|
|
73
87
|
this._pipedServerResponses = new Set();
|
|
74
|
-
this._cannotHaveBody = false;
|
|
75
88
|
this._unproxyEvents = noop;
|
|
76
89
|
this._triggerRead = false;
|
|
77
90
|
this._cancelTimeouts = noop;
|
|
@@ -110,7 +123,18 @@ export default class Request extends Duplex {
|
|
|
110
123
|
}
|
|
111
124
|
this.flush = async () => {
|
|
112
125
|
this.flush = async () => { };
|
|
113
|
-
|
|
126
|
+
// Defer error emission to next tick to allow user to attach error handlers
|
|
127
|
+
process.nextTick(() => {
|
|
128
|
+
// _beforeError requires options to access retry logic and hooks
|
|
129
|
+
if (this.options) {
|
|
130
|
+
this._beforeError(error);
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
// Options is undefined, skip _beforeError and destroy directly
|
|
134
|
+
const requestError = error instanceof RequestError ? error : new RequestError(error.message, error, this);
|
|
135
|
+
this.destroy(requestError);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
114
138
|
};
|
|
115
139
|
return;
|
|
116
140
|
}
|
|
@@ -221,19 +245,29 @@ export default class Request extends Duplex {
|
|
|
221
245
|
}
|
|
222
246
|
}
|
|
223
247
|
const retryOptions = options.retry;
|
|
224
|
-
|
|
248
|
+
const computedValue = calculateRetryDelay({
|
|
225
249
|
attemptCount,
|
|
226
250
|
retryOptions,
|
|
227
251
|
error: typedError,
|
|
228
252
|
retryAfter,
|
|
229
|
-
computedValue:
|
|
253
|
+
computedValue: retryOptions.maxRetryAfter ?? options.timeout.request ?? Number.POSITIVE_INFINITY,
|
|
254
|
+
});
|
|
255
|
+
// When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
|
|
256
|
+
// before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
|
|
257
|
+
// based on these rules), skip calling calculateDelay entirely.
|
|
258
|
+
// When false (default), always call calculateDelay, allowing it to override retry decisions.
|
|
259
|
+
if (retryOptions.enforceRetryRules && computedValue === 0) {
|
|
260
|
+
backoff = 0;
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
backoff = await retryOptions.calculateDelay({
|
|
230
264
|
attemptCount,
|
|
231
265
|
retryOptions,
|
|
232
266
|
error: typedError,
|
|
233
267
|
retryAfter,
|
|
234
|
-
computedValue
|
|
235
|
-
})
|
|
236
|
-
}
|
|
268
|
+
computedValue,
|
|
269
|
+
});
|
|
270
|
+
}
|
|
237
271
|
}
|
|
238
272
|
catch (error_) {
|
|
239
273
|
void this._error(new RequestError(error_.message, error_, this));
|
|
@@ -356,6 +390,19 @@ export default class Request extends Duplex {
|
|
|
356
390
|
if (this._request) {
|
|
357
391
|
this._request.destroy();
|
|
358
392
|
}
|
|
393
|
+
// Workaround: http-timer only sets timings.end when the response emits 'end'.
|
|
394
|
+
// When a stream is destroyed before completion, the 'end' event may not fire,
|
|
395
|
+
// leaving timings.end undefined. This should ideally be fixed in http-timer
|
|
396
|
+
// by listening to the 'close' event, but we handle it here for now.
|
|
397
|
+
// Only set timings.end if there was no error or abort (to maintain semantic correctness).
|
|
398
|
+
const timings = this._request?.timings;
|
|
399
|
+
if (timings && is.undefined(timings.end) && !is.undefined(timings.response) && is.undefined(timings.error) && is.undefined(timings.abort)) {
|
|
400
|
+
timings.end = Date.now();
|
|
401
|
+
if (is.undefined(timings.phases.total)) {
|
|
402
|
+
timings.phases.download = timings.end - timings.response;
|
|
403
|
+
timings.phases.total = timings.end - timings.start;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
359
406
|
if (error !== null && !is.undefined(error) && !(error instanceof RequestError)) {
|
|
360
407
|
error = new RequestError(error.message, error, this);
|
|
361
408
|
}
|
|
@@ -374,6 +421,22 @@ export default class Request extends Duplex {
|
|
|
374
421
|
super.unpipe(destination);
|
|
375
422
|
return this;
|
|
376
423
|
}
|
|
424
|
+
_checkContentLengthMismatch() {
|
|
425
|
+
if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
|
|
426
|
+
// Use ByteCounter's count when available (for compressed responses),
|
|
427
|
+
// otherwise use _downloadedSize (for uncompressed responses)
|
|
428
|
+
const actualSize = this._byteCounter?.count ?? this._downloadedSize;
|
|
429
|
+
if (actualSize !== this._expectedContentLength) {
|
|
430
|
+
this._beforeError(new ReadError({
|
|
431
|
+
message: `Content-Length mismatch: expected ${this._expectedContentLength} bytes, received ${actualSize} bytes`,
|
|
432
|
+
name: 'Error',
|
|
433
|
+
code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH',
|
|
434
|
+
}, this));
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
377
440
|
async _finalizeBody() {
|
|
378
441
|
const { options } = this;
|
|
379
442
|
const { headers } = options;
|
|
@@ -382,7 +445,6 @@ export default class Request extends Duplex {
|
|
|
382
445
|
const isJSON = !is.undefined(options.json);
|
|
383
446
|
const isBody = !is.undefined(options.body);
|
|
384
447
|
const cannotHaveBody = methodsWithoutBody.has(options.method) && !(options.method === 'GET' && options.allowGetBody);
|
|
385
|
-
this._cannotHaveBody = cannotHaveBody;
|
|
386
448
|
if (isForm || isJSON || isBody) {
|
|
387
449
|
if (cannotHaveBody) {
|
|
388
450
|
throw new TypeError(`The \`${options.method}\` method cannot be used with a body`);
|
|
@@ -463,10 +525,18 @@ export default class Request extends Duplex {
|
|
|
463
525
|
|| statusCode === 205
|
|
464
526
|
|| statusCode === 304;
|
|
465
527
|
if (options.decompress && !hasNoBody) {
|
|
528
|
+
// When strictContentLength is enabled, track compressed bytes by listening to
|
|
529
|
+
// the native response's data events before decompression
|
|
530
|
+
if (options.strictContentLength) {
|
|
531
|
+
this._byteCounter = new ByteCounter();
|
|
532
|
+
this._nativeResponse.on('data', (chunk) => {
|
|
533
|
+
this._byteCounter.count += chunk.length;
|
|
534
|
+
});
|
|
535
|
+
}
|
|
466
536
|
response = decompressResponse(response);
|
|
467
537
|
}
|
|
468
538
|
const typedResponse = response;
|
|
469
|
-
typedResponse.statusMessage = typedResponse.statusMessage
|
|
539
|
+
typedResponse.statusMessage = typedResponse.statusMessage || http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
|
|
470
540
|
typedResponse.url = options.url.toString();
|
|
471
541
|
typedResponse.requestUrl = this.requestUrl;
|
|
472
542
|
typedResponse.redirectUrls = this.redirectUrls;
|
|
@@ -478,10 +548,18 @@ export default class Request extends Duplex {
|
|
|
478
548
|
this._isFromCache = typedResponse.isFromCache;
|
|
479
549
|
this._responseSize = Number(response.headers['content-length']) || undefined;
|
|
480
550
|
this.response = typedResponse;
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
551
|
+
// Workaround for http-timer bug: when connecting to an IP address (no DNS lookup),
|
|
552
|
+
// http-timer sets lookup = connect instead of lookup = socket, resulting in
|
|
553
|
+
// dns = lookup - socket being a small positive number instead of 0.
|
|
554
|
+
// See https://github.com/sindresorhus/got/issues/2279
|
|
555
|
+
const { timings } = response;
|
|
556
|
+
if (timings?.lookup !== undefined && timings.socket !== undefined && timings.connect !== undefined && timings.lookup === timings.connect && timings.phases.dns !== 0) {
|
|
557
|
+
// Fix the DNS phase to be 0 and set lookup to socket time
|
|
558
|
+
timings.phases.dns = 0;
|
|
559
|
+
timings.lookup = timings.socket;
|
|
560
|
+
// Recalculate TCP time to be the full time from socket to connect
|
|
561
|
+
timings.phases.tcp = timings.connect - timings.socket;
|
|
562
|
+
}
|
|
485
563
|
response.once('error', (error) => {
|
|
486
564
|
this._aborted = true;
|
|
487
565
|
// Force clean-up, because some packages don't do this.
|
|
@@ -491,13 +569,15 @@ export default class Request extends Duplex {
|
|
|
491
569
|
});
|
|
492
570
|
response.once('aborted', () => {
|
|
493
571
|
this._aborted = true;
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
572
|
+
// Check if there's a content-length mismatch to provide a more specific error
|
|
573
|
+
if (!this._checkContentLengthMismatch()) {
|
|
574
|
+
this._beforeError(new ReadError({
|
|
575
|
+
name: 'Error',
|
|
576
|
+
message: 'The server aborted pending request',
|
|
577
|
+
code: 'ECONNRESET',
|
|
578
|
+
}, this));
|
|
579
|
+
}
|
|
499
580
|
});
|
|
500
|
-
this.emit('downloadProgress', this.downloadProgress);
|
|
501
581
|
const rawCookies = response.headers['set-cookie'];
|
|
502
582
|
if (is.object(options.cookieJar) && rawCookies) {
|
|
503
583
|
let promises = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString()));
|
|
@@ -536,6 +616,8 @@ export default class Request extends Duplex {
|
|
|
536
616
|
return;
|
|
537
617
|
}
|
|
538
618
|
this._request = undefined;
|
|
619
|
+
// Reset download progress for the new request
|
|
620
|
+
this._downloadedSize = 0;
|
|
539
621
|
const updatedOptions = new Options(undefined, undefined, this.options);
|
|
540
622
|
const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD';
|
|
541
623
|
const canRewrite = statusCode !== 307 && statusCode !== 308;
|
|
@@ -556,7 +638,11 @@ export default class Request extends Duplex {
|
|
|
556
638
|
return;
|
|
557
639
|
}
|
|
558
640
|
// Redirecting to a different site, clear sensitive data.
|
|
559
|
-
|
|
641
|
+
// For UNIX sockets, different socket paths are also different origins.
|
|
642
|
+
const isDifferentOrigin = redirectUrl.hostname !== url.hostname
|
|
643
|
+
|| redirectUrl.port !== url.port
|
|
644
|
+
|| getUnixSocketPath(url) !== getUnixSocketPath(redirectUrl);
|
|
645
|
+
if (isDifferentOrigin) {
|
|
560
646
|
if ('host' in updatedOptions.headers) {
|
|
561
647
|
delete updatedOptions.headers.host;
|
|
562
648
|
}
|
|
@@ -601,6 +687,33 @@ export default class Request extends Duplex {
|
|
|
601
687
|
this._beforeError(new HTTPError(typedResponse));
|
|
602
688
|
return;
|
|
603
689
|
}
|
|
690
|
+
// Store the expected content-length from the native response for validation.
|
|
691
|
+
// This is the content-length before decompression, which is what actually gets transferred.
|
|
692
|
+
// Skip storing for responses that shouldn't have bodies per RFC 9110.
|
|
693
|
+
// When decompression occurs, only store if strictContentLength is enabled.
|
|
694
|
+
const wasDecompressed = response !== this._nativeResponse;
|
|
695
|
+
if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
|
|
696
|
+
const contentLengthHeader = this._nativeResponse.headers['content-length'];
|
|
697
|
+
if (contentLengthHeader !== undefined) {
|
|
698
|
+
const expectedLength = Number(contentLengthHeader);
|
|
699
|
+
if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
|
|
700
|
+
this._expectedContentLength = expectedLength;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
// Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
|
|
705
|
+
response.once('end', () => {
|
|
706
|
+
// Validate content-length if it was provided
|
|
707
|
+
// Per RFC 9112: "If the sender closes the connection before the indicated number
|
|
708
|
+
// of octets are received, the recipient MUST consider the message to be incomplete"
|
|
709
|
+
if (this._checkContentLengthMismatch()) {
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
this._responseSize = this._downloadedSize;
|
|
713
|
+
this.emit('downloadProgress', this.downloadProgress);
|
|
714
|
+
this.push(null);
|
|
715
|
+
});
|
|
716
|
+
this.emit('downloadProgress', this.downloadProgress);
|
|
604
717
|
response.on('readable', () => {
|
|
605
718
|
if (this._triggerRead) {
|
|
606
719
|
this._read();
|
|
@@ -612,9 +725,6 @@ export default class Request extends Duplex {
|
|
|
612
725
|
this.on('pause', () => {
|
|
613
726
|
response.pause();
|
|
614
727
|
});
|
|
615
|
-
response.once('end', () => {
|
|
616
|
-
this.push(null);
|
|
617
|
-
});
|
|
618
728
|
if (this._noPipe) {
|
|
619
729
|
const success = await this._setRawBody();
|
|
620
730
|
if (success) {
|
|
@@ -627,12 +737,22 @@ export default class Request extends Duplex {
|
|
|
627
737
|
if (destination.headersSent) {
|
|
628
738
|
continue;
|
|
629
739
|
}
|
|
630
|
-
//
|
|
740
|
+
// Check if decompression actually occurred by comparing stream objects.
|
|
741
|
+
// decompressResponse wraps the response stream when it decompresses,
|
|
742
|
+
// so response !== this._nativeResponse indicates decompression happened.
|
|
743
|
+
const wasDecompressed = response !== this._nativeResponse;
|
|
631
744
|
for (const key in response.headers) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
745
|
+
if (Object.hasOwn(response.headers, key)) {
|
|
746
|
+
const value = response.headers[key];
|
|
747
|
+
// When decompression occurred, skip content-encoding and content-length
|
|
748
|
+
// as they refer to the compressed data, not the decompressed stream.
|
|
749
|
+
if (wasDecompressed && (key === 'content-encoding' || key === 'content-length')) {
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
// Skip if value is undefined
|
|
753
|
+
if (value !== undefined) {
|
|
754
|
+
destination.setHeader(key, value);
|
|
755
|
+
}
|
|
636
756
|
}
|
|
637
757
|
}
|
|
638
758
|
destination.statusCode = statusCode;
|
|
@@ -669,11 +789,19 @@ export default class Request extends Duplex {
|
|
|
669
789
|
const { options } = this;
|
|
670
790
|
const { timeout, url } = options;
|
|
671
791
|
timer(request);
|
|
792
|
+
this._cancelTimeouts = timedOut(request, timeout, url);
|
|
672
793
|
if (this.options.http2) {
|
|
673
794
|
// Unset stream timeout, as the `timeout` option was used only for connection timeout.
|
|
674
|
-
|
|
795
|
+
// We remove all 'timeout' listeners instead of calling setTimeout(0) because:
|
|
796
|
+
// 1. setTimeout(0) causes a memory leak (see https://github.com/sindresorhus/got/issues/690)
|
|
797
|
+
// 2. With HTTP/2 connection reuse, setTimeout(0) accumulates listeners on the socket
|
|
798
|
+
// 3. removeAllListeners('timeout') properly cleans up without the memory leak
|
|
799
|
+
request.removeAllListeners('timeout');
|
|
800
|
+
// For HTTP/2, wait for socket and remove timeout listeners from it
|
|
801
|
+
request.once('socket', (socket) => {
|
|
802
|
+
socket.removeAllListeners('timeout');
|
|
803
|
+
});
|
|
675
804
|
}
|
|
676
|
-
this._cancelTimeouts = timedOut(request, timeout, url);
|
|
677
805
|
const responseEventName = options.cache ? 'cacheableResponse' : 'response';
|
|
678
806
|
request.once(responseEventName, (response) => {
|
|
679
807
|
void this._onResponse(response);
|
|
@@ -709,7 +837,20 @@ export default class Request extends Duplex {
|
|
|
709
837
|
if (is.nodeStream(body)) {
|
|
710
838
|
body.pipe(currentRequest);
|
|
711
839
|
}
|
|
712
|
-
else if (is.
|
|
840
|
+
else if (is.buffer(body)) {
|
|
841
|
+
// Buffer should be sent directly without conversion
|
|
842
|
+
this._writeRequest(body, undefined, () => { });
|
|
843
|
+
currentRequest.end();
|
|
844
|
+
}
|
|
845
|
+
else if (is.typedArray(body)) {
|
|
846
|
+
// Typed arrays should be treated like buffers, not iterated over
|
|
847
|
+
// Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
|
|
848
|
+
const typedArray = body;
|
|
849
|
+
const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
|
|
850
|
+
this._writeRequest(uint8View, undefined, () => { });
|
|
851
|
+
currentRequest.end();
|
|
852
|
+
}
|
|
853
|
+
else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
|
|
713
854
|
(async () => {
|
|
714
855
|
try {
|
|
715
856
|
for await (const chunk of body) {
|
|
@@ -722,11 +863,16 @@ export default class Request extends Duplex {
|
|
|
722
863
|
}
|
|
723
864
|
})();
|
|
724
865
|
}
|
|
725
|
-
else if (
|
|
726
|
-
|
|
727
|
-
|
|
866
|
+
else if (is.undefined(body)) {
|
|
867
|
+
// No body to send, end the request
|
|
868
|
+
const cannotHaveBody = methodsWithoutBody.has(this.options.method) && !(this.options.method === 'GET' && this.options.allowGetBody);
|
|
869
|
+
const shouldAutoEndStream = methodsWithoutBodyStream.has(this.options.method);
|
|
870
|
+
if ((this._noPipe ?? false) || cannotHaveBody || currentRequest !== this || shouldAutoEndStream) {
|
|
871
|
+
currentRequest.end();
|
|
872
|
+
}
|
|
728
873
|
}
|
|
729
|
-
else
|
|
874
|
+
else {
|
|
875
|
+
this._writeRequest(body, undefined, () => { });
|
|
730
876
|
currentRequest.end();
|
|
731
877
|
}
|
|
732
878
|
}
|
|
@@ -783,9 +929,15 @@ export default class Request extends Duplex {
|
|
|
783
929
|
response._readableState.autoDestroy = false;
|
|
784
930
|
if (request) {
|
|
785
931
|
const fix = () => {
|
|
932
|
+
// For ResponseLike objects from cache, set complete to true if not already set.
|
|
933
|
+
// For real HTTP responses, copy from the underlying response.
|
|
786
934
|
if (response.req) {
|
|
787
935
|
response.complete = response.req.res.complete;
|
|
788
936
|
}
|
|
937
|
+
else if (response.complete === undefined) {
|
|
938
|
+
// ResponseLike from cache should have complete = true
|
|
939
|
+
response.complete = true;
|
|
940
|
+
}
|
|
789
941
|
};
|
|
790
942
|
response.prependOnceListener('end', fix);
|
|
791
943
|
fix();
|
|
@@ -832,7 +984,7 @@ export default class Request extends Duplex {
|
|
|
832
984
|
let request;
|
|
833
985
|
for (const hook of options.hooks.beforeRequest) {
|
|
834
986
|
// eslint-disable-next-line no-await-in-loop
|
|
835
|
-
const result = await hook(options);
|
|
987
|
+
const result = await hook(options, { retryCount: this.retryCount });
|
|
836
988
|
if (!is.undefined(result)) {
|
|
837
989
|
// @ts-expect-error Skip the type mismatch to support abstract responses
|
|
838
990
|
request = () => result;
|
|
@@ -846,7 +998,12 @@ export default class Request extends Duplex {
|
|
|
846
998
|
this._requestOptions._request = request;
|
|
847
999
|
this._requestOptions.cache = options.cache;
|
|
848
1000
|
this._requestOptions.body = options.body;
|
|
849
|
-
|
|
1001
|
+
try {
|
|
1002
|
+
this._prepareCache(options.cache);
|
|
1003
|
+
}
|
|
1004
|
+
catch (error) {
|
|
1005
|
+
throw new CacheError(error, this);
|
|
1006
|
+
}
|
|
850
1007
|
}
|
|
851
1008
|
// Cache support
|
|
852
1009
|
const function_ = options.cache ? this._createCacheableRequest : request;
|
|
@@ -886,12 +1043,12 @@ export default class Request extends Duplex {
|
|
|
886
1043
|
}
|
|
887
1044
|
async _error(error) {
|
|
888
1045
|
try {
|
|
889
|
-
if (error instanceof HTTPError && !this.options.throwHttpErrors) {
|
|
1046
|
+
if (this.options && error instanceof HTTPError && !this.options.throwHttpErrors) {
|
|
890
1047
|
// This branch can be reached only when using the Promise API
|
|
891
1048
|
// Skip calling the hooks on purpose.
|
|
892
1049
|
// See https://github.com/sindresorhus/got/issues/2103
|
|
893
1050
|
}
|
|
894
|
-
else {
|
|
1051
|
+
else if (this.options) {
|
|
895
1052
|
for (const hook of this.options.hooks.beforeError) {
|
|
896
1053
|
// eslint-disable-next-line no-await-in-loop
|
|
897
1054
|
error = await hook(error);
|
|
@@ -902,10 +1059,22 @@ export default class Request extends Duplex {
|
|
|
902
1059
|
error = new RequestError(error_.message, error_, this);
|
|
903
1060
|
}
|
|
904
1061
|
this.destroy(error);
|
|
1062
|
+
// Manually emit error for Promise API to ensure it receives it.
|
|
1063
|
+
// Node.js streams may not re-emit if an error was already emitted during retry attempts.
|
|
1064
|
+
// Only emit for Promise API (_noPipe = true) to avoid double emissions in stream mode.
|
|
1065
|
+
// Use process.nextTick to defer emission and allow destroy() to complete first.
|
|
1066
|
+
// See https://github.com/sindresorhus/got/issues/1995
|
|
1067
|
+
if (this._noPipe) {
|
|
1068
|
+
process.nextTick(() => {
|
|
1069
|
+
this.emit('error', error);
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
905
1072
|
}
|
|
906
1073
|
_writeRequest(chunk, encoding, callback) {
|
|
907
1074
|
if (!this._request || this._request.destroyed) {
|
|
908
|
-
//
|
|
1075
|
+
// When there's no request (e.g., using cached response from beforeRequest hook),
|
|
1076
|
+
// we still need to call the callback to allow the stream to finish properly.
|
|
1077
|
+
callback();
|
|
909
1078
|
return;
|
|
910
1079
|
}
|
|
911
1080
|
this._request.write(chunk, encoding, (error) => {
|