got 14.6.5 → 15.0.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.
Files changed (35) hide show
  1. package/dist/source/as-promise/index.d.ts +2 -2
  2. package/dist/source/as-promise/index.js +59 -41
  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 +7 -10
  9. package/dist/source/core/index.d.ts +19 -7
  10. package/dist/source/core/index.js +726 -311
  11. package/dist/source/core/options.d.ts +92 -91
  12. package/dist/source/core/options.js +616 -303
  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 +3 -3
  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/proxy-events.d.ts +1 -1
  21. package/dist/source/core/utils/proxy-events.js +3 -3
  22. package/dist/source/core/utils/strip-url-auth.d.ts +1 -0
  23. package/dist/source/core/utils/strip-url-auth.js +9 -0
  24. package/dist/source/core/utils/timer.js +5 -7
  25. package/dist/source/core/utils/unhandle.js +1 -2
  26. package/dist/source/create.js +83 -27
  27. package/dist/source/index.d.ts +2 -3
  28. package/dist/source/index.js +0 -4
  29. package/dist/source/types.d.ts +42 -70
  30. package/package.json +34 -38
  31. package/readme.md +2 -2
  32. package/dist/source/core/utils/is-form-data.d.ts +0 -7
  33. package/dist/source/core/utils/is-form-data.js +0 -4
  34. package/dist/source/core/utils/url-to-options.d.ts +0 -14
  35. package/dist/source/core/utils/url-to-options.js +0 -22
@@ -1,33 +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
- // Methods that should auto-end streams when no body is provided
28
- const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
29
28
  const cacheableStore = new WeakableMap();
30
- 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
+ ]);
31
44
  // Track errors that have been processed by beforeError hooks to preserve custom error types
32
45
  const errorsProcessedByHooks = new WeakSet();
33
46
  const proxiedRequestEvents = [
@@ -38,6 +51,64 @@ const proxiedRequestEvents = [
38
51
  'upgrade',
39
52
  ];
40
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
+ };
41
112
  export default class Request extends Duplex {
42
113
  // @ts-expect-error - Ignoring for now.
43
114
  ['constructor'];
@@ -49,24 +120,24 @@ export default class Request extends Duplex {
49
120
  redirectUrls = [];
50
121
  retryCount = 0;
51
122
  _stopReading = false;
52
- _stopRetry = noop;
123
+ _stopRetry;
53
124
  _downloadedSize = 0;
54
125
  _uploadedSize = 0;
55
126
  _pipedServerResponses = new Set();
56
127
  _request;
57
128
  _responseSize;
58
129
  _bodySize;
59
- _unproxyEvents = noop;
60
- _isFromCache;
130
+ _unproxyEvents;
61
131
  _triggerRead = false;
62
132
  _jobs = [];
63
- _cancelTimeouts = noop;
64
- _removeListeners = noop;
65
- _nativeResponse;
133
+ _cancelTimeouts;
134
+ _abortListenerDisposer;
66
135
  _flushed = false;
67
136
  _aborted = false;
68
137
  _expectedContentLength;
69
138
  _compressedBytesCount;
139
+ _skipRequestEndInFinal = false;
140
+ _incrementalDecode;
70
141
  _requestId = generateRequestId();
71
142
  // We need this because `this._request` if `undefined` when using cache
72
143
  _requestInitialized = false;
@@ -79,7 +150,17 @@ export default class Request extends Duplex {
79
150
  });
80
151
  this.on('pipe', (source) => {
81
152
  if (this.options.copyPipedHeaders && source?.headers) {
82
- 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
+ }
83
164
  }
84
165
  });
