got 14.6.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.
@@ -19,12 +19,16 @@ import { isResponseOk } from './response.js';
19
19
  import isClientRequest from './utils/is-client-request.js';
20
20
  import isUnixSocketURL, { getUnixSocketPath } from './utils/is-unix-socket-url.js';
21
21
  import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
22
+ import { generateRequestId, publishRequestCreate, publishRequestStart, publishResponseStart, publishResponseEnd, publishRetry, publishError, publishRedirect, } from './diagnostics-channel.js';
22
23
  const supportsBrotli = is.string(process.versions.brotli);
24
+ const supportsZstd = is.string(process.versions.zstd);
23
25
  const methodsWithoutBody = new Set(['GET', 'HEAD']);
24
26
  // Methods that should auto-end streams when no body is provided
25
27
  const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
26
28
  const cacheableStore = new WeakableMap();
27
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();
28
32
  const proxiedRequestEvents = [
29
33
  'socket',
30
34
  'connect',
@@ -52,28 +56,30 @@ export default class Request extends Duplex {
52
56
  options;
53
57
  response;
54
58
  requestUrl;
55
- redirectUrls;
56
- retryCount;
57
- _stopReading;
58
- _stopRetry;
59
- _downloadedSize;
60
- _uploadedSize;
61
- _pipedServerResponses;
59
+ redirectUrls = [];
60
+ retryCount = 0;
61
+ _stopReading = false;
62
+ _stopRetry = noop;
63
+ _downloadedSize = 0;
64
+ _uploadedSize = 0;
65
+ _pipedServerResponses = new Set();
62
66
  _request;
63
67
  _responseSize;
64
68
  _bodySize;
65
- _unproxyEvents;
69
+ _unproxyEvents = noop;
66
70
  _isFromCache;
67
- _triggerRead;
68
- _cancelTimeouts;
69
- _removeListeners;
71
+ _triggerRead = false;
72
+ _jobs = [];
73
+ _cancelTimeouts = noop;
74
+ _removeListeners = noop;
70
75
  _nativeResponse;
71
- _flushed;
72
- _aborted;
76
+ _flushed = false;
77
+ _aborted = false;
73
78
  _expectedContentLength;
74
79
  _byteCounter;
80
+ _requestId = generateRequestId();
75
81
  // We need this because `this._request` if `undefined` when using cache
76
- _requestInitialized;
82
+ _requestInitialized = false;
77
83
  constructor(url, options, defaults) {
78
84
  super({
79
85
  // Don't destroy immediately, as the error may be emitted on unsuccessful retry
@@ -81,23 +87,8 @@ export default class Request extends Duplex {
81
87
  // It needs to be zero because we're just proxying the data to another stream
82
88
  highWaterMark: 0,
83
89
  });
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
90
  this.on('pipe', (source) => {
100
- if (source?.headers) {
91
+ if (this.options.copyPipedHeaders && source?.headers) {
101
92
  Object.assign(this.options.headers, source.headers);
102
93
  }
103
94
  });
@@ -115,6 +106,12 @@ export default class Request extends Duplex {
115
106
  this.options.url = '';
116
107
  }
117
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
+ });
118
115
  }
119
116
  catch (error) {
120
117
  const { options } = error;
@@ -285,6 +282,8 @@ export default class Request extends Duplex {
285
282
  if (this.destroyed) {
286
283
  return;
287
284
  }
285
+ // Capture body BEFORE hooks run to detect reassignment
286
+ const bodyBeforeHooks = this.options.body;
288
287
  try {
289
288
  for (const hook of this.options.hooks.beforeRetry) {
290
289
  // eslint-disable-next-line no-await-in-loop
@@ -292,14 +291,58 @@ export default class Request extends Duplex {
292
291
  }
293
292
  }
294
293
  catch (error_) {
295
- void this._error(new RequestError(error_.message, error, this));
294
+ void this._error(new RequestError(error_.message, error_, this));
296
295
  return;
297
296
  }
298
297
  // Something forced us to abort the retry
299
298
  if (this.destroyed) {
300
299
  return;
301
300
  }
302
- 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
+ });
303
346
  this.emit('retry', this.retryCount + 1, error, (updatedOptions) => {
304
347
  const request = new Request(options.url, updatedOptions, options);
305
348
  request.retryCount = this.retryCount + 1;
@@ -403,8 +446,15 @@ export default class Request extends Duplex {
403
446
  timings.phases.total = timings.end - timings.start;
404
447
  }
405
448
  }
406
- if (error !== null && !is.undefined(error) && !(error instanceof RequestError)) {
407
- error = new RequestError(error.message, error, this);
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
+ }
408
458
  }
409
459
  callback(error);
410
460
  }
@@ -548,6 +598,14 @@ export default class Request extends Duplex {
548
598
  this._isFromCache = typedResponse.isFromCache;
549
599
  this._responseSize = Number(response.headers['content-length']) || undefined;
550
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
+ });
551
609
  // Workaround for http-timer bug: when connecting to an IP address (no DNS lookup),
552
610
  // http-timer sets lookup = connect instead of lookup = socket, resulting in
553
611
  // dns = lookup - socket being a small positive number instead of 0.
@@ -560,6 +618,15 @@ export default class Request extends Duplex {
560
618
  // Recalculate TCP time to be the full time from socket to connect
561
619
  timings.phases.tcp = timings.connect - timings.socket;
562
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
+ }
563
630
  response.once('error', (error) => {
564
631
  this._aborted = true;
565
632
  // Force clean-up, because some packages don't do this.
@@ -662,12 +729,18 @@ export default class Request extends Duplex {
662
729
  redirectUrl.password = updatedOptions.password;
663
730
  }
664
731
  this.redirectUrls.push(redirectUrl);
665
- updatedOptions.prefixUrl = '';
666
732
  updatedOptions.url = redirectUrl;
667
733
  for (const hook of updatedOptions.hooks.beforeRedirect) {
668
734
  // eslint-disable-next-line no-await-in-loop
669
735
  await hook(updatedOptions, typedResponse);
670
736
  }
737
+ // Publish redirect event
738
+ publishRedirect({
739
+ requestId: this._requestId,
740
+ fromUrl: url.toString(),
741
+ toUrl: redirectUrl.toString(),
742
+ statusCode,
743
+ });
671
744
  this.emit('redirect', updatedOptions, typedResponse);
672
745
  this.options = updatedOptions;
673
746
  await this._makeRequest();
@@ -711,6 +784,14 @@ export default class Request extends Duplex {
711
784
  }
712
785
  this._responseSize = this._downloadedSize;
713
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
+ });
714
795
  this.push(null);
715
796
  });
