got 14.5.0 → 14.6.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.
@@ -1,8 +1,6 @@
1
1
  import process from 'node:process';
2
2
  import { Buffer } from 'node:buffer';
3
- import { Duplex } from 'node:stream';
4
- import { gunzip, inflate, brotliDecompress } from 'node:zlib';
5
- import { promisify } from 'node:util';
3
+ import { Duplex, Transform } from 'node:stream';
6
4
  import http, { ServerResponse } from 'node:http';
7
5
  import timer from '@szmarczak/http-timer';
8
6
  import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request';
@@ -19,14 +17,18 @@ import calculateRetryDelay from './calculate-retry-delay.js';
19
17
  import Options from './options.js';
20
18
  import { isResponseOk } from './response.js';
21
19
  import isClientRequest from './utils/is-client-request.js';
22
- import isUnixSocketURL from './utils/is-unix-socket-url.js';
20
+ import isUnixSocketURL, { getUnixSocketPath } from './utils/is-unix-socket-url.js';
23
21
  import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
22
+ import { generateRequestId, publishRequestCreate, publishRequestStart, publishResponseStart, publishResponseEnd, publishRetry, publishError, publishRedirect, } from './diagnostics-channel.js';
24
23
  const supportsBrotli = is.string(process.versions.brotli);
24
+ const supportsZstd = is.string(process.versions.zstd);
25
25
  const methodsWithoutBody = new Set(['GET', 'HEAD']);
26
26
  // Methods that should auto-end streams when no body is provided
27
27
  const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
28
28
  const cacheableStore = new WeakableMap();
29
29
  const redirectCodes = new Set([300, 301, 302, 303, 304, 307, 308]);