85
166
  this.on('newListener', event => {
@@ -99,7 +180,7 @@ export default class Request extends Duplex {
99
180
  // Publish request creation event
100
181
  publishRequestCreate({
101
182
  requestId: this._requestId,
102
- url: this.options.url?.toString() ?? '',
183
+ url: getSanitizedUrl(this.options),
103
184
  method: this.options.method,
104
185
  });
105
186
  }
@@ -114,11 +195,12 @@ export default class Request extends Duplex {
114
195
  process.nextTick(() => {
115
196
  // _beforeError requires options to access retry logic and hooks
116
197
  if (this.options) {
117
- this._beforeError(error);
198
+ this._beforeError(normalizeError(error));
118
199
  }
119
200
  else {
120
201
  // Options is undefined, skip _beforeError and destroy directly
121
- 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);
122
204
  this.destroy(requestError);
123
205
  }
124
206
  });
@@ -129,17 +211,7 @@ export default class Request extends Duplex {
129
211
  // The below is run only once.
130
212
  const { body } = this.options;
131
213
  if (is.nodeStream(body)) {
132
- body.once('error', error => {
133
- if (this._flushed) {
134
- this._beforeError(new UploadError(error, this));
135
- }
136
- else {
137
- this.flush = async () => {
138
- this.flush = async () => { };
139
- this._beforeError(new UploadError(error, this));
140
- };
141
- }
142
- });
214
+ body.once('error', this._onBodyError);
143
215
  }
144
216
  if (this.options.signal) {
145
217
  const abort = () => {
@@ -155,10 +227,8 @@ export default class Request extends Duplex {
155
227
  abort();
156
228
  }
157
229
  else {
158
- this.options.signal.addEventListener('abort', abort);
159
- this._removeListeners = () => {
160
- this.options.signal?.removeEventListener('abort', abort);
161
- };
230
+ const abortListenerDisposer = addAbortListener(this.options.signal, abort);
231
+ this._abortListenerDisposer = abortListenerDisposer;
162
232
  }
163
233
  }
164
234
  }
@@ -186,7 +256,7 @@ export default class Request extends Duplex {
186
256
  this._requestInitialized = true;
187
257
  }
188
258
  catch (error) {
189
- this._beforeError(error);
259
+ this._beforeError(normalizeError(error));
190
260
  }
191
261
  }
192
262
  _beforeError(error) {
@@ -212,7 +282,7 @@ export default class Request extends Duplex {
212
282
  response.setEncoding(this.readableEncoding);
213
283
  const success = await this._setRawBody(response);
214
284
  if (success) {
215
- response.body = response.rawBody.toString();
285
+ response.body = decodeUint8Array(response.rawBody);
216
286
  }
217
287
  }
218
288
  if (this.listenerCount('retry') !== 0) {
@@ -242,7 +312,7 @@ export default class Request extends Duplex {
242
312
  // When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
243
313
  // before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
244
314
  // based on these rules), skip calling calculateDelay entirely.
245
- // When false (default), always call calculateDelay, allowing it to override retry decisions.
315
+ // When false, always call calculateDelay, allowing it to override retry decisions.
246
316
  if (retryOptions.enforceRetryRules && computedValue === 0) {
247
317
  backoff = 0;
248
318
  }
@@ -257,7 +327,8 @@ export default class Request extends Duplex {
257
327
  }
258
328
  }
259
329
  catch (error_) {
260
- void this._error(new RequestError(error_.message, error_, this));
330
+ const normalizedError = normalizeError(error_);
331
+ void this._error(new RequestError(normalizedError.message, normalizedError, this));
261
332
  return;
262
333
  }
263
334
  if (backoff) {
@@ -281,7 +352,8 @@ export default class Request extends Duplex {
281
352
  }
282
353
  }
283
354
  catch (error_) {
284
- void this._error(new RequestError(error_.message, error_, this));
355
+ const normalizedError = normalizeError(error_);
356
+ void this._error(new RequestError(normalizedError.message, normalizedError, this));
285
357
  return;
286
358
  }
287
359
  // Something forced us to abort the retry
@@ -301,30 +373,35 @@ export default class Request extends Duplex {
301
373
  // 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks
302
374
  // 3. We must restore the body reference after destroy() for identity checks in promise wrapper
303
375
  // 4. We cannot use the normal setter after destroy() because it validates stream readability
304
- if (bodyWasReassigned) {
305
- const oldBody = bodyBeforeHooks;
306
- // Temporarily clear body to prevent destroy() from destroying the new stream
307
- this.options.body = undefined;
308
- this.destroy();
309
- // Clean up the old stream resource if it's a stream and different from new body
310
- // (edge case: if old and new are same stream object, don't destroy it)
311
- if (is.nodeStream(oldBody) && oldBody !== bodyAfterHooks) {
312
- 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;
313
392
  }
314
- // Restore new body for promise wrapper's identity check
315
- // We bypass the setter because it validates stream.readable (which fails for destroyed request)
316
- // Type assertion is necessary here to access private _internals without exposing internal API
317
- if (is.nodeStream(bodyAfterHooks) && (bodyAfterHooks.readableEnded || bodyAfterHooks.destroyed)) {
318
- 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.
319
399
  }
320
- this.options._internals.body = bodyAfterHooks;
321
400
  }
322
- else {
323
- // Body wasn't reassigned - use normal destroy flow which handles body cleanup
324
- this.destroy();
325
- // Note: We do NOT restore the body reference here. The stream was destroyed by _destroy()
326
- // and should not be accessed. The promise wrapper will see that body identity hasn't changed
327
- // 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;
328
405
  }
329
406
  // Publish retry event
330
407
  publishRetry({
@@ -359,6 +436,17 @@ export default class Request extends Duplex {
359
436
  let data;
360
437
  while ((data = response.read()) !== null) {
361
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
+ }
362
450
  const progress = this.downloadProgress;
363
451
  if (progress.percent < 1) {
364
452
  this.emit('downloadProgress', progress);
@@ -380,22 +468,26 @@ export default class Request extends Duplex {
380
468
  }
381
469
  _final(callback) {
382
470
  const endRequest = () => {
471
+ if (this._skipRequestEndInFinal) {
472
+ this._skipRequestEndInFinal = false;
473
+ callback();
474
+ return;
475
+ }
476
+ const request = this._request;
383
477
  // We need to check if `this._request` is present,
384
478
  // because it isn't when we use cache.
385
- if (!this._request || this._request.destroyed) {
479
+ if (!request || request.destroyed) {
386
480
  callback();
387
481
  return;
388
482
  }
389
- this._request.end((error) => {
483
+ request.end((error) => {
390
484
  // The request has been destroyed before `_final` finished.
391
485
  // See https://github.com/nodejs/node/issues/39356
392
- if (this._request?._writableState?.errored) {
486
+ if (request?._writableState?.errored) {
393
487
  return;
394
488
  }
395
489
  if (!error) {
396
- this._bodySize = this._uploadedSize;
397
- this.emit('uploadProgress', this.uploadProgress);
398
- this._request?.emit('upload-complete');
490
+ this._emitUploadComplete(request);
399
491
  }
400
492
  callback(error);
401
493
  });
@@ -411,9 +503,9 @@ export default class Request extends Duplex {
411
503
  this._stopReading = true;
412
504
  this.flush = async () => { };
413
505
  // Prevent further retries
414
- this._stopRetry();
415
- this._cancelTimeouts();
416
- this._removeListeners();
506
+ this._stopRetry?.();
507
+ this._cancelTimeouts?.();
508
+ this._abortListenerDisposer?.[Symbol.dispose]();
417
509
  if (this.options) {
418
510
  const { body } = this.options;
419
511
  if (is.nodeStream(body)) {
@@ -461,6 +553,13 @@ export default class Request extends Duplex {
461
553
  super.unpipe(destination);
462
554
  return this;
463
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
+ }
464
563
  _checkContentLengthMismatch() {
465
564
  if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
466
565
  // Use compressed bytes count when available (for compressed responses),
@@ -479,7 +578,7 @@ export default class Request extends Duplex {
479
578
  }
480
579
  async _finalizeBody() {
481
580
  const { options } = this;
482
- const { headers } = options;
581
+ const headers = options.getInternalHeaders();
483
582
  const isForm = !is.undefined(options.form);
484
583
  // eslint-disable-next-line @typescript-eslint/naming-convention
485
584
  const isJSON = !is.undefined(options.json);
@@ -492,20 +591,16 @@ export default class Request extends Duplex {
492
591
  // Serialize body
493
592
  const noContentType = !is.string(headers['content-type']);
494
593
  if (isBody) {
495
- // Body is spec-compliant FormData
496
- if (isFormDataLike(options.body)) {
497
- const encoder = new FormDataEncoder(options.body);
594
+ // Native FormData
595
+ if (options.body instanceof FormData) {
596
+ const response = new Response(options.body);
498
597
  if (noContentType) {
499
- headers['content-type'] = encoder.headers['Content-Type'];
500
- }
501
- if ('Content-Length' in encoder.headers) {
502
- headers['content-length'] = encoder.headers['Content-Length'];
598
+ headers['content-type'] = response.headers.get('content-type') ?? 'multipart/form-data';
503
599
  }
504
- options.body = encoder.encode();
600
+ options.body = response.body;
505
601
  }
506
- // Special case for https://github.com/form-data/form-data
507
- if (isFormData(options.body) && noContentType) {
508
- 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.');
509
604
  }
510
605
  }
511
606
  else if (isForm) {
@@ -524,7 +619,7 @@ export default class Request extends Duplex {
524
619
  options.json = undefined;
525
620
  options.body = options.stringifyJson(json);
526
621
  }
527
- const uploadBodySize = await getBodySize(options.body, options.headers);
622
+ const uploadBodySize = getBodySize(options.body, headers);
528
623
  // See https://tools.ietf.org/html/rfc7230#section-3.3.2
529
624
  // A user agent SHOULD send a Content-Length in a request message when
530
625
  // no Transfer-Encoding is sent and the request method defines a meaning
@@ -538,8 +633,8 @@ export default class Request extends Duplex {
538
633
  headers['content-length'] = String(uploadBodySize);
539
634
  }
540
635
  }
541
- if (options.responseType === 'json' && !('accept' in options.headers)) {
542
- options.headers.accept = 'application/json';
636
+ if (options.responseType === 'json' && !('accept' in headers)) {
637
+ headers.accept = 'application/json';
543
638
  }
544
639
  this._bodySize = Number(headers['content-length']) || undefined;
545
640
  }
@@ -550,9 +645,12 @@ export default class Request extends Duplex {
550
645
  }
551
646
  const { options } = this;
552
647
  const { url } = options;
553
- this._nativeResponse = response;
648
+ const nativeResponse = response;
554
649
  const statusCode = response.statusCode;
555
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));
556
654
  // Skip decompression for responses that must not have bodies per RFC 9110:
557
655
  // - HEAD responses (any status code)
558
656
  // - 1xx (Informational): 100, 101, 102, 103, etc.
@@ -564,30 +662,46 @@ export default class Request extends Duplex {
564
662
  || statusCode === 204
565
663
  || statusCode === 205
566
664
  || statusCode === 304;
567
- 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) {
568
690
  // When strictContentLength is enabled, track compressed bytes by listening to
569
691
  // the native response's data events before decompression
570
692
  if (options.strictContentLength) {
571
693
  this._compressedBytesCount = 0;
572
- this._nativeResponse.on('data', (chunk) => {
694
+ nativeResponse.on('data', (chunk) => {
573
695
  this._compressedBytesCount += byteLength(chunk);
574
696
  });
575
697
  }
576
698
  response = decompressResponse(response);
699
+ typedResponse = prepareResponse(response);
577
700
  }
578
- const typedResponse = response;
579
- typedResponse.statusMessage = typedResponse.statusMessage || http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
580
- typedResponse.url = options.url.toString();
581
- typedResponse.requestUrl = this.requestUrl;
582
- typedResponse.redirectUrls = this.redirectUrls;
583
- typedResponse.request = this;
584
- typedResponse.isFromCache = this._nativeResponse.fromCache ?? false;
585
- typedResponse.ip = this.ip;
586
- typedResponse.retryCount = this.retryCount;
587
- typedResponse.ok = isResponseOk(typedResponse);
588
- this._isFromCache = typedResponse.isFromCache;
589
701
  this._responseSize = Number(response.headers['content-length']) || undefined;
590
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;
591
705
  // Publish response start event
592
706
  publishResponseStart({
593
707
  requestId: this._requestId,
@@ -598,9 +712,6 @@ export default class Request extends Duplex {
598
712
  });
599
713
  response.once('error', (error) => {
600
714
  this._aborted = true;
601
- // Force clean-up, because some packages don't do this.
602
- // TODO: Fix decompress-response
603
- response.destroy();
604
715
  this._beforeError(new ReadError(error, this));
605
716
  });
606
717
  response.once('aborted', () => {
@@ -614,11 +725,15 @@ export default class Request extends Duplex {
614
725
  }, this));
615
726
  }
616
727
  });
728
+ const noPipeCookieJarRawBodyPromise = this._noPipe
729
+ && is.object(options.cookieJar)
730
+ && !isRedirect
731
+ ? this._setRawBody(response)
732
+ : undefined;
617
733
  const rawCookies = response.headers['set-cookie'];
618
734
  if (is.object(options.cookieJar) && rawCookies) {
619
735
  let promises = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString()));
620
736
  if (options.ignoreInvalidCookies) {
621
- // eslint-disable-next-line @typescript-eslint/no-floating-promises
622
737
  promises = promises.map(async (promise) => {
623
738
  try {
624
739
  await promise;
@@ -630,7 +745,7 @@ export default class Request extends Duplex {
630
745
  await Promise.all(promises);
631
746
  }
632
747
  catch (error) {
633
- this._beforeError(error);
748
+ this._beforeError(normalizeError(error));
634
749
  return;
635
750
  }
636
751
  }
@@ -638,88 +753,122 @@ export default class Request extends Duplex {
638
753
  if (this.isAborted) {
639
754
  return;
640
755
  }
641
- if (response.headers.location && redirectCodes.has(statusCode)) {
756
+ if (shouldFollowRedirect) {
642
757
  // We're being redirected, we don't care about the response.
643
758
  // It'd be best to abort the request, but we can't because
644
759
  // we would have to sacrifice the TCP connection. We don't want that.
645
- const shouldFollow = typeof options.followRedirect === 'function' ? options.followRedirect(typedResponse) : options.followRedirect;
646
- if (shouldFollow) {
647
- response.resume();
648
- this._cancelTimeouts();
649
- this._unproxyEvents();
650
- if (this.redirectUrls.length >= options.maxRedirects) {
651
- 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));
652
785
  return;
653
786
  }
654
- this._request = undefined;
655
- // Reset download progress for the new request
656
- this._downloadedSize = 0;
657
- 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;
658
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';
659
796
  const canRewrite = statusCode !== 307 && statusCode !== 308;
660
797
  const userRequestedGet = updatedOptions.methodRewriting && canRewrite;
661
- if (serverRequestedGet || userRequestedGet) {
798
+ const shouldDropBody = serverRequestedGet || crossOriginRequestedGet || userRequestedGet;
799
+ if (shouldDropBody) {
662
800
  updatedOptions.method = 'GET';
663
- updatedOptions.body = undefined;
664
- updatedOptions.json = undefined;
665
- updatedOptions.form = undefined;
666
- delete updatedOptions.headers['content-length'];
801
+ this._dropBody(updatedOptions);
667
802
  }
668
- try {
669
- // We need this in order to support UTF-8
670
- const redirectBuffer = Buffer.from(response.headers.location, 'binary').toString();
671
- const redirectUrl = new URL(redirectBuffer, url);
672
- if (!isUnixSocketURL(url) && isUnixSocketURL(redirectUrl)) {
673
- this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
674
- return;
675
- }
676
- // Redirecting to a different site, clear sensitive data.
677
- // For UNIX sockets, different socket paths are also different origins.
678
- const isDifferentOrigin = redirectUrl.hostname !== url.hostname
679
- || redirectUrl.port !== url.port
680
- || getUnixSocketPath(url) !== getUnixSocketPath(redirectUrl);
681
- if (isDifferentOrigin) {
682
- if ('host' in updatedOptions.headers) {
683
- delete updatedOptions.headers.host;
684
- }
685
- if ('cookie' in updatedOptions.headers) {
686
- delete updatedOptions.headers.cookie;
687
- }
688
- if ('authorization' in updatedOptions.headers) {
689
- delete updatedOptions.headers.authorization;
690
- }
691
- if (updatedOptions.username || updatedOptions.password) {
692
- updatedOptions.username = '';
693
- updatedOptions.password = '';
694
- }
695
- }
696
- else {
697
- redirectUrl.username = updatedOptions.username;
698
- redirectUrl.password = updatedOptions.password;
699
- }
700
- this.redirectUrls.push(redirectUrl);
701
- 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) => {
702
825
  for (const hook of updatedOptions.hooks.beforeRedirect) {
703
826
  // eslint-disable-next-line no-await-in-loop
704
827
  await hook(updatedOptions, typedResponse);
705
828
  }
706
- // Publish redirect event
707
- publishRedirect({
708
- requestId: this._requestId,
709
- fromUrl: url.toString(),
710
- toUrl: redirectUrl.toString(),
711
- statusCode,
712
- });
713
- this.emit('redirect', updatedOptions, typedResponse);
714
- this.options = updatedOptions;
715
- await this._makeRequest();
716
- }
717
- catch (error) {
718
- this._beforeError(error);
719
- 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
+ }
720
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));
721
869
  return;
722
870
  }
871
+ return;
723
872
  }
724
873
  // `HTTPError`s always have `error.response.body` defined.
725
874
  // Therefore, we cannot retry if `options.throwHttpErrors` is false.
@@ -729,13 +878,15 @@ export default class Request extends Duplex {
729
878
  this._beforeError(new HTTPError(typedResponse));
730
879
  return;
731
880
  }
881
+ // `decompressResponse` wraps the response stream when it decompresses,
882
+ // so `response !== nativeResponse` indicates decompression happened.
883
+ const wasDecompressed = response !== nativeResponse;
732
884
  // Store the expected content-length from the native response for validation.
733
885
  // This is the content-length before decompression, which is what actually gets transferred.
734
886
  // Skip storing for responses that shouldn't have bodies per RFC 9110.
735
887
  // When decompression occurs, only store if strictContentLength is enabled.
736
- const wasDecompressed = response !== this._nativeResponse;
737
888
  if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
738
- const contentLengthHeader = this._nativeResponse.headers['content-length'];
889
+ const contentLengthHeader = nativeResponse.headers['content-length'];
739
890
  if (contentLengthHeader !== undefined) {
740
891
  const expectedLength = Number(contentLengthHeader);
741
892
  if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
@@ -744,7 +895,12 @@ export default class Request extends Duplex {
744
895
  }
745
896
  }
746
897
  // Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
747
- response.once('end', () => {
898
+ let responseEndHandled = false;
899
+ const handleResponseEnd = () => {
900
+ if (responseEndHandled) {
901
+ return;
902
+ }
903
+ responseEndHandled = true;
748
904
  // Validate content-length if it was provided
749
905
  // Per RFC 9112: "If the sender closes the connection before the indicated number
750
906
  // of octets are received, the recipient MUST consider the message to be incomplete"
@@ -762,7 +918,8 @@ export default class Request extends Duplex {
762
918
  timings: this.timings,
763
919
  });
764
920
  this.push(null);
765
- });
921
+ };
922
+ response.once('end', handleResponseEnd);
766
923
  this.emit('downloadProgress', this.downloadProgress);
767
924
  response.on('readable', () => {
768
925
  if (this._triggerRead) {
@@ -776,7 +933,13 @@ export default class Request extends Duplex {
776
933
  response.pause();
777
934
  });
778
935
  if (this._noPipe) {
779
- 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
+ }
780
943
  if (success) {
781
944
  this.emit('response', response);
782
945
  }
@@ -787,10 +950,6 @@ export default class Request extends Duplex {
787
950
  if (destination.headersSent) {
788
951
  continue;
789
952
  }
790
- // Check if decompression actually occurred by comparing stream objects.
791
- // decompressResponse wraps the response stream when it decompresses,
792
- // so response !== this._nativeResponse indicates decompression happened.
793
- const wasDecompressed = response !== this._nativeResponse;
794
953
  for (const key in response.headers) {
795
954
  if (Object.hasOwn(response.headers, key)) {
796
955
  const value = response.headers[key];
@@ -809,21 +968,39 @@ export default class Request extends Duplex {
809
968
  }
810
969
  }
811
970
  async _setRawBody(from = this) {
812
- if (from.readableEnded) {
813
- return false;
814
- }
815
971
  try {
816
972
  // Errors are emitted via the `error` event
817
973
  const fromArray = await from.toArray();
818
- 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;
819
979
  // On retry Request is destroyed with no error, therefore the above will successfully resolve.
820
- // So in order to check if this was really successfull, we need to check if it has been properly ended.
821
- 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) {
822
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
+ }
823
997
  return true;
824
998
  }
825
999
  }
826
1000
  catch { }
1001
+ finally {
1002
+ this._incrementalDecode = undefined;
1003
+ }
827
1004
  return false;
828
1005
  }
829
1006
  async _onResponse(response) {
@@ -832,7 +1009,7 @@ export default class Request extends Duplex {
832
1009
  }
833
1010
  catch (error) {
834
1011
  /* istanbul ignore next: better safe than sorry */
835
- this._beforeError(error);
1012
+ this._beforeError(normalizeError(error));
836
1013
  }
837
1014
  }
838
1015
  _onRequest(request) {
@@ -841,7 +1018,7 @@ export default class Request extends Duplex {
841
1018
  // Publish request start event
842
1019
  publishRequestStart({
843
1020
  requestId: this._requestId,
844
- url: url?.toString() ?? '',
1021
+ url: getSanitizedUrl(this.options),
845
1022
  method: options.method,
846
1023
  headers: options.headers,
847
1024
  });
@@ -859,32 +1036,80 @@ export default class Request extends Duplex {
859
1036
  socket.removeAllListeners('timeout');
860
1037
  });
861
1038
  }
1039
+ let lastRequestError;
862
1040
  const responseEventName = options.cache ? 'cacheableResponse' : 'response';
863
1041
  request.once(responseEventName, (response) => {
864
1042
  void this._onResponse(response);
865
1043
  });
866
- request.once('error', (error) => {
1044
+ const emitRequestError = (error) => {
867
1045
  this._aborted = true;
868
1046
  // Force clean-up, because some packages (e.g. nock) don't do this.
869
1047
  request.destroy();
870
- error = error instanceof TimedOutTimeoutError ? new TimeoutError(error, this.timings, this) : new RequestError(error.message, error, this);
871
- 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);
872
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
+ }
873
1085
  this._unproxyEvents = proxyEvents(request, this, proxiedRequestEvents);
874
1086
  this._request = request;
875
1087
  this.emit('uploadProgress', this.uploadProgress);
876
1088
  this._sendBody();
877
1089
  this.emit('request', request);
878
1090
  }
879
- 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) {
880
1095
  return new Promise((resolve, reject) => {
881
- 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 => {
882
1107
  if (error) {
883
1108
  reject(error);
884
1109
  return;
885
1110
  }
886
1111
  resolve();
887
- });
1112
+ }, request);
888
1113
  });
889
1114
  }
890
1115
  _sendBody() {
@@ -896,41 +1121,250 @@ export default class Request extends Duplex {
896
1121
  }
897
1122
  else if (is.buffer(body)) {
898
1123
  // Buffer should be sent directly without conversion
899
- this._writeRequest(body, undefined, () => { });
900
- currentRequest.end();
1124
+ this._writeBodyInChunks(body, currentRequest);
901
1125
  }
902
1126
  else if (is.typedArray(body)) {
903
1127
  // Typed arrays should be treated like buffers, not iterated over
904
1128
  // Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
905
1129
  const typedArray = body;
906
1130
  const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
907
- this._writeRequest(uint8View, undefined, () => { });
908
- currentRequest.end();
1131
+ this._writeBodyInChunks(uint8View, currentRequest);
909
1132
  }
910
1133
  else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
911
1134
  (async () => {
1135
+ const isInitialRequest = currentRequest === this;
912
1136
  try {
913
1137
  for await (const chunk of body) {
914
- 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);
915
1152
  }
916
- super.end();
917
1153
  }
918
1154
  catch (error) {
919
- this._beforeError(error);
1155
+ if (this.options.body !== body) {
1156
+ return;
1157
+ }
1158
+ this._beforeError(normalizeError(error));
920
1159
  }
921
1160
  })();
922
1161
  }
923
1162
  else if (is.undefined(body)) {
924
1163
  // No body to send, end the request
925
1164
  const cannotHaveBody = methodsWithoutBody.has(this.options.method) && !(this.options.method === 'GET' && this.options.allowGetBody);
926
- const shouldAutoEndStream = methodsWithoutBodyStream.has(this.options.method);
927
- if ((this._noPipe ?? false) || cannotHaveBody || currentRequest !== this || shouldAutoEndStream) {
1165
+ if ((this._noPipe ?? false) || cannotHaveBody || currentRequest !== this) {
928
1166
  currentRequest.end();
929
1167
  }
930
1168
  }
931
1169
  else {
932
- this._writeRequest(body, undefined, () => { });
933
- 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
+ result.catch(noop); // eslint-disable-line promise/prefer-await-to-then
1323
+ }
1324
+ }
1325
+ catch { }
1326
+ }
1327
+ }
1328
+ else if (!hadOptionBody && !this.writableEnded) {
1329
+ this._skipRequestEndInFinal = true;
1330
+ super.end();
1331
+ }
1332
+ updatedOptions.clearBody();
1333
+ this._bodySize = undefined;
1334
+ }
1335
+ _onBodyError = (error) => {
1336
+ if (this._flushed) {
1337
+ this._beforeError(new UploadError(error, this));
1338
+ }
1339
+ else {
1340
+ this.flush = async () => {
1341
+ this.flush = async () => { };
1342
+ this._beforeError(new UploadError(error, this));
1343
+ };
1344
+ }
1345
+ };
1346
+ async _writeChunksToRequest(buffer, request) {
1347
+ const chunkSize = 65_536; // 64 KB
1348
+ const isStale = () => this._isRequestStale(request);
1349
+ for (const part of chunk(buffer, chunkSize)) {
1350
+ if (isStale()) {
1351
+ return;
1352
+ }
1353
+ // eslint-disable-next-line no-await-in-loop
1354
+ await new Promise((resolve, reject) => {
1355
+ this._writeRequest(part, undefined, error => {
1356
+ if (isStale()) {
1357
+ resolve();
1358
+ return;
1359
+ }
1360
+ if (error) {
1361
+ reject(error);
1362
+ }
1363
+ else {
1364
+ setImmediate(resolve);
1365
+ }
1366
+ }, request);
1367
+ });
934
1368
  }
935
1369
  }
936
1370
  _prepareCache(cache) {
@@ -948,59 +1382,62 @@ export default class Request extends Duplex {
948
1382
  Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
949
1383
  Mutations take effect immediately and determine what gets cached.
950
1384
  */
951
- const wrappedHandler = handler ? (response) => {
952
- const { beforeCacheHooks, gotRequest } = requestOptions;
953
- // Early return if no hooks - cache the original response
954
- if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
955
- handler(response);
956
- return;
957
- }
958
- try {
959
- // Call each beforeCache hook with the response
960
- // Hooks can directly mutate the response - mutations take effect immediately
961
- for (const hook of beforeCacheHooks) {
962
- const result = hook(response);
963
- if (result === false) {
964
- // Prevent caching by adding no-cache headers
965
- // Mutate the response directly to add headers
966
- response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
967
- response.headers.pragma = 'no-cache';
968
- response.headers.expires = '0';
969
- handler(response);
970
- // Don't call remaining hooks - we've decided not to cache
971
- return;
972
- }
973
- if (is.promise(result)) {
974
- // BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
975
- 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.');
976
- }
977
- if (result !== undefined) {
978
- // Hooks should return false or undefined only
979
- // Mutations work directly - no need to return the response
980
- throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
1385
+ const wrappedHandler = handler
1386
+ ? (response) => {
1387
+ const { beforeCacheHooks, gotRequest } = requestOptions;
1388
+ // Early return if no hooks - cache the original response
1389
+ if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
1390
+ handler(response);
1391
+ return;
1392
+ }
1393
+ try {
1394
+ // Call each beforeCache hook with the response
1395
+ // Hooks can directly mutate the response - mutations take effect immediately
1396
+ for (const hook of beforeCacheHooks) {
1397
+ const result = hook(response);
1398
+ if (result === false) {
1399
+ // Prevent caching by adding no-cache headers
1400
+ // Mutate the response directly to add headers
1401
+ response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
1402
+ response.headers.pragma = 'no-cache';
1403
+ response.headers.expires = '0';
1404
+ handler(response);
1405
+ // Don't call remaining hooks - we've decided not to cache
1406
+ return;
1407
+ }
1408
+ if (is.promise(result)) {
1409
+ // BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
1410
+ 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.');
1411
+ }
1412
+ if (result !== undefined) {
1413
+ // Hooks should return false or undefined only
1414
+ // Mutations work directly - no need to return the response
1415
+ throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
1416
+ }
1417
+ // Else: void/undefined = continue
981
1418
  }
982
- // Else: void/undefined = continue
983
1419
  }
984
- }
985
- catch (error) {
986
- // Convert hook errors to RequestError and propagate
987
- // This is consistent with how other hooks handle errors
988
- if (gotRequest) {
989
- gotRequest._beforeError(error instanceof RequestError ? error : new RequestError(error.message, error, gotRequest));
990
- // Don't call handler when error was propagated successfully
1420
+ catch (error) {
1421
+ const normalizedError = normalizeError(error);
1422
+ // Convert hook errors to RequestError and propagate
1423
+ // This is consistent with how other hooks handle errors
1424
+ if (gotRequest) {
1425
+ gotRequest._beforeError(normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, gotRequest));
1426
+ // Don't call handler when error was propagated successfully
1427
+ return;
1428
+ }
1429
+ // If gotRequest is missing, log the error to aid debugging
1430
+ // We still call the handler to prevent the request from hanging
1431
+ console.error('Got: beforeCache hook error (request context unavailable):', normalizedError);
1432
+ // Call handler with response (potentially partially modified)
1433
+ handler(response);
991
1434
  return;
992
1435
  }
993
- // If gotRequest is missing, log the error to aid debugging
994
- // We still call the handler to prevent the request from hanging
995
- console.error('Got: beforeCache hook error (request context unavailable):', error);
996
- // Call handler with response (potentially partially modified)
1436
+ // All hooks ran successfully
1437
+ // Cache the response with any mutations applied
997
1438
  handler(response);
998
- return;
999
1439
  }
1000
- // All hooks ran successfully
1001
- // Cache the response with any mutations applied
1002
- handler(response);
1003
- } : handler;
1440
+ : handler;
1004
1441
  const result = requestOptions._request(requestOptions, wrappedHandler);
1005
1442
  // TODO: remove this when `cacheable-request` supports async request functions.
1006
1443
  if (is.promise(result)) {
@@ -1042,8 +1479,18 @@ export default class Request extends Duplex {
1042
1479
  }
1043
1480
  async _createCacheableRequest(url, options) {
1044
1481
  return new Promise((resolve, reject) => {
1045
- // TODO: Remove `utils/url-to-options.ts` when `cacheable-request` is fixed
1046
- Object.assign(options, urlToOptions(url));
1482
+ Object.assign(options, {
1483
+ protocol: url.protocol,
1484
+ hostname: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
1485
+ host: url.host,
1486
+ hash: url.hash === '' ? '' : (url.hash ?? null),
1487
+ search: url.search === '' ? '' : (url.search ?? null),
1488
+ pathname: url.pathname,
1489
+ href: url.href,
1490
+ path: `${url.pathname || ''}${url.search || ''}`,
1491
+ ...(is.string(url.port) && url.port.length > 0 ? { port: Number(url.port) } : {}),
1492
+ ...(url.username || url.password ? { auth: `${url.username || ''}:${url.password || ''}` } : {}),
1493
+ });
1047
1494
  let request;
1048
1495
  // TODO: Fix `cacheable-response`. This is ugly.
1049
1496
  const cacheRequest = cacheableStore.get(options.cache)(options, async (response) => {
@@ -1075,12 +1522,12 @@ export default class Request extends Duplex {
1075
1522
  }
1076
1523
  async _makeRequest() {
1077
1524
  const { options } = this;
1078
- const { headers, username, password } = options;
1525
+ const headers = options.getInternalHeaders();
1526
+ const { username, password } = options;
1079
1527
  const cookieJar = options.cookieJar;
1080
1528
  for (const key in headers) {
1081
1529
  if (is.undefined(headers[key])) {
1082
- // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
1083
- delete headers[key];
1530
+ options.deleteInternalHeader(key);
1084
1531
  }
1085
1532
  else if (is.null(headers[key])) {
1086
1533
  throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
@@ -1094,17 +1541,17 @@ export default class Request extends Duplex {
1094
1541
  if (supportsZstd) {
1095
1542
  encodings.push('zstd');
1096
1543
  }
1097
- headers['accept-encoding'] = encodings.join(', ');
1544
+ options.setInternalHeader('accept-encoding', encodings.join(', '));
1098
1545
  }
1099
1546
  if (username || password) {
1100
- const credentials = Buffer.from(`${username}:${password}`).toString('base64');
1101
- headers.authorization = `Basic ${credentials}`;
1547
+ const credentials = stringToBase64(`${username}:${password}`);
1548
+ options.setInternalHeader('authorization', `Basic ${credentials}`);
1102
1549
  }
1103
1550
  // Set cookies
1104
1551
  if (cookieJar) {
1105
1552
  const cookieString = await cookieJar.getCookieString(options.url.toString());
1106
1553
  if (is.nonEmptyString(cookieString)) {
1107
- headers.cookie = cookieString;
1554
+ options.setInternalHeader('cookie', cookieString);
1108
1555
  }
1109
1556
  }
1110
1557
  let request;
@@ -1117,7 +1564,11 @@ export default class Request extends Duplex {
1117
1564
  break;
1118
1565
  }
1119
1566
  }
1120
- request ||= options.getRequestFunction();
1567
+ if (!is.undefined(headers['transfer-encoding']) && !is.undefined(headers['content-length'])) {
1568
+ // TODO: Throw instead of silently dropping `content-length` in the next major version.
1569
+ options.deleteInternalHeader('content-length');
1570
+ }
1571
+ request ??= options.getRequestFunction();
1121
1572
  const url = options.url;
1122
1573
  this._requestOptions = options.createNativeRequestOptions();
1123
1574
  if (options.cache) {
@@ -1130,7 +1581,7 @@ export default class Request extends Duplex {
1130
1581
  this._prepareCache(options.cache);
1131
1582
  }
1132
1583
  catch (error) {
1133
- throw new CacheError(error, this);
1584
+ throw new CacheError(normalizeError(error), this);
1134
1585
  }
1135
1586
  }
1136
1587
  // Cache support
@@ -1142,13 +1593,6 @@ export default class Request extends Duplex {
1142
1593
  if (is.promise(requestOrResponse)) {
1143
1594
  requestOrResponse = await requestOrResponse;
1144
1595
  }
1145
- // Fallback
1146
- if (is.undefined(requestOrResponse)) {
1147
- requestOrResponse = options.getFallbackRequestFunction()(url, this._requestOptions);
1148
- if (is.promise(requestOrResponse)) {
1149
- requestOrResponse = await requestOrResponse;
1150
- }
1151
- }
1152
1596
  if (isClientRequest(requestOrResponse)) {
1153
1597
  this._onRequest(requestOrResponse);
1154
1598
  }
@@ -1171,12 +1615,9 @@ export default class Request extends Duplex {
1171
1615
  }
1172
1616
  async _error(error) {
1173
1617
  try {
1174
- if (this.options && error instanceof HTTPError && !this.options.throwHttpErrors) {
1175
- // This branch can be reached only when using the Promise API
1176
- // Skip calling the hooks on purpose.
1177
- // See https://github.com/sindresorhus/got/issues/2103
1178
- }
1179
- else if (this.options) {
1618
+ // Skip calling hooks for HTTP errors when throwHttpErrors is false (Promise API only).
1619
+ // See https://github.com/sindresorhus/got/issues/2103
1620
+ if (this.options && (!(error instanceof HTTPError) || this.options.throwHttpErrors)) {
1180
1621
  const hooks = this.options.hooks.beforeError;
1181
1622
  if (hooks.length > 0) {
1182
1623
  for (const hook of hooks) {
@@ -1197,12 +1638,13 @@ export default class Request extends Duplex {
1197
1638
  }
1198
1639
  }
1199
1640
  catch (error_) {
1200
- error = new RequestError(error_.message, error_, this);
1641
+ const normalizedError = normalizeError(error_);
1642
+ error = new RequestError(normalizedError.message, normalizedError, this);
1201
1643
  }
1202
1644
  // Publish error event
1203
1645
  publishError({
1204
1646
  requestId: this._requestId,
1205
- url: this.options?.url?.toString() ?? '',
1647
+ url: getSanitizedUrl(this.options),
1206
1648
  error,
1207
1649
  timings: this.timings,
1208
1650
  });
@@ -1218,16 +1660,17 @@ export default class Request extends Duplex {
1218
1660
  });
1219
1661
  }
1220
1662
  }
1221
- _writeRequest(chunk, encoding, callback) {
1222
- if (!this._request || this._request.destroyed) {
1663
+ _writeRequest(chunk, encoding, callback, request = this._request) {
1664
+ if (!request || request.destroyed) {
1223
1665
  // When there's no request (e.g., using cached response from beforeRequest hook),
1224
1666
  // we still need to call the callback to allow the stream to finish properly.
1225
1667
  callback();
1226
1668
  return;
1227
1669
  }
1228
- this._request.write(chunk, encoding, (error) => {
1229
- // The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed
1230
- if (!error && !this._request.destroyed) {
1670
+ request.write(chunk, encoding, (error) => {
1671
+ // The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed.
1672
+ // The `this._request === request` check prevents stale write callbacks from a pre-redirect request from incrementing `_uploadedSize` after it's been reset.
1673
+ if (!error && !request.destroyed && this._request === request) {
1231
1674
  // For strings, encode them first to measure the actual bytes that will be sent
1232
1675
  const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk;
1233
1676
  this._uploadedSize += byteLength(bytes);
@@ -1258,41 +1701,13 @@ export default class Request extends Duplex {
1258
1701
  Progress event for downloading (receiving a response).
1259
1702
  */
1260
1703
  get downloadProgress() {
1261
- let percent;
1262
- if (this._responseSize) {
1263
- percent = this._downloadedSize / this._responseSize;
1264
- }
1265
- else if (this._responseSize === this._downloadedSize) {
1266
- percent = 1;
1267
- }
1268
- else {
1269
- percent = 0;
1270
- }
1271
- return {
1272
- percent,
1273
- transferred: this._downloadedSize,
1274
- total: this._responseSize,
1275
- };
1704
+ return makeProgress(this._downloadedSize, this._responseSize);
1276
1705
  }
1277
1706
  /**
1278
1707
  Progress event for uploading (sending a request).
1279
1708
  */
1280
1709
  get uploadProgress() {
1281
- let percent;
1282
- if (this._bodySize) {
1283
- percent = this._uploadedSize / this._bodySize;
1284
- }
1285
- else if (this._bodySize === this._uploadedSize) {
1286
- percent = 1;
1287
- }
1288
- else {
1289
- percent = 0;
1290
- }
1291
- return {
1292
- percent,
1293
- transferred: this._uploadedSize,
1294
- total: this._bodySize,
1295
- };
1710
+ return makeProgress(this._uploadedSize, this._bodySize);
1296
1711
  }
1297
1712
  /**
1298
1713
  The object contains the following properties:
@@ -1328,7 +1743,7 @@ export default class Request extends Duplex {
1328
1743
  Whether the response was retrieved from the cache.
1329
1744
  */
1330
1745
  get isFromCache() {
1331
- return this._isFromCache;
1746
+ return this.response?.isFromCache;
1332
1747
  }
1333
1748
  get reusedSocket() {
1334
1749
  return this._request?.reusedSocket;