got 14.6.6 → 15.0.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.d.ts +2 -2
- package/dist/source/as-promise/index.js +105 -85
- package/dist/source/as-promise/types.d.ts +10 -23
- package/dist/source/as-promise/types.js +1 -17
- package/dist/source/core/calculate-retry-delay.js +1 -4
- package/dist/source/core/diagnostics-channel.js +12 -21
- package/dist/source/core/errors.d.ts +2 -1
- package/dist/source/core/errors.js +10 -13
- package/dist/source/core/index.d.ts +19 -7
- package/dist/source/core/index.js +748 -327
- package/dist/source/core/options.d.ts +117 -116
- package/dist/source/core/options.js +620 -309
- package/dist/source/core/response.d.ts +5 -3
- package/dist/source/core/response.js +26 -3
- package/dist/source/core/timed-out.d.ts +1 -1
- package/dist/source/core/timed-out.js +4 -4
- package/dist/source/core/utils/defer-to-connect.js +5 -17
- package/dist/source/core/utils/get-body-size.d.ts +1 -1
- package/dist/source/core/utils/get-body-size.js +3 -20
- package/dist/source/core/utils/is-unix-socket-url.d.ts +1 -1
- package/dist/source/core/utils/is-unix-socket-url.js +3 -4
- package/dist/source/core/utils/proxy-events.d.ts +1 -1
- package/dist/source/core/utils/proxy-events.js +3 -3
- package/dist/source/core/utils/strip-url-auth.d.ts +1 -0
- package/dist/source/core/utils/strip-url-auth.js +9 -0
- package/dist/source/core/utils/timer.js +5 -7
- package/dist/source/core/utils/unhandle.js +1 -2
- package/dist/source/create.js +83 -27
- package/dist/source/index.d.ts +2 -3
- package/dist/source/index.js +0 -4
- package/dist/source/types.d.ts +39 -70
- package/package.json +34 -38
- package/readme.md +2 -5
- package/dist/source/core/utils/is-form-data.d.ts +0 -7
- package/dist/source/core/utils/is-form-data.js +0 -4
- package/dist/source/core/utils/url-to-options.d.ts +0 -14
- package/dist/source/core/utils/url-to-options.js +0 -22
|
@@ -1,31 +1,46 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
3
|
import { Duplex } from 'node:stream';
|
|
4
|
+
import { addAbortListener } from 'node:events';
|
|
4
5
|
import http, { ServerResponse } from 'node:http';
|
|
5
6
|
import { byteLength } from 'byte-counter';
|
|
7
|
+
import { chunk } from 'chunk-data';
|
|
8
|
+
import { concatUint8Arrays, stringToBase64, stringToUint8Array } from 'uint8array-extras';
|
|
6
9
|
import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request';
|
|
7
10
|
import decompressResponse from 'decompress-response';
|
|
8
11
|
import is, { isBuffer } from '@sindresorhus/is';
|
|
9
|
-
import { FormDataEncoder, isFormData as isFormDataLike } from 'form-data-encoder';
|
|
10
12
|
import timer from './utils/timer.js';
|
|
11
13
|
import getBodySize from './utils/get-body-size.js';
|
|
12
|
-
import isFormData from './utils/is-form-data.js';
|
|
13
14
|
import proxyEvents from './utils/proxy-events.js';
|
|
14
15
|
import timedOut, { TimeoutError as TimedOutTimeoutError } from './timed-out.js';
|
|
15
|
-
import
|
|
16
|
+
import stripUrlAuth from './utils/strip-url-auth.js';
|
|
16
17
|
import WeakableMap from './utils/weakable-map.js';
|
|
17
18
|
import calculateRetryDelay from './calculate-retry-delay.js';
|
|
18
|
-
import Options from './options.js';
|
|
19
|
-
import { isResponseOk } from './response.js';
|
|
19
|
+
import Options, { crossOriginStripHeaders, hasExplicitCredentialInUrlChange, isCrossOriginCredentialChanged, isBodyUnchanged, isSameOrigin, snapshotCrossOriginState, } from './options.js';
|
|
20
|
+
import { cacheDecodedBody, decodeUint8Array, isResponseOk, isUtf8Encoding, } from './response.js';
|
|
20
21
|
import isClientRequest from './utils/is-client-request.js';
|
|
21
|
-
import
|
|
22
|
+
import { getUnixSocketPath } from './utils/is-unix-socket-url.js';
|
|
22
23
|
import { RequestError, ReadError, MaxRedirectsError, HTTPError, TimeoutError, UploadError, CacheError, AbortError, } from './errors.js';
|
|
23
24
|
import { generateRequestId, publishRequestCreate, publishRequestStart, publishResponseStart, publishResponseEnd, publishRetry, publishError, publishRedirect, } from './diagnostics-channel.js';
|
|
24
25
|
const supportsBrotli = is.string(process.versions.brotli);
|
|
25
26
|
const supportsZstd = is.string(process.versions.zstd);
|
|
26
27
|
const methodsWithoutBody = new Set(['GET', 'HEAD']);
|
|
27
28
|
const cacheableStore = new WeakableMap();
|
|
28
|
-
const redirectCodes = new Set([
|
|
29
|
+
const redirectCodes = new Set([301, 302, 303, 307, 308]);
|
|
30
|
+
export { crossOriginStripHeaders } from './options.js';
|
|
31
|
+
const transientWriteErrorCodes = new Set(['EPIPE', 'ECONNRESET']);
|
|
32
|
+
const omittedPipedHeaders = new Set([
|
|
33
|
+
'host',
|
|
34
|
+
'connection',
|
|
35
|
+
'keep-alive',
|
|
36
|
+
'proxy-authenticate',
|
|
37
|
+
'proxy-authorization',
|
|
38
|
+
'proxy-connection',
|
|
39
|
+
'te',
|
|
40
|
+
'trailer',
|
|
41
|
+
'transfer-encoding',
|
|
42
|
+
'upgrade',
|
|
43
|
+
]);
|
|
29
44
|
// Track errors that have been processed by beforeError hooks to preserve custom error types
|
|
30
45
|
const errorsProcessedByHooks = new WeakSet();
|
|
31
46
|
const proxiedRequestEvents = [
|
|
@@ -36,6 +51,64 @@ const proxiedRequestEvents = [
|
|
|
36
51
|
'upgrade',
|
|
37
52
|
];
|
|
38
53
|
const noop = () => { };
|
|
54
|
+
const isTransientWriteError = (error) => {
|
|
55
|
+
const { code } = error;
|
|
56
|
+
return typeof code === 'string' && transientWriteErrorCodes.has(code);
|
|
57
|
+
};
|
|
58
|
+
const getConnectionListedHeaders = (headers) => {
|
|
59
|
+
const connectionListedHeaders = new Set();
|
|
60
|
+
for (const [header, connectionHeader] of Object.entries(headers)) {
|
|
61
|
+
const normalizedHeader = header.toLowerCase();
|
|
62
|
+
if (normalizedHeader !== 'connection' && normalizedHeader !== 'proxy-connection') {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
const connectionHeaderValues = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader];
|
|
66
|
+
for (const value of connectionHeaderValues) {
|
|
67
|
+
if (typeof value !== 'string') {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const token of value.split(',')) {
|
|
71
|
+
const normalizedToken = token.trim().toLowerCase();
|
|
72
|
+
if (normalizedToken.length > 0) {
|
|
73
|
+
connectionListedHeaders.add(normalizedToken);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return connectionListedHeaders;
|
|
79
|
+
};
|
|
80
|
+
export const normalizeError = (error) => {
|
|
81
|
+
if (error instanceof globalThis.Error) {
|
|
82
|
+
return error;
|
|
83
|
+
}
|
|
84
|
+
if (is.object(error)) {
|
|
85
|
+
const errorLike = error;
|
|
86
|
+
const message = typeof errorLike.message === 'string' ? errorLike.message : 'Non-error object thrown';
|
|
87
|
+
const normalizedError = new globalThis.Error(message, { cause: error });
|
|
88
|
+
if (typeof errorLike.stack === 'string') {
|
|
89
|
+
normalizedError.stack = errorLike.stack;
|
|
90
|
+
}
|
|
91
|
+
if (typeof errorLike.code === 'string') {
|
|
92
|
+
normalizedError.code = errorLike.code;
|
|
93
|
+
}
|
|
94
|
+
if (typeof errorLike.input === 'string') {
|
|
95
|
+
normalizedError.input = errorLike.input;
|
|
96
|
+
}
|
|
97
|
+
return normalizedError;
|
|
98
|
+
}
|
|
99
|
+
return new globalThis.Error(String(error));
|
|
100
|
+
};
|
|
101
|
+
const getSanitizedUrl = (options) => options?.url ? stripUrlAuth(options.url) : '';
|
|
102
|
+
const makeProgress = (transferred, total) => {
|
|
103
|
+
let percent = 0;
|
|
104
|
+
if (total) {
|
|
105
|
+
percent = transferred / total;
|
|
106
|
+
}
|
|
107
|
+
else if (total === transferred) {
|
|
108
|
+
percent = 1;
|
|
109
|
+
}
|
|
110
|
+
return { percent, transferred, total };
|
|
111
|
+
};
|
|
39
112
|
export default class Request extends Duplex {
|
|
40
113
|
// @ts-expect-error - Ignoring for now.
|
|
41
114
|
['constructor'];
|
|
@@ -47,24 +120,24 @@ export default class Request extends Duplex {
|
|
|
47
120
|
redirectUrls = [];
|
|
48
121
|
retryCount = 0;
|
|
49
122
|
_stopReading = false;
|
|
50
|
-
_stopRetry
|
|
123
|
+
_stopRetry;
|
|
51
124
|
_downloadedSize = 0;
|
|
52
125
|
_uploadedSize = 0;
|
|
53
126
|
_pipedServerResponses = new Set();
|
|
54
127
|
_request;
|
|
55
128
|
_responseSize;
|
|
56
129
|
_bodySize;
|
|
57
|
-
_unproxyEvents
|
|
58
|
-
_isFromCache;
|
|
130
|
+
_unproxyEvents;
|
|
59
131
|
_triggerRead = false;
|
|
60
132
|
_jobs = [];
|
|
61
|
-
_cancelTimeouts
|
|
62
|
-
|
|
63
|
-
_nativeResponse;
|
|
133
|
+
_cancelTimeouts;
|
|
134
|
+
_abortListenerDisposer;
|
|
64
135
|
_flushed = false;
|
|
65
136
|
_aborted = false;
|
|
66
137
|
_expectedContentLength;
|
|
67
138
|
_compressedBytesCount;
|
|
139
|
+
_skipRequestEndInFinal = false;
|
|
140
|
+
_incrementalDecode;
|
|
68
141
|
_requestId = generateRequestId();
|
|
69
142
|
// We need this because `this._request` if `undefined` when using cache
|
|
70
143
|
_requestInitialized = false;
|
|
@@ -77,7 +150,17 @@ export default class Request extends Duplex {
|
|
|
77
150
|
});
|
|
78
151
|
this.on('pipe', (source) => {
|
|
79
152
|
if (this.options.copyPipedHeaders && source?.headers) {
|
|
80
|
-
|
|
153
|
+
const connectionListedHeaders = getConnectionListedHeaders(source.headers);
|
|
154
|
+
for (const [header, value] of Object.entries(source.headers)) {
|
|
155
|
+
const normalizedHeader = header.toLowerCase();
|
|
156
|
+
if (omittedPipedHeaders.has(normalizedHeader) || connectionListedHeaders.has(normalizedHeader)) {
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (!this.options.shouldCopyPipedHeader(normalizedHeader)) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
this.options.setPipedHeader(normalizedHeader, value);
|
|
163
|
+
}
|
|
81
164
|
}
|
|
82
165
|
});
|
|
83
166
|
this.on('newListener', event => {
|
|
@@ -97,7 +180,7 @@ export default class Request extends Duplex {
|
|
|
97
180
|
// Publish request creation event
|
|
98
181
|
publishRequestCreate({
|
|
99
182
|
requestId: this._requestId,
|
|
100
|
-
url: this.options
|
|
183
|
+
url: getSanitizedUrl(this.options),
|
|
101
184
|
method: this.options.method,
|
|
102
185
|
});
|
|
103
186
|
}
|
|
@@ -112,11 +195,12 @@ export default class Request extends Duplex {
|
|
|
112
195
|
process.nextTick(() => {
|
|
113
196
|
// _beforeError requires options to access retry logic and hooks
|
|
114
197
|
if (this.options) {
|
|
115
|
-
this._beforeError(error);
|
|
198
|
+
this._beforeError(normalizeError(error));
|
|
116
199
|
}
|
|
117
200
|
else {
|
|
118
201
|
// Options is undefined, skip _beforeError and destroy directly
|
|
119
|
-
const
|
|
202
|
+
const normalizedError = normalizeError(error);
|
|
203
|
+
const requestError = normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, this);
|
|
120
204
|
this.destroy(requestError);
|
|
121
205
|
}
|
|
122
206
|
});
|
|
@@ -127,17 +211,7 @@ export default class Request extends Duplex {
|
|
|
127
211
|
// The below is run only once.
|
|
128
212
|
const { body } = this.options;
|
|
129
213
|
if (is.nodeStream(body)) {
|
|
130
|
-
body.once('error',
|
|
131
|
-
if (this._flushed) {
|
|
132
|
-
this._beforeError(new UploadError(error, this));
|
|
133
|
-
}
|
|
134
|
-
else {
|
|
135
|
-
this.flush = async () => {
|
|
136
|
-
this.flush = async () => { };
|
|
137
|
-
this._beforeError(new UploadError(error, this));
|
|
138
|
-
};
|
|
139
|
-
}
|
|
140
|
-
});
|
|
214
|
+
body.once('error', this._onBodyError);
|
|
141
215
|
}
|
|
142
216
|
if (this.options.signal) {
|
|
143
217
|
const abort = () => {
|
|
@@ -153,10 +227,8 @@ export default class Request extends Duplex {
|
|
|
153
227
|
abort();
|
|
154
228
|
}
|
|
155
229
|
else {
|
|
156
|
-
this.options.signal
|
|
157
|
-
this.
|
|
158
|
-
this.options.signal?.removeEventListener('abort', abort);
|
|
159
|
-
};
|
|
230
|
+
const abortListenerDisposer = addAbortListener(this.options.signal, abort);
|
|
231
|
+
this._abortListenerDisposer = abortListenerDisposer;
|
|
160
232
|
}
|
|
161
233
|
}
|
|
162
234
|
}
|
|
@@ -184,7 +256,7 @@ export default class Request extends Duplex {
|
|
|
184
256
|
this._requestInitialized = true;
|
|
185
257
|
}
|
|
186
258
|
catch (error) {
|
|
187
|
-
this._beforeError(error);
|
|
259
|
+
this._beforeError(normalizeError(error));
|
|
188
260
|
}
|
|
189
261
|
}
|
|
190
262
|
_beforeError(error) {
|
|
@@ -210,7 +282,7 @@ export default class Request extends Duplex {
|
|
|
210
282
|
response.setEncoding(this.readableEncoding);
|
|
211
283
|
const success = await this._setRawBody(response);
|
|
212
284
|
if (success) {
|
|
213
|
-
response.body = response.rawBody
|
|
285
|
+
response.body = decodeUint8Array(response.rawBody);
|
|
214
286
|
}
|
|
215
287
|
}
|
|
216
288
|
if (this.listenerCount('retry') !== 0) {
|
|
@@ -240,7 +312,7 @@ export default class Request extends Duplex {
|
|
|
240
312
|
// When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
|
|
241
313
|
// before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
|
|
242
314
|
// based on these rules), skip calling calculateDelay entirely.
|
|
243
|
-
// When false
|
|
315
|
+
// When false, always call calculateDelay, allowing it to override retry decisions.
|
|
244
316
|
if (retryOptions.enforceRetryRules && computedValue === 0) {
|
|
245
317
|
backoff = 0;
|
|
246
318
|
}
|
|
@@ -255,7 +327,8 @@ export default class Request extends Duplex {
|
|
|
255
327
|
}
|
|
256
328
|
}
|
|
257
329
|
catch (error_) {
|
|
258
|
-
|
|
330
|
+
const normalizedError = normalizeError(error_);
|
|
331
|
+
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
259
332
|
return;
|
|
260
333
|
}
|
|
261
334
|
if (backoff) {
|
|
@@ -279,7 +352,8 @@ export default class Request extends Duplex {
|
|
|
279
352
|
}
|
|
280
353
|
}
|
|
281
354
|
catch (error_) {
|
|
282
|
-
|
|
355
|
+
const normalizedError = normalizeError(error_);
|
|
356
|
+
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
283
357
|
return;
|
|
284
358
|
}
|
|
285
359
|
// Something forced us to abort the retry
|
|
@@ -299,30 +373,35 @@ export default class Request extends Duplex {
|
|
|
299
373
|
// 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks
|
|
300
374
|
// 3. We must restore the body reference after destroy() for identity checks in promise wrapper
|
|
301
375
|
// 4. We cannot use the normal setter after destroy() because it validates stream readability
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
376
|
+
try {
|
|
377
|
+
if (bodyWasReassigned) {
|
|
378
|
+
const oldBody = bodyBeforeHooks;
|
|
379
|
+
// Temporarily clear body to prevent destroy() from destroying the new stream
|
|
380
|
+
this.options.body = undefined;
|
|
381
|
+
this.destroy();
|
|
382
|
+
// Clean up the old stream resource if it's a stream and different from new body
|
|
383
|
+
// (edge case: if old and new are same stream object, don't destroy it)
|
|
384
|
+
if (is.nodeStream(oldBody) && oldBody !== bodyAfterHooks) {
|
|
385
|
+
oldBody.destroy();
|
|
386
|
+
}
|
|
387
|
+
// Restore new body for promise wrapper's identity check
|
|
388
|
+
if (is.nodeStream(bodyAfterHooks) && (bodyAfterHooks.readableEnded || bodyAfterHooks.destroyed)) {
|
|
389
|
+
throw new TypeError('The reassigned stream body must be readable. Ensure you provide a fresh, readable stream in the beforeRetry hook.');
|
|
390
|
+
}
|
|
391
|
+
this.options.body = bodyAfterHooks;
|
|
311
392
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
393
|
+
else {
|
|
394
|
+
// Body wasn't reassigned - use normal destroy flow which handles body cleanup
|
|
395
|
+
this.destroy();
|
|
396
|
+
// Note: We do NOT restore the body reference here. The stream was destroyed by _destroy()
|
|
397
|
+
// and should not be accessed. The promise wrapper will see that body identity hasn't changed
|
|
398
|
+
// and will detect it's a consumed stream, which is the correct behavior.
|
|
317
399
|
}
|
|
318
|
-
this.options._internals.body = bodyAfterHooks;
|
|
319
400
|
}
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
this.
|
|
323
|
-
|
|
324
|
-
// and should not be accessed. The promise wrapper will see that body identity hasn't changed
|
|
325
|
-
// and will detect it's a consumed stream, which is the correct behavior.
|
|
401
|
+
catch (error_) {
|
|
402
|
+
const normalizedError = normalizeError(error_);
|
|
403
|
+
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
404
|
+
return;
|
|
326
405
|
}
|
|
327
406
|
// Publish retry event
|
|
328
407
|
publishRetry({
|
|
@@ -357,6 +436,17 @@ export default class Request extends Duplex {
|
|
|
357
436
|
let data;
|
|
358
437
|
while ((data = response.read()) !== null) {
|
|
359
438
|
this._downloadedSize += data.length; // eslint-disable-line @typescript-eslint/restrict-plus-operands
|
|
439
|
+
if (this._incrementalDecode) {
|
|
440
|
+
try {
|
|
441
|
+
const decodedChunk = typeof data === 'string' ? data : this._incrementalDecode.decoder.decode(data, { stream: true });
|
|
442
|
+
if (decodedChunk.length > 0) {
|
|
443
|
+
this._incrementalDecode.chunks.push(decodedChunk);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
this._incrementalDecode = undefined;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
360
450
|
const progress = this.downloadProgress;
|
|
361
451
|
if (progress.percent < 1) {
|
|
362
452
|
this.emit('downloadProgress', progress);
|
|
@@ -378,22 +468,26 @@ export default class Request extends Duplex {
|
|
|
378
468
|
}
|
|
379
469
|
_final(callback) {
|
|
380
470
|
const endRequest = () => {
|
|
471
|
+
if (this._skipRequestEndInFinal) {
|
|
472
|
+
this._skipRequestEndInFinal = false;
|
|
473
|
+
callback();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const request = this._request;
|
|
381
477
|
// We need to check if `this._request` is present,
|
|
382
478
|
// because it isn't when we use cache.
|
|
383
|
-
if (!
|
|
479
|
+
if (!request || request.destroyed) {
|
|
384
480
|
callback();
|
|
385
481
|
return;
|
|
386
482
|
}
|
|
387
|
-
|
|
483
|
+
request.end((error) => {
|
|
388
484
|
// The request has been destroyed before `_final` finished.
|
|
389
485
|
// See https://github.com/nodejs/node/issues/39356
|
|
390
|
-
if (
|
|
486
|
+
if (request?._writableState?.errored) {
|
|
391
487
|
return;
|
|
392
488
|
}
|
|
393
489
|
if (!error) {
|
|
394
|
-
this.
|
|
395
|
-
this.emit('uploadProgress', this.uploadProgress);
|
|
396
|
-
this._request?.emit('upload-complete');
|
|
490
|
+
this._emitUploadComplete(request);
|
|
397
491
|
}
|
|
398
492
|
callback(error);
|
|
399
493
|
});
|
|
@@ -409,9 +503,9 @@ export default class Request extends Duplex {
|
|
|
409
503
|
this._stopReading = true;
|
|
410
504
|
this.flush = async () => { };
|
|
411
505
|
// Prevent further retries
|
|
412
|
-
this._stopRetry();
|
|
413
|
-
this._cancelTimeouts();
|
|
414
|
-
this.
|
|
506
|
+
this._stopRetry?.();
|
|
507
|
+
this._cancelTimeouts?.();
|
|
508
|
+
this._abortListenerDisposer?.[Symbol.dispose]();
|
|
415
509
|
if (this.options) {
|
|
416
510
|
const { body } = this.options;
|
|
417
511
|
if (is.nodeStream(body)) {
|
|
@@ -459,6 +553,13 @@ export default class Request extends Duplex {
|
|
|
459
553
|
super.unpipe(destination);
|
|
460
554
|
return this;
|
|
461
555
|
}
|
|
556
|
+
_shouldIncrementallyDecodeBody() {
|
|
557
|
+
const { responseType, encoding } = this.options;
|
|
558
|
+
return Boolean(this._noPipe)
|
|
559
|
+
&& (responseType === 'text' || responseType === 'json')
|
|
560
|
+
&& isUtf8Encoding(encoding)
|
|
561
|
+
&& typeof globalThis.TextDecoder === 'function';
|
|
562
|
+
}
|
|
462
563
|
_checkContentLengthMismatch() {
|
|
463
564
|
if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
|
|
464
565
|
// Use compressed bytes count when available (for compressed responses),
|
|
@@ -477,7 +578,7 @@ export default class Request extends Duplex {
|
|
|
477
578
|
}
|
|
478
579
|
async _finalizeBody() {
|
|
479
580
|
const { options } = this;
|
|
480
|
-
const
|
|
581
|
+
const headers = options.getInternalHeaders();
|
|
481
582
|
const isForm = !is.undefined(options.form);
|
|
482
583
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
483
584
|
const isJSON = !is.undefined(options.json);
|
|
@@ -490,20 +591,16 @@ export default class Request extends Duplex {
|
|
|
490
591
|
// Serialize body
|
|
491
592
|
const noContentType = !is.string(headers['content-type']);
|
|
492
593
|
if (isBody) {
|
|
493
|
-
//
|
|
494
|
-
if (
|
|
495
|
-
const
|
|
594
|
+
// Native FormData
|
|
595
|
+
if (options.body instanceof FormData) {
|
|
596
|
+
const response = new Response(options.body);
|
|
496
597
|
if (noContentType) {
|
|
497
|
-
headers['content-type'] =
|
|
598
|
+
headers['content-type'] = response.headers.get('content-type') ?? 'multipart/form-data';
|
|
498
599
|
}
|
|
499
|
-
|
|
500
|
-
headers['content-length'] = encoder.headers['Content-Length'];
|
|
501
|
-
}
|
|
502
|
-
options.body = encoder.encode();
|
|
600
|
+
options.body = response.body;
|
|
503
601
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
headers['content-type'] = `multipart/form-data; boundary=${options.body.getBoundary()}`;
|
|
602
|
+
else if (Object.prototype.toString.call(options.body) === '[object FormData]') {
|
|
603
|
+
throw new TypeError('Non-native FormData is not supported. Use globalThis.FormData instead.');
|
|
507
604
|
}
|
|
508
605
|
}
|
|
509
606
|
else if (isForm) {
|
|
@@ -522,7 +619,7 @@ export default class Request extends Duplex {
|
|
|
522
619
|
options.json = undefined;
|
|
523
620
|
options.body = options.stringifyJson(json);
|
|
524
621
|
}
|
|
525
|
-
const uploadBodySize =
|
|
622
|
+
const uploadBodySize = getBodySize(options.body, headers);
|
|
526
623
|
// See https://tools.ietf.org/html/rfc7230#section-3.3.2
|
|
527
624
|
// A user agent SHOULD send a Content-Length in a request message when
|
|
528
625
|
// no Transfer-Encoding is sent and the request method defines a meaning
|
|
@@ -536,8 +633,8 @@ export default class Request extends Duplex {
|
|
|
536
633
|
headers['content-length'] = String(uploadBodySize);
|
|
537
634
|
}
|
|
538
635
|
}
|
|
539
|
-
if (options.responseType === 'json' && !('accept' in
|
|
540
|
-
|
|
636
|
+
if (options.responseType === 'json' && !('accept' in headers)) {
|
|
637
|
+
headers.accept = 'application/json';
|
|
541
638
|
}
|
|
542
639
|
this._bodySize = Number(headers['content-length']) || undefined;
|
|
543
640
|
}
|
|
@@ -548,9 +645,12 @@ export default class Request extends Duplex {
|
|
|
548
645
|
}
|
|
549
646
|
const { options } = this;
|
|
550
647
|
const { url } = options;
|
|
551
|
-
|
|
648
|
+
const nativeResponse = response;
|
|
552
649
|
const statusCode = response.statusCode;
|
|
553
650
|
const { method } = options;
|
|
651
|
+
const redirectLocationHeader = response.headers.location;
|
|
652
|
+
const redirectLocation = Array.isArray(redirectLocationHeader) ? redirectLocationHeader[0] : redirectLocationHeader;
|
|
653
|
+
const isRedirect = Boolean(redirectLocation && redirectCodes.has(statusCode));
|
|
554
654
|
// Skip decompression for responses that must not have bodies per RFC 9110:
|
|
555
655
|
// - HEAD responses (any status code)
|
|
556
656
|
// - 1xx (Informational): 100, 101, 102, 103, etc.
|
|
@@ -562,30 +662,46 @@ export default class Request extends Duplex {
|
|
|
562
662
|
|| statusCode === 204
|
|
563
663
|
|| statusCode === 205
|
|
564
664
|
|| statusCode === 304;
|
|
565
|
-
|
|
665
|
+
const prepareResponse = (response) => {
|
|
666
|
+
if (!Object.hasOwn(response, 'headers')) {
|
|
667
|
+
Object.defineProperty(response, 'headers', {
|
|
668
|
+
value: response.headers,
|
|
669
|
+
enumerable: true,
|
|
670
|
+
writable: true,
|
|
671
|
+
configurable: true,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
response.statusMessage ||= http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
|
|
675
|
+
response.url = stripUrlAuth(options.url);
|
|
676
|
+
response.requestUrl = this.requestUrl;
|
|
677
|
+
response.redirectUrls = this.redirectUrls;
|
|
678
|
+
response.request = this;
|
|
679
|
+
response.isFromCache = nativeResponse.fromCache ?? false;
|
|
680
|
+
response.ip = this.ip;
|
|
681
|
+
response.retryCount = this.retryCount;
|
|
682
|
+
response.ok = isResponseOk(response);
|
|
683
|
+
return response;
|
|
684
|
+
};
|
|
685
|
+
let typedResponse = prepareResponse(response);
|
|
686
|
+
// Redirect responses that will be followed are drained raw. Decompressing them can
|
|
687
|
+
// turn an irrelevant redirect body into a client-side failure or decompression DoS.
|
|
688
|
+
const shouldFollowRedirect = isRedirect && (typeof options.followRedirect === 'function' ? options.followRedirect(typedResponse) : options.followRedirect);
|
|
689
|
+
if (options.decompress && !hasNoBody && !shouldFollowRedirect) {
|
|
566
690
|
// When strictContentLength is enabled, track compressed bytes by listening to
|
|
567
691
|
// the native response's data events before decompression
|
|
568
692
|
if (options.strictContentLength) {
|
|
569
693
|
this._compressedBytesCount = 0;
|
|
570
|
-
|
|
694
|
+
nativeResponse.on('data', (chunk) => {
|
|
571
695
|
this._compressedBytesCount += byteLength(chunk);
|
|
572
696
|
});
|
|
573
697
|
}
|
|
574
698
|
response = decompressResponse(response);
|
|
699
|
+
typedResponse = prepareResponse(response);
|
|
575
700
|
}
|
|
576
|
-
const typedResponse = response;
|
|
577
|
-
typedResponse.statusMessage = typedResponse.statusMessage || http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
|
|
578
|
-
typedResponse.url = options.url.toString();
|
|
579
|
-
typedResponse.requestUrl = this.requestUrl;
|
|
580
|
-
typedResponse.redirectUrls = this.redirectUrls;
|
|
581
|
-
typedResponse.request = this;
|
|
582
|
-
typedResponse.isFromCache = this._nativeResponse.fromCache ?? false;
|
|
583
|
-
typedResponse.ip = this.ip;
|
|
584
|
-
typedResponse.retryCount = this.retryCount;
|
|
585
|
-
typedResponse.ok = isResponseOk(typedResponse);
|
|
586
|
-
this._isFromCache = typedResponse.isFromCache;
|
|
587
701
|
this._responseSize = Number(response.headers['content-length']) || undefined;
|
|
588
702
|
this.response = typedResponse;
|
|
703
|
+
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
704
|
+
this._incrementalDecode = this._shouldIncrementallyDecodeBody() ? { decoder: new globalThis.TextDecoder('utf8', { ignoreBOM: true }), chunks: [] } : undefined;
|
|
589
705
|
// Publish response start event
|
|
590
706
|
publishResponseStart({
|
|
591
707
|
requestId: this._requestId,
|
|
@@ -596,9 +712,6 @@ export default class Request extends Duplex {
|
|
|
596
712
|
});
|
|
597
713
|
response.once('error', (error) => {
|
|
598
714
|
this._aborted = true;
|
|
599
|
-
// Force clean-up, because some packages don't do this.
|
|
600
|
-
// TODO: Fix decompress-response
|
|
601
|
-
response.destroy();
|
|
602
715
|
this._beforeError(new ReadError(error, this));
|
|
603
716
|
});
|
|
604
717
|
response.once('aborted', () => {
|
|
@@ -612,11 +725,15 @@ export default class Request extends Duplex {
|
|
|
612
725
|
}, this));
|
|
613
726
|
}
|
|
614
727
|
});
|
|
728
|
+
const noPipeCookieJarRawBodyPromise = this._noPipe
|
|
729
|
+
&& is.object(options.cookieJar)
|
|
730
|
+
&& !isRedirect
|
|
731
|
+
? this._setRawBody(response)
|
|
732
|
+
: undefined;
|
|
615
733
|
const rawCookies = response.headers['set-cookie'];
|
|
616
734
|
if (is.object(options.cookieJar) && rawCookies) {
|
|
617
735
|
let promises = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString()));
|
|
618
736
|
if (options.ignoreInvalidCookies) {
|
|
619
|
-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
620
737
|
promises = promises.map(async (promise) => {
|
|
621
738
|
try {
|
|
622
739
|
await promise;
|
|
@@ -628,7 +745,7 @@ export default class Request extends Duplex {
|
|
|
628
745
|
await Promise.all(promises);
|
|
629
746
|
}
|
|
630
747
|
catch (error) {
|
|
631
|
-
this._beforeError(error);
|
|
748
|
+
this._beforeError(normalizeError(error));
|
|
632
749
|
return;
|
|
633
750
|
}
|
|
634
751
|
}
|
|
@@ -636,88 +753,122 @@ export default class Request extends Duplex {
|
|
|
636
753
|
if (this.isAborted) {
|
|
637
754
|
return;
|
|
638
755
|
}
|
|
639
|
-
if (
|
|
756
|
+
if (shouldFollowRedirect) {
|
|
640
757
|
// We're being redirected, we don't care about the response.
|
|
641
758
|
// It'd be best to abort the request, but we can't because
|
|
642
759
|
// we would have to sacrifice the TCP connection. We don't want that.
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
this.
|
|
648
|
-
|
|
649
|
-
|
|
760
|
+
response.resume();
|
|
761
|
+
this._cancelTimeouts?.();
|
|
762
|
+
this._unproxyEvents?.();
|
|
763
|
+
if (this.redirectUrls.length >= options.maxRedirects) {
|
|
764
|
+
this._beforeError(new MaxRedirectsError(this));
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
this._request = undefined;
|
|
768
|
+
// Reset progress for the new request.
|
|
769
|
+
this._downloadedSize = 0;
|
|
770
|
+
this._uploadedSize = 0;
|
|
771
|
+
const updatedOptions = new Options(undefined, undefined, this.options);
|
|
772
|
+
try {
|
|
773
|
+
// We need this in order to support UTF-8
|
|
774
|
+
const redirectBuffer = Buffer.from(redirectLocation, 'binary').toString();
|
|
775
|
+
const redirectUrl = new URL(redirectBuffer, url);
|
|
776
|
+
const currentUnixSocketPath = getUnixSocketPath(url);
|
|
777
|
+
const redirectUnixSocketPath = getUnixSocketPath(redirectUrl);
|
|
778
|
+
if (redirectUrl.protocol === 'unix:' && redirectUnixSocketPath === undefined) {
|
|
779
|
+
this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
// Relative redirects on the same socket are fine, but a redirect must not switch to a different local socket.
|
|
783
|
+
if (redirectUnixSocketPath !== undefined && currentUnixSocketPath !== redirectUnixSocketPath) {
|
|
784
|
+
this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
|
|
650
785
|
return;
|
|
651
786
|
}
|
|
652
|
-
|
|
653
|
-
//
|
|
654
|
-
|
|
655
|
-
|
|
787
|
+
// Redirecting to a different site, clear sensitive data.
|
|
788
|
+
// For UNIX sockets, different socket paths are also different origins.
|
|
789
|
+
const isDifferentOrigin = redirectUrl.origin !== url.origin
|
|
790
|
+
|| currentUnixSocketPath !== redirectUnixSocketPath;
|
|
656
791
|
const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD';
|
|
792
|
+
// Avoid forwarding a POST body to a different origin on historical 301/302 redirects.
|
|
793
|
+
const crossOriginRequestedGet = isDifferentOrigin
|
|
794
|
+
&& (statusCode === 301 || statusCode === 302)
|
|
795
|
+
&& updatedOptions.method === 'POST';
|
|
657
796
|
const canRewrite = statusCode !== 307 && statusCode !== 308;
|
|
658
797
|
const userRequestedGet = updatedOptions.methodRewriting && canRewrite;
|
|
659
|
-
|
|
798
|
+
const shouldDropBody = serverRequestedGet || crossOriginRequestedGet || userRequestedGet;
|
|
799
|
+
if (shouldDropBody) {
|
|
660
800
|
updatedOptions.method = 'GET';
|
|
661
|
-
updatedOptions
|
|
662
|
-
updatedOptions.json = undefined;
|
|
663
|
-
updatedOptions.form = undefined;
|
|
664
|
-
delete updatedOptions.headers['content-length'];
|
|
801
|
+
this._dropBody(updatedOptions);
|
|
665
802
|
}
|
|
666
|
-
|
|
667
|
-
//
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
}
|
|
689
|
-
if (updatedOptions.username || updatedOptions.password) {
|
|
690
|
-
updatedOptions.username = '';
|
|
691
|
-
updatedOptions.password = '';
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
else {
|
|
695
|
-
redirectUrl.username = updatedOptions.username;
|
|
696
|
-
redirectUrl.password = updatedOptions.password;
|
|
697
|
-
}
|
|
698
|
-
this.redirectUrls.push(redirectUrl);
|
|
699
|
-
updatedOptions.url = redirectUrl;
|
|
803
|
+
if (isDifferentOrigin) {
|
|
804
|
+
// Also strip body on cross-origin redirects to prevent data leakage.
|
|
805
|
+
// 301/302 POST already drops the body (converted to GET above).
|
|
806
|
+
// 307/308 preserve the method per RFC, but the body must not be
|
|
807
|
+
// forwarded to a different origin.
|
|
808
|
+
// Strip credentials embedded in the redirect URL itself
|
|
809
|
+
// to prevent a malicious server from injecting auth to third parties.
|
|
810
|
+
this._stripCrossOriginState(updatedOptions, redirectUrl, shouldDropBody);
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
redirectUrl.username = updatedOptions.username;
|
|
814
|
+
redirectUrl.password = updatedOptions.password;
|
|
815
|
+
}
|
|
816
|
+
updatedOptions.url = redirectUrl;
|
|
817
|
+
this.redirectUrls.push(redirectUrl);
|
|
818
|
+
const preHookState = isDifferentOrigin
|
|
819
|
+
? undefined
|
|
820
|
+
: {
|
|
821
|
+
...snapshotCrossOriginState(updatedOptions),
|
|
822
|
+
url: new URL(updatedOptions.url),
|
|
823
|
+
};
|
|
824
|
+
const changedState = await updatedOptions.trackStateMutations(async (changedState) => {
|
|
700
825
|
for (const hook of updatedOptions.hooks.beforeRedirect) {
|
|
701
826
|
// eslint-disable-next-line no-await-in-loop
|
|
702
827
|
await hook(updatedOptions, typedResponse);
|
|
703
828
|
}
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
829
|
+
return changedState;
|
|
830
|
+
});
|
|
831
|
+
// If a beforeRedirect hook changed the URL to a different origin,
|
|
832
|
+
// strip sensitive headers that were preserved for the original origin.
|
|
833
|
+
// When isDifferentOrigin was already true, headers were already stripped above.
|
|
834
|
+
if (!isDifferentOrigin) {
|
|
835
|
+
const state = preHookState;
|
|
836
|
+
const hookUrl = updatedOptions.url;
|
|
837
|
+
if (!isSameOrigin(state.url, hookUrl)) {
|
|
838
|
+
this._stripUnchangedCrossOriginState(updatedOptions, hookUrl, shouldDropBody, {
|
|
839
|
+
headers: state.headers,
|
|
840
|
+
username: state.username,
|
|
841
|
+
password: state.password,
|
|
842
|
+
body: state.body,
|
|
843
|
+
json: state.json,
|
|
844
|
+
form: state.form,
|
|
845
|
+
bodySnapshot: state.bodySnapshot,
|
|
846
|
+
jsonSnapshot: state.jsonSnapshot,
|
|
847
|
+
formSnapshot: state.formSnapshot,
|
|
848
|
+
changedState,
|
|
849
|
+
preserveUsername: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'username')
|
|
850
|
+
|| isCrossOriginCredentialChanged(state.url, hookUrl, 'username'),
|
|
851
|
+
preservePassword: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'password')
|
|
852
|
+
|| isCrossOriginCredentialChanged(state.url, hookUrl, 'password'),
|
|
853
|
+
});
|
|
854
|
+
}
|
|
718
855
|
}
|
|
856
|
+
// Publish redirect event
|
|
857
|
+
publishRedirect({
|
|
858
|
+
requestId: this._requestId,
|
|
859
|
+
fromUrl: url.toString(),
|
|
860
|
+
toUrl: (updatedOptions.url).toString(),
|
|
861
|
+
statusCode,
|
|
862
|
+
});
|
|
863
|
+
this.emit('redirect', updatedOptions, typedResponse);
|
|
864
|
+
this.options = updatedOptions;
|
|
865
|
+
await this._makeRequest();
|
|
866
|
+
}
|
|
867
|
+
catch (error) {
|
|
868
|
+
this._beforeError(normalizeError(error));
|
|
719
869
|
return;
|
|
720
870
|
}
|
|
871
|
+
return;
|
|
721
872
|
}
|
|
722
873
|
// `HTTPError`s always have `error.response.body` defined.
|
|
723
874
|
// Therefore, we cannot retry if `options.throwHttpErrors` is false.
|
|
@@ -727,13 +878,15 @@ export default class Request extends Duplex {
|
|
|
727
878
|
this._beforeError(new HTTPError(typedResponse));
|
|
728
879
|
return;
|
|
729
880
|
}
|
|
881
|
+
// `decompressResponse` wraps the response stream when it decompresses,
|
|
882
|
+
// so `response !== nativeResponse` indicates decompression happened.
|
|
883
|
+
const wasDecompressed = response !== nativeResponse;
|
|
730
884
|
// Store the expected content-length from the native response for validation.
|
|
731
885
|
// This is the content-length before decompression, which is what actually gets transferred.
|
|
732
886
|
// Skip storing for responses that shouldn't have bodies per RFC 9110.
|
|
733
887
|
// When decompression occurs, only store if strictContentLength is enabled.
|
|
734
|
-
const wasDecompressed = response !== this._nativeResponse;
|
|
735
888
|
if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
|
|
736
|
-
const contentLengthHeader =
|
|
889
|
+
const contentLengthHeader = nativeResponse.headers['content-length'];
|
|
737
890
|
if (contentLengthHeader !== undefined) {
|
|
738
891
|
const expectedLength = Number(contentLengthHeader);
|
|
739
892
|
if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
|
|
@@ -742,7 +895,12 @@ export default class Request extends Duplex {
|
|
|
742
895
|
}
|
|
743
896
|
}
|
|
744
897
|
// Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
|
|
745
|
-
|
|
898
|
+
let responseEndHandled = false;
|
|
899
|
+
const handleResponseEnd = () => {
|
|
900
|
+
if (responseEndHandled) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
responseEndHandled = true;
|
|
746
904
|
// Validate content-length if it was provided
|
|
747
905
|
// Per RFC 9112: "If the sender closes the connection before the indicated number
|
|
748
906
|
// of octets are received, the recipient MUST consider the message to be incomplete"
|
|
@@ -760,7 +918,8 @@ export default class Request extends Duplex {
|
|
|
760
918
|
timings: this.timings,
|
|
761
919
|
});
|
|
762
920
|
this.push(null);
|
|
763
|
-
}
|
|
921
|
+
};
|
|
922
|
+
response.once('end', handleResponseEnd);
|
|
764
923
|
this.emit('downloadProgress', this.downloadProgress);
|
|
765
924
|
response.on('readable', () => {
|
|
766
925
|
if (this._triggerRead) {
|
|
@@ -774,7 +933,13 @@ export default class Request extends Duplex {
|
|
|
774
933
|
response.pause();
|
|
775
934
|
});
|
|
776
935
|
if (this._noPipe) {
|
|
777
|
-
const
|
|
936
|
+
const captureFromResponse = response.readableEnded || noPipeCookieJarRawBodyPromise !== undefined;
|
|
937
|
+
const success = noPipeCookieJarRawBodyPromise
|
|
938
|
+
? await noPipeCookieJarRawBodyPromise
|
|
939
|
+
: await this._setRawBody(captureFromResponse ? response : this);
|
|
940
|
+
if (captureFromResponse) {
|
|
941
|
+
handleResponseEnd();
|
|
942
|
+
}
|
|
778
943
|
if (success) {
|
|
779
944
|
this.emit('response', response);
|
|
780
945
|
}
|
|
@@ -785,10 +950,6 @@ export default class Request extends Duplex {
|
|
|
785
950
|
if (destination.headersSent) {
|
|
786
951
|
continue;
|
|
787
952
|
}
|
|
788
|
-
// Check if decompression actually occurred by comparing stream objects.
|
|
789
|
-
// decompressResponse wraps the response stream when it decompresses,
|
|
790
|
-
// so response !== this._nativeResponse indicates decompression happened.
|
|
791
|
-
const wasDecompressed = response !== this._nativeResponse;
|
|
792
953
|
for (const key in response.headers) {
|
|
793
954
|
if (Object.hasOwn(response.headers, key)) {
|
|
794
955
|
const value = response.headers[key];
|
|
@@ -807,21 +968,39 @@ export default class Request extends Duplex {
|
|
|
807
968
|
}
|
|
808
969
|
}
|
|
809
970
|
async _setRawBody(from = this) {
|
|
810
|
-
if (from.readableEnded) {
|
|
811
|
-
return false;
|
|
812
|
-
}
|
|
813
971
|
try {
|
|
814
972
|
// Errors are emitted via the `error` event
|
|
815
973
|
const fromArray = await from.toArray();
|
|
816
|
-
const
|
|
974
|
+
const hasNonStringChunk = fromArray.some(chunk => typeof chunk !== 'string');
|
|
975
|
+
const rawBody = hasNonStringChunk
|
|
976
|
+
? concatUint8Arrays(fromArray.map(chunk => typeof chunk === 'string' ? stringToUint8Array(chunk) : chunk))
|
|
977
|
+
: stringToUint8Array(fromArray.join(''));
|
|
978
|
+
const shouldUseIncrementalDecodedBody = from === this && this._incrementalDecode !== undefined;
|
|
817
979
|
// On retry Request is destroyed with no error, therefore the above will successfully resolve.
|
|
818
|
-
// So in order to check if this was really
|
|
819
|
-
if (!this.isAborted) {
|
|
980
|
+
// So in order to check if this was really successful, we need to check if it has been properly ended.
|
|
981
|
+
if (!this.isAborted && this.response) {
|
|
820
982
|
this.response.rawBody = rawBody;
|
|
983
|
+
if (from !== this) {
|
|
984
|
+
this._downloadedSize = rawBody.byteLength;
|
|
985
|
+
}
|
|
986
|
+
if (shouldUseIncrementalDecodedBody) {
|
|
987
|
+
try {
|
|
988
|
+
const { decoder, chunks } = this._incrementalDecode;
|
|
989
|
+
const finalDecodedChunk = decoder.decode();
|
|
990
|
+
if (finalDecodedChunk.length > 0) {
|
|
991
|
+
chunks.push(finalDecodedChunk);
|
|
992
|
+
}
|
|
993
|
+
cacheDecodedBody(this.response, chunks.join(''));
|
|
994
|
+
}
|
|
995
|
+
catch { }
|
|
996
|
+
}
|
|
821
997
|
return true;
|
|
822
998
|
}
|
|
823
999
|
}
|
|
824
1000
|
catch { }
|
|
1001
|
+
finally {
|
|
1002
|
+
this._incrementalDecode = undefined;
|
|
1003
|
+
}
|
|
825
1004
|
return false;
|
|
826
1005
|
}
|
|
827
1006
|
async _onResponse(response) {
|
|
@@ -830,7 +1009,7 @@ export default class Request extends Duplex {
|
|
|
830
1009
|
}
|
|
831
1010
|
catch (error) {
|
|
832
1011
|
/* istanbul ignore next: better safe than sorry */
|
|
833
|
-
this._beforeError(error);
|
|
1012
|
+
this._beforeError(normalizeError(error));
|
|
834
1013
|
}
|
|
835
1014
|
}
|
|
836
1015
|
_onRequest(request) {
|
|
@@ -839,7 +1018,7 @@ export default class Request extends Duplex {
|
|
|
839
1018
|
// Publish request start event
|
|
840
1019
|
publishRequestStart({
|
|
841
1020
|
requestId: this._requestId,
|
|
842
|
-
url:
|
|
1021
|
+
url: getSanitizedUrl(this.options),
|
|
843
1022
|
method: options.method,
|
|
844
1023
|
headers: options.headers,
|
|
845
1024
|
});
|
|
@@ -857,32 +1036,80 @@ export default class Request extends Duplex {
|
|
|
857
1036
|
socket.removeAllListeners('timeout');
|
|
858
1037
|
});
|
|
859
1038
|
}
|
|
1039
|
+
let lastRequestError;
|
|
860
1040
|
const responseEventName = options.cache ? 'cacheableResponse' : 'response';
|
|
861
1041
|
request.once(responseEventName, (response) => {
|
|
862
1042
|
void this._onResponse(response);
|
|
863
1043
|
});
|
|
864
|
-
|
|
1044
|
+
const emitRequestError = (error) => {
|
|
865
1045
|
this._aborted = true;
|
|
866
1046
|
// Force clean-up, because some packages (e.g. nock) don't do this.
|
|
867
1047
|
request.destroy();
|
|
868
|
-
|
|
869
|
-
this._beforeError(
|
|
1048
|
+
const wrappedError = error instanceof TimedOutTimeoutError ? new TimeoutError(error, this.timings, this) : new RequestError(error.message, error, this);
|
|
1049
|
+
this._beforeError(wrappedError);
|
|
1050
|
+
};
|
|
1051
|
+
request.once('error', (error) => {
|
|
1052
|
+
lastRequestError = error;
|
|
1053
|
+
// Ignore errors from requests superseded by a redirect.
|
|
1054
|
+
if (this._request !== request) {
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
/*
|
|
1058
|
+
Transient write errors (EPIPE, ECONNRESET) often fire during redirects when the
|
|
1059
|
+
server closes the connection after sending the redirect response. Defer by one
|
|
1060
|
+
microtask to let the response event make the request stale.
|
|
1061
|
+
*/
|
|
1062
|
+
if (isTransientWriteError(error)) {
|
|
1063
|
+
queueMicrotask(() => {
|
|
1064
|
+
if (this._isRequestStale(request)) {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
emitRequestError(error);
|
|
1068
|
+
});
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
emitRequestError(error);
|
|
870
1072
|
});
|
|
1073
|
+
if (!options.cache) {
|
|
1074
|
+
request.once('close', () => {
|
|
1075
|
+
if (this._request !== request || Boolean(request.res) || this._stopReading) {
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
this._beforeError(lastRequestError ?? new ReadError({
|
|
1079
|
+
name: 'Error',
|
|
1080
|
+
message: 'The server aborted pending request',
|
|
1081
|
+
code: 'ECONNRESET',
|
|
1082
|
+
}, this));
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
871
1085
|
this._unproxyEvents = proxyEvents(request, this, proxiedRequestEvents);
|
|
872
1086
|
this._request = request;
|
|
873
1087
|
this.emit('uploadProgress', this.uploadProgress);
|
|
874
1088
|
this._sendBody();
|
|
875
1089
|
this.emit('request', request);
|
|
876
1090
|
}
|
|
877
|
-
|
|
1091
|
+
_isRequestStale(request) {
|
|
1092
|
+
return this._request !== request || Boolean(request.res) || request.destroyed || request.writableEnded;
|
|
1093
|
+
}
|
|
1094
|
+
async _asyncWrite(chunk, request = this) {
|
|
878
1095
|
return new Promise((resolve, reject) => {
|
|
879
|
-
|
|
1096
|
+
if (request === this) {
|
|
1097
|
+
super.write(chunk, error => {
|
|
1098
|
+
if (error) {
|
|
1099
|
+
reject(error);
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
resolve();
|
|
1103
|
+
});
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
this._writeRequest(chunk, undefined, error => {
|
|
880
1107
|
if (error) {
|
|
881
1108
|
reject(error);
|
|
882
1109
|
return;
|
|
883
1110
|
}
|
|
884
1111
|
resolve();
|
|
885
|
-
});
|
|
1112
|
+
}, request);
|
|
886
1113
|
});
|
|
887
1114
|
}
|
|
888
1115
|
_sendBody() {
|
|
@@ -894,27 +1121,41 @@ export default class Request extends Duplex {
|
|
|
894
1121
|
}
|
|
895
1122
|
else if (is.buffer(body)) {
|
|
896
1123
|
// Buffer should be sent directly without conversion
|
|
897
|
-
this.
|
|
898
|
-
currentRequest.end();
|
|
1124
|
+
this._writeBodyInChunks(body, currentRequest);
|
|
899
1125
|
}
|
|
900
1126
|
else if (is.typedArray(body)) {
|
|
901
1127
|
// Typed arrays should be treated like buffers, not iterated over
|
|
902
1128
|
// Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
|
|
903
1129
|
const typedArray = body;
|
|
904
1130
|
const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
|
|
905
|
-
this.
|
|
906
|
-
currentRequest.end();
|
|
1131
|
+
this._writeBodyInChunks(uint8View, currentRequest);
|
|
907
1132
|
}
|
|
908
1133
|
else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
|
|
909
1134
|
(async () => {
|
|
1135
|
+
const isInitialRequest = currentRequest === this;
|
|
910
1136
|
try {
|
|
911
1137
|
for await (const chunk of body) {
|
|
912
|
-
|
|
1138
|
+
if (this.options.body !== body) {
|
|
1139
|
+
return;
|
|
1140
|
+
}
|
|
1141
|
+
await this._asyncWrite(chunk, currentRequest);
|
|
1142
|
+
if (this.options.body !== body) {
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
if (this.options.body === body) {
|
|
1147
|
+
if (isInitialRequest) {
|
|
1148
|
+
super.end();
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
await this._endWritableRequest(currentRequest);
|
|
913
1152
|
}
|
|
914
|
-
super.end();
|
|
915
1153
|
}
|
|
916
1154
|
catch (error) {
|
|
917
|
-
this.
|
|
1155
|
+
if (this.options.body !== body) {
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
this._beforeError(normalizeError(error));
|
|
918
1159
|
}
|
|
919
1160
|
})();
|
|
920
1161
|
}
|
|
@@ -926,8 +1167,205 @@ export default class Request extends Duplex {
|
|
|
926
1167
|
}
|
|
927
1168
|
}
|
|
928
1169
|
else {
|
|
929
|
-
|
|
930
|
-
|
|
1170
|
+
// Handles string bodies (from json/form options).
|
|
1171
|
+
this._writeBodyInChunks(stringToUint8Array(body), currentRequest);
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
/*
|
|
1175
|
+
Write a body buffer in chunks to enable granular `uploadProgress` events.
|
|
1176
|
+
|
|
1177
|
+
Without chunking, string/Uint8Array/TypedArray bodies are written in a single call, causing `uploadProgress` to only emit 0% and 100% with nothing in between.
|
|
1178
|
+
|
|
1179
|
+
The 64 KB chunk size matches Node.js fs stream defaults.
|
|
1180
|
+
*/
|
|
1181
|
+
_writeBodyInChunks(buffer, currentRequest) {
|
|
1182
|
+
const isInitialRequest = currentRequest === this;
|
|
1183
|
+
(async () => {
|
|
1184
|
+
let request;
|
|
1185
|
+
try {
|
|
1186
|
+
request = isInitialRequest ? this._request : currentRequest;
|
|
1187
|
+
const activeRequest = request;
|
|
1188
|
+
if (!activeRequest) {
|
|
1189
|
+
if (isInitialRequest) {
|
|
1190
|
+
super.end();
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
1194
|
+
if (activeRequest.destroyed) {
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
await this._writeChunksToRequest(buffer, activeRequest);
|
|
1198
|
+
if (this._isRequestStale(activeRequest)) {
|
|
1199
|
+
this._finalizeStaleChunkedWrite(activeRequest, isInitialRequest);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
if (isInitialRequest) {
|
|
1203
|
+
super.end();
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
await this._endWritableRequest(activeRequest);
|
|
1207
|
+
}
|
|
1208
|
+
catch (error) {
|
|
1209
|
+
const normalizedError = normalizeError(error);
|
|
1210
|
+
// Transient write errors (EPIPE, ECONNRESET) are handled by the request-level
|
|
1211
|
+
// error and close handlers. For initial redirected writes, still finalize
|
|
1212
|
+
// writable state once the stale transition becomes observable.
|
|
1213
|
+
if (isTransientWriteError(normalizedError)) {
|
|
1214
|
+
if (isInitialRequest && request) {
|
|
1215
|
+
const initialRequest = request;
|
|
1216
|
+
let didFinalize = false;
|
|
1217
|
+
const finalizeIfStale = () => {
|
|
1218
|
+
if (didFinalize || !this._isRequestStale(initialRequest)) {
|
|
1219
|
+
return;
|
|
1220
|
+
}
|
|
1221
|
+
didFinalize = true;
|
|
1222
|
+
this._finalizeStaleChunkedWrite(initialRequest, true);
|
|
1223
|
+
};
|
|
1224
|
+
finalizeIfStale();
|
|
1225
|
+
if (!didFinalize) {
|
|
1226
|
+
initialRequest.once('response', finalizeIfStale);
|
|
1227
|
+
queueMicrotask(finalizeIfStale);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
if (!isInitialRequest && this._isRequestStale(currentRequest)) {
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
this._beforeError(normalizedError);
|
|
1236
|
+
}
|
|
1237
|
+
})();
|
|
1238
|
+
}
|
|
1239
|
+
_finalizeStaleChunkedWrite(request, isInitialRequest) {
|
|
1240
|
+
if (!request.destroyed && !request.writableEnded) {
|
|
1241
|
+
request.destroy();
|
|
1242
|
+
}
|
|
1243
|
+
if (isInitialRequest) {
|
|
1244
|
+
// Finalize writable state without ending the active redirected request.
|
|
1245
|
+
this._skipRequestEndInFinal = true;
|
|
1246
|
+
super.end();
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
_emitUploadComplete(request) {
|
|
1250
|
+
this._bodySize = this._uploadedSize;
|
|
1251
|
+
this.emit('uploadProgress', this.uploadProgress);
|
|
1252
|
+
request.emit('upload-complete');
|
|
1253
|
+
}
|
|
1254
|
+
async _endWritableRequest(request) {
|
|
1255
|
+
await new Promise((resolve, reject) => {
|
|
1256
|
+
request.end((error) => {
|
|
1257
|
+
if (error) {
|
|
1258
|
+
reject(error);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (this._request === request && !request.destroyed) {
|
|
1262
|
+
this._emitUploadComplete(request);
|
|
1263
|
+
}
|
|
1264
|
+
resolve();
|
|
1265
|
+
});
|
|
1266
|
+
});
|
|
1267
|
+
}
|
|
1268
|
+
_stripCrossOriginState(options, urlToClear, bodyAlreadyDropped) {
|
|
1269
|
+
for (const header of crossOriginStripHeaders) {
|
|
1270
|
+
options.deleteInternalHeader(header);
|
|
1271
|
+
}
|
|
1272
|
+
options.username = '';
|
|
1273
|
+
options.password = '';
|
|
1274
|
+
urlToClear.username = '';
|
|
1275
|
+
urlToClear.password = '';
|
|
1276
|
+
if (!bodyAlreadyDropped) {
|
|
1277
|
+
this._dropBody(options);
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
_stripUnchangedCrossOriginState(options, urlToClear, bodyAlreadyDropped, state) {
|
|
1281
|
+
const headers = options.getInternalHeaders();
|
|
1282
|
+
for (const header of crossOriginStripHeaders) {
|
|
1283
|
+
if (!state.changedState.has(header) && headers[header] === state.headers[header]) {
|
|
1284
|
+
options.deleteInternalHeader(header);
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
if (!state.preserveUsername) {
|
|
1288
|
+
options.username = '';
|
|
1289
|
+
urlToClear.username = '';
|
|
1290
|
+
}
|
|
1291
|
+
if (!state.preservePassword) {
|
|
1292
|
+
options.password = '';
|
|
1293
|
+
urlToClear.password = '';
|
|
1294
|
+
}
|
|
1295
|
+
if (!bodyAlreadyDropped
|
|
1296
|
+
&& !state.changedState.has('body')
|
|
1297
|
+
&& !state.changedState.has('json')
|
|
1298
|
+
&& !state.changedState.has('form')
|
|
1299
|
+
&& isBodyUnchanged(options, state)) {
|
|
1300
|
+
this._dropBody(options);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
_dropBody(updatedOptions) {
|
|
1304
|
+
const { body } = this.options;
|
|
1305
|
+
const hadOptionBody = !is.undefined(body) || !is.undefined(this.options.json) || !is.undefined(this.options.form);
|
|
1306
|
+
this.options.clearBody();
|
|
1307
|
+
if (is.nodeStream(body)) {
|
|
1308
|
+
body.off('error', this._onBodyError);
|
|
1309
|
+
body.unpipe();
|
|
1310
|
+
body.on('error', noop);
|
|
1311
|
+
body.destroy();
|
|
1312
|
+
}
|
|
1313
|
+
else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
|
|
1314
|
+
const iterableBody = body;
|
|
1315
|
+
// Signal the iterator to clean up, but don't await it:
|
|
1316
|
+
// the for-await loop in _sendBody exits via the options.body sentinel,
|
|
1317
|
+
// and awaiting return() would deadlock when next() is pending.
|
|
1318
|
+
if (typeof iterableBody.return === 'function') {
|
|
1319
|
+
try {
|
|
1320
|
+
const result = iterableBody.return();
|
|
1321
|
+
if (result instanceof Promise) {
|
|
1322
|
+
// eslint-disable-next-line promise/prefer-await-to-then
|
|
1323
|
+
result.catch(noop);
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
catch { }
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
else if (!hadOptionBody && !this.writableEnded) {
|
|
1330
|
+
this._skipRequestEndInFinal = true;
|
|
1331
|
+
super.end();
|
|
1332
|
+
}
|
|
1333
|
+
updatedOptions.clearBody();
|
|
1334
|
+
this._bodySize = undefined;
|
|
1335
|
+
}
|
|
1336
|
+
_onBodyError = (error) => {
|
|
1337
|
+
if (this._flushed) {
|
|
1338
|
+
this._beforeError(new UploadError(error, this));
|
|
1339
|
+
}
|
|
1340
|
+
else {
|
|
1341
|
+
this.flush = async () => {
|
|
1342
|
+
this.flush = async () => { };
|
|
1343
|
+
this._beforeError(new UploadError(error, this));
|
|
1344
|
+
};
|
|
1345
|
+
}
|
|
1346
|
+
};
|
|
1347
|
+
async _writeChunksToRequest(buffer, request) {
|
|
1348
|
+
const chunkSize = 65_536; // 64 KB
|
|
1349
|
+
const isStale = () => this._isRequestStale(request);
|
|
1350
|
+
for (const part of chunk(buffer, chunkSize)) {
|
|
1351
|
+
if (isStale()) {
|
|
1352
|
+
return;
|
|
1353
|
+
}
|
|
1354
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1355
|
+
await new Promise((resolve, reject) => {
|
|
1356
|
+
this._writeRequest(part, undefined, error => {
|
|
1357
|
+
if (isStale()) {
|
|
1358
|
+
resolve();
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
if (error) {
|
|
1362
|
+
reject(error);
|
|
1363
|
+
}
|
|
1364
|
+
else {
|
|
1365
|
+
setImmediate(resolve);
|
|
1366
|
+
}
|
|
1367
|
+
}, request);
|
|
1368
|
+
});
|
|
931
1369
|
}
|
|
932
1370
|
}
|
|
933
1371
|
_prepareCache(cache) {
|
|
@@ -945,59 +1383,62 @@ export default class Request extends Duplex {
|
|
|
945
1383
|
Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
|
|
946
1384
|
Mutations take effect immediately and determine what gets cached.
|
|
947
1385
|
*/
|
|
948
|
-
const wrappedHandler = handler
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
1386
|
+
const wrappedHandler = handler
|
|
1387
|
+
? (response) => {
|
|
1388
|
+
const { beforeCacheHooks, gotRequest } = requestOptions;
|
|
1389
|
+
// Early return if no hooks - cache the original response
|
|
1390
|
+
if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
|
|
1391
|
+
handler(response);
|
|
1392
|
+
return;
|
|
1393
|
+
}
|
|
1394
|
+
try {
|
|
1395
|
+
// Call each beforeCache hook with the response
|
|
1396
|
+
// Hooks can directly mutate the response - mutations take effect immediately
|
|
1397
|
+
for (const hook of beforeCacheHooks) {
|
|
1398
|
+
const result = hook(response);
|
|
1399
|
+
if (result === false) {
|
|
1400
|
+
// Prevent caching by adding no-cache headers
|
|
1401
|
+
// Mutate the response directly to add headers
|
|
1402
|
+
response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
|
|
1403
|
+
response.headers.pragma = 'no-cache';
|
|
1404
|
+
response.headers.expires = '0';
|
|
1405
|
+
handler(response);
|
|
1406
|
+
// Don't call remaining hooks - we've decided not to cache
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
if (is.promise(result)) {
|
|
1410
|
+
// BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
|
|
1411
|
+
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.');
|
|
1412
|
+
}
|
|
1413
|
+
if (result !== undefined) {
|
|
1414
|
+
// Hooks should return false or undefined only
|
|
1415
|
+
// Mutations work directly - no need to return the response
|
|
1416
|
+
throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
|
|
1417
|
+
}
|
|
1418
|
+
// Else: void/undefined = continue
|
|
978
1419
|
}
|
|
979
|
-
// Else: void/undefined = continue
|
|
980
1420
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
1421
|
+
catch (error) {
|
|
1422
|
+
const normalizedError = normalizeError(error);
|
|
1423
|
+
// Convert hook errors to RequestError and propagate
|
|
1424
|
+
// This is consistent with how other hooks handle errors
|
|
1425
|
+
if (gotRequest) {
|
|
1426
|
+
gotRequest._beforeError(normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, gotRequest));
|
|
1427
|
+
// Don't call handler when error was propagated successfully
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
// If gotRequest is missing, log the error to aid debugging
|
|
1431
|
+
// We still call the handler to prevent the request from hanging
|
|
1432
|
+
console.error('Got: beforeCache hook error (request context unavailable):', normalizedError);
|
|
1433
|
+
// Call handler with response (potentially partially modified)
|
|
1434
|
+
handler(response);
|
|
988
1435
|
return;
|
|
989
1436
|
}
|
|
990
|
-
//
|
|
991
|
-
//
|
|
992
|
-
console.error('Got: beforeCache hook error (request context unavailable):', error);
|
|
993
|
-
// Call handler with response (potentially partially modified)
|
|
1437
|
+
// All hooks ran successfully
|
|
1438
|
+
// Cache the response with any mutations applied
|
|
994
1439
|
handler(response);
|
|
995
|
-
return;
|
|
996
1440
|
}
|
|
997
|
-
|
|
998
|
-
// Cache the response with any mutations applied
|
|
999
|
-
handler(response);
|
|
1000
|
-
} : handler;
|
|
1441
|
+
: handler;
|
|
1001
1442
|
const result = requestOptions._request(requestOptions, wrappedHandler);
|
|
1002
1443
|
// TODO: remove this when `cacheable-request` supports async request functions.
|
|
1003
1444
|
if (is.promise(result)) {
|
|
@@ -1039,32 +1480,44 @@ export default class Request extends Duplex {
|
|
|
1039
1480
|
}
|
|
1040
1481
|
async _createCacheableRequest(url, options) {
|
|
1041
1482
|
return new Promise((resolve, reject) => {
|
|
1042
|
-
|
|
1043
|
-
|
|
1483
|
+
Object.assign(options, {
|
|
1484
|
+
protocol: url.protocol,
|
|
1485
|
+
hostname: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
|
|
1486
|
+
host: url.host,
|
|
1487
|
+
hash: url.hash === '' ? '' : (url.hash ?? null),
|
|
1488
|
+
search: url.search === '' ? '' : (url.search ?? null),
|
|
1489
|
+
pathname: url.pathname,
|
|
1490
|
+
href: url.href,
|
|
1491
|
+
path: `${url.pathname || ''}${url.search || ''}`,
|
|
1492
|
+
...(is.string(url.port) && url.port.length > 0 ? { port: Number(url.port) } : {}),
|
|
1493
|
+
...(url.username || url.password ? { auth: `${url.username || ''}:${url.password || ''}` } : {}),
|
|
1494
|
+
});
|
|
1044
1495
|
let request;
|
|
1045
1496
|
// TODO: Fix `cacheable-response`. This is ugly.
|
|
1046
|
-
const cacheRequest = cacheableStore.get(options.cache)(options,
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1497
|
+
const cacheRequest = cacheableStore.get(options.cache)(options, (response) => {
|
|
1498
|
+
void (async () => {
|
|
1499
|
+
response._readableState.autoDestroy = false;
|
|
1500
|
+
if (request) {
|
|
1501
|
+
const fix = () => {
|
|
1502
|
+
// For ResponseLike objects from cache, set complete to true if not already set.
|
|
1503
|
+
// For real HTTP responses, copy from the underlying response.
|
|
1504
|
+
if (response.req) {
|
|
1505
|
+
response.complete = response.req.res.complete;
|
|
1506
|
+
}
|
|
1507
|
+
else if (response.complete === undefined) {
|
|
1508
|
+
// ResponseLike from cache should have complete = true
|
|
1509
|
+
response.complete = true;
|
|
1510
|
+
}
|
|
1511
|
+
};
|
|
1512
|
+
response.prependOnceListener('end', fix);
|
|
1513
|
+
fix();
|
|
1514
|
+
(await request).emit('cacheableResponse', response);
|
|
1515
|
+
}
|
|
1516
|
+
resolve(response);
|
|
1517
|
+
})();
|
|
1065
1518
|
});
|
|
1066
1519
|
cacheRequest.once('error', reject);
|
|
1067
|
-
cacheRequest.once('request',
|
|
1520
|
+
cacheRequest.once('request', (requestOrPromise) => {
|
|
1068
1521
|
request = requestOrPromise;
|
|
1069
1522
|
resolve(request);
|
|
1070
1523
|
});
|
|
@@ -1072,12 +1525,12 @@ export default class Request extends Duplex {
|
|
|
1072
1525
|
}
|
|
1073
1526
|
async _makeRequest() {
|
|
1074
1527
|
const { options } = this;
|
|
1075
|
-
const
|
|
1528
|
+
const headers = options.getInternalHeaders();
|
|
1529
|
+
const { username, password } = options;
|
|
1076
1530
|
const cookieJar = options.cookieJar;
|
|
1077
1531
|
for (const key in headers) {
|
|
1078
1532
|
if (is.undefined(headers[key])) {
|
|
1079
|
-
|
|
1080
|
-
delete headers[key];
|
|
1533
|
+
options.deleteInternalHeader(key);
|
|
1081
1534
|
}
|
|
1082
1535
|
else if (is.null(headers[key])) {
|
|
1083
1536
|
throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
|
|
@@ -1091,17 +1544,17 @@ export default class Request extends Duplex {
|
|
|
1091
1544
|
if (supportsZstd) {
|
|
1092
1545
|
encodings.push('zstd');
|
|
1093
1546
|
}
|
|
1094
|
-
|
|
1547
|
+
options.setInternalHeader('accept-encoding', encodings.join(', '));
|
|
1095
1548
|
}
|
|
1096
1549
|
if (username || password) {
|
|
1097
|
-
const credentials =
|
|
1098
|
-
|
|
1550
|
+
const credentials = stringToBase64(`${username}:${password}`);
|
|
1551
|
+
options.setInternalHeader('authorization', `Basic ${credentials}`);
|
|
1099
1552
|
}
|
|
1100
1553
|
// Set cookies
|
|
1101
1554
|
if (cookieJar) {
|
|
1102
1555
|
const cookieString = await cookieJar.getCookieString(options.url.toString());
|
|
1103
1556
|
if (is.nonEmptyString(cookieString)) {
|
|
1104
|
-
|
|
1557
|
+
options.setInternalHeader('cookie', cookieString);
|
|
1105
1558
|
}
|
|
1106
1559
|
}
|
|
1107
1560
|
let request;
|
|
@@ -1114,7 +1567,11 @@ export default class Request extends Duplex {
|
|
|
1114
1567
|
break;
|
|
1115
1568
|
}
|
|
1116
1569
|
}
|
|
1117
|
-
|
|
1570
|
+
if (!is.undefined(headers['transfer-encoding']) && !is.undefined(headers['content-length'])) {
|
|
1571
|
+
// TODO: Throw instead of silently dropping `content-length` in the next major version.
|
|
1572
|
+
options.deleteInternalHeader('content-length');
|
|
1573
|
+
}
|
|
1574
|
+
request ??= options.getRequestFunction();
|
|
1118
1575
|
const url = options.url;
|
|
1119
1576
|
this._requestOptions = options.createNativeRequestOptions();
|
|
1120
1577
|
if (options.cache) {
|
|
@@ -1127,7 +1584,7 @@ export default class Request extends Duplex {
|
|
|
1127
1584
|
this._prepareCache(options.cache);
|
|
1128
1585
|
}
|
|
1129
1586
|
catch (error) {
|
|
1130
|
-
throw new CacheError(error, this);
|
|
1587
|
+
throw new CacheError(normalizeError(error), this);
|
|
1131
1588
|
}
|
|
1132
1589
|
}
|
|
1133
1590
|
// Cache support
|
|
@@ -1139,13 +1596,6 @@ export default class Request extends Duplex {
|
|
|
1139
1596
|
if (is.promise(requestOrResponse)) {
|
|
1140
1597
|
requestOrResponse = await requestOrResponse;
|
|
1141
1598
|
}
|
|
1142
|
-
// Fallback
|
|
1143
|
-
if (is.undefined(requestOrResponse)) {
|
|
1144
|
-
requestOrResponse = options.getFallbackRequestFunction()(url, this._requestOptions);
|
|
1145
|
-
if (is.promise(requestOrResponse)) {
|
|
1146
|
-
requestOrResponse = await requestOrResponse;
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
1599
|
if (isClientRequest(requestOrResponse)) {
|
|
1150
1600
|
this._onRequest(requestOrResponse);
|
|
1151
1601
|
}
|
|
@@ -1168,12 +1618,9 @@ export default class Request extends Duplex {
|
|
|
1168
1618
|
}
|
|
1169
1619
|
async _error(error) {
|
|
1170
1620
|
try {
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
// See https://github.com/sindresorhus/got/issues/2103
|
|
1175
|
-
}
|
|
1176
|
-
else if (this.options) {
|
|
1621
|
+
// Skip calling hooks for HTTP errors when throwHttpErrors is false (Promise API only).
|
|
1622
|
+
// See https://github.com/sindresorhus/got/issues/2103
|
|
1623
|
+
if (this.options && (!(error instanceof HTTPError) || this.options.throwHttpErrors)) {
|
|
1177
1624
|
const hooks = this.options.hooks.beforeError;
|
|
1178
1625
|
if (hooks.length > 0) {
|
|
1179
1626
|
for (const hook of hooks) {
|
|
@@ -1194,12 +1641,13 @@ export default class Request extends Duplex {
|
|
|
1194
1641
|
}
|
|
1195
1642
|
}
|
|
1196
1643
|
catch (error_) {
|
|
1197
|
-
|
|
1644
|
+
const normalizedError = normalizeError(error_);
|
|
1645
|
+
error = new RequestError(normalizedError.message, normalizedError, this);
|
|
1198
1646
|
}
|
|
1199
1647
|
// Publish error event
|
|
1200
1648
|
publishError({
|
|
1201
1649
|
requestId: this._requestId,
|
|
1202
|
-
url: this.options
|
|
1650
|
+
url: getSanitizedUrl(this.options),
|
|
1203
1651
|
error,
|
|
1204
1652
|
timings: this.timings,
|
|
1205
1653
|
});
|
|
@@ -1215,16 +1663,17 @@ export default class Request extends Duplex {
|
|
|
1215
1663
|
});
|
|
1216
1664
|
}
|
|
1217
1665
|
}
|
|
1218
|
-
_writeRequest(chunk, encoding, callback) {
|
|
1219
|
-
if (!
|
|
1666
|
+
_writeRequest(chunk, encoding, callback, request = this._request) {
|
|
1667
|
+
if (!request || request.destroyed) {
|
|
1220
1668
|
// When there's no request (e.g., using cached response from beforeRequest hook),
|
|
1221
1669
|
// we still need to call the callback to allow the stream to finish properly.
|
|
1222
1670
|
callback();
|
|
1223
1671
|
return;
|
|
1224
1672
|
}
|
|
1225
|
-
|
|
1226
|
-
// The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed
|
|
1227
|
-
|
|
1673
|
+
request.write(chunk, encoding, (error) => {
|
|
1674
|
+
// The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed.
|
|
1675
|
+
// The `this._request === request` check prevents stale write callbacks from a pre-redirect request from incrementing `_uploadedSize` after it's been reset.
|
|
1676
|
+
if (!error && !request.destroyed && this._request === request) {
|
|
1228
1677
|
// For strings, encode them first to measure the actual bytes that will be sent
|
|
1229
1678
|
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk;
|
|
1230
1679
|
this._uploadedSize += byteLength(bytes);
|
|
@@ -1255,41 +1704,13 @@ export default class Request extends Duplex {
|
|
|
1255
1704
|
Progress event for downloading (receiving a response).
|
|
1256
1705
|
*/
|
|
1257
1706
|
get downloadProgress() {
|
|
1258
|
-
|
|
1259
|
-
if (this._responseSize) {
|
|
1260
|
-
percent = this._downloadedSize / this._responseSize;
|
|
1261
|
-
}
|
|
1262
|
-
else if (this._responseSize === this._downloadedSize) {
|
|
1263
|
-
percent = 1;
|
|
1264
|
-
}
|
|
1265
|
-
else {
|
|
1266
|
-
percent = 0;
|
|
1267
|
-
}
|
|
1268
|
-
return {
|
|
1269
|
-
percent,
|
|
1270
|
-
transferred: this._downloadedSize,
|
|
1271
|
-
total: this._responseSize,
|
|
1272
|
-
};
|
|
1707
|
+
return makeProgress(this._downloadedSize, this._responseSize);
|
|
1273
1708
|
}
|
|
1274
1709
|
/**
|
|
1275
1710
|
Progress event for uploading (sending a request).
|
|
1276
1711
|
*/
|
|
1277
1712
|
get uploadProgress() {
|
|
1278
|
-
|
|
1279
|
-
if (this._bodySize) {
|
|
1280
|
-
percent = this._uploadedSize / this._bodySize;
|
|
1281
|
-
}
|
|
1282
|
-
else if (this._bodySize === this._uploadedSize) {
|
|
1283
|
-
percent = 1;
|
|
1284
|
-
}
|
|
1285
|
-
else {
|
|
1286
|
-
percent = 0;
|
|
1287
|
-
}
|
|
1288
|
-
return {
|
|
1289
|
-
percent,
|
|
1290
|
-
transferred: this._uploadedSize,
|
|
1291
|
-
total: this._bodySize,
|
|
1292
|
-
};
|
|
1713
|
+
return makeProgress(this._uploadedSize, this._bodySize);
|
|
1293
1714
|
}
|
|
1294
1715
|
/**
|
|
1295
1716
|
The object contains the following properties:
|
|
@@ -1325,7 +1746,7 @@ export default class Request extends Duplex {
|
|
|
1325
1746
|
Whether the response was retrieved from the cache.
|
|
1326
1747
|
*/
|
|
1327
1748
|
get isFromCache() {
|
|
1328
|
-
return this.
|
|
1749
|
+
return this.response?.isFromCache;
|
|
1329
1750
|
}
|
|
1330
1751
|
get reusedSocket() {
|
|
1331
1752
|
return this._request?.reusedSocket;
|