got 14.6.5 → 15.0.0
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 +59 -41
- 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 +7 -10
- package/dist/source/core/index.d.ts +19 -7
- package/dist/source/core/index.js +726 -311
- package/dist/source/core/options.d.ts +92 -91
- package/dist/source/core/options.js +616 -303
- 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 +3 -3
- 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/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 +42 -70
- package/package.json +34 -38
- package/readme.md +2 -2
- 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,33 +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
|
-
// Methods that should auto-end streams when no body is provided
|
|
28
|
-
const methodsWithoutBodyStream = new Set(['OPTIONS', 'DELETE', 'PATCH']);
|
|
29
28
|
const cacheableStore = new WeakableMap();
|
|
30
|
-
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
|
+
]);
|
|
31
44
|
// Track errors that have been processed by beforeError hooks to preserve custom error types
|
|
32
45
|
const errorsProcessedByHooks = new WeakSet();
|
|
33
46
|
const proxiedRequestEvents = [
|
|
@@ -38,6 +51,64 @@ const proxiedRequestEvents = [
|
|
|
38
51
|
'upgrade',
|
|
39
52
|
];
|
|
40
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
|
+
};
|
|
41
112
|
export default class Request extends Duplex {
|
|
42
113
|
// @ts-expect-error - Ignoring for now.
|
|
43
114
|
['constructor'];
|
|
@@ -49,24 +120,24 @@ export default class Request extends Duplex {
|
|
|
49
120
|
redirectUrls = [];
|
|
50
121
|
retryCount = 0;
|
|
51
122
|
_stopReading = false;
|
|
52
|
-
_stopRetry
|
|
123
|
+
_stopRetry;
|
|
53
124
|
_downloadedSize = 0;
|
|
54
125
|
_uploadedSize = 0;
|
|
55
126
|
_pipedServerResponses = new Set();
|
|
56
127
|
_request;
|
|
57
128
|
_responseSize;
|
|
58
129
|
_bodySize;
|
|
59
|
-
_unproxyEvents
|
|
60
|
-
_isFromCache;
|
|
130
|
+
_unproxyEvents;
|
|
61
131
|
_triggerRead = false;
|
|
62
132
|
_jobs = [];
|
|
63
|
-
_cancelTimeouts
|
|
64
|
-
|
|
65
|
-
_nativeResponse;
|
|
133
|
+
_cancelTimeouts;
|
|
134
|
+
_abortListenerDisposer;
|
|
66
135
|
_flushed = false;
|
|
67
136
|
_aborted = false;
|
|
68
137
|
_expectedContentLength;
|
|
69
138
|
_compressedBytesCount;
|
|
139
|
+
_skipRequestEndInFinal = false;
|
|
140
|
+
_incrementalDecode;
|
|
70
141
|
_requestId = generateRequestId();
|
|
71
142
|
// We need this because `this._request` if `undefined` when using cache
|
|
72
143
|
_requestInitialized = false;
|
|
@@ -79,7 +150,17 @@ export default class Request extends Duplex {
|
|
|
79
150
|
});
|
|
80
151
|
this.on('pipe', (source) => {
|
|
81
152
|
if (this.options.copyPipedHeaders && source?.headers) {
|
|
82
|
-
|
|
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
|
+
}
|
|
83
164
|
}
|
|
84
165
|
});
|
|
85
166
|
this.on('newListener', event => {
|
|
@@ -99,7 +180,7 @@ export default class Request extends Duplex {
|
|
|
99
180
|
// Publish request creation event
|
|
100
181
|
publishRequestCreate({
|
|
101
182
|
requestId: this._requestId,
|
|
102
|
-
url: this.options
|
|
183
|
+
url: getSanitizedUrl(this.options),
|
|
103
184
|
method: this.options.method,
|
|
104
185
|
});
|
|
105
186
|
}
|
|
@@ -114,11 +195,12 @@ export default class Request extends Duplex {
|
|
|
114
195
|
process.nextTick(() => {
|
|
115
196
|
// _beforeError requires options to access retry logic and hooks
|
|
116
197
|
if (this.options) {
|
|
117
|
-
this._beforeError(error);
|
|
198
|
+
this._beforeError(normalizeError(error));
|
|
118
199
|
}
|
|
119
200
|
else {
|
|
120
201
|
// Options is undefined, skip _beforeError and destroy directly
|
|
121
|
-
const
|
|
202
|
+
const normalizedError = normalizeError(error);
|
|
203
|
+
const requestError = normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, this);
|
|
122
204
|
this.destroy(requestError);
|
|
123
205
|
}
|
|
124
206
|
});
|
|
@@ -129,17 +211,7 @@ export default class Request extends Duplex {
|
|
|
129
211
|
// The below is run only once.
|
|
130
212
|
const { body } = this.options;
|
|
131
213
|
if (is.nodeStream(body)) {
|
|
132
|
-
body.once('error',
|
|
133
|
-
if (this._flushed) {
|
|
134
|
-
this._beforeError(new UploadError(error, this));
|
|
135
|
-
}
|
|
136
|
-
else {
|
|
137
|
-
this.flush = async () => {
|
|
138
|
-
this.flush = async () => { };
|
|
139
|
-
this._beforeError(new UploadError(error, this));
|
|
140
|
-
};
|
|
141
|
-
}
|
|
142
|
-
});
|
|
214
|
+
body.once('error', this._onBodyError);
|
|
143
215
|
}
|
|
144
216
|
if (this.options.signal) {
|
|
145
217
|
const abort = () => {
|
|
@@ -155,10 +227,8 @@ export default class Request extends Duplex {
|
|
|
155
227
|
abort();
|
|
156
228
|
}
|
|
157
229
|
else {
|
|
158
|
-
this.options.signal
|
|
159
|
-
this.
|
|
160
|
-
this.options.signal?.removeEventListener('abort', abort);
|
|
161
|
-
};
|
|
230
|
+
const abortListenerDisposer = addAbortListener(this.options.signal, abort);
|
|
231
|
+
this._abortListenerDisposer = abortListenerDisposer;
|
|
162
232
|
}
|
|
163
233
|
}
|
|
164
234
|
}
|
|
@@ -186,7 +256,7 @@ export default class Request extends Duplex {
|
|
|
186
256
|
this._requestInitialized = true;
|
|
187
257
|
}
|
|
188
258
|
catch (error) {
|
|
189
|
-
this._beforeError(error);
|
|
259
|
+
this._beforeError(normalizeError(error));
|
|
190
260
|
}
|
|
191
261
|
}
|
|
192
262
|
_beforeError(error) {
|
|
@@ -212,7 +282,7 @@ export default class Request extends Duplex {
|
|
|
212
282
|
response.setEncoding(this.readableEncoding);
|
|
213
283
|
const success = await this._setRawBody(response);
|
|
214
284
|
if (success) {
|
|
215
|
-
response.body = response.rawBody
|
|
285
|
+
response.body = decodeUint8Array(response.rawBody);
|
|
216
286
|
}
|
|
217
287
|
}
|
|
218
288
|
if (this.listenerCount('retry') !== 0) {
|
|
@@ -242,7 +312,7 @@ export default class Request extends Duplex {
|
|
|
242
312
|
// When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
|
|
243
313
|
// before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
|
|
244
314
|
// based on these rules), skip calling calculateDelay entirely.
|
|
245
|
-
// When false
|
|
315
|
+
// When false, always call calculateDelay, allowing it to override retry decisions.
|
|
246
316
|
if (retryOptions.enforceRetryRules && computedValue === 0) {
|
|
247
317
|
backoff = 0;
|
|
248
318
|
}
|
|
@@ -257,7 +327,8 @@ export default class Request extends Duplex {
|
|
|
257
327
|
}
|
|
258
328
|
}
|
|
259
329
|
catch (error_) {
|
|
260
|
-
|
|
330
|
+
const normalizedError = normalizeError(error_);
|
|
331
|
+
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
261
332
|
return;
|
|
262
333
|
}
|
|
263
334
|
if (backoff) {
|
|
@@ -281,7 +352,8 @@ export default class Request extends Duplex {
|
|
|
281
352
|
}
|
|
282
353
|
}
|
|
283
354
|
catch (error_) {
|
|
284
|
-
|
|
355
|
+
const normalizedError = normalizeError(error_);
|
|
356
|
+
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
285
357
|
return;
|
|
286
358
|
}
|
|
287
359
|
// Something forced us to abort the retry
|
|
@@ -301,30 +373,35 @@ export default class Request extends Duplex {
|
|
|
301
373
|
// 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks
|
|
302
374
|
// 3. We must restore the body reference after destroy() for identity checks in promise wrapper
|
|
303
375
|
// 4. We cannot use the normal setter after destroy() because it validates stream readability
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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;
|
|
313
392
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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.
|
|
319
399
|
}
|
|
320
|
-
this.options._internals.body = bodyAfterHooks;
|
|
321
400
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
this.
|
|
325
|
-
|
|
326
|
-
// and should not be accessed. The promise wrapper will see that body identity hasn't changed
|
|
327
|
-
// and will detect it's a consumed stream, which is the correct behavior.
|
|
401
|
+
catch (error_) {
|
|
402
|
+
const normalizedError = normalizeError(error_);
|
|
403
|
+
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
404
|
+
return;
|
|
328
405
|
}
|
|
329
406
|
// Publish retry event
|
|
330
407
|
publishRetry({
|
|
@@ -359,6 +436,17 @@ export default class Request extends Duplex {
|
|
|
359
436
|
let data;
|
|
360
437
|
while ((data = response.read()) !== null) {
|
|
361
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
|
+
}
|
|
362
450
|
const progress = this.downloadProgress;
|
|
363
451
|
if (progress.percent < 1) {
|
|
364
452
|
this.emit('downloadProgress', progress);
|
|
@@ -380,22 +468,26 @@ export default class Request extends Duplex {
|
|
|
380
468
|
}
|
|
381
469
|
_final(callback) {
|
|
382
470
|
const endRequest = () => {
|
|
471
|
+
if (this._skipRequestEndInFinal) {
|
|
472
|
+
this._skipRequestEndInFinal = false;
|
|
473
|
+
callback();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const request = this._request;
|
|
383
477
|
// We need to check if `this._request` is present,
|
|
384
478
|
// because it isn't when we use cache.
|
|
385
|
-
if (!
|
|
479
|
+
if (!request || request.destroyed) {
|
|
386
480
|
callback();
|
|
387
481
|
return;
|
|
388
482
|
}
|
|
389
|
-
|
|
483
|
+
request.end((error) => {
|
|
390
484
|
// The request has been destroyed before `_final` finished.
|
|
391
485
|
// See https://github.com/nodejs/node/issues/39356
|
|
392
|
-
if (
|
|
486
|
+
if (request?._writableState?.errored) {
|
|
393
487
|
return;
|
|
394
488
|
}
|
|
395
489
|
if (!error) {
|
|
396
|
-
this.
|
|
397
|
-
this.emit('uploadProgress', this.uploadProgress);
|
|
398
|
-
this._request?.emit('upload-complete');
|
|
490
|
+
this._emitUploadComplete(request);
|
|
399
491
|
}
|
|
400
492
|
callback(error);
|
|
401
493
|
});
|
|
@@ -411,9 +503,9 @@ export default class Request extends Duplex {
|
|
|
411
503
|
this._stopReading = true;
|
|
412
504
|
this.flush = async () => { };
|
|
413
505
|
// Prevent further retries
|
|
414
|
-
this._stopRetry();
|
|
415
|
-
this._cancelTimeouts();
|
|
416
|
-
this.
|
|
506
|
+
this._stopRetry?.();
|
|
507
|
+
this._cancelTimeouts?.();
|
|
508
|
+
this._abortListenerDisposer?.[Symbol.dispose]();
|
|
417
509
|
if (this.options) {
|
|
418
510
|
const { body } = this.options;
|
|
419
511
|
if (is.nodeStream(body)) {
|
|
@@ -461,6 +553,13 @@ export default class Request extends Duplex {
|
|
|
461
553
|
super.unpipe(destination);
|
|
462
554
|
return this;
|
|
463
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
|
+
}
|
|
464
563
|
_checkContentLengthMismatch() {
|
|
465
564
|
if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
|
|
466
565
|
// Use compressed bytes count when available (for compressed responses),
|
|
@@ -479,7 +578,7 @@ export default class Request extends Duplex {
|
|
|
479
578
|
}
|
|
480
579
|
async _finalizeBody() {
|
|
481
580
|
const { options } = this;
|
|
482
|
-
const
|
|
581
|
+
const headers = options.getInternalHeaders();
|
|
483
582
|
const isForm = !is.undefined(options.form);
|
|
484
583
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
485
584
|
const isJSON = !is.undefined(options.json);
|
|
@@ -492,20 +591,16 @@ export default class Request extends Duplex {
|
|
|
492
591
|
// Serialize body
|
|
493
592
|
const noContentType = !is.string(headers['content-type']);
|
|
494
593
|
if (isBody) {
|
|
495
|
-
//
|
|
496
|
-
if (
|
|
497
|
-
const
|
|
594
|
+
// Native FormData
|
|
595
|
+
if (options.body instanceof FormData) {
|
|
596
|
+
const response = new Response(options.body);
|
|
498
597
|
if (noContentType) {
|
|
499
|
-
headers['content-type'] =
|
|
500
|
-
}
|
|
501
|
-
if ('Content-Length' in encoder.headers) {
|
|
502
|
-
headers['content-length'] = encoder.headers['Content-Length'];
|
|
598
|
+
headers['content-type'] = response.headers.get('content-type') ?? 'multipart/form-data';
|
|
503
599
|
}
|
|
504
|
-
options.body =
|
|
600
|
+
options.body = response.body;
|
|
505
601
|
}
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
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.');
|
|
509
604
|
}
|
|
510
605
|
}
|
|
511
606
|
else if (isForm) {
|
|
@@ -524,7 +619,7 @@ export default class Request extends Duplex {
|
|
|
524
619
|
options.json = undefined;
|
|
525
620
|
options.body = options.stringifyJson(json);
|
|
526
621
|
}
|
|
527
|
-
const uploadBodySize =
|
|
622
|
+
const uploadBodySize = getBodySize(options.body, headers);
|
|
528
623
|
// See https://tools.ietf.org/html/rfc7230#section-3.3.2
|
|
529
624
|
// A user agent SHOULD send a Content-Length in a request message when
|
|
530
625
|
// no Transfer-Encoding is sent and the request method defines a meaning
|
|
@@ -538,8 +633,8 @@ export default class Request extends Duplex {
|
|
|
538
633
|
headers['content-length'] = String(uploadBodySize);
|
|
539
634
|
}
|
|
540
635
|
}
|
|
541
|
-
if (options.responseType === 'json' && !('accept' in
|
|
542
|
-
|
|
636
|
+
if (options.responseType === 'json' && !('accept' in headers)) {
|
|
637
|
+
headers.accept = 'application/json';
|
|
543
638
|
}
|
|
544
639
|
this._bodySize = Number(headers['content-length']) || undefined;
|
|
545
640
|
}
|
|
@@ -550,9 +645,12 @@ export default class Request extends Duplex {
|
|
|
550
645
|
}
|
|
551
646
|
const { options } = this;
|
|
552
647
|
const { url } = options;
|
|
553
|
-
|
|
648
|
+
const nativeResponse = response;
|
|
554
649
|
const statusCode = response.statusCode;
|
|
555
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));
|
|
556
654
|
// Skip decompression for responses that must not have bodies per RFC 9110:
|
|
557
655
|
// - HEAD responses (any status code)
|
|
558
656
|
// - 1xx (Informational): 100, 101, 102, 103, etc.
|
|
@@ -564,30 +662,46 @@ export default class Request extends Duplex {
|
|
|
564
662
|
|| statusCode === 204
|
|
565
663
|
|| statusCode === 205
|
|
566
664
|
|| statusCode === 304;
|
|
567
|
-
|
|
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) {
|
|
568
690
|
// When strictContentLength is enabled, track compressed bytes by listening to
|
|
569
691
|
// the native response's data events before decompression
|
|
570
692
|
if (options.strictContentLength) {
|
|
571
693
|
this._compressedBytesCount = 0;
|
|
572
|
-
|
|
694
|
+
nativeResponse.on('data', (chunk) => {
|
|
573
695
|
this._compressedBytesCount += byteLength(chunk);
|
|
574
696
|
});
|
|
575
697
|
}
|
|
576
698
|
response = decompressResponse(response);
|
|
699
|
+
typedResponse = prepareResponse(response);
|
|
577
700
|
}
|
|
578
|
-
const typedResponse = response;
|
|
579
|
-
typedResponse.statusMessage = typedResponse.statusMessage || http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
|
|
580
|
-
typedResponse.url = options.url.toString();
|
|
581
|
-
typedResponse.requestUrl = this.requestUrl;
|
|
582
|
-
typedResponse.redirectUrls = this.redirectUrls;
|
|
583
|
-
typedResponse.request = this;
|
|
584
|
-
typedResponse.isFromCache = this._nativeResponse.fromCache ?? false;
|
|
585
|
-
typedResponse.ip = this.ip;
|
|
586
|
-
typedResponse.retryCount = this.retryCount;
|
|
587
|
-
typedResponse.ok = isResponseOk(typedResponse);
|
|
588
|
-
this._isFromCache = typedResponse.isFromCache;
|
|
589
701
|
this._responseSize = Number(response.headers['content-length']) || undefined;
|
|
590
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;
|
|
591
705
|
// Publish response start event
|
|
592
706
|
publishResponseStart({
|
|
593
707
|
requestId: this._requestId,
|
|
@@ -598,9 +712,6 @@ export default class Request extends Duplex {
|
|
|
598
712
|
});
|
|
599
713
|
response.once('error', (error) => {
|
|
600
714
|
this._aborted = true;
|
|
601
|
-
// Force clean-up, because some packages don't do this.
|
|
602
|
-
// TODO: Fix decompress-response
|
|
603
|
-
response.destroy();
|
|
604
715
|
this._beforeError(new ReadError(error, this));
|
|
605
716
|
});
|
|
606
717
|
response.once('aborted', () => {
|
|
@@ -614,11 +725,15 @@ export default class Request extends Duplex {
|
|
|
614
725
|
}, this));
|
|
615
726
|
}
|
|
616
727
|
});
|
|
728
|
+
const noPipeCookieJarRawBodyPromise = this._noPipe
|
|
729
|
+
&& is.object(options.cookieJar)
|
|
730
|
+
&& !isRedirect
|
|
731
|
+
? this._setRawBody(response)
|
|
732
|
+
: undefined;
|
|
617
733
|
const rawCookies = response.headers['set-cookie'];
|
|
618
734
|
if (is.object(options.cookieJar) && rawCookies) {
|
|
619
735
|
let promises = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString()));
|
|
620
736
|
if (options.ignoreInvalidCookies) {
|
|
621
|
-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
|
622
737
|
promises = promises.map(async (promise) => {
|
|
623
738
|
try {
|
|
624
739
|
await promise;
|
|
@@ -630,7 +745,7 @@ export default class Request extends Duplex {
|
|
|
630
745
|
await Promise.all(promises);
|
|
631
746
|
}
|
|
632
747
|
catch (error) {
|
|
633
|
-
this._beforeError(error);
|
|
748
|
+
this._beforeError(normalizeError(error));
|
|
634
749
|
return;
|
|
635
750
|
}
|
|
636
751
|
}
|
|
@@ -638,88 +753,122 @@ export default class Request extends Duplex {
|
|
|
638
753
|
if (this.isAborted) {
|
|
639
754
|
return;
|
|
640
755
|
}
|
|
641
|
-
if (
|
|
756
|
+
if (shouldFollowRedirect) {
|
|
642
757
|
// We're being redirected, we don't care about the response.
|
|
643
758
|
// It'd be best to abort the request, but we can't because
|
|
644
759
|
// we would have to sacrifice the TCP connection. We don't want that.
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
this.
|
|
650
|
-
|
|
651
|
-
|
|
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));
|
|
652
785
|
return;
|
|
653
786
|
}
|
|
654
|
-
|
|
655
|
-
//
|
|
656
|
-
|
|
657
|
-
|
|
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;
|
|
658
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';
|
|
659
796
|
const canRewrite = statusCode !== 307 && statusCode !== 308;
|
|
660
797
|
const userRequestedGet = updatedOptions.methodRewriting && canRewrite;
|
|
661
|
-
|
|
798
|
+
const shouldDropBody = serverRequestedGet || crossOriginRequestedGet || userRequestedGet;
|
|
799
|
+
if (shouldDropBody) {
|
|
662
800
|
updatedOptions.method = 'GET';
|
|
663
|
-
updatedOptions
|
|
664
|
-
updatedOptions.json = undefined;
|
|
665
|
-
updatedOptions.form = undefined;
|
|
666
|
-
delete updatedOptions.headers['content-length'];
|
|
801
|
+
this._dropBody(updatedOptions);
|
|
667
802
|
}
|
|
668
|
-
|
|
669
|
-
//
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
}
|
|
691
|
-
if (updatedOptions.username || updatedOptions.password) {
|
|
692
|
-
updatedOptions.username = '';
|
|
693
|
-
updatedOptions.password = '';
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
else {
|
|
697
|
-
redirectUrl.username = updatedOptions.username;
|
|
698
|
-
redirectUrl.password = updatedOptions.password;
|
|
699
|
-
}
|
|
700
|
-
this.redirectUrls.push(redirectUrl);
|
|
701
|
-
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) => {
|
|
702
825
|
for (const hook of updatedOptions.hooks.beforeRedirect) {
|
|
703
826
|
// eslint-disable-next-line no-await-in-loop
|
|
704
827
|
await hook(updatedOptions, typedResponse);
|
|
705
828
|
}
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
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
|
+
}
|
|
720
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));
|
|
721
869
|
return;
|
|
722
870
|
}
|
|
871
|
+
return;
|
|
723
872
|
}
|
|
724
873
|
// `HTTPError`s always have `error.response.body` defined.
|
|
725
874
|
// Therefore, we cannot retry if `options.throwHttpErrors` is false.
|
|
@@ -729,13 +878,15 @@ export default class Request extends Duplex {
|
|
|
729
878
|
this._beforeError(new HTTPError(typedResponse));
|
|
730
879
|
return;
|
|
731
880
|
}
|
|
881
|
+
// `decompressResponse` wraps the response stream when it decompresses,
|
|
882
|
+
// so `response !== nativeResponse` indicates decompression happened.
|
|
883
|
+
const wasDecompressed = response !== nativeResponse;
|
|
732
884
|
// Store the expected content-length from the native response for validation.
|
|
733
885
|
// This is the content-length before decompression, which is what actually gets transferred.
|
|
734
886
|
// Skip storing for responses that shouldn't have bodies per RFC 9110.
|
|
735
887
|
// When decompression occurs, only store if strictContentLength is enabled.
|
|
736
|
-
const wasDecompressed = response !== this._nativeResponse;
|
|
737
888
|
if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
|
|
738
|
-
const contentLengthHeader =
|
|
889
|
+
const contentLengthHeader = nativeResponse.headers['content-length'];
|
|
739
890
|
if (contentLengthHeader !== undefined) {
|
|
740
891
|
const expectedLength = Number(contentLengthHeader);
|
|
741
892
|
if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
|
|
@@ -744,7 +895,12 @@ export default class Request extends Duplex {
|
|
|
744
895
|
}
|
|
745
896
|
}
|
|
746
897
|
// Set up end listener AFTER redirect check to avoid emitting progress for redirect responses
|
|
747
|
-
|
|
898
|
+
let responseEndHandled = false;
|
|
899
|
+
const handleResponseEnd = () => {
|
|
900
|
+
if (responseEndHandled) {
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
responseEndHandled = true;
|
|
748
904
|
// Validate content-length if it was provided
|
|
749
905
|
// Per RFC 9112: "If the sender closes the connection before the indicated number
|
|
750
906
|
// of octets are received, the recipient MUST consider the message to be incomplete"
|
|
@@ -762,7 +918,8 @@ export default class Request extends Duplex {
|
|
|
762
918
|
timings: this.timings,
|
|
763
919
|
});
|
|
764
920
|
this.push(null);
|
|
765
|
-
}
|
|
921
|
+
};
|
|
922
|
+
response.once('end', handleResponseEnd);
|
|
766
923
|
this.emit('downloadProgress', this.downloadProgress);
|
|
767
924
|
response.on('readable', () => {
|
|
768
925
|
if (this._triggerRead) {
|
|
@@ -776,7 +933,13 @@ export default class Request extends Duplex {
|
|
|
776
933
|
response.pause();
|
|
777
934
|
});
|
|
778
935
|
if (this._noPipe) {
|
|
779
|
-
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
|
+
}
|
|
780
943
|
if (success) {
|
|
781
944
|
this.emit('response', response);
|
|
782
945
|
}
|
|
@@ -787,10 +950,6 @@ export default class Request extends Duplex {
|
|
|
787
950
|
if (destination.headersSent) {
|
|
788
951
|
continue;
|
|
789
952
|
}
|
|
790
|
-
// Check if decompression actually occurred by comparing stream objects.
|
|
791
|
-
// decompressResponse wraps the response stream when it decompresses,
|
|
792
|
-
// so response !== this._nativeResponse indicates decompression happened.
|
|
793
|
-
const wasDecompressed = response !== this._nativeResponse;
|
|
794
953
|
for (const key in response.headers) {
|
|
795
954
|
if (Object.hasOwn(response.headers, key)) {
|
|
796
955
|
const value = response.headers[key];
|
|
@@ -809,21 +968,39 @@ export default class Request extends Duplex {
|
|
|
809
968
|
}
|
|
810
969
|
}
|
|
811
970
|
async _setRawBody(from = this) {
|
|
812
|
-
if (from.readableEnded) {
|
|
813
|
-
return false;
|
|
814
|
-
}
|
|
815
971
|
try {
|
|
816
972
|
// Errors are emitted via the `error` event
|
|
817
973
|
const fromArray = await from.toArray();
|
|
818
|
-
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;
|
|
819
979
|
// On retry Request is destroyed with no error, therefore the above will successfully resolve.
|
|
820
|
-
// So in order to check if this was really
|
|
821
|
-
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) {
|
|
822
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
|
+
}
|
|
823
997
|
return true;
|
|
824
998
|
}
|
|
825
999
|
}
|
|
826
1000
|
catch { }
|
|
1001
|
+
finally {
|
|
1002
|
+
this._incrementalDecode = undefined;
|
|
1003
|
+
}
|
|
827
1004
|
return false;
|
|
828
1005
|
}
|
|
829
1006
|
async _onResponse(response) {
|
|
@@ -832,7 +1009,7 @@ export default class Request extends Duplex {
|
|
|
832
1009
|
}
|
|
833
1010
|
catch (error) {
|
|
834
1011
|
/* istanbul ignore next: better safe than sorry */
|
|
835
|
-
this._beforeError(error);
|
|
1012
|
+
this._beforeError(normalizeError(error));
|
|
836
1013
|
}
|
|
837
1014
|
}
|
|
838
1015
|
_onRequest(request) {
|
|
@@ -841,7 +1018,7 @@ export default class Request extends Duplex {
|
|
|
841
1018
|
// Publish request start event
|
|
842
1019
|
publishRequestStart({
|
|
843
1020
|
requestId: this._requestId,
|
|
844
|
-
url:
|
|
1021
|
+
url: getSanitizedUrl(this.options),
|
|
845
1022
|
method: options.method,
|
|
846
1023
|
headers: options.headers,
|
|
847
1024
|
});
|
|
@@ -859,32 +1036,80 @@ export default class Request extends Duplex {
|
|
|
859
1036
|
socket.removeAllListeners('timeout');
|
|
860
1037
|
});
|
|
861
1038
|
}
|
|
1039
|
+
let lastRequestError;
|
|
862
1040
|
const responseEventName = options.cache ? 'cacheableResponse' : 'response';
|
|
863
1041
|
request.once(responseEventName, (response) => {
|
|
864
1042
|
void this._onResponse(response);
|
|
865
1043
|
});
|
|
866
|
-
|
|
1044
|
+
const emitRequestError = (error) => {
|
|
867
1045
|
this._aborted = true;
|
|
868
1046
|
// Force clean-up, because some packages (e.g. nock) don't do this.
|
|
869
1047
|
request.destroy();
|
|
870
|
-
|
|
871
|
-
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);
|
|
872
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
|
+
}
|
|
873
1085
|
this._unproxyEvents = proxyEvents(request, this, proxiedRequestEvents);
|
|
874
1086
|
this._request = request;
|
|
875
1087
|
this.emit('uploadProgress', this.uploadProgress);
|
|
876
1088
|
this._sendBody();
|
|
877
1089
|
this.emit('request', request);
|
|
878
1090
|
}
|
|
879
|
-
|
|
1091
|
+
_isRequestStale(request) {
|
|
1092
|
+
return this._request !== request || Boolean(request.res) || request.destroyed || request.writableEnded;
|
|
1093
|
+
}
|
|
1094
|
+
async _asyncWrite(chunk, request = this) {
|
|
880
1095
|
return new Promise((resolve, reject) => {
|
|
881
|
-
|
|
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 => {
|
|
882
1107
|
if (error) {
|
|
883
1108
|
reject(error);
|
|
884
1109
|
return;
|
|
885
1110
|
}
|
|
886
1111
|
resolve();
|
|
887
|
-
});
|
|
1112
|
+
}, request);
|
|
888
1113
|
});
|
|
889
1114
|
}
|
|
890
1115
|
_sendBody() {
|
|
@@ -896,41 +1121,250 @@ export default class Request extends Duplex {
|
|
|
896
1121
|
}
|
|
897
1122
|
else if (is.buffer(body)) {
|
|
898
1123
|
// Buffer should be sent directly without conversion
|
|
899
|
-
this.
|
|
900
|
-
currentRequest.end();
|
|
1124
|
+
this._writeBodyInChunks(body, currentRequest);
|
|
901
1125
|
}
|
|
902
1126
|
else if (is.typedArray(body)) {
|
|
903
1127
|
// Typed arrays should be treated like buffers, not iterated over
|
|
904
1128
|
// Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
|
|
905
1129
|
const typedArray = body;
|
|
906
1130
|
const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
|
|
907
|
-
this.
|
|
908
|
-
currentRequest.end();
|
|
1131
|
+
this._writeBodyInChunks(uint8View, currentRequest);
|
|
909
1132
|
}
|
|
910
1133
|
else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
|
|
911
1134
|
(async () => {
|
|
1135
|
+
const isInitialRequest = currentRequest === this;
|
|
912
1136
|
try {
|
|
913
1137
|
for await (const chunk of body) {
|
|
914
|
-
|
|
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);
|
|
915
1152
|
}
|
|
916
|
-
super.end();
|
|
917
1153
|
}
|
|
918
1154
|
catch (error) {
|
|
919
|
-
this.
|
|
1155
|
+
if (this.options.body !== body) {
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
this._beforeError(normalizeError(error));
|
|
920
1159
|
}
|
|
921
1160
|
})();
|
|
922
1161
|
}
|
|
923
1162
|
else if (is.undefined(body)) {
|
|
924
1163
|
// No body to send, end the request
|
|
925
1164
|
const cannotHaveBody = methodsWithoutBody.has(this.options.method) && !(this.options.method === 'GET' && this.options.allowGetBody);
|
|
926
|
-
|
|
927
|
-
if ((this._noPipe ?? false) || cannotHaveBody || currentRequest !== this || shouldAutoEndStream) {
|
|
1165
|
+
if ((this._noPipe ?? false) || cannotHaveBody || currentRequest !== this) {
|
|
928
1166
|
currentRequest.end();
|
|
929
1167
|
}
|
|
930
1168
|
}
|
|
931
1169
|
else {
|
|
932
|
-
|
|
933
|
-
|
|
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
|
+
result.catch(noop); // eslint-disable-line promise/prefer-await-to-then
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
catch { }
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
else if (!hadOptionBody && !this.writableEnded) {
|
|
1329
|
+
this._skipRequestEndInFinal = true;
|
|
1330
|
+
super.end();
|
|
1331
|
+
}
|
|
1332
|
+
updatedOptions.clearBody();
|
|
1333
|
+
this._bodySize = undefined;
|
|
1334
|
+
}
|
|
1335
|
+
_onBodyError = (error) => {
|
|
1336
|
+
if (this._flushed) {
|
|
1337
|
+
this._beforeError(new UploadError(error, this));
|
|
1338
|
+
}
|
|
1339
|
+
else {
|
|
1340
|
+
this.flush = async () => {
|
|
1341
|
+
this.flush = async () => { };
|
|
1342
|
+
this._beforeError(new UploadError(error, this));
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
async _writeChunksToRequest(buffer, request) {
|
|
1347
|
+
const chunkSize = 65_536; // 64 KB
|
|
1348
|
+
const isStale = () => this._isRequestStale(request);
|
|
1349
|
+
for (const part of chunk(buffer, chunkSize)) {
|
|
1350
|
+
if (isStale()) {
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1354
|
+
await new Promise((resolve, reject) => {
|
|
1355
|
+
this._writeRequest(part, undefined, error => {
|
|
1356
|
+
if (isStale()) {
|
|
1357
|
+
resolve();
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (error) {
|
|
1361
|
+
reject(error);
|
|
1362
|
+
}
|
|
1363
|
+
else {
|
|
1364
|
+
setImmediate(resolve);
|
|
1365
|
+
}
|
|
1366
|
+
}, request);
|
|
1367
|
+
});
|
|
934
1368
|
}
|
|
935
1369
|
}
|
|
936
1370
|
_prepareCache(cache) {
|
|
@@ -948,59 +1382,62 @@ export default class Request extends Duplex {
|
|
|
948
1382
|
Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
|
|
949
1383
|
Mutations take effect immediately and determine what gets cached.
|
|
950
1384
|
*/
|
|
951
|
-
const wrappedHandler = handler
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
const
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
1385
|
+
const wrappedHandler = handler
|
|
1386
|
+
? (response) => {
|
|
1387
|
+
const { beforeCacheHooks, gotRequest } = requestOptions;
|
|
1388
|
+
// Early return if no hooks - cache the original response
|
|
1389
|
+
if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
|
|
1390
|
+
handler(response);
|
|
1391
|
+
return;
|
|
1392
|
+
}
|
|
1393
|
+
try {
|
|
1394
|
+
// Call each beforeCache hook with the response
|
|
1395
|
+
// Hooks can directly mutate the response - mutations take effect immediately
|
|
1396
|
+
for (const hook of beforeCacheHooks) {
|
|
1397
|
+
const result = hook(response);
|
|
1398
|
+
if (result === false) {
|
|
1399
|
+
// Prevent caching by adding no-cache headers
|
|
1400
|
+
// Mutate the response directly to add headers
|
|
1401
|
+
response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
|
|
1402
|
+
response.headers.pragma = 'no-cache';
|
|
1403
|
+
response.headers.expires = '0';
|
|
1404
|
+
handler(response);
|
|
1405
|
+
// Don't call remaining hooks - we've decided not to cache
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
if (is.promise(result)) {
|
|
1409
|
+
// BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
|
|
1410
|
+
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.');
|
|
1411
|
+
}
|
|
1412
|
+
if (result !== undefined) {
|
|
1413
|
+
// Hooks should return false or undefined only
|
|
1414
|
+
// Mutations work directly - no need to return the response
|
|
1415
|
+
throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
|
|
1416
|
+
}
|
|
1417
|
+
// Else: void/undefined = continue
|
|
981
1418
|
}
|
|
982
|
-
// Else: void/undefined = continue
|
|
983
1419
|
}
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1420
|
+
catch (error) {
|
|
1421
|
+
const normalizedError = normalizeError(error);
|
|
1422
|
+
// Convert hook errors to RequestError and propagate
|
|
1423
|
+
// This is consistent with how other hooks handle errors
|
|
1424
|
+
if (gotRequest) {
|
|
1425
|
+
gotRequest._beforeError(normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, gotRequest));
|
|
1426
|
+
// Don't call handler when error was propagated successfully
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
// If gotRequest is missing, log the error to aid debugging
|
|
1430
|
+
// We still call the handler to prevent the request from hanging
|
|
1431
|
+
console.error('Got: beforeCache hook error (request context unavailable):', normalizedError);
|
|
1432
|
+
// Call handler with response (potentially partially modified)
|
|
1433
|
+
handler(response);
|
|
991
1434
|
return;
|
|
992
1435
|
}
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
console.error('Got: beforeCache hook error (request context unavailable):', error);
|
|
996
|
-
// Call handler with response (potentially partially modified)
|
|
1436
|
+
// All hooks ran successfully
|
|
1437
|
+
// Cache the response with any mutations applied
|
|
997
1438
|
handler(response);
|
|
998
|
-
return;
|
|
999
1439
|
}
|
|
1000
|
-
|
|
1001
|
-
// Cache the response with any mutations applied
|
|
1002
|
-
handler(response);
|
|
1003
|
-
} : handler;
|
|
1440
|
+
: handler;
|
|
1004
1441
|
const result = requestOptions._request(requestOptions, wrappedHandler);
|
|
1005
1442
|
// TODO: remove this when `cacheable-request` supports async request functions.
|
|
1006
1443
|
if (is.promise(result)) {
|
|
@@ -1042,8 +1479,18 @@ export default class Request extends Duplex {
|
|
|
1042
1479
|
}
|
|
1043
1480
|
async _createCacheableRequest(url, options) {
|
|
1044
1481
|
return new Promise((resolve, reject) => {
|
|
1045
|
-
|
|
1046
|
-
|
|
1482
|
+
Object.assign(options, {
|
|
1483
|
+
protocol: url.protocol,
|
|
1484
|
+
hostname: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
|
|
1485
|
+
host: url.host,
|
|
1486
|
+
hash: url.hash === '' ? '' : (url.hash ?? null),
|
|
1487
|
+
search: url.search === '' ? '' : (url.search ?? null),
|
|
1488
|
+
pathname: url.pathname,
|
|
1489
|
+
href: url.href,
|
|
1490
|
+
path: `${url.pathname || ''}${url.search || ''}`,
|
|
1491
|
+
...(is.string(url.port) && url.port.length > 0 ? { port: Number(url.port) } : {}),
|
|
1492
|
+
...(url.username || url.password ? { auth: `${url.username || ''}:${url.password || ''}` } : {}),
|
|
1493
|
+
});
|
|
1047
1494
|
let request;
|
|
1048
1495
|
// TODO: Fix `cacheable-response`. This is ugly.
|
|
1049
1496
|
const cacheRequest = cacheableStore.get(options.cache)(options, async (response) => {
|
|
@@ -1075,12 +1522,12 @@ export default class Request extends Duplex {
|
|
|
1075
1522
|
}
|
|
1076
1523
|
async _makeRequest() {
|
|
1077
1524
|
const { options } = this;
|
|
1078
|
-
const
|
|
1525
|
+
const headers = options.getInternalHeaders();
|
|
1526
|
+
const { username, password } = options;
|
|
1079
1527
|
const cookieJar = options.cookieJar;
|
|
1080
1528
|
for (const key in headers) {
|
|
1081
1529
|
if (is.undefined(headers[key])) {
|
|
1082
|
-
|
|
1083
|
-
delete headers[key];
|
|
1530
|
+
options.deleteInternalHeader(key);
|
|
1084
1531
|
}
|
|
1085
1532
|
else if (is.null(headers[key])) {
|
|
1086
1533
|
throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
|
|
@@ -1094,17 +1541,17 @@ export default class Request extends Duplex {
|
|
|
1094
1541
|
if (supportsZstd) {
|
|
1095
1542
|
encodings.push('zstd');
|
|
1096
1543
|
}
|
|
1097
|
-
|
|
1544
|
+
options.setInternalHeader('accept-encoding', encodings.join(', '));
|
|
1098
1545
|
}
|
|
1099
1546
|
if (username || password) {
|
|
1100
|
-
const credentials =
|
|
1101
|
-
|
|
1547
|
+
const credentials = stringToBase64(`${username}:${password}`);
|
|
1548
|
+
options.setInternalHeader('authorization', `Basic ${credentials}`);
|
|
1102
1549
|
}
|
|
1103
1550
|
// Set cookies
|
|
1104
1551
|
if (cookieJar) {
|
|
1105
1552
|
const cookieString = await cookieJar.getCookieString(options.url.toString());
|
|
1106
1553
|
if (is.nonEmptyString(cookieString)) {
|
|
1107
|
-
|
|
1554
|
+
options.setInternalHeader('cookie', cookieString);
|
|
1108
1555
|
}
|
|
1109
1556
|
}
|
|
1110
1557
|
let request;
|
|
@@ -1117,7 +1564,11 @@ export default class Request extends Duplex {
|
|
|
1117
1564
|
break;
|
|
1118
1565
|
}
|
|
1119
1566
|
}
|
|
1120
|
-
|
|
1567
|
+
if (!is.undefined(headers['transfer-encoding']) && !is.undefined(headers['content-length'])) {
|
|
1568
|
+
// TODO: Throw instead of silently dropping `content-length` in the next major version.
|
|
1569
|
+
options.deleteInternalHeader('content-length');
|
|
1570
|
+
}
|
|
1571
|
+
request ??= options.getRequestFunction();
|
|
1121
1572
|
const url = options.url;
|
|
1122
1573
|
this._requestOptions = options.createNativeRequestOptions();
|
|
1123
1574
|
if (options.cache) {
|
|
@@ -1130,7 +1581,7 @@ export default class Request extends Duplex {
|
|
|
1130
1581
|
this._prepareCache(options.cache);
|
|
1131
1582
|
}
|
|
1132
1583
|
catch (error) {
|
|
1133
|
-
throw new CacheError(error, this);
|
|
1584
|
+
throw new CacheError(normalizeError(error), this);
|
|
1134
1585
|
}
|
|
1135
1586
|
}
|
|
1136
1587
|
// Cache support
|
|
@@ -1142,13 +1593,6 @@ export default class Request extends Duplex {
|
|
|
1142
1593
|
if (is.promise(requestOrResponse)) {
|
|
1143
1594
|
requestOrResponse = await requestOrResponse;
|
|
1144
1595
|
}
|
|
1145
|
-
// Fallback
|
|
1146
|
-
if (is.undefined(requestOrResponse)) {
|
|
1147
|
-
requestOrResponse = options.getFallbackRequestFunction()(url, this._requestOptions);
|
|
1148
|
-
if (is.promise(requestOrResponse)) {
|
|
1149
|
-
requestOrResponse = await requestOrResponse;
|
|
1150
|
-
}
|
|
1151
|
-
}
|
|
1152
1596
|
if (isClientRequest(requestOrResponse)) {
|
|
1153
1597
|
this._onRequest(requestOrResponse);
|
|
1154
1598
|
}
|
|
@@ -1171,12 +1615,9 @@ export default class Request extends Duplex {
|
|
|
1171
1615
|
}
|
|
1172
1616
|
async _error(error) {
|
|
1173
1617
|
try {
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
// See https://github.com/sindresorhus/got/issues/2103
|
|
1178
|
-
}
|
|
1179
|
-
else if (this.options) {
|
|
1618
|
+
// Skip calling hooks for HTTP errors when throwHttpErrors is false (Promise API only).
|
|
1619
|
+
// See https://github.com/sindresorhus/got/issues/2103
|
|
1620
|
+
if (this.options && (!(error instanceof HTTPError) || this.options.throwHttpErrors)) {
|
|
1180
1621
|
const hooks = this.options.hooks.beforeError;
|
|
1181
1622
|
if (hooks.length > 0) {
|
|
1182
1623
|
for (const hook of hooks) {
|
|
@@ -1197,12 +1638,13 @@ export default class Request extends Duplex {
|
|
|
1197
1638
|
}
|
|
1198
1639
|
}
|
|
1199
1640
|
catch (error_) {
|
|
1200
|
-
|
|
1641
|
+
const normalizedError = normalizeError(error_);
|
|
1642
|
+
error = new RequestError(normalizedError.message, normalizedError, this);
|
|
1201
1643
|
}
|
|
1202
1644
|
// Publish error event
|
|
1203
1645
|
publishError({
|
|
1204
1646
|
requestId: this._requestId,
|
|
1205
|
-
url: this.options
|
|
1647
|
+
url: getSanitizedUrl(this.options),
|
|
1206
1648
|
error,
|
|
1207
1649
|
timings: this.timings,
|
|
1208
1650
|
});
|
|
@@ -1218,16 +1660,17 @@ export default class Request extends Duplex {
|
|
|
1218
1660
|
});
|
|
1219
1661
|
}
|
|
1220
1662
|
}
|
|
1221
|
-
_writeRequest(chunk, encoding, callback) {
|
|
1222
|
-
if (!
|
|
1663
|
+
_writeRequest(chunk, encoding, callback, request = this._request) {
|
|
1664
|
+
if (!request || request.destroyed) {
|
|
1223
1665
|
// When there's no request (e.g., using cached response from beforeRequest hook),
|
|
1224
1666
|
// we still need to call the callback to allow the stream to finish properly.
|
|
1225
1667
|
callback();
|
|
1226
1668
|
return;
|
|
1227
1669
|
}
|
|
1228
|
-
|
|
1229
|
-
// The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed
|
|
1230
|
-
|
|
1670
|
+
request.write(chunk, encoding, (error) => {
|
|
1671
|
+
// The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed.
|
|
1672
|
+
// The `this._request === request` check prevents stale write callbacks from a pre-redirect request from incrementing `_uploadedSize` after it's been reset.
|
|
1673
|
+
if (!error && !request.destroyed && this._request === request) {
|
|
1231
1674
|
// For strings, encode them first to measure the actual bytes that will be sent
|
|
1232
1675
|
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk;
|
|
1233
1676
|
this._uploadedSize += byteLength(bytes);
|
|
@@ -1258,41 +1701,13 @@ export default class Request extends Duplex {
|
|
|
1258
1701
|
Progress event for downloading (receiving a response).
|
|
1259
1702
|
*/
|
|
1260
1703
|
get downloadProgress() {
|
|
1261
|
-
|
|
1262
|
-
if (this._responseSize) {
|
|
1263
|
-
percent = this._downloadedSize / this._responseSize;
|
|
1264
|
-
}
|
|
1265
|
-
else if (this._responseSize === this._downloadedSize) {
|
|
1266
|
-
percent = 1;
|
|
1267
|
-
}
|
|
1268
|
-
else {
|
|
1269
|
-
percent = 0;
|
|
1270
|
-
}
|
|
1271
|
-
return {
|
|
1272
|
-
percent,
|
|
1273
|
-
transferred: this._downloadedSize,
|
|
1274
|
-
total: this._responseSize,
|
|
1275
|
-
};
|
|
1704
|
+
return makeProgress(this._downloadedSize, this._responseSize);
|
|
1276
1705
|
}
|
|
1277
1706
|
/**
|
|
1278
1707
|
Progress event for uploading (sending a request).
|
|
1279
1708
|
*/
|
|
1280
1709
|
get uploadProgress() {
|
|
1281
|
-
|
|
1282
|
-
if (this._bodySize) {
|
|
1283
|
-
percent = this._uploadedSize / this._bodySize;
|
|
1284
|
-
}
|
|
1285
|
-
else if (this._bodySize === this._uploadedSize) {
|
|
1286
|
-
percent = 1;
|
|
1287
|
-
}
|
|
1288
|
-
else {
|
|
1289
|
-
percent = 0;
|
|
1290
|
-
}
|
|
1291
|
-
return {
|
|
1292
|
-
percent,
|
|
1293
|
-
transferred: this._uploadedSize,
|
|
1294
|
-
total: this._bodySize,
|
|
1295
|
-
};
|
|
1710
|
+
return makeProgress(this._uploadedSize, this._bodySize);
|
|
1296
1711
|
}
|
|
1297
1712
|
/**
|
|
1298
1713
|
The object contains the following properties:
|
|
@@ -1328,7 +1743,7 @@ export default class Request extends Duplex {
|
|
|
1328
1743
|
Whether the response was retrieved from the cache.
|
|
1329
1744
|
*/
|
|
1330
1745
|
get isFromCache() {
|
|
1331
|
-
return this.
|
|
1746
|
+
return this.response?.isFromCache;
|
|
1332
1747
|
}
|
|
1333
1748
|
get reusedSocket() {
|
|
1334
1749
|
return this._request?.reusedSocket;
|