got 14.6.6 → 15.0.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.
Files changed (37) hide show
  1. package/dist/source/as-promise/index.d.ts +2 -2
  2. package/dist/source/as-promise/index.js +105 -85
  3. package/dist/source/as-promise/types.d.ts +10 -23
  4. package/dist/source/as-promise/types.js +1 -17
  5. package/dist/source/core/calculate-retry-delay.js +1 -4
  6. package/dist/source/core/diagnostics-channel.js +12 -21
  7. package/dist/source/core/errors.d.ts +2 -1
  8. package/dist/source/core/errors.js +10 -13
  9. package/dist/source/core/index.d.ts +19 -7
  10. package/dist/source/core/index.js +748 -327
  11. package/dist/source/core/options.d.ts +117 -116
  12. package/dist/source/core/options.js +620 -309
  13. package/dist/source/core/response.d.ts +5 -3
  14. package/dist/source/core/response.js +26 -3
  15. package/dist/source/core/timed-out.d.ts +1 -1
  16. package/dist/source/core/timed-out.js +4 -4
  17. package/dist/source/core/utils/defer-to-connect.js +5 -17
  18. package/dist/source/core/utils/get-body-size.d.ts +1 -1
  19. package/dist/source/core/utils/get-body-size.js +3 -20
  20. package/dist/source/core/utils/is-unix-socket-url.d.ts +1 -1
  21. package/dist/source/core/utils/is-unix-socket-url.js +3 -4
  22. package/dist/source/core/utils/proxy-events.d.ts +1 -1
  23. package/dist/source/core/utils/proxy-events.js +3 -3
  24. package/dist/source/core/utils/strip-url-auth.d.ts +1 -0
  25. package/dist/source/core/utils/strip-url-auth.js +9 -0
  26. package/dist/source/core/utils/timer.js +5 -7
  27. package/dist/source/core/utils/unhandle.js +1 -2
  28. package/dist/source/create.js +83 -27
  29. package/dist/source/index.d.ts +2 -3
  30. package/dist/source/index.js +0 -4
  31. package/dist/source/types.d.ts +39 -70
  32. package/package.json +34 -38
  33. package/readme.md +2 -5
  34. package/dist/source/core/utils/is-form-data.d.ts +0 -7
  35. package/dist/source/core/utils/is-form-data.js +0 -4
  36. package/dist/source/core/utils/url-to-options.d.ts +0 -14
  37. package/dist/source/core/utils/url-to-options.js +0 -22
@@ -1,31 +1,46 @@
1
1
  import process from 'node:process';
2
2
  import { Buffer } from 'node:buffer';
3
3
  import { Duplex } from 'node:stream';
4
+ import { addAbortListener } from 'node:events';
4
5
  import http, { ServerResponse } from 'node:http';
5
6
  import { byteLength } from 'byte-counter';
7
+ import { chunk } from 'chunk-data';
8
+ import { concatUint8Arrays, stringToBase64, stringToUint8Array } from 'uint8array-extras';
6
9
  import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request';
7
10
  import decompressResponse from 'decompress-response';
8
11
  import is, { isBuffer } from '@sindresorhus/is';
9
- import { FormDataEncoder, isFormData as isFormDataLike } from 'form-data-encoder';
10
12
  import timer from './utils/timer.js';
11
13
  import getBodySize from './utils/get-body-size.js';
12
- import isFormData from './utils/is-form-data.js';
13
14
  import proxyEvents from './utils/proxy-events.js';
14
15
  import timedOut, { TimeoutError as TimedOutTimeoutError } from './timed-out.js';
15
- import urlToOptions from './utils/url-to-options.js';
16
+ import stripUrlAuth from './utils/strip-url-auth.js';
16
17
  import WeakableMap from './utils/weakable-map.js';
17
18
  import calculateRetryDelay from './calculate-retry-delay.js';
18
- import Options from './options.js';
19
- import { isResponseOk } from './response.js';
19
+ import Options, { crossOriginStripHeaders, hasExplicitCredentialInUrlChange, isCrossOriginCredentialChanged, isBodyUnchanged, isSameOrigin, snapshotCrossOriginState, } from './options.js';
20
+ import { cacheDecodedBody, decodeUint8Array, isResponseOk, isUtf8Encoding, } from './response.js';
20
21
  import isClientRequest from './utils/is-client-request.js';
21
- import isUnixSocketURL, { getUnixSocketPath } from './utils/is-unix-socket-url.js';
22
+ import { getUnixSocketPath } from './utils/is-unix-socket-url.js';
22
23
  import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
23
24
  import { generateRequestId, publishRequestCreate, publishRequestStart, publishResponseStart, publishResponseEnd, publishRetry, publishError, publishRedirect, } from './diagnostics-channel.js';
24
25
  const supportsBrotli = is.string(process.versions.brotli);
25
26
  const supportsZstd = is.string(process.versions.zstd);
26
27
  const methodsWithoutBody = new Set(['GET', 'HEAD']);
27
28
  const cacheableStore = new WeakableMap();
28
- const redirectCodes = new Set([300, 301, 302, 303, 304, 307, 308]);
29
+ const redirectCodes = new Set([301, 302, 303, 307, 308]);
30
+ export { crossOriginStripHeaders } from './options.js';
31
+ const transientWriteErrorCodes = new Set(['EPIPE', 'ECONNRESET']);
32
+ const omittedPipedHeaders = new Set([
33
+ 'host',
34
+ 'connection',
35
+ 'keep-alive',
36
+ 'proxy-authenticate',
37
+ 'proxy-authorization',
38
+ 'proxy-connection',
39
+ 'te',
40
+ 'trailer',
41
+ 'transfer-encoding',
42
+ 'upgrade',
43
+ ]);
29
44
  // Track errors that have been processed by beforeError hooks to preserve custom error types
30
45
  const errorsProcessedByHooks = new WeakSet();