30
+ // Track errors that have been processed by beforeError hooks to preserve custom error types
31
+ const errorsProcessedByHooks = new WeakSet();
30
32
  const proxiedRequestEvents = [
31
33
  'socket',
32
34
  'connect',
@@ -35,6 +37,17 @@ const proxiedRequestEvents = [
35
37
  'upgrade',
36
38
  ];
37
39
  const noop = () => { };
40
+ /**
41
+ Stream transform that counts bytes passing through.
42
+ Used to track compressed bytes before decompression for content-length validation.
43
+ */
44
+ class ByteCounter extends Transform {
45
+ count = 0;
46
+ _transform(chunk, _encoding, callback) {
47
+ this.count += chunk.length;
48
+ callback(null, chunk);
49
+ }
50
+ }
38
51
  export default class Request extends Duplex {
39
52
  // @ts-expect-error - Ignoring for now.
40
53
  ['constructor'];
@@ -43,26 +56,30 @@ export default class Request extends Duplex {
43
56
  options;
44
57
  response;
45
58
  requestUrl;
46
- redirectUrls;
47
- retryCount;
48
- _stopRetry;
49
- _downloadedSize;
50
- _uploadedSize;
51
- _stopReading;
52
- _pipedServerResponses;
59
+ redirectUrls = [];
60
+ retryCount = 0;
61
+ _stopReading = false;
62
+ _stopRetry = noop;
63
+ _downloadedSize = 0;
64
+ _uploadedSize = 0;
65
+ _pipedServerResponses = new Set();
53
66
  _request;
54
67
  _responseSize;
55
68
  _bodySize;
56
- _unproxyEvents;
69
+ _unproxyEvents = noop;
57
70
  _isFromCache;
58
- _triggerRead;
59
- _cancelTimeouts;
60
- _removeListeners;
71
+ _triggerRead = false;
72
+ _jobs = [];
73
+ _cancelTimeouts = noop;
74
+ _removeListeners = noop;
61
75
  _nativeResponse;
62
- _flushed;
63
- _aborted;
76
+ _flushed = false;
77
+ _aborted = false;
78
+ _expectedContentLength;
79
+ _byteCounter;
80
+ _requestId = generateRequestId();
64
81
  // We need this because `this._request` if `undefined` when using cache
65
- _requestInitialized;
82
+ _requestInitialized = false;
66
83
  constructor(url, options, defaults) {
67
84
  super({
68
85
  // Don't destroy immediately, as the error may be emitted on unsuccessful retry
@@ -70,23 +87,8 @@ export default class Request extends Duplex {
70
87
  // It needs to be zero because we're just proxying the data to another stream
71
88
  highWaterMark: 0,
72
89
  });
73
- this._downloadedSize = 0;
74
- this._uploadedSize = 0;
75
- this._stopReading = false;
76
- this._pipedServerResponses = new Set();
77
- this._unproxyEvents = noop;
78
- this._triggerRead = false;
79
- this._cancelTimeouts = noop;
80
- this._removeListeners = noop;
81
- this._jobs = [];
82
- this._flushed = false;
83
- this._requestInitialized = false;
84
- this._aborted = false;
85
- this.redirectUrls = [];
86
- this.retryCount = 0;
87
- this._stopRetry = noop;
88
90
  this.on('pipe', (source) => {
89
- if (source?.headers) {
91
+ if (this.options.copyPipedHeaders && source?.headers) {
90
92
  Object.assign(this.options.headers, source.headers);
91
93
  }
92
94
  });
@@ -104,6 +106,12 @@ export default class Request extends Duplex {
104
106
  this.options.url = '';
105
107
  }
106
108
  this.requestUrl = this.options.url;
109
+ // Publish request creation event
110
+ publishRequestCreate({
111
+ requestId: this._requestId,
112
+ url: this.options.url?.toString() ?? '',
113
+ method: this.options.method,
114
+ });
107
115
  }
108
116
  catch (error) {
109
117
  const { options } = error;
@@ -112,7 +120,18 @@ export default class Request extends Duplex {
112
120
  }
113
121
  this.flush = async () => {
114
122
  this.flush = async () => { };
115
- this.destroy(error);
123
+ // Defer error emission to next tick to allow user to attach error handlers
124
+ process.nextTick(() => {
125
+ // _beforeError requires options to access retry logic and hooks
126
+ if (this.options) {
127
+ this._beforeError(error);
128
+ }
129
+ else {
130
+ // Options is undefined, skip _beforeError and destroy directly
131
+ const requestError = error instanceof RequestError ? error : new RequestError(error.message, error, this);
132
+ this.destroy(requestError);
133
+ }
134
+ });
116
135
  };
117
136
  return;
118
137
  }
@@ -263,6 +282,8 @@ export default class Request extends Duplex {
263
282
  if (this.destroyed) {
264
283
  return;
265
284
  }
285
+ // Capture body BEFORE hooks run to detect reassignment
286
+ const bodyBeforeHooks = this.options.body;
266
287
  try {
267
288
  for (const hook of this.options.hooks.beforeRetry) {
268
289
  // eslint-disable-next-line no-await-in-loop
@@ -270,14 +291,58 @@ export default class Request extends Duplex {
270
291
  }
271
292
  }
272
293
  catch (error_) {
273
- void this._error(new RequestError(error_.message, error, this));
294
+ void this._error(new RequestError(error_.message, error_, this));
274
295
  return;
275
296
  }
276
297
  // Something forced us to abort the retry
277
298
  if (this.destroyed) {
278
299
  return;
279
300
  }
280
- this.destroy();
301
+ // Preserve stream body reassigned in beforeRetry hooks.
302
+ const bodyAfterHooks = this.options.body;
303
+ const bodyWasReassigned = bodyBeforeHooks !== bodyAfterHooks;
304
+ // Resource cleanup and preservation logic for retry with body reassignment.
305
+ // The Promise wrapper (as-promise/index.ts) compares body identity to detect consumed streams,
306
+ // so we must preserve the body reference across destroy(). However, destroy() calls _destroy()
307
+ // which destroys this.options.body, creating a complex dance of clear/restore operations.
308
+ //
309
+ // Key constraints:
310
+ // 1. If body was reassigned, we must NOT destroy the NEW stream (it will be used for retry)
311
+ // 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks
312
+ // 3. We must restore the body reference after destroy() for identity checks in promise wrapper
313
+ // 4. We cannot use the normal setter after destroy() because it validates stream readability
314
+ if (bodyWasReassigned) {
315
+ const oldBody = bodyBeforeHooks;
316
+ // Temporarily clear body to prevent destroy() from destroying the new stream
317
+ this.options.body = undefined;
318
+ this.destroy();
319
+ // Clean up the old stream resource if it's a stream and different from new body
320
+ // (edge case: if old and new are same stream object, don't destroy it)
321
+ if (is.nodeStream(oldBody) && oldBody !== bodyAfterHooks) {
322
+ oldBody.destroy();
323
+ }
324
+ // Restore new body for promise wrapper's identity check
325
+ // We bypass the setter because it validates stream.readable (which fails for destroyed request)
326
+ // Type assertion is necessary here to access private _internals without exposing internal API
327
+ if (is.nodeStream(bodyAfterHooks) && (bodyAfterHooks.readableEnded || bodyAfterHooks.destroyed)) {
328
+ throw new TypeError('The reassigned stream body must be readable. Ensure you provide a fresh, readable stream in the beforeRetry hook.');
329
+ }
330
+ this.options._internals.body = bodyAfterHooks;
331
+ }
332
+ else {
333
+ // Body wasn't reassigned - use normal destroy flow which handles body cleanup
334
+ this.destroy();
335
+ // Note: We do NOT restore the body reference here. The stream was destroyed by _destroy()
336
+ // and should not be accessed. The promise wrapper will see that body identity hasn't changed
337
+ // and will detect it's a consumed stream, which is the correct behavior.
338
+ }
339
+ // Publish retry event
340
+ publishRetry({
341
+ requestId: this._requestId,
342
+ retryCount: this.retryCount + 1,
343
+ error: typedError,
344
+ delay: backoff,
345
+ });
281
346
  this.emit('retry', this.retryCount + 1, error, (updatedOptions) => {
282
347
  const request = new Request(options.url, updatedOptions, options);
283
348
  request.retryCount = this.retryCount + 1;
@@ -368,8 +433,28 @@ export default class Request extends Duplex {
368
433
  if (this._request) {
369
434
  this._request.destroy();
370
435
  }
371
- if (error !== null && !is.undefined(error) && !(error instanceof RequestError)) {
372
- error = new RequestError(error.message, error, this);
436
+ // Workaround: http-timer only sets timings.end when the response emits 'end'.
437
+ // When a stream is destroyed before completion, the 'end' event may not fire,
438
+ // leaving timings.end undefined. This should ideally be fixed in http-timer
439
+ // by listening to the 'close' event, but we handle it here for now.
440
+ // Only set timings.end if there was no error or abort (to maintain semantic correctness).
441
+ const timings = this._request?.timings;
442
+ if (timings && is.undefined(timings.end) && !is.undefined(timings.response) && is.undefined(timings.error) && is.undefined(timings.abort)) {
443
+ timings.end = Date.now();
444
+ if (is.undefined(timings.phases.total)) {
445
+ timings.phases.download = timings.end - timings.response;
446
+ timings.phases.total = timings.end - timings.start;
447
+ }
448
+ }
449
+ // Preserve custom errors returned by beforeError hooks.
450
+ // For other errors, wrap non-RequestError instances for consistency.
451
+ if (error !== null && !is.undefined(error)) {
452
+ const processedByHooks = error instanceof Error && errorsProcessedByHooks.has(error);
453
+ if (!processedByHooks && !(error instanceof RequestError)) {
454
+ error = error instanceof Error
455
+ ? new RequestError(error.message, error, this)
456
+ : new RequestError(String(error), {}, this);
457
+ }
373
458
  }
374
459
  callback(error);
375
460
  }
@@ -386,6 +471,22 @@ export default class Request extends Duplex {
386
471
  super.unpipe(destination);
387
472
  return this;
388
473
  }
474
+ _checkContentLengthMismatch() {
475
+ if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
476
+ // Use ByteCounter's count when available (for compressed responses),
477
+ // otherwise use _downloadedSize (for uncompressed responses)
478
+ const actualSize = this._byteCounter?.count ?? this._downloadedSize;
479
+ if (actualSize !== this._expectedContentLength) {
480
+ this._beforeError(new ReadError({
481
+ message: `Content-Length mismatch: expected ${this._expectedContentLength} bytes, received ${actualSize} bytes`,
482
+ name: 'Error',
483
+ code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH',
484
+ }, this));
485
+ return true;
486
+ }
487
+ }
488
+ return false;
489
+ }
389
490
  async _finalizeBody() {
390
491
  const { options } = this;
391
492
  const { headers } = options;
@@ -473,86 +574,19 @@ export default class Request extends Duplex {
473
574
  || statusCode === 204
474
575
  || statusCode === 205
475
576
  || statusCode === 304;
476
- const nativeResponse = response;
477
577
  if (options.decompress && !hasNoBody) {
578
+ // When strictContentLength is enabled, track compressed bytes by listening to
579
+ // the native response's data events before decompression
580
+ if (options.strictContentLength) {
581
+ this._byteCounter = new ByteCounter();
582
+ this._nativeResponse.on('data', (chunk) => {
583
+ this._byteCounter.count += chunk.length;
584
+ });
585
+ }
478
586
  response = decompressResponse(response);
479
587
  }
480
- // For revalidated cached responses (304 Not Modified), cacheable-request may return
481
- // a ResponseLike object with the body stored in a .body property but not pushed into
482
- // the stream. The stream is created and may be ended without data, and the 'end' event is
483
- // never emitted, causing Got to hang waiting for it. Detect and fix this.
484
- const nativeAsAny = nativeResponse;
485
- if (nativeAsAny.body) {
486
- // Mark the response as complete immediately (ResponseLike doesn't have req.res.complete)
487
- nativeResponse.complete = true;
488
- response.complete = true;
489
- // Use setImmediate to check if this is a stuck revalidated response
490
- setImmediate(() => {
491
- // Check if the stream ended with no data (revalidated response)
492
- if (nativeResponse.readableEnded && !nativeResponse.readableLength) {
493
- // The body is in nativeAsAny.body but was never pushed to the stream.
494
- // We need to push it to the native response so it flows through decompression if needed.
495
- try {
496
- // Push the body to the native stream
497
- nativeResponse.push(nativeAsAny.body);
498
- // eslint-disable-next-line unicorn/no-array-push-push
499
- nativeResponse.push(null);
500
- // Update download size with the cached body length
501
- let bodyLength = 0;
502
- if (Buffer.isBuffer(nativeAsAny.body)) {
503
- bodyLength = nativeAsAny.body.length;
504
- }
505
- else if (typeof nativeAsAny.body === 'string') {
506
- bodyLength = Buffer.byteLength(nativeAsAny.body);
507
- }
508
- this._downloadedSize += bodyLength;
509
- }
510
- catch {
511
- // If push fails (stream already ended), we need to decompress manually for compressed responses
512
- const encoding = nativeResponse.headers['content-encoding'];
513
- if (encoding && Buffer.isBuffer(nativeAsAny.body)) {
514
- // Decompress the body based on the encoding
515
- let decompressAsync;
516
- switch (encoding) {
517
- case 'gzip': {
518
- decompressAsync = promisify(gunzip);
519
- break;
520
- }
521
- case 'deflate': {
522
- decompressAsync = promisify(inflate);
523
- break;
524
- }
525
- case 'br': {
526
- decompressAsync = promisify(brotliDecompress);
527
- break;
528
- }
529
- default: {
530
- break;
531
- }
532
- }
533
- if (decompressAsync) {
534
- // Decompress asynchronously and set rawBody
535
- void decompressAsync(nativeAsAny.body).then((decompressed) => {
536
- response.rawBody = decompressed;
537
- this._downloadedSize += nativeAsAny.body.length;
538
- response.emit('end');
539
- }).catch(() => {
540
- // Decompression failed, use compressed body as-is
541
- response.rawBody = nativeAsAny.body;
542
- response.emit('end');
543
- });
544
- return;
545
- }
546
- }
547
- // Not compressed or decompression not needed, set rawBody directly
548
- response.rawBody = nativeAsAny.body;
549
- response.emit('end');
550
- }
551
- }
552
- });
553
- }
554
588
  const typedResponse = response;
555
- typedResponse.statusMessage = typedResponse.statusMessage ?? http.STATUS_CODES[statusCode];
589
+ typedResponse.statusMessage = typedResponse.statusMessage || http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
556
590
  typedResponse.url = options.url.toString();
557
591
  typedResponse.requestUrl = this.requestUrl;
558
592
  typedResponse.redirectUrls = this.redirectUrls;
@@ -564,6 +598,35 @@ export default class Request extends Duplex {
564
598
  this._isFromCache = typedResponse.isFromCache;
565
599
  this._responseSize = Number(response.headers['content-length']) || undefined;
566
600
  this.response = typedResponse;
601
+ // Publish response start event
602
+ publishResponseStart({
603
+ requestId: this._requestId,
604
+ url: typedResponse.url,
605
+ statusCode,
606
+ headers: response.headers,
607
+ isFromCache: typedResponse.isFromCache,
608
+ });
609
+ // Workaround for http-timer bug: when connecting to an IP address (no DNS lookup),
610
+ // http-timer sets lookup = connect instead of lookup = socket, resulting in
611
+ // dns = lookup - socket being a small positive number instead of 0.
612
+ // See https://github.com/sindresorhus/got/issues/2279
613
+ const { timings } = response;
614
+ if (timings?.lookup !== undefined && timings.socket !== undefined && timings.connect !== undefined && timings.lookup === timings.connect && timings.phases.dns !== 0) {
615
+ // Fix the DNS phase to be 0 and set lookup to socket time
616
+ timings.phases.dns = 0;
617
+ timings.lookup = timings.socket;
618
+ // Recalculate TCP time to be the full time from socket to connect
619
+ timings.phases.tcp = timings.connect - timings.socket;
620
+ }
621
+ // Workaround for http-timer limitation with HTTP/2:
622
+ // When using HTTP/2, the socket is a proxy that http-timer discards,
623
+ // so lookup, connect, and secureConnect events are never captured.
624
+ // This results in phases.request being NaN (undefined - undefined).
625
+ // Set it to undefined to be consistent with other unavailable timings.
626
+ // See https://github.com/sindresorhus/got/issues/1958
627
+ if (timings && Number.isNaN(timings.phases.request)) {
628
+ timings.phases.request = undefined;
629
+ }
567
630
  response.once('error', (error) => {
568
631
  this._aborted = true;
569
632
  // Force clean-up, because some packages don't do this.
@@ -573,11 +636,14 @@ export default class Request extends Duplex {
573
636
  });
574
637
  response.once('aborted', () => {
575
638
  this._aborted = true;
576
- this._beforeError(new ReadError({
577
- name: 'Error',
578
- message: 'The server aborted pending request',
579
- code: 'ECONNRESET',
580
- }, this));
639
+ // Check if there's a content-length mismatch to provide a more specific error
640
+ if (!this._checkContentLengthMismatch()) {
641
+ this._beforeError(new ReadError({
642
+ name: 'Error',
643
+ message: 'The server aborted pending request',
644
+ code: 'ECONNRESET',
645
+ }, this));
646
+ }
581
647
  });
582
648
  const rawCookies = response.headers['set-cookie'];
583
649
  if (is.object(options.cookieJar) && rawCookies) {
@@ -639,7 +705,11 @@ export default class Request extends Duplex {
639
705
  return;
640
706
  }
641
707
  // Redirecting to a different site, clear sensitive data.
642
- if (redirectUrl.hostname !== url.hostname || redirectUrl.port !== url.port) {
708
+ // For UNIX sockets, different socket paths are also different origins.
709
+ const isDifferentOrigin = redirectUrl.hostname !== url.hostname
710
+ || redirectUrl.port !== url.port
711
+ || getUnixSocketPath(url) !== getUnixSocketPath(redirectUrl);
712
+ if (isDifferentOrigin) {
643
713
  if ('host' in updatedOptions.headers) {
644
714
  delete updatedOptions.headers.host;
645
715
  }
@@ -659,12 +729,18 @@ export default class Request extends Duplex {
659
729
  redirectUrl.password = updatedOptions.password;
660
730
  }
661
731
  this.redirectUrls.push(redirectUrl);
662
- updatedOptions.prefixUrl = '';
663
732
  updatedOptions.url = redirectUrl;
664
733
  for (const hook of updatedOptions.hooks.beforeRedirect) {
665
734
  // eslint-disable-next-line no-await-in-loop
666
735
  await hook(updatedOptions, typedResponse);
667
736
  }
737
+ // Publish redirect event
738
+ publishRedirect({
739
+ requestId: this._requestId,
740
+ fromUrl: url.toString(),
741
+ toUrl: redirectUrl.toString(),
742
+ statusCode,
743
+ });
668
744
  this.emit('redirect', updatedOptions, typedResponse);
669
745
  this.options = updatedOptions;
670
746
  await this._makeRequest();
@@ -684,13 +760,40 @@ export default class Request extends Duplex {
684
760
  this._beforeError(new HTTPError(typedResponse));
685
761
  return;
686
762
  }
763
+ // Store the expected content-length from the native response for validation.
764
+ // This is the content-length before decompression, which is what actually gets transferred.
765
+ // Skip storing for responses that shouldn't have bodies per RFC 9110.
766
+ // When decompression occurs, only store if strictContentLength is enabled.
767
+ const wasDecompressed = response !== this._nativeResponse;
768
+ if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
769
+ const contentLengthHeader = this._nativeResponse.headers['content-length'];
770
+ if (contentLengthHeader !== undefined) {
771
+ const expectedLength = Number(contentLengthHeader);
772
+ if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
773
+ this._expectedContentLength = expectedLength;
774
+ }
775
+ }
776
+ }
687
777
  // Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
688
- const endStream = () => {
778
+ response.once('end', () => {
779
+ // Validate content-length if it was provided
780
+ // Per RFC 9112: "If the sender closes the connection before the indicated number
781
+ // of octets are received, the recipient MUST consider the message to be incomplete"
782
+ if (this._checkContentLengthMismatch()) {
783
+ return;
784
+ }
689
785
  this._responseSize = this._downloadedSize;
690
786
  this.emit('downloadProgress', this.downloadProgress);
787
+ // Publish response end event
788
+ publishResponseEnd({
789
+ requestId: this._requestId,
790
+ url: typedResponse.url,
791
+ statusCode,
792
+ bodySize: this._downloadedSize,
793
+ timings: this.timings,
794
+ });
691
795
  this.push(null);
692
- };
693
- response.once('end', endStream);
796
+ });
694
797
  this.emit('downloadProgress', this.downloadProgress);
695
798
  response.on('readable', () => {
696
799
  if (this._triggerRead) {
@@ -715,12 +818,22 @@ export default class Request extends Duplex {
715
818
  if (destination.headersSent) {
716
819
  continue;
717
820
  }
718
- // eslint-disable-next-line guard-for-in
821
+ // Check if decompression actually occurred by comparing stream objects.
822
+ // decompressResponse wraps the response stream when it decompresses,
823
+ // so response !== this._nativeResponse indicates decompression happened.
824
+ const wasDecompressed = response !== this._nativeResponse;
719
825
  for (const key in response.headers) {
720
- const isAllowed = options.decompress ? key !== 'content-encoding' : true;
721
- const value = response.headers[key];
722
- if (isAllowed) {
723
- destination.setHeader(key, value);
826
+ if (Object.hasOwn(response.headers, key)) {
827
+ const value = response.headers[key];
828
+ // When decompression occurred, skip content-encoding and content-length
829
+ // as they refer to the compressed data, not the decompressed stream.
830
+ if (wasDecompressed && (key === 'content-encoding' || key === 'content-length')) {
831
+ continue;
832
+ }
833
+ // Skip if value is undefined
834
+ if (value !== undefined) {
835
+ destination.setHeader(key, value);
836
+ }
724
837
  }
725
838
  }
726
839
  destination.statusCode = statusCode;
@@ -756,6 +869,13 @@ export default class Request extends Duplex {
756
869
  _onRequest(request) {
757
870
  const { options } = this;
758
871
  const { timeout, url } = options;
872
+ // Publish request start event
873
+ publishRequestStart({
874
+ requestId: this._requestId,
875
+ url: url?.toString() ?? '',
876
+ method: options.method,
877
+ headers: options.headers,
878
+ });
759
879
  timer(request);
760
880
  this._cancelTimeouts = timedOut(request, timeout, url);
761
881
  if (this.options.http2) {
@@ -805,6 +925,19 @@ export default class Request extends Duplex {
805
925
  if (is.nodeStream(body)) {
806
926
  body.pipe(currentRequest);
807
927
  }
928
+ else if (is.buffer(body)) {
929
+ // Buffer should be sent directly without conversion
930
+ this._writeRequest(body, undefined, () => { });
931
+ currentRequest.end();
932
+ }
933
+ else if (is.typedArray(body)) {
934
+ // Typed arrays should be treated like buffers, not iterated over
935
+ // Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
936
+ const typedArray = body;
937
+ const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
938
+ this._writeRequest(uint8View, undefined, () => { });
939
+ currentRequest.end();
940
+ }
808
941
  else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
809
942
  (async () => {
810
943
  try {
@@ -832,47 +965,111 @@ export default class Request extends Duplex {
832
965
  }
833
966
  }
834
967
  _prepareCache(cache) {
835
- if (!cacheableStore.has(cache)) {
836
- const cacheableRequest = new CacheableRequest(((requestOptions, handler) => {
837
- const result = requestOptions._request(requestOptions, handler);
838
- // TODO: remove this when `cacheable-request` supports async request functions.
839
- if (is.promise(result)) {
840
- // We only need to implement the error handler in order to support HTTP2 caching.
841
- // The result will be a promise anyway.
842
- // @ts-expect-error ignore
843
- result.once = (event, handler) => {
844
- if (event === 'error') {
845
- (async () => {
846
- try {
847
- await result;
848
- }
849
- catch (error) {
850
- handler(error);
851
- }
852
- })();
968
+ if (cacheableStore.has(cache)) {
969
+ return;
970
+ }
971
+ const cacheableRequest = new CacheableRequest(((requestOptions, handler) => {
972
+ /**
973
+ Wraps the cacheable-request handler to run beforeCache hooks.
974
+ These hooks control caching behavior by:
975
+ - Directly mutating the response object (changes apply to what gets cached)
976
+ - Returning `false` to prevent caching
977
+ - Returning `void`/`undefined` to use default caching behavior
978
+
979
+ Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
980
+ Mutations take effect immediately and determine what gets cached.
981
+ */
982
+ const wrappedHandler = handler ? (response) => {
983
+ const { beforeCacheHooks, gotRequest } = requestOptions;
984
+ // Early return if no hooks - cache the original response
985
+ if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
986
+ handler(response);
987
+ return;
988
+ }
989
+ try {
990
+ // Call each beforeCache hook with the response
991
+ // Hooks can directly mutate the response - mutations take effect immediately
992
+ for (const hook of beforeCacheHooks) {
993
+ const result = hook(response);
994
+ if (result === false) {
995
+ // Prevent caching by adding no-cache headers
996
+ // Mutate the response directly to add headers
997
+ response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
998
+ response.headers.pragma = 'no-cache';
999
+ response.headers.expires = '0';
1000
+ handler(response);
1001
+ // Don't call remaining hooks - we've decided not to cache
1002
+ return;
853
1003
  }
854
- else if (event === 'abort' || event === 'destroy') {
855
- // The empty catch is needed here in case when
856
- // it rejects before it's `await`ed in `_makeRequest`.
857
- (async () => {
858
- try {
859
- const request = (await result);
860
- request.once(event, handler);
861
- }
862
- catch { }
863
- })();
1004
+ if (is.promise(result)) {
1005
+ // BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
1006
+ 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.');
864
1007
  }
865
- else {
866
- /* istanbul ignore next: safety check */
867
- throw new Error(`Unknown HTTP2 promise event: ${event}`);
1008
+ if (result !== undefined) {
1009
+ // Hooks should return false or undefined only
1010
+ // Mutations work directly - no need to return the response
1011
+ throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
868
1012
  }
869
- return result;
870
- };
1013
+ // Else: void/undefined = continue
1014
+ }
871
1015
  }
872
- return result;
873
- }), cache);
874
- cacheableStore.set(cache, cacheableRequest.request());
875
- }
1016
+ catch (error) {
1017
+ // Convert hook errors to RequestError and propagate
1018
+ // This is consistent with how other hooks handle errors
1019
+ if (gotRequest) {
1020
+ gotRequest._beforeError(error instanceof RequestError ? error : new RequestError(error.message, error, gotRequest));
1021
+ // Don't call handler when error was propagated successfully
1022
+ return;
1023
+ }
1024
+ // If gotRequest is missing, log the error to aid debugging
1025
+ // We still call the handler to prevent the request from hanging
1026
+ console.error('Got: beforeCache hook error (request context unavailable):', error);
1027
+ // Call handler with response (potentially partially modified)
1028
+ handler(response);
1029
+ return;
1030
+ }
1031
+ // All hooks ran successfully
1032
+ // Cache the response with any mutations applied
1033
+ handler(response);
1034
+ } : handler;
1035
+ const result = requestOptions._request(requestOptions, wrappedHandler);
1036
+ // TODO: remove this when `cacheable-request` supports async request functions.
1037
+ if (is.promise(result)) {
1038
+ // We only need to implement the error handler in order to support HTTP2 caching.
1039
+ // The result will be a promise anyway.
1040
+ // @ts-expect-error ignore
1041
+ result.once = (event, handler) => {
1042
+ if (event === 'error') {
1043
+ (async () => {
1044
+ try {
1045
+ await result;
1046
+ }
1047
+ catch (error) {
1048
+ handler(error);
1049
+ }
1050
+ })();
1051
+ }
1052
+ else if (event === 'abort' || event === 'destroy') {
1053
+ // The empty catch is needed here in case when
1054
+ // it rejects before it's `await`ed in `_makeRequest`.
1055
+ (async () => {
1056
+ try {
1057
+ const request = (await result);
1058
+ request.once(event, handler);
1059
+ }
1060
+ catch { }
1061
+ })();
1062
+ }
1063
+ else {
1064
+ /* istanbul ignore next: safety check */
1065
+ throw new Error(`Unknown HTTP2 promise event: ${event}`);
1066
+ }
1067
+ return result;
1068
+ };
1069
+ }
1070
+ return result;
1071
+ }), cache);
1072
+ cacheableStore.set(cache, cacheableRequest.request());
876
1073
  }
877
1074
  async _createCacheableRequest(url, options) {
878
1075
  return new Promise((resolve, reject) => {
@@ -884,9 +1081,15 @@ export default class Request extends Duplex {
884
1081
  response._readableState.autoDestroy = false;
885
1082
  if (request) {
886
1083
  const fix = () => {
1084
+ // For ResponseLike objects from cache, set complete to true if not already set.
1085
+ // For real HTTP responses, copy from the underlying response.
887
1086
  if (response.req) {
888
1087
  response.complete = response.req.res.complete;
889
1088
  }
1089
+ else if (response.complete === undefined) {
1090
+ // ResponseLike from cache should have complete = true
1091
+ response.complete = true;
1092
+ }
890
1093
  };
891
1094
  response.prependOnceListener('end', fix);
892
1095
  fix();
@@ -915,7 +1118,14 @@ export default class Request extends Duplex {
915
1118
  }
916
1119
  }
917
1120
  if (options.decompress && is.undefined(headers['accept-encoding'])) {
918
- headers['accept-encoding'] = supportsBrotli ? 'gzip, deflate, br' : 'gzip, deflate';
1121
+ const encodings = ['gzip', 'deflate'];
1122
+ if (supportsBrotli) {
1123
+ encodings.push('br');
1124
+ }
1125
+ if (supportsZstd) {
1126
+ encodings.push('zstd');
1127
+ }
1128
+ headers['accept-encoding'] = encodings.join(', ');
919
1129
  }
920
1130
  if (username || password) {
921
1131
  const credentials = Buffer.from(`${username}:${password}`).toString('base64');
@@ -928,12 +1138,10 @@ export default class Request extends Duplex {
928
1138
  headers.cookie = cookieString;
929
1139
  }
930
1140
  }
931
- // Reset `prefixUrl`
932
- options.prefixUrl = '';
933
1141
  let request;
934
1142
  for (const hook of options.hooks.beforeRequest) {
935
1143
  // eslint-disable-next-line no-await-in-loop
936
- const result = await hook(options);
1144
+ const result = await hook(options, { retryCount: this.retryCount });
937
1145
  if (!is.undefined(result)) {
938
1146
  // @ts-expect-error Skip the type mismatch to support abstract responses
939
1147
  request = () => result;
@@ -947,6 +1155,8 @@ export default class Request extends Duplex {
947
1155
  this._requestOptions._request = request;
948
1156
  this._requestOptions.cache = options.cache;
949
1157
  this._requestOptions.body = options.body;
1158
+ this._requestOptions.beforeCacheHooks = options.hooks.beforeCache;
1159
+ this._requestOptions.gotRequest = this;
950
1160
  try {
951
1161
  this._prepareCache(options.cache);
952
1162
  }
@@ -973,15 +1183,15 @@ export default class Request extends Duplex {
973
1183
  if (isClientRequest(requestOrResponse)) {
974
1184
  this._onRequest(requestOrResponse);
975
1185
  }
976
- else if (this.writable) {
1186
+ else if (this.writableEnded) {
1187
+ void this._onResponse(requestOrResponse);
1188
+ }
1189
+ else {
977
1190
  this.once('finish', () => {
978
1191
  void this._onResponse(requestOrResponse);
979
1192
  });
980
1193
  this._sendBody();
981
1194
  }
982
- else {
983
- void this._onResponse(requestOrResponse);
984
- }
985
1195
  }
986
1196
  catch (error) {
987
1197
  if (error instanceof CacheableCacheError) {
@@ -992,26 +1202,58 @@ export default class Request extends Duplex {
992
1202
  }
993
1203
  async _error(error) {
994
1204
  try {
995
- if (error instanceof HTTPError && !this.options.throwHttpErrors) {
1205
+ if (this.options && error instanceof HTTPError && !this.options.throwHttpErrors) {
996
1206
  // This branch can be reached only when using the Promise API
997
1207
  // Skip calling the hooks on purpose.
998
1208
  // See https://github.com/sindresorhus/got/issues/2103
999
1209
  }
1000
- else {
1001
- for (const hook of this.options.hooks.beforeError) {
1002
- // eslint-disable-next-line no-await-in-loop
1003
- error = await hook(error);
1210
+ else if (this.options) {
1211
+ const hooks = this.options.hooks.beforeError;
1212
+ if (hooks.length > 0) {
1213
+ for (const hook of hooks) {
1214
+ // eslint-disable-next-line no-await-in-loop
1215
+ error = await hook(error);
1216
+ // Validate hook return value
1217
+ if (!(error instanceof Error)) {
1218
+ throw new TypeError(`The \`beforeError\` hook must return an Error instance. Received ${is.string(error) ? 'string' : String(typeof error)}.`);
1219
+ }
1220
+ }
1221
+ // Mark this error as processed by hooks so _destroy preserves custom error types.
1222
+ // Only mark non-RequestError errors, since RequestErrors are already preserved
1223
+ // by the instanceof check in _destroy (line 642).
1224
+ if (!(error instanceof RequestError)) {
1225
+ errorsProcessedByHooks.add(error);
1226
+ }
1004
1227
  }
1005
1228
  }
1006
1229
  }
1007
1230
  catch (error_) {
1008
1231
  error = new RequestError(error_.message, error_, this);
1009
1232
  }
1233
+ // Publish error event
1234
+ publishError({
1235
+ requestId: this._requestId,
1236
+ url: this.options?.url?.toString() ?? '',
1237
+ error,
1238
+ timings: this.timings,
1239
+ });
1010
1240
  this.destroy(error);
1241
+ // Manually emit error for Promise API to ensure it receives it.
1242
+ // Node.js streams may not re-emit if an error was already emitted during retry attempts.
1243
+ // Only emit for Promise API (_noPipe = true) to avoid double emissions in stream mode.
1244
+ // Use process.nextTick to defer emission and allow destroy() to complete first.
1245
+ // See https://github.com/sindresorhus/got/issues/1995
1246
+ if (this._noPipe) {
1247
+ process.nextTick(() => {
1248
+ this.emit('error', error);
1249
+ });
1250
+ }
1011
1251
  }
1012
1252
  _writeRequest(chunk, encoding, callback) {
1013
1253
  if (!this._request || this._request.destroyed) {
1014
- // Probably the `ClientRequest` instance will throw
1254
+ // When there's no request (e.g., using cached response from beforeRequest hook),
1255
+ // we still need to call the callback to allow the stream to finish properly.
1256
+ callback();
1015
1257
  return;
1016
1258
  }
1017
1259
  this._request.write(chunk, encoding, (error) => {
@@ -1120,4 +1362,10 @@ export default class Request extends Duplex {
1120
1362
  get reusedSocket() {
1121
1363
  return this._request?.reusedSocket;
1122
1364
  }
1365
+ /**
1366
+ Whether the stream is read-only. Returns `true` when `body`, `json`, or `form` options are provided.
1367
+ */
1368
+ get isReadonly() {
1369
+ return !is.undefined(this.options?.body) || !is.undefined(this.options?.json) || !is.undefined(this.options?.form);
1370
+ }
1123
1371
  }