got 14.6.0 → 14.6.2

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,7 +1,8 @@
1
1
  import process from 'node:process';
2
2
  import { Buffer } from 'node:buffer';
3
- import { Duplex, Transform } from 'node:stream';
3
+ import { Duplex } from 'node:stream';
4
4
  import http, { ServerResponse } from 'node:http';
5
+ import { byteLength } from 'byte-counter';
5
6
  import timer from '@szmarczak/http-timer';
6
7
  import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request';
7
8
  import decompressResponse from 'decompress-response';
@@ -19,12 +20,16 @@ import { isResponseOk } from './response.js';
19
20
  import isClientRequest from './utils/is-client-request.js';
20
21
  import isUnixSocketURL, { getUnixSocketPath } from './utils/is-unix-socket-url.js';
21
22
  import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
23
+ import { generateRequestId, publishRequestCreate, publishRequestStart, publishResponseStart, publishResponseEnd, publishRetry, publishError, publishRedirect, } from './diagnostics-channel.js';
22
24
  const supportsBrotli = is.string(process.versions.brotli);
25
+ const supportsZstd = is.string(process.versions.zstd);
23
26
  const methodsWithoutBody = new Set(['GET', 'HEAD']);
24
27
  // Methods that should auto-end streams when no body is provided
25
28
  const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
26
29
  const cacheableStore = new WeakableMap();
27
30
  const redirectCodes = new Set([300, 301, 302, 303, 304, 307, 308]);