716
797
  this.emit('downloadProgress', this.downloadProgress);
@@ -788,6 +869,13 @@ export default class Request extends Duplex {
788
869
  _onRequest(request) {
789
870
  const { options } = this;
790
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
+ });
791
879
  timer(request);
792
880
  this._cancelTimeouts = timedOut(request, timeout, url);
793
881
  if (this.options.http2) {
@@ -877,47 +965,111 @@ export default class Request extends Duplex {
877
965
  }
878
966
  }
879
967
  _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
- })();
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;
898
1003
  }
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
- })();
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.');
909
1007
  }
910
- else {
911
- /* istanbul ignore next: safety check */
912
- 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.');
913
1012
  }
914
- return result;
915
- };
1013
+ // Else: void/undefined = continue
1014
+ }
916
1015
  }
917
- return result;
918
- }), cache);
919
- cacheableStore.set(cache, cacheableRequest.request());
920
- }
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());
921
1073
  }
922
1074
  async _createCacheableRequest(url, options) {
923
1075
  return new Promise((resolve, reject) => {
@@ -966,7 +1118,14 @@ export default class Request extends Duplex {
966
1118
  }
967
1119
  }
968
1120
  if (options.decompress && is.undefined(headers['accept-encoding'])) {
969
- 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(', ');
970
1129
  }
971
1130
  if (username || password) {
972
1131
  const credentials = Buffer.from(`${username}:${password}`).toString('base64');
@@ -979,8 +1138,6 @@ export default class Request extends Duplex {
979
1138
  headers.cookie = cookieString;
980
1139
  }
981
1140
  }
982
- // Reset `prefixUrl`
983
- options.prefixUrl = '';
984
1141
  let request;
985
1142
  for (const hook of options.hooks.beforeRequest) {
986
1143
  // eslint-disable-next-line no-await-in-loop
@@ -998,6 +1155,8 @@ export default class Request extends Duplex {
998
1155
  this._requestOptions._request = request;
999
1156
  this._requestOptions.cache = options.cache;
1000
1157
  this._requestOptions.body = options.body;
1158
+ this._requestOptions.beforeCacheHooks = options.hooks.beforeCache;
1159
+ this._requestOptions.gotRequest = this;
1001
1160
  try {
1002
1161
  this._prepareCache(options.cache);
1003
1162
  }
@@ -1024,15 +1183,15 @@ export default class Request extends Duplex {
1024
1183
  if (isClientRequest(requestOrResponse)) {
1025
1184
  this._onRequest(requestOrResponse);
1026
1185
  }
1027
- else if (this.writable) {
1186
+ else if (this.writableEnded) {
1187
+ void this._onResponse(requestOrResponse);
1188
+ }
1189
+ else {
1028
1190
  this.once('finish', () => {
1029
1191
  void this._onResponse(requestOrResponse);
1030
1192
  });
1031
1193
  this._sendBody();
1032
1194
  }
1033
- else {
1034
- void this._onResponse(requestOrResponse);
1035
- }
1036
1195
  }
1037
1196
  catch (error) {
1038
1197
  if (error instanceof CacheableCacheError) {
@@ -1049,15 +1208,35 @@ export default class Request extends Duplex {
1049
1208
  // See https://github.com/sindresorhus/got/issues/2103
1050
1209
  }
1051
1210
  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);
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
+ }
1055
1227
  }
1056
1228
  }
1057
1229
  }
1058
1230
  catch (error_) {
1059
1231
  error = new RequestError(error_.message, error_, this);
1060
1232
  }
1233
+ // Publish error event
1234
+ publishError({
1235
+ requestId: this._requestId,
1236
+ url: this.options?.url?.toString() ?? '',
1237
+ error,
1238
+ timings: this.timings,
1239
+ });
1061
1240
  this.destroy(error);
1062
1241
  // Manually emit error for Promise API to ensure it receives it.
1063
1242
  // Node.js streams may not re-emit if an error was already emitted during retry attempts.
@@ -1183,4 +1362,10 @@ export default class Request extends Duplex {
1183
1362
  get reusedSocket() {
1184
1363
  return this._request?.reusedSocket;
1185
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
+ }
1186
1371
  }