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.
- package/dist/source/as-promise/index.js +46 -10
- package/dist/source/core/diagnostics-channel.d.ts +89 -0
- package/dist/source/core/diagnostics-channel.js +49 -0
- package/dist/source/core/errors.d.ts +14 -1
- package/dist/source/core/errors.js +27 -19
- package/dist/source/core/index.d.ts +9 -1
- package/dist/source/core/index.js +429 -181
- package/dist/source/core/options.d.ts +245 -11
- package/dist/source/core/options.js +194 -68
- package/dist/source/core/response.d.ts +2 -0
- package/dist/source/core/response.js +2 -2
- package/dist/source/core/timed-out.d.ts +1 -0
- package/dist/source/core/timed-out.js +2 -3
- package/dist/source/core/utils/get-body-size.js +4 -2
- package/dist/source/core/utils/is-unix-socket-url.d.ts +16 -0
- package/dist/source/core/utils/is-unix-socket-url.js +21 -0
- package/dist/source/core/utils/weakable-map.d.ts +0 -1
- package/dist/source/core/utils/weakable-map.js +2 -6
- package/dist/source/create.js +9 -1
- package/dist/source/index.d.ts +1 -0
- package/dist/source/index.js +1 -0
- package/package.json +10 -8
- package/readme.md +1 -0
|
@@ -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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
372
|
-
|
|
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
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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 (
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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
|
-
|
|
855
|
-
//
|
|
856
|
-
|
|
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
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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
|
-
|
|
870
|
-
}
|
|
1013
|
+
// Else: void/undefined = continue
|
|
1014
|
+
}
|
|
871
1015
|
}
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
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
|
-
//
|
|
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
|
}
|