31
+ // Track errors that have been processed by beforeError hooks to preserve custom error types
32
+ const errorsProcessedByHooks = new WeakSet();
28
33
  const proxiedRequestEvents = [
29
34
  'socket',
30
35
  'connect',
@@ -33,17 +38,6 @@ const proxiedRequestEvents = [
33
38
  'upgrade',
34
39
  ];
35
40
  const noop = () => { };
36
- /**
37
- Stream transform that counts bytes passing through.
38
- Used to track compressed bytes before decompression for content-length validation.
39
- */
40
- class ByteCounter extends Transform {
41
- count = 0;
42
- _transform(chunk, _encoding, callback) {
43
- this.count += chunk.length;
44
- callback(null, chunk);
45
- }
46
- }
47
41
  export default class Request extends Duplex {
48
42
  // @ts-expect-error - Ignoring for now.
49
43
  ['constructor'];
@@ -52,28 +46,30 @@ export default class Request extends Duplex {
52
46
  options;
53
47
  response;
54
48
  requestUrl;
55
- redirectUrls;
56
- retryCount;
57
- _stopReading;
58
- _stopRetry;
59
- _downloadedSize;
60
- _uploadedSize;
61
- _pipedServerResponses;
49
+ redirectUrls = [];
50
+ retryCount = 0;
51
+ _stopReading = false;
52
+ _stopRetry = noop;
53
+ _downloadedSize = 0;
54
+ _uploadedSize = 0;
55
+ _pipedServerResponses = new Set();
62
56
  _request;
63
57
  _responseSize;
64
58
  _bodySize;
65
- _unproxyEvents;
59
+ _unproxyEvents = noop;
66
60
  _isFromCache;
67
- _triggerRead;
68
- _cancelTimeouts;
69
- _removeListeners;
61
+ _triggerRead = false;
62
+ _jobs = [];
63
+ _cancelTimeouts = noop;
64
+ _removeListeners = noop;
70
65
  _nativeResponse;
71
- _flushed;
72
- _aborted;
66
+ _flushed = false;
67
+ _aborted = false;
73
68
  _expectedContentLength;
74
- _byteCounter;
69
+ _compressedBytesCount;
70
+ _requestId = generateRequestId();
75
71
  // We need this because `this._request` if `undefined` when using cache
76
- _requestInitialized;
72
+ _requestInitialized = false;
77
73
  constructor(url, options, defaults) {
78
74
  super({
79
75
  // Don't destroy immediately, as the error may be emitted on unsuccessful retry
@@ -81,23 +77,8 @@ export default class Request extends Duplex {
81
77
  // It needs to be zero because we're just proxying the data to another stream
82
78
  highWaterMark: 0,
83
79
  });
84
- this._downloadedSize = 0;
85
- this._uploadedSize = 0;
86
- this._stopReading = false;
87
- this._pipedServerResponses = new Set();
88
- this._unproxyEvents = noop;
89
- this._triggerRead = false;
90
- this._cancelTimeouts = noop;
91
- this._removeListeners = noop;
92
- this._jobs = [];
93
- this._flushed = false;
94
- this._requestInitialized = false;
95
- this._aborted = false;
96
- this.redirectUrls = [];
97
- this.retryCount = 0;
98
- this._stopRetry = noop;
99
80
  this.on('pipe', (source) => {
100
- if (source?.headers) {
81
+ if (this.options.copyPipedHeaders && source?.headers) {
101
82
  Object.assign(this.options.headers, source.headers);
102
83
  }
103
84
  });
@@ -115,6 +96,12 @@ export default class Request extends Duplex {
115
96
  this.options.url = '';
116
97
  }
117
98
  this.requestUrl = this.options.url;
99
+ // Publish request creation event
100
+ publishRequestCreate({
101
+ requestId: this._requestId,
102
+ url: this.options.url?.toString() ?? '',
103
+ method: this.options.method,
104
+ });
118
105
  }
119
106
  catch (error) {
120
107
  const { options } = error;
@@ -285,6 +272,8 @@ export default class Request extends Duplex {
285
272
  if (this.destroyed) {
286
273
  return;
287
274
  }
275
+ // Capture body BEFORE hooks run to detect reassignment
276
+ const bodyBeforeHooks = this.options.body;
288
277
  try {
289
278
  for (const hook of this.options.hooks.beforeRetry) {
290
279
  // eslint-disable-next-line no-await-in-loop
@@ -292,14 +281,58 @@ export default class Request extends Duplex {
292
281
  }
293
282
  }
294
283
  catch (error_) {
295
- void this._error(new RequestError(error_.message, error, this));
284
+ void this._error(new RequestError(error_.message, error_, this));
296
285
  return;
297
286
  }
298
287
  // Something forced us to abort the retry
299
288
  if (this.destroyed) {
300
289
  return;
301
290
  }
302
- this.destroy();
291
+ // Preserve stream body reassigned in beforeRetry hooks.
292
+ const bodyAfterHooks = this.options.body;
293
+ const bodyWasReassigned = bodyBeforeHooks !== bodyAfterHooks;
294
+ // Resource cleanup and preservation logic for retry with body reassignment.
295
+ // The Promise wrapper (as-promise/index.ts) compares body identity to detect consumed streams,
296
+ // so we must preserve the body reference across destroy(). However, destroy() calls _destroy()
297
+ // which destroys this.options.body, creating a complex dance of clear/restore operations.
298
+ //
299
+ // Key constraints:
300
+ // 1. If body was reassigned, we must NOT destroy the NEW stream (it will be used for retry)
301
+ // 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks
302
+ // 3. We must restore the body reference after destroy() for identity checks in promise wrapper
303
+ // 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();
313
+ }
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.');
319
+ }
320
+ this.options._internals.body = bodyAfterHooks;
321
+ }
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.
328
+ }
329
+ // Publish retry event
330
+ publishRetry({
331
+ requestId: this._requestId,
332
+ retryCount: this.retryCount + 1,
333
+ error: typedError,
334
+ delay: backoff,
335
+ });
303
336
  this.emit('retry', this.retryCount + 1, error, (updatedOptions) => {
304
337
  const request = new Request(options.url, updatedOptions, options);
305
338
  request.retryCount = this.retryCount + 1;
@@ -403,8 +436,15 @@ export default class Request extends Duplex {
403
436
  timings.phases.total = timings.end - timings.start;
404
437
  }
405
438
  }
406
- if (error !== null && !is.undefined(error) && !(error instanceof RequestError)) {
407
- error = new RequestError(error.message, error, this);
439
+ // Preserve custom errors returned by beforeError hooks.
440
+ // For other errors, wrap non-RequestError instances for consistency.
441
+ if (error !== null && !is.undefined(error)) {
442
+ const processedByHooks = error instanceof Error && errorsProcessedByHooks.has(error);
443
+ if (!processedByHooks && !(error instanceof RequestError)) {
444
+ error = error instanceof Error
445
+ ? new RequestError(error.message, error, this)
446
+ : new RequestError(String(error), {}, this);
447
+ }
408
448
  }
409
449
  callback(error);
410
450
  }
@@ -423,9 +463,9 @@ export default class Request extends Duplex {
423
463
  }
424
464
  _checkContentLengthMismatch() {
425
465
  if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
426
- // Use ByteCounter's count when available (for compressed responses),
466
+ // Use compressed bytes count when available (for compressed responses),
427
467
  // otherwise use _downloadedSize (for uncompressed responses)
428
- const actualSize = this._byteCounter?.count ?? this._downloadedSize;
468
+ const actualSize = this._compressedBytesCount ?? this._downloadedSize;
429
469
  if (actualSize !== this._expectedContentLength) {
430
470
  this._beforeError(new ReadError({
431
471
  message: `Content-Length mismatch: expected ${this._expectedContentLength} bytes, received ${actualSize} bytes`,
@@ -528,9 +568,9 @@ export default class Request extends Duplex {
528
568
  // When strictContentLength is enabled, track compressed bytes by listening to
529
569
  // the native response's data events before decompression
530
570
  if (options.strictContentLength) {
531
- this._byteCounter = new ByteCounter();
571
+ this._compressedBytesCount = 0;
532
572
  this._nativeResponse.on('data', (chunk) => {
533
- this._byteCounter.count += chunk.length;
573
+ this._compressedBytesCount += byteLength(chunk);
534
574
  });
535
575
  }
536
576
  response = decompressResponse(response);
@@ -548,6 +588,14 @@ export default class Request extends Duplex {
548
588
  this._isFromCache = typedResponse.isFromCache;
549
589
  this._responseSize = Number(response.headers['content-length']) || undefined;
550
590
  this.response = typedResponse;
591
+ // Publish response start event
592
+ publishResponseStart({
593
+ requestId: this._requestId,
594
+ url: typedResponse.url,
595
+ statusCode,
596
+ headers: response.headers,
597
+ isFromCache: typedResponse.isFromCache,
598
+ });
551
599
  // Workaround for http-timer bug: when connecting to an IP address (no DNS lookup),
552
600
  // http-timer sets lookup = connect instead of lookup = socket, resulting in
553
601
  // dns = lookup - socket being a small positive number instead of 0.
@@ -560,6 +608,15 @@ export default class Request extends Duplex {
560
608
  // Recalculate TCP time to be the full time from socket to connect
561
609
  timings.phases.tcp = timings.connect - timings.socket;
562
610
  }
611
+ // Workaround for http-timer limitation with HTTP/2:
612
+ // When using HTTP/2, the socket is a proxy that http-timer discards,
613
+ // so lookup, connect, and secureConnect events are never captured.
614
+ // This results in phases.request being NaN (undefined - undefined).
615
+ // Set it to undefined to be consistent with other unavailable timings.
616
+ // See https://github.com/sindresorhus/got/issues/1958
617
+ if (timings && Number.isNaN(timings.phases.request)) {
618
+ timings.phases.request = undefined;
619
+ }
563
620
  response.once('error', (error) => {
564
621
  this._aborted = true;
565
622
  // Force clean-up, because some packages don't do this.
@@ -662,12 +719,18 @@ export default class Request extends Duplex {
662
719
  redirectUrl.password = updatedOptions.password;
663
720
  }
664
721
  this.redirectUrls.push(redirectUrl);
665
- updatedOptions.prefixUrl = '';
666
722
  updatedOptions.url = redirectUrl;
667
723
  for (const hook of updatedOptions.hooks.beforeRedirect) {
668
724
  // eslint-disable-next-line no-await-in-loop
669
725
  await hook(updatedOptions, typedResponse);
670
726
  }
727
+ // Publish redirect event
728
+ publishRedirect({
729
+ requestId: this._requestId,
730
+ fromUrl: url.toString(),
731
+ toUrl: redirectUrl.toString(),
732
+ statusCode,
733
+ });
671
734
  this.emit('redirect', updatedOptions, typedResponse);
672
735
  this.options = updatedOptions;
673
736
  await this._makeRequest();
@@ -711,6 +774,14 @@ export default class Request extends Duplex {
711
774
  }
712
775
  this._responseSize = this._downloadedSize;
713
776
  this.emit('downloadProgress', this.downloadProgress);
777
+ // Publish response end event
778
+ publishResponseEnd({
779
+ requestId: this._requestId,
780
+ url: typedResponse.url,
781
+ statusCode,
782
+ bodySize: this._downloadedSize,
783
+ timings: this.timings,
784
+ });
714
785
  this.push(null);
715
786
  });
716
787
  this.emit('downloadProgress', this.downloadProgress);
@@ -788,6 +859,13 @@ export default class Request extends Duplex {
788
859
  _onRequest(request) {
789
860
  const { options } = this;
790
861
  const { timeout, url } = options;
862
+ // Publish request start event
863
+ publishRequestStart({
864
+ requestId: this._requestId,
865
+ url: url?.toString() ?? '',
866
+ method: options.method,
867
+ headers: options.headers,
868
+ });
791
869
  timer(request);
792
870
  this._cancelTimeouts = timedOut(request, timeout, url);
793
871
  if (this.options.http2) {
@@ -877,47 +955,111 @@ export default class Request extends Duplex {
877
955
  }
878
956
  }
879
957
  _prepareCache(cache) {
880
- if (!cacheableStore.has(cache)) {
881
- const cacheableRequest = new CacheableRequest(((requestOptions, handler) => {
882
- const result = requestOptions._request(requestOptions, handler);
883
- // TODO: remove this when `cacheable-request` supports async request functions.
884
- if (is.promise(result)) {
885
- // We only need to implement the error handler in order to support HTTP2 caching.
886
- // The result will be a promise anyway.
887
- // @ts-expect-error ignore
888
- result.once = (event, handler) => {
889
- if (event === 'error') {
890
- (async () => {
891
- try {
892
- await result;
893
- }
894
- catch (error) {
895
- handler(error);
896
- }
897
- })();
958
+ if (cacheableStore.has(cache)) {
959
+ return;
960
+ }
961
+ const cacheableRequest = new CacheableRequest(((requestOptions, handler) => {
962
+ /**
963
+ Wraps the cacheable-request handler to run beforeCache hooks.
964
+ These hooks control caching behavior by:
965
+ - Directly mutating the response object (changes apply to what gets cached)
966
+ - Returning `false` to prevent caching
967
+ - Returning `void`/`undefined` to use default caching behavior
968
+
969
+ Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
970
+ Mutations take effect immediately and determine what gets cached.
971
+ */
972
+ const wrappedHandler = handler ? (response) => {
973
+ const { beforeCacheHooks, gotRequest } = requestOptions;
974
+ // Early return if no hooks - cache the original response
975
+ if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
976
+ handler(response);
977
+ return;
978
+ }
979
+ try {
980
+ // Call each beforeCache hook with the response
981
+ // Hooks can directly mutate the response - mutations take effect immediately
982
+ for (const hook of beforeCacheHooks) {
983
+ const result = hook(response);
984
+ if (result === false) {
985
+ // Prevent caching by adding no-cache headers
986
+ // Mutate the response directly to add headers
987
+ response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
988
+ response.headers.pragma = 'no-cache';
989
+ response.headers.expires = '0';
990
+ handler(response);
991
+ // Don't call remaining hooks - we've decided not to cache
992
+ return;
898
993
  }
899
- else if (event === 'abort' || event === 'destroy') {
900
- // The empty catch is needed here in case when
901
- // it rejects before it's `await`ed in `_makeRequest`.
902
- (async () => {
903
- try {
904
- const request = (await result);
905
- request.once(event, handler);
906
- }
907
- catch { }
908
- })();
994
+ if (is.promise(result)) {
995
+ // BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
996
+ 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.');
909
997
  }
910
- else {
911
- /* istanbul ignore next: safety check */
912
- throw new Error(`Unknown HTTP2 promise event: ${event}`);
998
+ if (result !== undefined) {
999
+ // Hooks should return false or undefined only
1000
+ // Mutations work directly - no need to return the response
1001
+ throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
913
1002
  }
914
- return result;
915
- };
1003
+ // Else: void/undefined = continue
1004
+ }
916
1005
  }
917
- return result;
918
- }), cache);
919
- cacheableStore.set(cache, cacheableRequest.request());
920
- }
1006
+ catch (error) {
1007
+ // Convert hook errors to RequestError and propagate
1008
+ // This is consistent with how other hooks handle errors
1009
+ if (gotRequest) {
1010
+ gotRequest._beforeError(error instanceof RequestError ? error : new RequestError(error.message, error, gotRequest));
1011
+ // Don't call handler when error was propagated successfully
1012
+ return;
1013
+ }
1014
+ // If gotRequest is missing, log the error to aid debugging
1015
+ // We still call the handler to prevent the request from hanging
1016
+ console.error('Got: beforeCache hook error (request context unavailable):', error);
1017
+ // Call handler with response (potentially partially modified)
1018
+ handler(response);
1019
+ return;
1020
+ }
1021
+ // All hooks ran successfully
1022
+ // Cache the response with any mutations applied
1023
+ handler(response);
1024
+ } : handler;
1025
+ const result = requestOptions._request(requestOptions, wrappedHandler);
1026
+ // TODO: remove this when `cacheable-request` supports async request functions.
1027
+ if (is.promise(result)) {
1028
+ // We only need to implement the error handler in order to support HTTP2 caching.
1029
+ // The result will be a promise anyway.
1030
+ // @ts-expect-error ignore
1031
+ result.once = (event, handler) => {
1032
+ if (event === 'error') {
1033
+ (async () => {
1034
+ try {
1035
+ await result;
1036
+ }
1037
+ catch (error) {
1038
+ handler(error);
1039
+ }
1040
+ })();
1041
+ }
1042
+ else if (event === 'abort' || event === 'destroy') {
1043
+ // The empty catch is needed here in case when
1044
+ // it rejects before it's `await`ed in `_makeRequest`.
1045
+ (async () => {
1046
+ try {
1047
+ const request = (await result);
1048
+ request.once(event, handler);
1049
+ }
1050
+ catch { }
1051
+ })();
1052
+ }
1053
+ else {
1054
+ /* istanbul ignore next: safety check */
1055
+ throw new Error(`Unknown HTTP2 promise event: ${event}`);
1056
+ }
1057
+ return result;
1058
+ };
1059
+ }
1060
+ return result;
1061
+ }), cache);
1062
+ cacheableStore.set(cache, cacheableRequest.request());
921
1063
  }
922
1064
  async _createCacheableRequest(url, options) {
923
1065
  return new Promise((resolve, reject) => {
@@ -966,7 +1108,14 @@ export default class Request extends Duplex {
966
1108
  }
967
1109
  }
968
1110
  if (options.decompress && is.undefined(headers['accept-encoding'])) {
969
- headers['accept-encoding'] = supportsBrotli ? 'gzip, deflate, br' : 'gzip, deflate';
1111
+ const encodings = ['gzip', 'deflate'];
1112
+ if (supportsBrotli) {
1113
+ encodings.push('br');
1114
+ }
1115
+ if (supportsZstd) {
1116
+ encodings.push('zstd');
1117
+ }
1118
+ headers['accept-encoding'] = encodings.join(', ');
970
1119
  }
971
1120
  if (username || password) {
972
1121
  const credentials = Buffer.from(`${username}:${password}`).toString('base64');
@@ -979,8 +1128,6 @@ export default class Request extends Duplex {
979
1128
  headers.cookie = cookieString;
980
1129
  }
981
1130
  }
982
- // Reset `prefixUrl`
983
- options.prefixUrl = '';
984
1131
  let request;
985
1132
  for (const hook of options.hooks.beforeRequest) {
986
1133
  // eslint-disable-next-line no-await-in-loop
@@ -998,6 +1145,8 @@ export default class Request extends Duplex {
998
1145
  this._requestOptions._request = request;
999
1146
  this._requestOptions.cache = options.cache;
1000
1147
  this._requestOptions.body = options.body;
1148
+ this._requestOptions.beforeCacheHooks = options.hooks.beforeCache;
1149
+ this._requestOptions.gotRequest = this;
1001
1150
  try {
1002
1151
  this._prepareCache(options.cache);
1003
1152
  }
@@ -1024,15 +1173,15 @@ export default class Request extends Duplex {
1024
1173
  if (isClientRequest(requestOrResponse)) {
1025
1174
  this._onRequest(requestOrResponse);
1026
1175
  }
1027
- else if (this.writable) {
1176
+ else if (this.writableEnded) {
1177
+ void this._onResponse(requestOrResponse);
1178
+ }
1179
+ else {
1028
1180
  this.once('finish', () => {
1029
1181
  void this._onResponse(requestOrResponse);
1030
1182
  });
1031
1183
  this._sendBody();
1032
1184
  }
1033
- else {
1034
- void this._onResponse(requestOrResponse);
1035
- }
1036
1185
  }
1037
1186
  catch (error) {
1038
1187
  if (error instanceof CacheableCacheError) {
@@ -1049,15 +1198,35 @@ export default class Request extends Duplex {
1049
1198
  // See https://github.com/sindresorhus/got/issues/2103
1050
1199
  }
1051
1200
  else if (this.options) {
1052
- for (const hook of this.options.hooks.beforeError) {
1053
- // eslint-disable-next-line no-await-in-loop
1054
- error = await hook(error);
1201
+ const hooks = this.options.hooks.beforeError;
1202
+ if (hooks.length > 0) {
1203
+ for (const hook of hooks) {
1204
+ // eslint-disable-next-line no-await-in-loop
1205
+ error = await hook(error);
1206
+ // Validate hook return value
1207
+ if (!(error instanceof Error)) {
1208
+ throw new TypeError(`The \`beforeError\` hook must return an Error instance. Received ${is.string(error) ? 'string' : String(typeof error)}.`);
1209
+ }
1210
+ }
1211
+ // Mark this error as processed by hooks so _destroy preserves custom error types.
1212
+ // Only mark non-RequestError errors, since RequestErrors are already preserved
1213
+ // by the instanceof check in _destroy (line 642).
1214
+ if (!(error instanceof RequestError)) {
1215
+ errorsProcessedByHooks.add(error);
1216
+ }
1055
1217
  }
1056
1218
  }
1057
1219
  }
1058
1220
  catch (error_) {
1059
1221
  error = new RequestError(error_.message, error_, this);
1060
1222
  }
1223
+ // Publish error event
1224
+ publishError({
1225
+ requestId: this._requestId,
1226
+ url: this.options?.url?.toString() ?? '',
1227
+ error,
1228
+ timings: this.timings,
1229
+ });
1061
1230
  this.destroy(error);
1062
1231
  // Manually emit error for Promise API to ensure it receives it.
1063
1232
  // Node.js streams may not re-emit if an error was already emitted during retry attempts.
@@ -1080,7 +1249,9 @@ export default class Request extends Duplex {
1080
1249
  this._request.write(chunk, encoding, (error) => {
1081
1250
  // The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed
1082
1251
  if (!error && !this._request.destroyed) {
1083
- this._uploadedSize += Buffer.byteLength(chunk, encoding);
1252
+ // For strings, encode them first to measure the actual bytes that will be sent
1253
+ const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk;
1254
+ this._uploadedSize += byteLength(bytes);
1084
1255
  const progress = this.uploadProgress;
1085
1256
  if (progress.percent < 1) {
1086
1257
  this.emit('uploadProgress', progress);
@@ -1183,4 +1354,10 @@ export default class Request extends Duplex {
1183
1354
  get reusedSocket() {
1184
1355
  return this._request?.reusedSocket;
1185
1356
  }
1357
+ /**
1358
+ Whether the stream is read-only. Returns `true` when `body`, `json`, or `form` options are provided.
1359
+ */
1360
+ get isReadonly() {
1361
+ return !is.undefined(this.options?.body) || !is.undefined(this.options?.json) || !is.undefined(this.options?.form);
1362
+ }
1186
1363
  }