31
46
  const proxiedRequestEvents = [
@@ -36,6 +51,64 @@ const proxiedRequestEvents = [
36
51
  'upgrade',
37
52
  ];
38
53
  const noop = () => { };
54
+ const isTransientWriteError = (error) => {
55
+ const { code } = error;
56
+ return typeof code === 'string' && transientWriteErrorCodes.has(code);
57
+ };
58
+ const getConnectionListedHeaders = (headers) => {
59
+ const connectionListedHeaders = new Set();
60
+ for (const [header, connectionHeader] of Object.entries(headers)) {
61
+ const normalizedHeader = header.toLowerCase();
62
+ if (normalizedHeader !== 'connection' && normalizedHeader !== 'proxy-connection') {
63
+ continue;
64
+ }
65
+ const connectionHeaderValues = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader];
66
+ for (const value of connectionHeaderValues) {
67
+ if (typeof value !== 'string') {
68
+ continue;
69
+ }
70
+ for (const token of value.split(',')) {
71
+ const normalizedToken = token.trim().toLowerCase();
72
+ if (normalizedToken.length > 0) {
73
+ connectionListedHeaders.add(normalizedToken);
74
+ }
75
+ }
76
+ }
77
+ }
78
+ return connectionListedHeaders;
79
+ };
80
+ export const normalizeError = (error) => {
81
+ if (error instanceof globalThis.Error) {
82
+ return error;
83
+ }
84
+ if (is.object(error)) {
85
+ const errorLike = error;
86
+ const message = typeof errorLike.message === 'string' ? errorLike.message : 'Non-error object thrown';
87
+ const normalizedError = new globalThis.Error(message, { cause: error });
88
+ if (typeof errorLike.stack === 'string') {
89
+ normalizedError.stack = errorLike.stack;
90
+ }
91
+ if (typeof errorLike.code === 'string') {
92
+ normalizedError.code = errorLike.code;
93
+ }
94
+ if (typeof errorLike.input === 'string') {
95
+ normalizedError.input = errorLike.input;
96
+ }
97
+ return normalizedError;
98
+ }
99
+ return new globalThis.Error(String(error));
100
+ };
101
+ const getSanitizedUrl = (options) => options?.url ? stripUrlAuth(options.url) : '';
102
+ const makeProgress = (transferred, total) => {
103
+ let percent = 0;
104
+ if (total) {
105
+ percent = transferred / total;
106
+ }
107
+ else if (total === transferred) {
108
+ percent = 1;
109
+ }
110
+ return { percent, transferred, total };
111
+ };
39
112
  export default class Request extends Duplex {
40
113
  // @ts-expect-error - Ignoring for now.
41
114
  ['constructor'];
@@ -47,24 +120,24 @@ export default class Request extends Duplex {
47
120
  redirectUrls = [];
48
121
  retryCount = 0;
49
122
  _stopReading = false;
50
- _stopRetry = noop;
123
+ _stopRetry;
51
124
  _downloadedSize = 0;
52
125
  _uploadedSize = 0;
53
126
  _pipedServerResponses = new Set();
54
127
  _request;
55
128
  _responseSize;
56
129
  _bodySize;
57
- _unproxyEvents = noop;
58
- _isFromCache;
130
+ _unproxyEvents;
59
131
  _triggerRead = false;
60
132
  _jobs = [];
61
- _cancelTimeouts = noop;
62
- _removeListeners = noop;
63
- _nativeResponse;
133
+ _cancelTimeouts;
134
+ _abortListenerDisposer;
64
135
  _flushed = false;
65
136
  _aborted = false;
66
137
  _expectedContentLength;
67
138
  _compressedBytesCount;
139
+ _skipRequestEndInFinal = false;
140
+ _incrementalDecode;
68
141
  _requestId = generateRequestId();
69
142
  // We need this because `this._request` if `undefined` when using cache
70
143
  _requestInitialized = false;
@@ -77,7 +150,17 @@ export default class Request extends Duplex {
77
150
  });
78
151
  this.on('pipe', (source) => {
79
152
  if (this.options.copyPipedHeaders && source?.headers) {
80
- Object.assign(this.options.headers, source.headers);
153
+ const connectionListedHeaders = getConnectionListedHeaders(source.headers);
154
+ for (const [header, value] of Object.entries(source.headers)) {
155
+ const normalizedHeader = header.toLowerCase();
156
+ if (omittedPipedHeaders.has(normalizedHeader) || connectionListedHeaders.has(normalizedHeader)) {
157
+ continue;
158
+ }
159
+ if (!this.options.shouldCopyPipedHeader(normalizedHeader)) {
160
+ continue;
161
+ }
162
+ this.options.setPipedHeader(normalizedHeader, value);
163
+ }
81
164
  }
82
165
  });
83
166
  this.on('newListener', event => {
@@ -97,7 +180,7 @@ export default class Request extends Duplex {
97
180
  // Publish request creation event
98
181
  publishRequestCreate({
99
182
  requestId: this._requestId,
100
- url: this.options.url?.toString() ?? '',
183
+ url: getSanitizedUrl(this.options),
101
184
  method: this.options.method,
102
185
  });
103
186
  }
@@ -112,11 +195,12 @@ export default class Request extends Duplex {
112
195
  process.nextTick(() => {
113
196
  // _beforeError requires options to access retry logic and hooks
114
197
  if (this.options) {
115
- this._beforeError(error);
198
+ this._beforeError(normalizeError(error));
116
199
  }
117
200
  else {
118
201
  // Options is undefined, skip _beforeError and destroy directly
119
- const requestError = error instanceof RequestError ? error : new RequestError(error.message, error, this);
202
+ const normalizedError = normalizeError(error);
203
+ const requestError = normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, this);
120
204
  this.destroy(requestError);
121
205
  }
122
206
  });
@@ -127,17 +211,7 @@ export default class Request extends Duplex {
127
211
  // The below is run only once.
128
212
  const { body } = this.options;
129
213
  if (is.nodeStream(body)) {
130
- body.once('error', error => {
131
- if (this._flushed) {
132
- this._beforeError(new UploadError(error, this));
133
- }
134
- else {
135
- this.flush = async () => {
136
- this.flush = async () => { };
137
- this._beforeError(new UploadError(error, this));
138
- };
139
- }
140
- });
214
+ body.once('error', this._onBodyError);
141
215
  }
142
216
  if (this.options.signal) {
143
217
  const abort = () => {
@@ -153,10 +227,8 @@ export default class Request extends Duplex {
153
227
  abort();
154
228
  }
155
229
  else {
156
- this.options.signal.addEventListener('abort', abort);
157
- this._removeListeners = () => {
158
- this.options.signal?.removeEventListener('abort', abort);
159
- };
230
+ const abortListenerDisposer = addAbortListener(this.options.signal, abort);
231
+ this._abortListenerDisposer = abortListenerDisposer;
160
232
  }
161
233
  }
162
234
  }
@@ -184,7 +256,7 @@ export default class Request extends Duplex {
184
256
  this._requestInitialized = true;
185
257
  }
186
258
  catch (error) {
187
- this._beforeError(error);
259
+ this._beforeError(normalizeError(error));
188
260
  }
189
261
  }
190
262
  _beforeError(error) {
@@ -210,7 +282,7 @@ export default class Request extends Duplex {
210
282
  response.setEncoding(this.readableEncoding);
211
283
  const success = await this._setRawBody(response);
212
284
  if (success) {
213
- response.body = response.rawBody.toString();
285
+ response.body = decodeUint8Array(response.rawBody);
214
286
  }
215
287
  }
216
288
  if (this.listenerCount('retry') !== 0) {
@@ -240,7 +312,7 @@ export default class Request extends Duplex {
240
312
  // When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
241
313
  // before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
242
314
  // based on these rules), skip calling calculateDelay entirely.
243
- // When false (default), always call calculateDelay, allowing it to override retry decisions.
315
+ // When false, always call calculateDelay, allowing it to override retry decisions.
244
316
  if (retryOptions.enforceRetryRules && computedValue === 0) {
245
317
  backoff = 0;
246
318
  }
@@ -255,7 +327,8 @@ export default class Request extends Duplex {
255
327
  }
256
328
  }
257
329
  catch (error_) {
258
- void this._error(new RequestError(error_.message, error_, this));
330
+ const normalizedError = normalizeError(error_);
331
+ void this._error(new RequestError(normalizedError.message, normalizedError, this));
259
332
  return;
260
333
  }
261
334
  if (backoff) {
@@ -279,7 +352,8 @@ export default class Request extends Duplex {
279
352
  }
280
353
  }
281
354
  catch (error_) {
282
- void this._error(new RequestError(error_.message, error_, this));
355
+ const normalizedError = normalizeError(error_);
356
+ void this._error(new RequestError(normalizedError.message, normalizedError, this));
283
357
  return;
284
358
  }
285
359
  // Something forced us to abort the retry
@@ -299,30 +373,35 @@ export default class Request extends Duplex {
299
373
  // 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks
300
374
  // 3. We must restore the body reference after destroy() for identity checks in promise wrapper
301
375
  // 4. We cannot use the normal setter after destroy() because it validates stream readability
302
- if (bodyWasReassigned) {
303
- const oldBody = bodyBeforeHooks;
304
- // Temporarily clear body to prevent destroy() from destroying the new stream
305
- this.options.body = undefined;
306
- this.destroy();
307
- // Clean up the old stream resource if it's a stream and different from new body
308
- // (edge case: if old and new are same stream object, don't destroy it)
309
- if (is.nodeStream(oldBody) && oldBody !== bodyAfterHooks) {
310
- oldBody.destroy();
376
+ try {
377
+ if (bodyWasReassigned) {
378
+ const oldBody = bodyBeforeHooks;
379
+ // Temporarily clear body to prevent destroy() from destroying the new stream
380
+ this.options.body = undefined;
381
+ this.destroy();
382
+ // Clean up the old stream resource if it's a stream and different from new body
383
+ // (edge case: if old and new are same stream object, don't destroy it)
384
+ if (is.nodeStream(oldBody) && oldBody !== bodyAfterHooks) {
385
+ oldBody.destroy();
386
+ }
387
+ // Restore new body for promise wrapper's identity check
388
+ if (is.nodeStream(bodyAfterHooks) && (bodyAfterHooks.readableEnded || bodyAfterHooks.destroyed)) {
389
+ throw new TypeError('The reassigned stream body must be readable. Ensure you provide a fresh, readable stream in the beforeRetry hook.');
390
+ }
391
+ this.options.body = bodyAfterHooks;
311
392
  }
312
- // Restore new body for promise wrapper's identity check
313
- // We bypass the setter because it validates stream.readable (which fails for destroyed request)
314
- // Type assertion is necessary here to access private _internals without exposing internal API
315
- if (is.nodeStream(bodyAfterHooks) && (bodyAfterHooks.readableEnded || bodyAfterHooks.destroyed)) {
316
- throw new TypeError('The reassigned stream body must be readable. Ensure you provide a fresh, readable stream in the beforeRetry hook.');
393
+ else {
394
+ // Body wasn't reassigned - use normal destroy flow which handles body cleanup
395
+ this.destroy();
396
+ // Note: We do NOT restore the body reference here. The stream was destroyed by _destroy()
397
+ // and should not be accessed. The promise wrapper will see that body identity hasn't changed
398
+ // and will detect it's a consumed stream, which is the correct behavior.
317
399
  }
318
- this.options._internals.body = bodyAfterHooks;
319
400
  }
320
- else {
321
- // Body wasn't reassigned - use normal destroy flow which handles body cleanup
322
- this.destroy();
323
- // Note: We do NOT restore the body reference here. The stream was destroyed by _destroy()
324
- // and should not be accessed. The promise wrapper will see that body identity hasn't changed
325
- // and will detect it's a consumed stream, which is the correct behavior.
401
+ catch (error_) {
402
+ const normalizedError = normalizeError(error_);
403
+ void this._error(new RequestError(normalizedError.message, normalizedError, this));
404
+ return;
326
405
  }
327
406
  // Publish retry event
328
407
  publishRetry({
@@ -357,6 +436,17 @@ export default class Request extends Duplex {
357
436
  let data;
358
437
  while ((data = response.read()) !== null) {
359
438
  this._downloadedSize += data.length; // eslint-disable-line @typescript-eslint/restrict-plus-operands
439
+ if (this._incrementalDecode) {
440
+ try {
441
+ const decodedChunk = typeof data === 'string' ? data : this._incrementalDecode.decoder.decode(data, { stream: true });
442
+ if (decodedChunk.length > 0) {
443
+ this._incrementalDecode.chunks.push(decodedChunk);
444
+ }
445
+ }
446
+ catch {
447
+ this._incrementalDecode = undefined;
448
+ }
449
+ }
360
450
  const progress = this.downloadProgress;
361
451
  if (progress.percent < 1) {
362
452
  this.emit('downloadProgress', progress);
@@ -378,22 +468,26 @@ export default class Request extends Duplex {
378
468
  }
379
469
  _final(callback) {
380
470
  const endRequest = () => {
471
+ if (this._skipRequestEndInFinal) {
472
+ this._skipRequestEndInFinal = false;
473
+ callback();
474
+ return;
475
+ }
476
+ const request = this._request;
381
477
  // We need to check if `this._request` is present,
382
478
  // because it isn't when we use cache.
383
- if (!this._request || this._request.destroyed) {
479
+ if (!request || request.destroyed) {
384
480
  callback();
385
481
  return;
386
482
  }
387
- this._request.end((error) => {
483
+ request.end((error) => {
388
484
  // The request has been destroyed before `_final` finished.
389
485
  // See https://github.com/nodejs/node/issues/39356
390
- if (this._request?._writableState?.errored) {
486
+ if (request?._writableState?.errored) {
391
487
  return;
392
488
  }
393
489
  if (!error) {
394
- this._bodySize = this._uploadedSize;
395
- this.emit('uploadProgress', this.uploadProgress);
396
- this._request?.emit('upload-complete');
490
+ this._emitUploadComplete(request);
397
491
  }
398
492
  callback(error);
399
493
  });
@@ -409,9 +503,9 @@ export default class Request extends Duplex {
409
503
  this._stopReading = true;
410
504
  this.flush = async () => { };
411
505
  // Prevent further retries
412
- this._stopRetry();
413
- this._cancelTimeouts();
414
- this._removeListeners();
506
+ this._stopRetry?.();
507
+ this._cancelTimeouts?.();
508
+ this._abortListenerDisposer?.[Symbol.dispose]();
415
509
  if (this.options) {
416
510
  const { body } = this.options;
417
511
  if (is.nodeStream(body)) {
@@ -459,6 +553,13 @@ export default class Request extends Duplex {
459
553
  super.unpipe(destination);
460
554
  return this;
461
555
  }
556
+ _shouldIncrementallyDecodeBody() {
557
+ const { responseType, encoding } = this.options;
558
+ return Boolean(this._noPipe)
559
+ && (responseType === 'text' || responseType === 'json')
560
+ && isUtf8Encoding(encoding)
561
+ && typeof globalThis.TextDecoder === 'function';
562
+ }
462
563
  _checkContentLengthMismatch() {
463
564
  if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
464
565
  // Use compressed bytes count when available (for compressed responses),
@@ -477,7 +578,7 @@ export default class Request extends Duplex {
477
578
  }
478
579
  async _finalizeBody() {
479
580
  const { options } = this;
480
- const { headers } = options;
581
+ const headers = options.getInternalHeaders();
481
582
  const isForm = !is.undefined(options.form);
482
583
  // eslint-disable-next-line @typescript-eslint/naming-convention
483
584
  const isJSON = !is.undefined(options.json);
@@ -490,20 +591,16 @@ export default class Request extends Duplex {
490
591
  // Serialize body
491
592
  const noContentType = !is.string(headers['content-type']);
492
593
  if (isBody) {
493
- // Body is spec-compliant FormData
494
- if (isFormDataLike(options.body)) {
495
- const encoder = new FormDataEncoder(options.body);
594
+ // Native FormData
595
+ if (options.body instanceof FormData) {
596
+ const response = new Response(options.body);
496
597
  if (noContentType) {
497
- headers['content-type'] = encoder.headers['Content-Type'];
598
+ headers['content-type'] = response.headers.get('content-type') ?? 'multipart/form-data';
498
599
  }
499
- if ('Content-Length' in encoder.headers) {
500
- headers['content-length'] = encoder.headers['Content-Length'];
501
- }
502
- options.body = encoder.encode();
600
+ options.body = response.body;
503
601
  }
504
- // Special case for https://github.com/form-data/form-data
505
- if (isFormData(options.body) && noContentType) {
506
- headers['content-type'] = `multipart/form-data; boundary=${options.body.getBoundary()}`;
602
+ else if (Object.prototype.toString.call(options.body) === '[object FormData]') {
603
+ throw new TypeError('Non-native FormData is not supported. Use globalThis.FormData instead.');
507
604
  }
508
605
  }
509
606
  else if (isForm) {
@@ -522,7 +619,7 @@ export default class Request extends Duplex {
522
619
  options.json = undefined;
523
620
  options.body = options.stringifyJson(json);
524
621
  }
525
- const uploadBodySize = await getBodySize(options.body, options.headers);
622
+ const uploadBodySize = getBodySize(options.body, headers);
526
623
  // See https://tools.ietf.org/html/rfc7230#section-3.3.2
527
624
  // A user agent SHOULD send a Content-Length in a request message when
528
625
  // no Transfer-Encoding is sent and the request method defines a meaning
@@ -536,8 +633,8 @@ export default class Request extends Duplex {
536
633
  headers['content-length'] = String(uploadBodySize);
537
634
  }
538
635
  }
539
- if (options.responseType === 'json' && !('accept' in options.headers)) {
540
- options.headers.accept = 'application/json';
636
+ if (options.responseType === 'json' && !('accept' in headers)) {
637
+ headers.accept = 'application/json';
541
638
  }
542
639
  this._bodySize = Number(headers['content-length']) || undefined;
543
640
  }
@@ -548,9 +645,12 @@ export default class Request extends Duplex {
548
645
  }
549
646
  const { options } = this;
550
647
  const { url } = options;
551
- this._nativeResponse = response;
648
+ const nativeResponse = response;
552
649
  const statusCode = response.statusCode;
553
650
  const { method } = options;
651
+ const redirectLocationHeader = response.headers.location;
652
+ const redirectLocation = Array.isArray(redirectLocationHeader) ? redirectLocationHeader[0] : redirectLocationHeader;
653
+ const isRedirect = Boolean(redirectLocation && redirectCodes.has(statusCode));
554
654
  // Skip decompression for responses that must not have bodies per RFC 9110:
555
655
  // - HEAD responses (any status code)
556
656
  // - 1xx (Informational): 100, 101, 102, 103, etc.
@@ -562,30 +662,46 @@ export default class Request extends Duplex {
562
662
  || statusCode === 204
563
663
  || statusCode === 205
564
664
  || statusCode === 304;
565
- if (options.decompress && !hasNoBody) {
665
+ const prepareResponse = (response) => {
666
+ if (!Object.hasOwn(response, 'headers')) {
667
+ Object.defineProperty(response, 'headers', {
668
+ value: response.headers,
669
+ enumerable: true,
670
+ writable: true,
671
+ configurable: true,
672
+ });
673
+ }
674
+ response.statusMessage ||= http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
675
+ response.url = stripUrlAuth(options.url);
676
+ response.requestUrl = this.requestUrl;
677
+ response.redirectUrls = this.redirectUrls;
678
+ response.request = this;
679
+ response.isFromCache = nativeResponse.fromCache ?? false;
680
+ response.ip = this.ip;
681
+ response.retryCount = this.retryCount;
682
+ response.ok = isResponseOk(response);
683
+ return response;
684
+ };
685
+ let typedResponse = prepareResponse(response);
686
+ // Redirect responses that will be followed are drained raw. Decompressing them can
687
+ // turn an irrelevant redirect body into a client-side failure or decompression DoS.
688
+ const shouldFollowRedirect = isRedirect && (typeof options.followRedirect === 'function' ? options.followRedirect(typedResponse) : options.followRedirect);
689
+ if (options.decompress && !hasNoBody && !shouldFollowRedirect) {
566
690
  // When strictContentLength is enabled, track compressed bytes by listening to
567
691
  // the native response's data events before decompression
568
692
  if (options.strictContentLength) {
569
693
  this._compressedBytesCount = 0;
570
- this._nativeResponse.on('data', (chunk) => {
694
+ nativeResponse.on('data', (chunk) => {
571
695
  this._compressedBytesCount += byteLength(chunk);
572
696
  });
573
697
  }
574
698
  response = decompressResponse(response);
699
+ typedResponse = prepareResponse(response);
575
700
  }
576
- const typedResponse = response;
577
- typedResponse.statusMessage = typedResponse.statusMessage || http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
578
- typedResponse.url = options.url.toString();
579
- typedResponse.requestUrl = this.requestUrl;
580
- typedResponse.redirectUrls = this.redirectUrls;
581
- typedResponse.request = this;
582
- typedResponse.isFromCache = this._nativeResponse.fromCache ?? false;
583
- typedResponse.ip = this.ip;
584
- typedResponse.retryCount = this.retryCount;
585
- typedResponse.ok = isResponseOk(typedResponse);
586
- this._isFromCache = typedResponse.isFromCache;
587
701
  this._responseSize = Number(response.headers['content-length']) || undefined;
588
702
  this.response = typedResponse;
703
+ // eslint-disable-next-line @typescript-eslint/naming-convention
704
+ this._incrementalDecode = this._shouldIncrementallyDecodeBody() ? { decoder: new globalThis.TextDecoder('utf8', { ignoreBOM: true }), chunks: [] } : undefined;
589
705
  // Publish response start event
590
706
  publishResponseStart({
591
707
  requestId: this._requestId,
@@ -596,9 +712,6 @@ export default class Request extends Duplex {
596
712
  });
597
713
  response.once('error', (error) => {
598
714
  this._aborted = true;
599
- // Force clean-up, because some packages don't do this.
600
- // TODO: Fix decompress-response
601
- response.destroy();
602
715
  this._beforeError(new ReadError(error, this));
603
716
  });
604
717
  response.once('aborted', () => {
@@ -612,11 +725,15 @@ export default class Request extends Duplex {
612
725
  }, this));
613
726
  }
614
727
  });
728
+ const noPipeCookieJarRawBodyPromise = this._noPipe
729
+ && is.object(options.cookieJar)
730
+ && !isRedirect
731
+ ? this._setRawBody(response)
732
+ : undefined;
615
733
  const rawCookies = response.headers['set-cookie'];
616
734
  if (is.object(options.cookieJar) && rawCookies) {
617
735
  let promises = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString()));
618
736
  if (options.ignoreInvalidCookies) {
619
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
620
737
  promises = promises.map(async (promise) => {
621
738
  try {
622
739
  await promise;
@@ -628,7 +745,7 @@ export default class Request extends Duplex {
628
745
  await Promise.all(promises);
629
746
  }
630
747
  catch (error) {
631
- this._beforeError(error);
748
+ this._beforeError(normalizeError(error));
632
749
  return;
633
750
  }
634
751
  }
@@ -636,88 +753,122 @@ export default class Request extends Duplex {
636
753
  if (this.isAborted) {
637
754
  return;
638
755
  }
639
- if (response.headers.location && redirectCodes.has(statusCode)) {
756
+ if (shouldFollowRedirect) {
640
757
  // We're being redirected, we don't care about the response.
641
758
  // It'd be best to abort the request, but we can't because
642
759
  // we would have to sacrifice the TCP connection. We don't want that.
643
- const shouldFollow = typeof options.followRedirect === 'function' ? options.followRedirect(typedResponse) : options.followRedirect;
644
- if (shouldFollow) {
645
- response.resume();
646
- this._cancelTimeouts();
647
- this._unproxyEvents();
648
- if (this.redirectUrls.length >= options.maxRedirects) {
649
- this._beforeError(new MaxRedirectsError(this));
760
+ response.resume();
761
+ this._cancelTimeouts?.();
762
+ this._unproxyEvents?.();
763
+ if (this.redirectUrls.length >= options.maxRedirects) {
764
+ this._beforeError(new MaxRedirectsError(this));
765
+ return;
766
+ }
767
+ this._request = undefined;
768
+ // Reset progress for the new request.
769
+ this._downloadedSize = 0;
770
+ this._uploadedSize = 0;
771
+ const updatedOptions = new Options(undefined, undefined, this.options);
772
+ try {
773
+ // We need this in order to support UTF-8
774
+ const redirectBuffer = Buffer.from(redirectLocation, 'binary').toString();
775
+ const redirectUrl = new URL(redirectBuffer, url);
776
+ const currentUnixSocketPath = getUnixSocketPath(url);
777
+ const redirectUnixSocketPath = getUnixSocketPath(redirectUrl);
778
+ if (redirectUrl.protocol === 'unix:' && redirectUnixSocketPath === undefined) {
779
+ this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
780
+ return;
781
+ }
782
+ // Relative redirects on the same socket are fine, but a redirect must not switch to a different local socket.
783
+ if (redirectUnixSocketPath !== undefined && currentUnixSocketPath !== redirectUnixSocketPath) {
784
+ this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
650
785
  return;
651
786
  }
652
- this._request = undefined;
653
- // Reset download progress for the new request
654
- this._downloadedSize = 0;
655
- const updatedOptions = new Options(undefined, undefined, this.options);
787
+ // Redirecting to a different site, clear sensitive data.
788
+ // For UNIX sockets, different socket paths are also different origins.
789
+ const isDifferentOrigin = redirectUrl.origin !== url.origin
790
+ || currentUnixSocketPath !== redirectUnixSocketPath;
656
791
  const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD';
792
+ // Avoid forwarding a POST body to a different origin on historical 301/302 redirects.
793
+ const crossOriginRequestedGet = isDifferentOrigin
794
+ && (statusCode === 301 || statusCode === 302)
795
+ && updatedOptions.method === 'POST';
657
796
  const canRewrite = statusCode !== 307 && statusCode !== 308;
658
797
  const userRequestedGet = updatedOptions.methodRewriting && canRewrite;
659
- if (serverRequestedGet || userRequestedGet) {
798
+ const shouldDropBody = serverRequestedGet || crossOriginRequestedGet || userRequestedGet;
799
+ if (shouldDropBody) {
660
800
  updatedOptions.method = 'GET';
661
- updatedOptions.body = undefined;
662
- updatedOptions.json = undefined;
663
- updatedOptions.form = undefined;
664
- delete updatedOptions.headers['content-length'];
801
+ this._dropBody(updatedOptions);
665
802
  }
666
- try {
667
- // We need this in order to support UTF-8
668
- const redirectBuffer = Buffer.from(response.headers.location, 'binary').toString();
669
- const redirectUrl = new URL(redirectBuffer, url);
670
- if (!isUnixSocketURL(url) && isUnixSocketURL(redirectUrl)) {
671
- this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
672
- return;
673
- }
674
- // Redirecting to a different site, clear sensitive data.
675
- // For UNIX sockets, different socket paths are also different origins.
676
- const isDifferentOrigin = redirectUrl.hostname !== url.hostname
677
- || redirectUrl.port !== url.port
678
- || getUnixSocketPath(url) !== getUnixSocketPath(redirectUrl);
679
- if (isDifferentOrigin) {
680
- if ('host' in updatedOptions.headers) {
681
- delete updatedOptions.headers.host;
682
- }
683
- if ('cookie' in updatedOptions.headers) {
684
- delete updatedOptions.headers.cookie;
685
- }
686
- if ('authorization' in updatedOptions.headers) {
687
- delete updatedOptions.headers.authorization;
688
- }
689
- if (updatedOptions.username || updatedOptions.password) {
690
- updatedOptions.username = '';
691
- updatedOptions.password = '';
692
- }
693
- }
694
- else {
695
- redirectUrl.username = updatedOptions.username;
696
- redirectUrl.password = updatedOptions.password;
697
- }
698
- this.redirectUrls.push(redirectUrl);
699
- updatedOptions.url = redirectUrl;
803
+ if (isDifferentOrigin) {
804
+ // Also strip body on cross-origin redirects to prevent data leakage.
805
+ // 301/302 POST already drops the body (converted to GET above).
806
+ // 307/308 preserve the method per RFC, but the body must not be
807
+ // forwarded to a different origin.
808
+ // Strip credentials embedded in the redirect URL itself
809
+ // to prevent a malicious server from injecting auth to third parties.
810
+ this._stripCrossOriginState(updatedOptions, redirectUrl, shouldDropBody);
811
+ }
812
+ else {
813
+ redirectUrl.username = updatedOptions.username;
814
+ redirectUrl.password = updatedOptions.password;
815
+ }
816
+ updatedOptions.url = redirectUrl;
817
+ this.redirectUrls.push(redirectUrl);
818
+ const preHookState = isDifferentOrigin
819
+ ? undefined
820
+ : {
821
+ ...snapshotCrossOriginState(updatedOptions),
822
+ url: new URL(updatedOptions.url),
823
+ };
824
+ const changedState = await updatedOptions.trackStateMutations(async (changedState) => {
700
825
  for (const hook of updatedOptions.hooks.beforeRedirect) {
701
826
  // eslint-disable-next-line no-await-in-loop
702
827
  await hook(updatedOptions, typedResponse);
703
828
  }
704
- // Publish redirect event
705
- publishRedirect({
706
- requestId: this._requestId,
707
- fromUrl: url.toString(),
708
- toUrl: redirectUrl.toString(),
709
- statusCode,
710
- });
711
- this.emit('redirect', updatedOptions, typedResponse);
712
- this.options = updatedOptions;
713
- await this._makeRequest();
714
- }
715
- catch (error) {
716
- this._beforeError(error);
717
- return;
829
+ return changedState;
830
+ });
831
+ // If a beforeRedirect hook changed the URL to a different origin,
832
+ // strip sensitive headers that were preserved for the original origin.
833
+ // When isDifferentOrigin was already true, headers were already stripped above.
834
+ if (!isDifferentOrigin) {
835
+ const state = preHookState;
836
+ const hookUrl = updatedOptions.url;
837
+ if (!isSameOrigin(state.url, hookUrl)) {
838
+ this._stripUnchangedCrossOriginState(updatedOptions, hookUrl, shouldDropBody, {
839
+ headers: state.headers,
840
+ username: state.username,
841
+ password: state.password,
842
+ body: state.body,
843
+ json: state.json,
844
+ form: state.form,
845
+ bodySnapshot: state.bodySnapshot,
846
+ jsonSnapshot: state.jsonSnapshot,
847
+ formSnapshot: state.formSnapshot,
848
+ changedState,
849
+ preserveUsername: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'username')
850
+ || isCrossOriginCredentialChanged(state.url, hookUrl, 'username'),
851
+ preservePassword: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'password')
852
+ || isCrossOriginCredentialChanged(state.url, hookUrl, 'password'),
853
+ });
854
+ }
718
855
  }
856
+ // Publish redirect event
857
+ publishRedirect({
858
+ requestId: this._requestId,
859
+ fromUrl: url.toString(),
860
+ toUrl: (updatedOptions.url).toString(),
861
+ statusCode,
862
+ });
863
+ this.emit('redirect', updatedOptions, typedResponse);
864
+ this.options = updatedOptions;
865
+ await this._makeRequest();
866
+ }
867
+ catch (error) {
868
+ this._beforeError(normalizeError(error));
719
869
  return;
720
870
  }
871
+ return;
721
872
  }
722
873
  // `HTTPError`s always have `error.response.body` defined.
723
874
  // Therefore, we cannot retry if `options.throwHttpErrors` is false.
@@ -727,13 +878,15 @@ export default class Request extends Duplex {
727
878
  this._beforeError(new HTTPError(typedResponse));
728
879
  return;
729
880
  }
881
+ // `decompressResponse` wraps the response stream when it decompresses,
882
+ // so `response !== nativeResponse` indicates decompression happened.
883
+ const wasDecompressed = response !== nativeResponse;
730
884
  // Store the expected content-length from the native response for validation.
731
885
  // This is the content-length before decompression, which is what actually gets transferred.
732
886
  // Skip storing for responses that shouldn't have bodies per RFC 9110.
733
887
  // When decompression occurs, only store if strictContentLength is enabled.
734
- const wasDecompressed = response !== this._nativeResponse;
735
888
  if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
736
- const contentLengthHeader = this._nativeResponse.headers['content-length'];
889
+ const contentLengthHeader = nativeResponse.headers['content-length'];
737
890
  if (contentLengthHeader !== undefined) {
738
891
  const expectedLength = Number(contentLengthHeader);
739
892
  if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
@@ -742,7 +895,12 @@ export default class Request extends Duplex {
742
895
  }
743
896
  }
744
897
  // Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
745
- response.once('end', () => {
898
+ let responseEndHandled = false;
899
+ const handleResponseEnd = () => {
900
+ if (responseEndHandled) {
901
+ return;
902
+ }
903
+ responseEndHandled = true;
746
904
  // Validate content-length if it was provided
747
905
  // Per RFC 9112: "If the sender closes the connection before the indicated number
748
906
  // of octets are received, the recipient MUST consider the message to be incomplete"
@@ -760,7 +918,8 @@ export default class Request extends Duplex {
760
918
  timings: this.timings,
761
919
  });
762
920
  this.push(null);
763
- });
921
+ };
922
+ response.once('end', handleResponseEnd);
764
923
  this.emit('downloadProgress', this.downloadProgress);
765
924
  response.on('readable', () => {
766
925
  if (this._triggerRead) {
@@ -774,7 +933,13 @@ export default class Request extends Duplex {
774
933
  response.pause();
775
934
  });
776
935
  if (this._noPipe) {
777
- const success = await this._setRawBody();
936
+ const captureFromResponse = response.readableEnded || noPipeCookieJarRawBodyPromise !== undefined;
937
+ const success = noPipeCookieJarRawBodyPromise
938
+ ? await noPipeCookieJarRawBodyPromise
939
+ : await this._setRawBody(captureFromResponse ? response : this);
940
+ if (captureFromResponse) {
941
+ handleResponseEnd();
942
+ }
778
943
  if (success) {
779
944
  this.emit('response', response);
780
945
  }
@@ -785,10 +950,6 @@ export default class Request extends Duplex {
785
950
  if (destination.headersSent) {
786
951
  continue;
787
952
  }
788
- // Check if decompression actually occurred by comparing stream objects.
789
- // decompressResponse wraps the response stream when it decompresses,
790
- // so response !== this._nativeResponse indicates decompression happened.
791
- const wasDecompressed = response !== this._nativeResponse;
792
953
  for (const key in response.headers) {
793
954
  if (Object.hasOwn(response.headers, key)) {
794
955
  const value = response.headers[key];
@@ -807,21 +968,39 @@ export default class Request extends Duplex {
807
968
  }
808
969
  }
809
970
  async _setRawBody(from = this) {
810
- if (from.readableEnded) {
811
- return false;
812
- }
813
971
  try {
814
972
  // Errors are emitted via the `error` event
815
973
  const fromArray = await from.toArray();
816
- const rawBody = isBuffer(fromArray.at(0)) ? Buffer.concat(fromArray) : Buffer.from(fromArray.join(''));
974
+ const hasNonStringChunk = fromArray.some(chunk => typeof chunk !== 'string');
975
+ const rawBody = hasNonStringChunk
976
+ ? concatUint8Arrays(fromArray.map(chunk => typeof chunk === 'string' ? stringToUint8Array(chunk) : chunk))
977
+ : stringToUint8Array(fromArray.join(''));
978
+ const shouldUseIncrementalDecodedBody = from === this && this._incrementalDecode !== undefined;
817
979
  // On retry Request is destroyed with no error, therefore the above will successfully resolve.
818
- // So in order to check if this was really successfull, we need to check if it has been properly ended.
819
- if (!this.isAborted) {
980
+ // So in order to check if this was really successful, we need to check if it has been properly ended.
981
+ if (!this.isAborted && this.response) {
820
982
  this.response.rawBody = rawBody;
983
+ if (from !== this) {
984
+ this._downloadedSize = rawBody.byteLength;
985
+ }
986
+ if (shouldUseIncrementalDecodedBody) {
987
+ try {
988
+ const { decoder, chunks } = this._incrementalDecode;
989
+ const finalDecodedChunk = decoder.decode();
990
+ if (finalDecodedChunk.length > 0) {
991
+ chunks.push(finalDecodedChunk);
992
+ }
993
+ cacheDecodedBody(this.response, chunks.join(''));
994
+ }
995
+ catch { }
996
+ }
821
997
  return true;
822
998
  }
823
999
  }
824
1000
  catch { }
1001
+ finally {
1002
+ this._incrementalDecode = undefined;
1003
+ }
825
1004
  return false;
826
1005
  }
827
1006
  async _onResponse(response) {
@@ -830,7 +1009,7 @@ export default class Request extends Duplex {
830
1009
  }
831
1010
  catch (error) {
832
1011
  /* istanbul ignore next: better safe than sorry */
833
- this._beforeError(error);
1012
+ this._beforeError(normalizeError(error));
834
1013
  }
835
1014
  }
836
1015
  _onRequest(request) {
@@ -839,7 +1018,7 @@ export default class Request extends Duplex {
839
1018
  // Publish request start event
840
1019
  publishRequestStart({
841
1020
  requestId: this._requestId,
842
- url: url?.toString() ?? '',
1021
+ url: getSanitizedUrl(this.options),
843
1022
  method: options.method,
844
1023
  headers: options.headers,
845
1024
  });
@@ -857,32 +1036,80 @@ export default class Request extends Duplex {
857
1036
  socket.removeAllListeners('timeout');
858
1037
  });
859
1038
  }
1039
+ let lastRequestError;
860
1040
  const responseEventName = options.cache ? 'cacheableResponse' : 'response';
861
1041
  request.once(responseEventName, (response) => {
862
1042
  void this._onResponse(response);
863
1043
  });
864
- request.once('error', (error) => {
1044
+ const emitRequestError = (error) => {
865
1045
  this._aborted = true;
866
1046
  // Force clean-up, because some packages (e.g. nock) don't do this.
867
1047
  request.destroy();
868
- error = error instanceof TimedOutTimeoutError ? new TimeoutError(error, this.timings, this) : new RequestError(error.message, error, this);
869
- this._beforeError(error);
1048
+ const wrappedError = error instanceof TimedOutTimeoutError ? new TimeoutError(error, this.timings, this) : new RequestError(error.message, error, this);
1049
+ this._beforeError(wrappedError);
1050
+ };
1051
+ request.once('error', (error) => {
1052
+ lastRequestError = error;
1053
+ // Ignore errors from requests superseded by a redirect.
1054
+ if (this._request !== request) {
1055
+ return;
1056
+ }
1057
+ /*
1058
+ Transient write errors (EPIPE, ECONNRESET) often fire during redirects when the
1059
+ server closes the connection after sending the redirect response. Defer by one
1060
+ microtask to let the response event make the request stale.
1061
+ */
1062
+ if (isTransientWriteError(error)) {
1063
+ queueMicrotask(() => {
1064
+ if (this._isRequestStale(request)) {
1065
+ return;
1066
+ }
1067
+ emitRequestError(error);
1068
+ });
1069
+ return;
1070
+ }
1071
+ emitRequestError(error);
870
1072
  });
1073
+ if (!options.cache) {
1074
+ request.once('close', () => {
1075
+ if (this._request !== request || Boolean(request.res) || this._stopReading) {
1076
+ return;
1077
+ }
1078
+ this._beforeError(lastRequestError ?? new ReadError({
1079
+ name: 'Error',
1080
+ message: 'The server aborted pending request',
1081
+ code: 'ECONNRESET',
1082
+ }, this));
1083
+ });
1084
+ }
871
1085
  this._unproxyEvents = proxyEvents(request, this, proxiedRequestEvents);
872
1086
  this._request = request;
873
1087
  this.emit('uploadProgress', this.uploadProgress);
874
1088
  this._sendBody();
875
1089
  this.emit('request', request);
876
1090
  }
877
- async _asyncWrite(chunk) {
1091
+ _isRequestStale(request) {
1092
+ return this._request !== request || Boolean(request.res) || request.destroyed || request.writableEnded;
1093
+ }
1094
+ async _asyncWrite(chunk, request = this) {
878
1095
  return new Promise((resolve, reject) => {
879
- super.write(chunk, error => {
1096
+ if (request === this) {
1097
+ super.write(chunk, error => {
1098
+ if (error) {
1099
+ reject(error);
1100
+ return;
1101
+ }
1102
+ resolve();
1103
+ });
1104
+ return;
1105
+ }
1106
+ this._writeRequest(chunk, undefined, error => {
880
1107
  if (error) {
881
1108
  reject(error);
882
1109
  return;
883
1110
  }
884
1111
  resolve();
885
- });
1112
+ }, request);
886
1113
  });
887
1114
  }
888
1115
  _sendBody() {
@@ -894,27 +1121,41 @@ export default class Request extends Duplex {
894
1121
  }
895
1122
  else if (is.buffer(body)) {
896
1123
  // Buffer should be sent directly without conversion
897
- this._writeRequest(body, undefined, () => { });
898
- currentRequest.end();
1124
+ this._writeBodyInChunks(body, currentRequest);
899
1125
  }
900
1126
  else if (is.typedArray(body)) {
901
1127
  // Typed arrays should be treated like buffers, not iterated over
902
1128
  // Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
903
1129
  const typedArray = body;
904
1130
  const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
905
- this._writeRequest(uint8View, undefined, () => { });
906
- currentRequest.end();
1131
+ this._writeBodyInChunks(uint8View, currentRequest);
907
1132
  }
908
1133
  else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
909
1134
  (async () => {
1135
+ const isInitialRequest = currentRequest === this;
910
1136
  try {
911
1137
  for await (const chunk of body) {
912
- await this._asyncWrite(chunk);
1138
+ if (this.options.body !== body) {
1139
+ return;
1140
+ }
1141
+ await this._asyncWrite(chunk, currentRequest);
1142
+ if (this.options.body !== body) {
1143
+ return;
1144
+ }
1145
+ }
1146
+ if (this.options.body === body) {
1147
+ if (isInitialRequest) {
1148
+ super.end();
1149
+ return;
1150
+ }
1151
+ await this._endWritableRequest(currentRequest);
913
1152
  }
914
- super.end();
915
1153
  }
916
1154
  catch (error) {
917
- this._beforeError(error);
1155
+ if (this.options.body !== body) {
1156
+ return;
1157
+ }
1158
+ this._beforeError(normalizeError(error));
918
1159
  }
919
1160
  })();
920
1161
  }
@@ -926,8 +1167,205 @@ export default class Request extends Duplex {
926
1167
  }
927
1168
  }
928
1169
  else {
929
- this._writeRequest(body, undefined, () => { });
930
- currentRequest.end();
1170
+ // Handles string bodies (from json/form options).
1171
+ this._writeBodyInChunks(stringToUint8Array(body), currentRequest);
1172
+ }
1173
+ }
1174
+ /*
1175
+ Write a body buffer in chunks to enable granular `uploadProgress` events.
1176
+
1177
+ Without chunking, string/Uint8Array/TypedArray bodies are written in a single call, causing `uploadProgress` to only emit 0% and 100% with nothing in between.
1178
+
1179
+ The 64 KB chunk size matches Node.js fs stream defaults.
1180
+ */
1181
+ _writeBodyInChunks(buffer, currentRequest) {
1182
+ const isInitialRequest = currentRequest === this;
1183
+ (async () => {
1184
+ let request;
1185
+ try {
1186
+ request = isInitialRequest ? this._request : currentRequest;
1187
+ const activeRequest = request;
1188
+ if (!activeRequest) {
1189
+ if (isInitialRequest) {
1190
+ super.end();
1191
+ }
1192
+ return;
1193
+ }
1194
+ if (activeRequest.destroyed) {
1195
+ return;
1196
+ }
1197
+ await this._writeChunksToRequest(buffer, activeRequest);
1198
+ if (this._isRequestStale(activeRequest)) {
1199
+ this._finalizeStaleChunkedWrite(activeRequest, isInitialRequest);
1200
+ return;
1201
+ }
1202
+ if (isInitialRequest) {
1203
+ super.end();
1204
+ return;
1205
+ }
1206
+ await this._endWritableRequest(activeRequest);
1207
+ }
1208
+ catch (error) {
1209
+ const normalizedError = normalizeError(error);
1210
+ // Transient write errors (EPIPE, ECONNRESET) are handled by the request-level
1211
+ // error and close handlers. For initial redirected writes, still finalize
1212
+ // writable state once the stale transition becomes observable.
1213
+ if (isTransientWriteError(normalizedError)) {
1214
+ if (isInitialRequest && request) {
1215
+ const initialRequest = request;
1216
+ let didFinalize = false;
1217
+ const finalizeIfStale = () => {
1218
+ if (didFinalize || !this._isRequestStale(initialRequest)) {
1219
+ return;
1220
+ }
1221
+ didFinalize = true;
1222
+ this._finalizeStaleChunkedWrite(initialRequest, true);
1223
+ };
1224
+ finalizeIfStale();
1225
+ if (!didFinalize) {
1226
+ initialRequest.once('response', finalizeIfStale);
1227
+ queueMicrotask(finalizeIfStale);
1228
+ }
1229
+ }
1230
+ return;
1231
+ }
1232
+ if (!isInitialRequest && this._isRequestStale(currentRequest)) {
1233
+ return;
1234
+ }
1235
+ this._beforeError(normalizedError);
1236
+ }
1237
+ })();
1238
+ }
1239
+ _finalizeStaleChunkedWrite(request, isInitialRequest) {
1240
+ if (!request.destroyed && !request.writableEnded) {
1241
+ request.destroy();
1242
+ }
1243
+ if (isInitialRequest) {
1244
+ // Finalize writable state without ending the active redirected request.
1245
+ this._skipRequestEndInFinal = true;
1246
+ super.end();
1247
+ }
1248
+ }
1249
+ _emitUploadComplete(request) {
1250
+ this._bodySize = this._uploadedSize;
1251
+ this.emit('uploadProgress', this.uploadProgress);
1252
+ request.emit('upload-complete');
1253
+ }
1254
+ async _endWritableRequest(request) {
1255
+ await new Promise((resolve, reject) => {
1256
+ request.end((error) => {
1257
+ if (error) {
1258
+ reject(error);
1259
+ return;
1260
+ }
1261
+ if (this._request === request && !request.destroyed) {
1262
+ this._emitUploadComplete(request);
1263
+ }
1264
+ resolve();
1265
+ });
1266
+ });
1267
+ }
1268
+ _stripCrossOriginState(options, urlToClear, bodyAlreadyDropped) {
1269
+ for (const header of crossOriginStripHeaders) {
1270
+ options.deleteInternalHeader(header);
1271
+ }
1272
+ options.username = '';
1273
+ options.password = '';
1274
+ urlToClear.username = '';
1275
+ urlToClear.password = '';
1276
+ if (!bodyAlreadyDropped) {
1277
+ this._dropBody(options);
1278
+ }
1279
+ }
1280
+ _stripUnchangedCrossOriginState(options, urlToClear, bodyAlreadyDropped, state) {
1281
+ const headers = options.getInternalHeaders();
1282
+ for (const header of crossOriginStripHeaders) {
1283
+ if (!state.changedState.has(header) && headers[header] === state.headers[header]) {
1284
+ options.deleteInternalHeader(header);
1285
+ }
1286
+ }
1287
+ if (!state.preserveUsername) {
1288
+ options.username = '';
1289
+ urlToClear.username = '';
1290
+ }
1291
+ if (!state.preservePassword) {
1292
+ options.password = '';
1293
+ urlToClear.password = '';
1294
+ }
1295
+ if (!bodyAlreadyDropped
1296
+ && !state.changedState.has('body')
1297
+ && !state.changedState.has('json')
1298
+ && !state.changedState.has('form')
1299
+ && isBodyUnchanged(options, state)) {
1300
+ this._dropBody(options);
1301
+ }
1302
+ }
1303
+ _dropBody(updatedOptions) {
1304
+ const { body } = this.options;
1305
+ const hadOptionBody = !is.undefined(body) || !is.undefined(this.options.json) || !is.undefined(this.options.form);
1306
+ this.options.clearBody();
1307
+ if (is.nodeStream(body)) {
1308
+ body.off('error', this._onBodyError);
1309
+ body.unpipe();
1310
+ body.on('error', noop);
1311
+ body.destroy();
1312
+ }
1313
+ else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
1314
+ const iterableBody = body;
1315
+ // Signal the iterator to clean up, but don't await it:
1316
+ // the for-await loop in _sendBody exits via the options.body sentinel,
1317
+ // and awaiting return() would deadlock when next() is pending.
1318
+ if (typeof iterableBody.return === 'function') {
1319
+ try {
1320
+ const result = iterableBody.return();
1321
+ if (result instanceof Promise) {
1322
+ // eslint-disable-next-line promise/prefer-await-to-then
1323
+ result.catch(noop);
1324
+ }
1325
+ }
1326
+ catch { }
1327
+ }
1328
+ }
1329
+ else if (!hadOptionBody && !this.writableEnded) {
1330
+ this._skipRequestEndInFinal = true;
1331
+ super.end();
1332
+ }
1333
+ updatedOptions.clearBody();
1334
+ this._bodySize = undefined;
1335
+ }
1336
+ _onBodyError = (error) => {
1337
+ if (this._flushed) {
1338
+ this._beforeError(new UploadError(error, this));
1339
+ }
1340
+ else {
1341
+ this.flush = async () => {
1342
+ this.flush = async () => { };
1343
+ this._beforeError(new UploadError(error, this));
1344
+ };
1345
+ }
1346
+ };
1347
+ async _writeChunksToRequest(buffer, request) {
1348
+ const chunkSize = 65_536; // 64 KB
1349
+ const isStale = () => this._isRequestStale(request);
1350
+ for (const part of chunk(buffer, chunkSize)) {
1351
+ if (isStale()) {
1352
+ return;
1353
+ }
1354
+ // eslint-disable-next-line no-await-in-loop
1355
+ await new Promise((resolve, reject) => {
1356
+ this._writeRequest(part, undefined, error => {
1357
+ if (isStale()) {
1358
+ resolve();
1359
+ return;
1360
+ }
1361
+ if (error) {
1362
+ reject(error);
1363
+ }
1364
+ else {
1365
+ setImmediate(resolve);
1366
+ }
1367
+ }, request);
1368
+ });
931
1369
  }
932
1370
  }
933
1371
  _prepareCache(cache) {
@@ -945,59 +1383,62 @@ export default class Request extends Duplex {
945
1383
  Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
946
1384
  Mutations take effect immediately and determine what gets cached.
947
1385
  */
948
- const wrappedHandler = handler ? (response) => {
949
- const { beforeCacheHooks, gotRequest } = requestOptions;
950
- // Early return if no hooks - cache the original response
951
- if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
952
- handler(response);
953
- return;
954
- }
955
- try {
956
- // Call each beforeCache hook with the response
957
- // Hooks can directly mutate the response - mutations take effect immediately
958
- for (const hook of beforeCacheHooks) {
959
- const result = hook(response);
960
- if (result === false) {
961
- // Prevent caching by adding no-cache headers
962
- // Mutate the response directly to add headers
963
- response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
964
- response.headers.pragma = 'no-cache';
965
- response.headers.expires = '0';
966
- handler(response);
967
- // Don't call remaining hooks - we've decided not to cache
968
- return;
969
- }
970
- if (is.promise(result)) {
971
- // BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
972
- throw new TypeError('beforeCache hooks must be synchronous. The hook returned a Promise, but this hook must return synchronously. If you need async logic, use beforeRequest hook instead.');
973
- }
974
- if (result !== undefined) {
975
- // Hooks should return false or undefined only
976
- // Mutations work directly - no need to return the response
977
- throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
1386
+ const wrappedHandler = handler
1387
+ ? (response) => {
1388
+ const { beforeCacheHooks, gotRequest } = requestOptions;
1389
+ // Early return if no hooks - cache the original response
1390
+ if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
1391
+ handler(response);
1392
+ return;
1393
+ }
1394
+ try {
1395
+ // Call each beforeCache hook with the response
1396
+ // Hooks can directly mutate the response - mutations take effect immediately
1397
+ for (const hook of beforeCacheHooks) {
1398
+ const result = hook(response);
1399
+ if (result === false) {
1400
+ // Prevent caching by adding no-cache headers
1401
+ // Mutate the response directly to add headers
1402
+ response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
1403
+ response.headers.pragma = 'no-cache';
1404
+ response.headers.expires = '0';
1405
+ handler(response);
1406
+ // Don't call remaining hooks - we've decided not to cache
1407
+ return;
1408
+ }
1409
+ if (is.promise(result)) {
1410
+ // BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
1411
+ throw new TypeError('beforeCache hooks must be synchronous. The hook returned a Promise, but this hook must return synchronously. If you need async logic, use beforeRequest hook instead.');
1412
+ }
1413
+ if (result !== undefined) {
1414
+ // Hooks should return false or undefined only
1415
+ // Mutations work directly - no need to return the response
1416
+ throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
1417
+ }
1418
+ // Else: void/undefined = continue
978
1419
  }
979
- // Else: void/undefined = continue
980
1420
  }
981
- }
982
- catch (error) {
983
- // Convert hook errors to RequestError and propagate
984
- // This is consistent with how other hooks handle errors
985
- if (gotRequest) {
986
- gotRequest._beforeError(error instanceof RequestError ? error : new RequestError(error.message, error, gotRequest));
987
- // Don't call handler when error was propagated successfully
1421
+ catch (error) {
1422
+ const normalizedError = normalizeError(error);
1423
+ // Convert hook errors to RequestError and propagate
1424
+ // This is consistent with how other hooks handle errors
1425
+ if (gotRequest) {
1426
+ gotRequest._beforeError(normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, gotRequest));
1427
+ // Don't call handler when error was propagated successfully
1428
+ return;
1429
+ }
1430
+ // If gotRequest is missing, log the error to aid debugging
1431
+ // We still call the handler to prevent the request from hanging
1432
+ console.error('Got: beforeCache hook error (request context unavailable):', normalizedError);
1433
+ // Call handler with response (potentially partially modified)
1434
+ handler(response);
988
1435
  return;
989
1436
  }
990
- // If gotRequest is missing, log the error to aid debugging
991
- // We still call the handler to prevent the request from hanging
992
- console.error('Got: beforeCache hook error (request context unavailable):', error);
993
- // Call handler with response (potentially partially modified)
1437
+ // All hooks ran successfully
1438
+ // Cache the response with any mutations applied
994
1439
  handler(response);
995
- return;
996
1440
  }
997
- // All hooks ran successfully
998
- // Cache the response with any mutations applied
999
- handler(response);
1000
- } : handler;
1441
+ : handler;
1001
1442
  const result = requestOptions._request(requestOptions, wrappedHandler);
1002
1443
  // TODO: remove this when `cacheable-request` supports async request functions.
1003
1444
  if (is.promise(result)) {
@@ -1039,32 +1480,44 @@ export default class Request extends Duplex {
1039
1480
  }
1040
1481
  async _createCacheableRequest(url, options) {
1041
1482
  return new Promise((resolve, reject) => {
1042
- // TODO: Remove `utils/url-to-options.ts` when `cacheable-request` is fixed
1043
- Object.assign(options, urlToOptions(url));
1483
+ Object.assign(options, {
1484
+ protocol: url.protocol,
1485
+ hostname: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
1486
+ host: url.host,
1487
+ hash: url.hash === '' ? '' : (url.hash ?? null),
1488
+ search: url.search === '' ? '' : (url.search ?? null),
1489
+ pathname: url.pathname,
1490
+ href: url.href,
1491
+ path: `${url.pathname || ''}${url.search || ''}`,
1492
+ ...(is.string(url.port) && url.port.length > 0 ? { port: Number(url.port) } : {}),
1493
+ ...(url.username || url.password ? { auth: `${url.username || ''}:${url.password || ''}` } : {}),
1494
+ });
1044
1495
  let request;
1045
1496
  // TODO: Fix `cacheable-response`. This is ugly.
1046
- const cacheRequest = cacheableStore.get(options.cache)(options, async (response) => {
1047
- response._readableState.autoDestroy = false;
1048
- if (request) {
1049
- const fix = () => {
1050
- // For ResponseLike objects from cache, set complete to true if not already set.
1051
- // For real HTTP responses, copy from the underlying response.
1052
- if (response.req) {
1053
- response.complete = response.req.res.complete;
1054
- }
1055
- else if (response.complete === undefined) {
1056
- // ResponseLike from cache should have complete = true
1057
- response.complete = true;
1058
- }
1059
- };
1060
- response.prependOnceListener('end', fix);
1061
- fix();
1062
- (await request).emit('cacheableResponse', response);
1063
- }
1064
- resolve(response);
1497
+ const cacheRequest = cacheableStore.get(options.cache)(options, (response) => {
1498
+ void (async () => {
1499
+ response._readableState.autoDestroy = false;
1500
+ if (request) {
1501
+ const fix = () => {
1502
+ // For ResponseLike objects from cache, set complete to true if not already set.
1503
+ // For real HTTP responses, copy from the underlying response.
1504
+ if (response.req) {
1505
+ response.complete = response.req.res.complete;
1506
+ }
1507
+ else if (response.complete === undefined) {
1508
+ // ResponseLike from cache should have complete = true
1509
+ response.complete = true;
1510
+ }
1511
+ };
1512
+ response.prependOnceListener('end', fix);
1513
+ fix();
1514
+ (await request).emit('cacheableResponse', response);
1515
+ }
1516
+ resolve(response);
1517
+ })();
1065
1518
  });
1066
1519
  cacheRequest.once('error', reject);
1067
- cacheRequest.once('request', async (requestOrPromise) => {
1520
+ cacheRequest.once('request', (requestOrPromise) => {
1068
1521
  request = requestOrPromise;
1069
1522
  resolve(request);
1070
1523
  });
@@ -1072,12 +1525,12 @@ export default class Request extends Duplex {
1072
1525
  }
1073
1526
  async _makeRequest() {
1074
1527
  const { options } = this;
1075
- const { headers, username, password } = options;
1528
+ const headers = options.getInternalHeaders();
1529
+ const { username, password } = options;
1076
1530
  const cookieJar = options.cookieJar;
1077
1531
  for (const key in headers) {
1078
1532
  if (is.undefined(headers[key])) {
1079
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
1080
- delete headers[key];
1533
+ options.deleteInternalHeader(key);
1081
1534
  }
1082
1535
  else if (is.null(headers[key])) {
1083
1536
  throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
@@ -1091,17 +1544,17 @@ export default class Request extends Duplex {
1091
1544
  if (supportsZstd) {
1092
1545
  encodings.push('zstd');
1093
1546
  }
1094
- headers['accept-encoding'] = encodings.join(', ');
1547
+ options.setInternalHeader('accept-encoding', encodings.join(', '));
1095
1548
  }
1096
1549
  if (username || password) {
1097
- const credentials = Buffer.from(`${username}:${password}`).toString('base64');
1098
- headers.authorization = `Basic ${credentials}`;
1550
+ const credentials = stringToBase64(`${username}:${password}`);
1551
+ options.setInternalHeader('authorization', `Basic ${credentials}`);
1099
1552
  }
1100
1553
  // Set cookies
1101
1554
  if (cookieJar) {
1102
1555
  const cookieString = await cookieJar.getCookieString(options.url.toString());
1103
1556
  if (is.nonEmptyString(cookieString)) {
1104
- headers.cookie = cookieString;
1557
+ options.setInternalHeader('cookie', cookieString);
1105
1558
  }
1106
1559
  }
1107
1560
  let request;
@@ -1114,7 +1567,11 @@ export default class Request extends Duplex {
1114
1567
  break;
1115
1568
  }
1116
1569
  }
1117
- request ||= options.getRequestFunction();
1570
+ if (!is.undefined(headers['transfer-encoding']) && !is.undefined(headers['content-length'])) {
1571
+ // TODO: Throw instead of silently dropping `content-length` in the next major version.
1572
+ options.deleteInternalHeader('content-length');
1573
+ }
1574
+ request ??= options.getRequestFunction();
1118
1575
  const url = options.url;
1119
1576
  this._requestOptions = options.createNativeRequestOptions();
1120
1577
  if (options.cache) {
@@ -1127,7 +1584,7 @@ export default class Request extends Duplex {
1127
1584
  this._prepareCache(options.cache);
1128
1585
  }
1129
1586
  catch (error) {
1130
- throw new CacheError(error, this);
1587
+ throw new CacheError(normalizeError(error), this);
1131
1588
  }
1132
1589
  }
1133
1590
  // Cache support
@@ -1139,13 +1596,6 @@ export default class Request extends Duplex {
1139
1596
  if (is.promise(requestOrResponse)) {
1140
1597
  requestOrResponse = await requestOrResponse;
1141
1598
  }
1142
- // Fallback
1143
- if (is.undefined(requestOrResponse)) {
1144
- requestOrResponse = options.getFallbackRequestFunction()(url, this._requestOptions);
1145
- if (is.promise(requestOrResponse)) {
1146
- requestOrResponse = await requestOrResponse;
1147
- }
1148
- }
1149
1599
  if (isClientRequest(requestOrResponse)) {
1150
1600
  this._onRequest(requestOrResponse);
1151
1601
  }
@@ -1168,12 +1618,9 @@ export default class Request extends Duplex {
1168
1618
  }
1169
1619
  async _error(error) {
1170
1620
  try {
1171
- if (this.options && error instanceof HTTPError && !this.options.throwHttpErrors) {
1172
- // This branch can be reached only when using the Promise API
1173
- // Skip calling the hooks on purpose.
1174
- // See https://github.com/sindresorhus/got/issues/2103
1175
- }
1176
- else if (this.options) {
1621
+ // Skip calling hooks for HTTP errors when throwHttpErrors is false (Promise API only).
1622
+ // See https://github.com/sindresorhus/got/issues/2103
1623
+ if (this.options && (!(error instanceof HTTPError) || this.options.throwHttpErrors)) {
1177
1624
  const hooks = this.options.hooks.beforeError;
1178
1625
  if (hooks.length > 0) {
1179
1626
  for (const hook of hooks) {
@@ -1194,12 +1641,13 @@ export default class Request extends Duplex {
1194
1641
  }
1195
1642
  }
1196
1643
  catch (error_) {
1197
- error = new RequestError(error_.message, error_, this);
1644
+ const normalizedError = normalizeError(error_);
1645
+ error = new RequestError(normalizedError.message, normalizedError, this);
1198
1646
  }
1199
1647
  // Publish error event
1200
1648
  publishError({
1201
1649
  requestId: this._requestId,
1202
- url: this.options?.url?.toString() ?? '',
1650
+ url: getSanitizedUrl(this.options),
1203
1651
  error,
1204
1652
  timings: this.timings,
1205
1653
  });
@@ -1215,16 +1663,17 @@ export default class Request extends Duplex {
1215
1663
  });
1216
1664
  }
1217
1665
  }
1218
- _writeRequest(chunk, encoding, callback) {
1219
- if (!this._request || this._request.destroyed) {
1666
+ _writeRequest(chunk, encoding, callback, request = this._request) {
1667
+ if (!request || request.destroyed) {
1220
1668
  // When there's no request (e.g., using cached response from beforeRequest hook),
1221
1669
  // we still need to call the callback to allow the stream to finish properly.
1222
1670
  callback();
1223
1671
  return;
1224
1672
  }
1225
- this._request.write(chunk, encoding, (error) => {
1226
- // The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed
1227
- if (!error && !this._request.destroyed) {
1673
+ request.write(chunk, encoding, (error) => {
1674
+ // The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed.
1675
+ // The `this._request === request` check prevents stale write callbacks from a pre-redirect request from incrementing `_uploadedSize` after it's been reset.
1676
+ if (!error && !request.destroyed && this._request === request) {
1228
1677
  // For strings, encode them first to measure the actual bytes that will be sent
1229
1678
  const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk;
1230
1679
  this._uploadedSize += byteLength(bytes);
@@ -1255,41 +1704,13 @@ export default class Request extends Duplex {
1255
1704
  Progress event for downloading (receiving a response).
1256
1705
  */
1257
1706
  get downloadProgress() {
1258
- let percent;
1259
- if (this._responseSize) {
1260
- percent = this._downloadedSize / this._responseSize;
1261
- }
1262
- else if (this._responseSize === this._downloadedSize) {
1263
- percent = 1;
1264
- }
1265
- else {
1266
- percent = 0;
1267
- }
1268
- return {
1269
- percent,
1270
- transferred: this._downloadedSize,
1271
- total: this._responseSize,
1272
- };
1707
+ return makeProgress(this._downloadedSize, this._responseSize);
1273
1708
  }
1274
1709
  /**
1275
1710
  Progress event for uploading (sending a request).
1276
1711
  */
1277
1712
  get uploadProgress() {
1278
- let percent;
1279
- if (this._bodySize) {
1280
- percent = this._uploadedSize / this._bodySize;
1281
- }
1282
- else if (this._bodySize === this._uploadedSize) {
1283
- percent = 1;
1284
- }
1285
- else {
1286
- percent = 0;
1287
- }
1288
- return {
1289
- percent,
1290
- transferred: this._uploadedSize,
1291
- total: this._bodySize,
1292
- };
1713
+ return makeProgress(this._uploadedSize, this._bodySize);
1293
1714
  }
1294
1715
  /**
1295
1716
  The object contains the following properties:
@@ -1325,7 +1746,7 @@ export default class Request extends Duplex {
1325
1746
  Whether the response was retrieved from the cache.
1326
1747
  */
1327
1748
  get isFromCache() {
1328
- return this._isFromCache;
1749
+ return this.response?.isFromCache;
1329
1750
  }
1330
1751
  get reusedSocket() {
1331
1752
  return this._request?.reusedSocket;