The numeric Retry-After branch multiplied the header value by 1000 without guarding non-positive results, so `Retry-After: 0` produced a falsy delay that fell through to exponential backoff instead of retrying immediately. The RFC-1123 date branch already clamps non-positive values to 1ms; mirror that guard in the numeric branch so a non-positive Retry-After retries at once.
2663 lines
86 KiB
TypeScript
2663 lines
86 KiB
TypeScript
import process from 'node:process';
|
|
import {Buffer} from 'node:buffer';
|
|
import {Duplex, type Readable} from 'node:stream';
|
|
import {addAbortListener} from 'node:events';
|
|
import http, {ServerResponse, type ClientRequest, type RequestOptions} from 'node:http';
|
|
import type {Socket} from 'node:net';
|
|
import {byteLength} from 'byte-counter';
|
|
import {chunk} from 'chunk-data';
|
|
import {concatUint8Arrays, stringToBase64, stringToUint8Array} from 'uint8array-extras';
|
|
import CacheableRequest, {
|
|
CacheError as CacheableCacheError,
|
|
type CacheableRequestFunction,
|
|
type CacheableOptions,
|
|
} from 'cacheable-request';
|
|
import decompressResponse from 'decompress-response';
|
|
import type {KeyvStoreAdapter} from 'keyv';
|
|
import type KeyvType from 'keyv';
|
|
import is, {isBuffer} from '@sindresorhus/is';
|
|
import type ResponseLike from 'responselike';
|
|
import timer, {type ClientRequestWithTimings, type Timings, type IncomingMessageWithTimings} from './utils/timer.js';
|
|
import getBodySize from './utils/get-body-size.js';
|
|
import proxyEvents from './utils/proxy-events.js';
|
|
import timedOut, {TimeoutError as TimedOutTimeoutError} from './timed-out.js';
|
|
import stripUrlAuth from './utils/strip-url-auth.js';
|
|
import WeakableMap from './utils/weakable-map.js';
|
|
import calculateRetryDelay from './calculate-retry-delay.js';
|
|
import Options, {
|
|
assertUrlHasSameOriginAsPrefixUrlIfNeeded,
|
|
crossOriginStripHeaders,
|
|
getUrlPrefixBoundary,
|
|
hasUrlOrPrefixUrlBoundaryChanged,
|
|
hasExplicitCredentialInUrlChange,
|
|
isBodyUnchanged,
|
|
isCrossOriginCredentialChanged,
|
|
isSameOrigin,
|
|
snapshotCrossOriginState,
|
|
type CrossOriginState,
|
|
type PromiseCookieJar,
|
|
type NativeRequestOptions,
|
|
type RetryOptions,
|
|
type OptionsError,
|
|
type OptionsInit,
|
|
type NormalizedOptions,
|
|
} from './options.js';
|
|
import {
|
|
cacheDecodedBody,
|
|
decodeUint8Array,
|
|
isResponseOk,
|
|
isUtf8Encoding,
|
|
type PlainResponse,
|
|
type Response,
|
|
} from './response.js';
|
|
import isClientRequest from './utils/is-client-request.js';
|
|
import {getUnixSocketPath} from './utils/is-unix-socket-url.js';
|
|
import {
|
|
RequestError,
|
|
ReadError,
|
|
MaxRedirectsError,
|
|
HTTPError,
|
|
TimeoutError,
|
|
UploadError,
|
|
CacheError,
|
|
AbortError,
|
|
} from './errors.js';
|
|
import {
|
|
generateRequestId,
|
|
publishRequestCreate,
|
|
publishRequestStart,
|
|
publishResponseStart,
|
|
publishResponseEnd,
|
|
publishRetry,
|
|
publishError,
|
|
publishRedirect,
|
|
} from './diagnostics-channel.js';
|
|
|
|
type Error = NodeJS.ErrnoException;
|
|
|
|
export type Progress = {
|
|
percent: number;
|
|
transferred: number;
|
|
total?: number;
|
|
};
|
|
|
|
const supportsBrotli = is.string(process.versions.brotli);
|
|
const supportsZstd = is.string(process.versions.zstd);
|
|
const methodsWithoutBody: ReadonlySet<string> = new Set(['GET', 'HEAD']);
|
|
const singleValueRequestHeaders: ReadonlySet<string> = new Set([
|
|
'authorization',
|
|
'content-length',
|
|
'proxy-authorization',
|
|
]);
|
|
|
|
export type GotEventFunction<T> =
|
|
/**
|
|
`request` event to get the request object of the request.
|
|
|
|
__Tip__: You can use `request` event to abort requests.
|
|
|
|
@example
|
|
```
|
|
import got from 'got';
|
|
|
|
got.stream('https://github.com')
|
|
.on('request', request => setTimeout(() => request.destroy(), 50));
|
|
```
|
|
*/
|
|
((name: 'request', listener: (request: ClientRequest) => void) => T)
|
|
|
|
/**
|
|
The `response` event to get the response object of the final request.
|
|
*/
|
|
& (<R extends Response>(name: 'response', listener: (response: R) => void) => T)
|
|
|
|
/**
|
|
The `redirect` event to get the response object of a redirect. The second argument is options for the next request to the redirect location.
|
|
*/
|
|
& (<R extends Response, N extends Options>(name: 'redirect', listener: (response: R, nextOptions: N) => void) => T)
|
|
|
|
/**
|
|
Progress events for uploading (sending a request) and downloading (receiving a response).
|
|
The `progress` argument is an object like:
|
|
|
|
```
|
|
{
|
|
percent: 0.1,
|
|
transferred: 1024,
|
|
total: 10240
|
|
}
|
|
```
|
|
|
|
If the `content-length` header is missing, `total` will be `undefined`.
|
|
|
|
@example
|
|
```
|
|
import got from 'got';
|
|
|
|
const response = await got('https://sindresorhus.com')
|
|
.on('downloadProgress', progress => {
|
|
// Report download progress
|
|
})
|
|
.on('uploadProgress', progress => {
|
|
// Report upload progress
|
|
});
|
|
|
|
console.log(response);
|
|
```
|
|
*/
|
|
& ((name: 'uploadProgress' | 'downloadProgress', listener: (progress: Progress) => void) => T)
|
|
/**
|
|
To enable retrying on a Got stream, it is required to have a `retry` handler attached.
|
|
|
|
When this event is emitted, you should reset the stream you were writing to and prepare the body again.
|
|
|
|
See `got.options.retry` for more information.
|
|
*/
|
|
& ((name: 'retry', listener: (retryCount: number, error: RequestError, createRetryStream: (options?: OptionsInit) => Request) => void) => T);
|
|
|
|
export type RequestEvents<T> = {
|
|
on: GotEventFunction<T>;
|
|
once: GotEventFunction<T>;
|
|
off: GotEventFunction<T>;
|
|
};
|
|
|
|
type StorageAdapter = KeyvStoreAdapter | KeyvType | Map<unknown, unknown>;
|
|
|
|
const cacheableStore = new WeakableMap<string | StorageAdapter, CacheableRequestFunction>();
|
|
|
|
const redirectCodes: ReadonlySet<number> = new Set([301, 302, 303, 307, 308]);
|
|
export {crossOriginStripHeaders} from './options.js';
|
|
const transientWriteErrorCodes: ReadonlySet<string> = new Set(['EPIPE', 'ECONNRESET']);
|
|
const omittedPipedHeaders = new Set([
|
|
'host',
|
|
'connection',
|
|
'keep-alive',
|
|
'proxy-authenticate',
|
|
'proxy-authorization',
|
|
'proxy-connection',
|
|
'te',
|
|
'trailer',
|
|
'transfer-encoding',
|
|
'upgrade',
|
|
]);
|
|
|
|
// Track errors that have been processed by beforeError hooks to preserve custom error types
|
|
const errorsProcessedByHooks = new WeakSet<Error>();
|
|
|
|
const proxiedRequestEvents = [
|
|
'socket',
|
|
'connect',
|
|
'continue',
|
|
'information',
|
|
'upgrade',
|
|
] as const;
|
|
|
|
const noop = (): void => {};
|
|
|
|
const createPreRequestErrorTimings = (): Timings => {
|
|
const now = Date.now();
|
|
|
|
return {
|
|
start: now,
|
|
error: now,
|
|
phases: {
|
|
total: 0,
|
|
},
|
|
};
|
|
};
|
|
|
|
type NativeFormDataBodyMetadata = {
|
|
form: FormData;
|
|
body: ReadableStream;
|
|
contentTypeWasGenerated: boolean;
|
|
};
|
|
|
|
const serializeNativeFormDataBody = (form: FormData) => {
|
|
const response = new globalThis.Response(form);
|
|
return {
|
|
body: response.body!,
|
|
contentType: response.headers.get('content-type') ?? 'multipart/form-data',
|
|
};
|
|
};
|
|
|
|
// A body is replayable only if iterating it again restarts from the beginning.
|
|
// Node streams, Web `ReadableStream`s, generators, and self-iterating (one-shot) iterators all yield their data only once, so they cannot be replayed on a redirect.
|
|
const isNonReplayableBody = (body: unknown): boolean =>
|
|
is.nodeStream(body)
|
|
|| (typeof ReadableStream !== 'undefined' && body instanceof ReadableStream)
|
|
|| is.generator(body)
|
|
|| (is.asyncIterable(body) && (body[Symbol.asyncIterator]() as unknown) === body)
|
|
|| (is.iterable(body) && (body[Symbol.iterator]() as unknown) === body);
|
|
|
|
const isTransientWriteError = (error: Error): boolean => {
|
|
const {code} = error;
|
|
return typeof code === 'string' && transientWriteErrorCodes.has(code);
|
|
};
|
|
|
|
const getConnectionListedHeaders = (headers: Record<string, string | string[] | undefined>): Set<string> => {
|
|
const connectionListedHeaders = new Set<string>();
|
|
|
|
for (const [header, connectionHeader] of Object.entries(headers)) {
|
|
const normalizedHeader = header.toLowerCase();
|
|
|
|
if (normalizedHeader !== 'connection' && normalizedHeader !== 'proxy-connection') {
|
|
continue;
|
|
}
|
|
|
|
const connectionHeaderValues = Array.isArray(connectionHeader) ? connectionHeader : [connectionHeader];
|
|
|
|
for (const value of connectionHeaderValues) {
|
|
if (typeof value !== 'string') {
|
|
continue;
|
|
}
|
|
|
|
for (const token of value.split(',')) {
|
|
const normalizedToken = token.trim().toLowerCase();
|
|
|
|
if (normalizedToken.length > 0) {
|
|
connectionListedHeaders.add(normalizedToken);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return connectionListedHeaders;
|
|
};
|
|
|
|
export const normalizeError = (error: unknown): Error => {
|
|
if (error instanceof globalThis.Error) {
|
|
return error;
|
|
}
|
|
|
|
if (is.object(error)) {
|
|
const errorLike = error as Partial<Error & {code?: string; input?: string}>;
|
|
const message = typeof errorLike.message === 'string' ? errorLike.message : 'Non-error object thrown';
|
|
const normalizedError = new globalThis.Error(message, {cause: error}) as Error & {code?: string; input?: string};
|
|
|
|
if (typeof errorLike.stack === 'string') {
|
|
normalizedError.stack = errorLike.stack;
|
|
}
|
|
|
|
if (typeof errorLike.code === 'string') {
|
|
normalizedError.code = errorLike.code;
|
|
}
|
|
|
|
if (typeof errorLike.input === 'string') {
|
|
normalizedError.input = errorLike.input;
|
|
}
|
|
|
|
return normalizedError;
|
|
}
|
|
|
|
return new globalThis.Error(String(error));
|
|
};
|
|
|
|
type UrlType = ConstructorParameters<typeof Options>[0];
|
|
type OptionsType = ConstructorParameters<typeof Options>[1];
|
|
type DefaultsType = ConstructorParameters<typeof Options>[2];
|
|
const getSanitizedUrl = (options?: Options): string => options?.url ? stripUrlAuth(options.url) : '';
|
|
|
|
const makeProgress = (transferred: number, total?: number): Progress => {
|
|
let percent = 0;
|
|
if (total) {
|
|
percent = transferred / total;
|
|
} else if (total === transferred) {
|
|
percent = 1;
|
|
}
|
|
|
|
return {percent, transferred, total};
|
|
};
|
|
|
|
export default class Request extends Duplex implements RequestEvents<Request> {
|
|
// @ts-expect-error - Ignoring for now.
|
|
override ['constructor']: typeof Request;
|
|
|
|
_noPipe?: boolean;
|
|
|
|
// @ts-expect-error https://github.com/microsoft/TypeScript/issues/9568
|
|
options: Options;
|
|
response?: PlainResponse;
|
|
requestUrl?: URL;
|
|
redirectUrls: URL[] = [];
|
|
retryCount = 0;
|
|
_stopReading = false;
|
|
|
|
declare private _requestOptions: NativeRequestOptions;
|
|
|
|
private _stopRetry?: () => void;
|
|
private _downloadedSize = 0;
|
|
private _uploadedSize = 0;
|
|
private readonly _pipedServerResponses = new Set<ServerResponse>();
|
|
private _request?: ClientRequest;
|
|
private _responseSize?: number;
|
|
private _bodySize?: number;
|
|
private _nativeFormDataBody?: NativeFormDataBodyMetadata;
|
|
private _unproxyEvents?: () => void;
|
|
private _triggerRead = false;
|
|
private readonly _jobs: Array<() => void> = [];
|
|
private _cancelTimeouts?: () => void;
|
|
private _abortListenerDisposer?: {[Symbol.dispose](): void};
|
|
private _flushed = false;
|
|
private _aborted = false;
|
|
private _expectedContentLength?: number;
|
|
private _compressedBytesCount?: number;
|
|
private _skipRequestEndInFinal = false;
|
|
private _hasWrittenBody = false;
|
|
private _hasWritableBody = false;
|
|
private _discardBodyWrites = false;
|
|
private _incrementalDecode?: {decoder: TextDecoder; chunks: string[]};
|
|
private readonly _requestId = generateRequestId();
|
|
|
|
// We need this because `this._request` if `undefined` when using cache
|
|
private _requestInitialized = false;
|
|
|
|
constructor(url: UrlType, options?: OptionsType, defaults?: DefaultsType) {
|
|
super({
|
|
// Don't destroy immediately, as the error may be emitted on unsuccessful retry
|
|
autoDestroy: false,
|
|
// It needs to be zero because we're just proxying the data to another stream
|
|
highWaterMark: 0,
|
|
});
|
|
|
|
this.on('pipe', (source: NodeJS.ReadableStream & {headers?: Record<string, string | string[] | undefined>}) => {
|
|
if (this.options.copyPipedHeaders && source?.headers) {
|
|
const connectionListedHeaders = getConnectionListedHeaders(source.headers);
|
|
|
|
for (const [header, value] of Object.entries(source.headers)) {
|
|
const normalizedHeader = header.toLowerCase();
|
|
|
|
if (omittedPipedHeaders.has(normalizedHeader) || connectionListedHeaders.has(normalizedHeader)) {
|
|
continue;
|
|
}
|
|
|
|
if (!this.options.shouldCopyPipedHeader(normalizedHeader)) {
|
|
continue;
|
|
}
|
|
|
|
this.options.setPipedHeader(normalizedHeader, value);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.on('newListener', event => {
|
|
if (event === 'retry' && this.listenerCount('retry') > 0) {
|
|
throw new Error('A retry listener has been attached already.');
|
|
}
|
|
});
|
|
|
|
try {
|
|
this.options = new Options(url, options, defaults);
|
|
|
|
if (!this.options.url) {
|
|
if (this.options.prefixUrl === '') {
|
|
throw new TypeError('Missing `url` property');
|
|
}
|
|
|
|
this.options.url = '';
|
|
}
|
|
|
|
this.requestUrl = this.options.url as URL;
|
|
|
|
// Publish request creation event
|
|
publishRequestCreate({
|
|
requestId: this._requestId,
|
|
url: getSanitizedUrl(this.options),
|
|
method: this.options.method,
|
|
});
|
|
} catch (error: unknown) {
|
|
const {options} = error as OptionsError;
|
|
if (options) {
|
|
this.options = options;
|
|
}
|
|
|
|
this.flush = async () => {
|
|
this.flush = async () => {};
|
|
|
|
// Defer error emission to next tick to allow user to attach error handlers
|
|
process.nextTick(() => {
|
|
// _beforeError requires options to access retry logic and hooks
|
|
if (this.options) {
|
|
this._beforeError(normalizeError(error));
|
|
} else {
|
|
// Options is undefined, skip _beforeError and destroy directly
|
|
const normalizedError = normalizeError(error);
|
|
const requestError = normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, this);
|
|
this.destroy(requestError);
|
|
}
|
|
});
|
|
};
|
|
|
|
return;
|
|
}
|
|
|
|
// Important! If you replace `body` in a handler with another stream, make sure it's readable first.
|
|
// The below is run only once.
|
|
const {body} = this.options;
|
|
if (is.nodeStream(body)) {
|
|
body.once('error', this._onBodyError);
|
|
}
|
|
}
|
|
|
|
async flush() {
|
|
if (this._flushed) {
|
|
return;
|
|
}
|
|
|
|
this._flushed = true;
|
|
|
|
try {
|
|
this._attachAbortListener();
|
|
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
await this._finalizeBody();
|
|
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
this._hasWritableBody = this._canWriteBody();
|
|
|
|
await this._makeRequest();
|
|
|
|
if (this.destroyed) {
|
|
this._request?.destroy();
|
|
return;
|
|
}
|
|
|
|
// Queued writes etc.
|
|
for (const job of this._jobs) {
|
|
job();
|
|
}
|
|
|
|
// Prevent memory leak
|
|
this._jobs.length = 0;
|
|
|
|
this._requestInitialized = true;
|
|
} catch (error: unknown) {
|
|
this._beforeError(normalizeError(error));
|
|
}
|
|
}
|
|
|
|
_beforeError(error: Error): void {
|
|
if (this._stopReading) {
|
|
return;
|
|
}
|
|
|
|
const {response, options} = this;
|
|
const attemptCount = this.retryCount + (error.name === 'RetryError' ? 0 : 1);
|
|
|
|
this._stopReading = true;
|
|
|
|
if (error instanceof TimedOutTimeoutError) {
|
|
error = new TimeoutError(error, this.timings ?? createPreRequestErrorTimings(), this);
|
|
} else if (!(error instanceof RequestError)) {
|
|
error = new RequestError(error.message, error, this);
|
|
}
|
|
|
|
const typedError = error as RequestError;
|
|
|
|
void (async () => {
|
|
// Node.js parser is really weird.
|
|
// It emits post-request Parse Errors on the same instance as previous request. WTF.
|
|
// Therefore, we need to check if it has been destroyed as well.
|
|
//
|
|
// Furthermore, Node.js 16 `response.destroy()` doesn't immediately destroy the socket,
|
|
// but makes the response unreadable. So we additionally need to check `response.readable`.
|
|
if (response?.readable && !response.rawBody && !this._request?.socket?.destroyed) {
|
|
// @types/node has incorrect typings. `setEncoding` accepts `null` as well.
|
|
response.setEncoding(this.readableEncoding!);
|
|
|
|
const success = await this._setRawBody(response);
|
|
|
|
if (success) {
|
|
response.body = decodeUint8Array(response.rawBody!);
|
|
}
|
|
}
|
|
|
|
if (this.listenerCount('retry') !== 0) {
|
|
let backoff: number;
|
|
|
|
try {
|
|
let retryAfter;
|
|
if (response && 'retry-after' in response.headers) {
|
|
retryAfter = Number(response.headers['retry-after']);
|
|
if (Number.isNaN(retryAfter)) {
|
|
retryAfter = Date.parse(response.headers['retry-after']!) - Date.now();
|
|
|
|
if (retryAfter <= 0) {
|
|
retryAfter = 1;
|
|
}
|
|
} else {
|
|
retryAfter *= 1000;
|
|
|
|
if (retryAfter <= 0) {
|
|
retryAfter = 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
const retryOptions = options.retry as RetryOptions;
|
|
|
|
const computedValue = calculateRetryDelay({
|
|
attemptCount,
|
|
retryOptions,
|
|
error: typedError,
|
|
retryAfter,
|
|
computedValue: retryOptions.maxRetryAfter ?? options.timeout.request ?? Number.POSITIVE_INFINITY,
|
|
});
|
|
|
|
// When enforceRetryRules is true, respect the retry rules (limit, methods, statusCodes, errorCodes)
|
|
// before calling the user's calculateDelay function. If computedValue is 0 (meaning retry is not allowed
|
|
// based on these rules), skip calling calculateDelay entirely.
|
|
// When false, always call calculateDelay, allowing it to override retry decisions.
|
|
if (retryOptions.enforceRetryRules && computedValue === 0) {
|
|
backoff = 0;
|
|
} else {
|
|
backoff = await retryOptions.calculateDelay({
|
|
attemptCount,
|
|
retryOptions,
|
|
error: typedError,
|
|
retryAfter,
|
|
computedValue,
|
|
});
|
|
}
|
|
} catch (error_: unknown) {
|
|
const normalizedError = normalizeError(error_);
|
|
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
return;
|
|
}
|
|
|
|
if (backoff) {
|
|
await new Promise<void>(resolve => {
|
|
const timeout = setTimeout(resolve, backoff);
|
|
this._stopRetry = () => {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
};
|
|
});
|
|
|
|
// Something forced us to abort the retry
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
// Capture body BEFORE hooks run to detect reassignment
|
|
const bodyBeforeHooks = this.options.body;
|
|
|
|
try {
|
|
for (const hook of this.options.hooks.beforeRetry) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await hook(typedError, this.retryCount + 1);
|
|
}
|
|
} catch (error_: unknown) {
|
|
const normalizedError = normalizeError(error_);
|
|
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
return;
|
|
}
|
|
|
|
// Something forced us to abort the retry
|
|
if (this.destroyed) {
|
|
return;
|
|
}
|
|
|
|
// Preserve stream body reassigned in beforeRetry hooks.
|
|
const bodyAfterHooks = this.options.body;
|
|
const bodyWasReassigned = bodyBeforeHooks !== bodyAfterHooks;
|
|
|
|
// Resource cleanup and preservation logic for retry with body reassignment.
|
|
// The Promise wrapper (as-promise/index.ts) compares body identity to detect consumed streams,
|
|
// so we must preserve the body reference across destroy(). However, destroy() calls _destroy()
|
|
// which destroys this.options.body, creating a complex dance of clear/restore operations.
|
|
//
|
|
// Key constraints:
|
|
// 1. If body was reassigned, we must NOT destroy the NEW stream (it will be used for retry)
|
|
// 2. If body was reassigned, we MUST destroy the OLD stream to prevent memory leaks
|
|
// 3. We must restore the body reference after destroy() for identity checks in promise wrapper
|
|
// 4. We cannot use the normal setter after destroy() because it validates stream readability
|
|
try {
|
|
if (bodyWasReassigned) {
|
|
const oldBody = bodyBeforeHooks;
|
|
// Temporarily clear body to prevent destroy() from destroying the new stream
|
|
this.options.body = undefined;
|
|
this.destroy();
|
|
|
|
// Clean up the old stream resource if it's a stream and different from new body
|
|
// (edge case: if old and new are same stream object, don't destroy it)
|
|
if (is.nodeStream(oldBody) && oldBody !== bodyAfterHooks) {
|
|
oldBody.destroy();
|
|
}
|
|
|
|
// Restore new body for promise wrapper's identity check
|
|
if (is.nodeStream(bodyAfterHooks) && (bodyAfterHooks.readableEnded || bodyAfterHooks.destroyed)) {
|
|
throw new TypeError('The reassigned stream body must be readable. Ensure you provide a fresh, readable stream in the beforeRetry hook.');
|
|
}
|
|
|
|
this.options.body = bodyAfterHooks;
|
|
} else {
|
|
// Body wasn't reassigned - use normal destroy flow which handles body cleanup
|
|
this.destroy();
|
|
// Note: We do NOT restore the body reference here. The stream was destroyed by _destroy()
|
|
// and should not be accessed. The promise wrapper will see that body identity hasn't changed
|
|
// and will detect it's a consumed stream, which is the correct behavior.
|
|
}
|
|
} catch (error_: unknown) {
|
|
const normalizedError = normalizeError(error_);
|
|
void this._error(new RequestError(normalizedError.message, normalizedError, this));
|
|
return;
|
|
}
|
|
|
|
// Publish retry event
|
|
publishRetry({
|
|
requestId: this._requestId,
|
|
retryCount: this.retryCount + 1,
|
|
error: typedError,
|
|
delay: backoff,
|
|
});
|
|
|
|
this.emit('retry', this.retryCount + 1, error, (updatedOptions?: OptionsInit) => {
|
|
const request = new Request(undefined, updatedOptions, options);
|
|
request.retryCount = this.retryCount + 1;
|
|
|
|
process.nextTick(() => {
|
|
void request.flush();
|
|
});
|
|
|
|
return request;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
void this._error(typedError);
|
|
})();
|
|
}
|
|
|
|
override _read(): void {
|
|
this._triggerRead = true;
|
|
|
|
const {response} = this;
|
|
if (response && !this._stopReading) {
|
|
// We cannot put this in the `if` above
|
|
// because `.read()` also triggers the `end` event
|
|
if (response.readableLength) {
|
|
this._triggerRead = false;
|
|
}
|
|
|
|
let data;
|
|
|
|
while ((data = response.read()) !== null) {
|
|
this._downloadedSize += data.length; // eslint-disable-line @typescript-eslint/restrict-plus-operands
|
|
|
|
if (this._incrementalDecode) {
|
|
try {
|
|
const decodedChunk = typeof data === 'string' ? data : this._incrementalDecode.decoder.decode(data, {stream: true});
|
|
if (decodedChunk.length > 0) {
|
|
this._incrementalDecode.chunks.push(decodedChunk);
|
|
}
|
|
} catch {
|
|
this._incrementalDecode = undefined;
|
|
}
|
|
}
|
|
|
|
const progress = this.downloadProgress;
|
|
|
|
if (progress.percent < 1) {
|
|
this.emit('downloadProgress', progress);
|
|
}
|
|
|
|
if (this._stopReading) {
|
|
return;
|
|
}
|
|
|
|
this.push(data);
|
|
|
|
if (this._stopReading) {
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override _write(chunk: unknown, encoding: BufferEncoding | undefined, callback: (error?: Error | null) => void): void { // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
const write = (): void => {
|
|
if (this._discardBodyWrites) {
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
this._hasWrittenBody = true;
|
|
this._writeRequest(chunk, encoding, callback);
|
|
};
|
|
|
|
if (this._requestInitialized) {
|
|
write();
|
|
} else {
|
|
this._jobs.push(write);
|
|
}
|
|
}
|
|
|
|
override _final(callback: (error?: Error | null) => void): void { // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
const endRequest = (): void => {
|
|
if (this._discardBodyWrites) {
|
|
this._hasWritableBody = false;
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
if (this._skipRequestEndInFinal) {
|
|
this._skipRequestEndInFinal = false;
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
const request = this._request;
|
|
|
|
// We need to check if `this._request` is present,
|
|
// because it isn't when we use cache.
|
|
if (!request || request.destroyed) {
|
|
this._hasWritableBody = false;
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
request.end((error?: Error | null) => { // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
// The request has been destroyed before `_final` finished.
|
|
// See https://github.com/nodejs/node/issues/39356
|
|
if ((request as any)?._writableState?.errored) {
|
|
return;
|
|
}
|
|
|
|
if (!error) {
|
|
this._emitUploadComplete(request);
|
|
}
|
|
|
|
this._hasWritableBody = false;
|
|
callback(error);
|
|
});
|
|
};
|
|
|
|
if (this._requestInitialized) {
|
|
endRequest();
|
|
} else {
|
|
this._jobs.push(endRequest);
|
|
}
|
|
}
|
|
|
|
override _destroy(error: Error | null, callback: (error: Error | null) => void): void { // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
this._stopReading = true;
|
|
this.flush = async () => {};
|
|
|
|
// Prevent further retries
|
|
this._stopRetry?.();
|
|
this._cancelTimeouts?.();
|
|
this._abortListenerDisposer?.[Symbol.dispose]();
|
|
this._destroyInFlightAlpnSocket();
|
|
|
|
if (this.options) {
|
|
const {body} = this.options;
|
|
if (is.nodeStream(body)) {
|
|
body.destroy();
|
|
}
|
|
}
|
|
|
|
if (this._request) {
|
|
this._request.destroy();
|
|
}
|
|
|
|
// Workaround: http-timer only sets timings.end when the response emits 'end'.
|
|
// When a stream is destroyed before completion, the 'end' event may not fire,
|
|
// leaving timings.end undefined. This should ideally be fixed in http-timer
|
|
// by listening to the 'close' event, but we handle it here for now.
|
|
// Only set timings.end if there was no error or abort (to maintain semantic correctness).
|
|
const timings = (this._request as ClientRequestWithTimings)?.timings;
|
|
if (timings && is.undefined(timings.end) && !is.undefined(timings.response) && is.undefined(timings.error) && is.undefined(timings.abort)) {
|
|
timings.end = Date.now();
|
|
if (is.undefined(timings.phases.total)) {
|
|
timings.phases.download = timings.end - timings.response;
|
|
timings.phases.total = timings.end - timings.start;
|
|
}
|
|
}
|
|
|
|
// Preserve custom errors returned by beforeError hooks.
|
|
// For other errors, wrap non-RequestError instances for consistency.
|
|
if (error !== null && !is.undefined(error)) {
|
|
const processedByHooks = error instanceof Error && errorsProcessedByHooks.has(error);
|
|
|
|
if (!processedByHooks && !(error instanceof RequestError)) {
|
|
error = error instanceof Error
|
|
? new RequestError(error.message, error, this)
|
|
: new RequestError(String(error), {}, this);
|
|
}
|
|
}
|
|
|
|
callback(error);
|
|
}
|
|
|
|
override pipe<T extends NodeJS.WritableStream>(destination: T, options?: {end?: boolean}): T {
|
|
if (destination instanceof ServerResponse) {
|
|
this._pipedServerResponses.add(destination);
|
|
}
|
|
|
|
return super.pipe(destination, options);
|
|
}
|
|
|
|
override unpipe<T extends NodeJS.WritableStream>(destination: T): this {
|
|
if (destination instanceof ServerResponse) {
|
|
this._pipedServerResponses.delete(destination);
|
|
}
|
|
|
|
super.unpipe(destination);
|
|
|
|
return this;
|
|
}
|
|
|
|
private _attachAbortListener(): void {
|
|
if (this._abortListenerDisposer) {
|
|
return;
|
|
}
|
|
|
|
const {signal} = this.options;
|
|
if (!signal) {
|
|
return;
|
|
}
|
|
|
|
const abort = () => {
|
|
this._destroyInFlightAlpnSocket();
|
|
|
|
// See https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static#return_value
|
|
if (signal.reason?.name === 'TimeoutError') {
|
|
this.destroy(new TimeoutError(signal.reason, this.timings ?? createPreRequestErrorTimings(), this));
|
|
} else {
|
|
this.destroy(new AbortError(this));
|
|
}
|
|
};
|
|
|
|
if (signal.aborted) {
|
|
abort();
|
|
} else {
|
|
this._abortListenerDisposer = addAbortListener(signal, abort);
|
|
}
|
|
}
|
|
|
|
private _destroyInFlightAlpnSocket(): void {
|
|
(this._requestOptions as (NativeRequestOptions & {_alpnSocket?: Socket}) | undefined)?._alpnSocket?.destroy();
|
|
}
|
|
|
|
private _shouldIncrementallyDecodeBody(): boolean {
|
|
const {responseType, encoding} = this.options;
|
|
|
|
return Boolean(this._noPipe)
|
|
&& (responseType === 'text' || responseType === 'json')
|
|
&& isUtf8Encoding(encoding)
|
|
&& typeof globalThis.TextDecoder === 'function';
|
|
}
|
|
|
|
private _checkContentLengthMismatch(): boolean {
|
|
if (this.options.strictContentLength && this._expectedContentLength !== undefined) {
|
|
// Use compressed bytes count when available (for compressed responses),
|
|
// otherwise use _downloadedSize (for uncompressed responses)
|
|
const actualSize = this._compressedBytesCount ?? this._downloadedSize;
|
|
if (actualSize !== this._expectedContentLength) {
|
|
this._beforeError(new ReadError({
|
|
message: `Content-Length mismatch: expected ${this._expectedContentLength} bytes, received ${actualSize} bytes`,
|
|
name: 'Error',
|
|
code: 'ERR_HTTP_CONTENT_LENGTH_MISMATCH',
|
|
}, this));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private async _finalizeBody(): Promise<void> {
|
|
const {options} = this;
|
|
const headers = options.getInternalHeaders();
|
|
|
|
const isForm = !is.undefined(options.form);
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
const isJSON = !is.undefined(options.json);
|
|
const isBody = !is.undefined(options.body);
|
|
const cannotHaveBody = !this._methodCanHaveBody;
|
|
|
|
if (isForm || isJSON || isBody) {
|
|
if (cannotHaveBody) {
|
|
throw new TypeError(`The \`${options.method}\` method cannot be used with a body`);
|
|
}
|
|
|
|
// Serialize body
|
|
const noContentType = !is.string(headers['content-type']);
|
|
|
|
if (isBody) {
|
|
// Native FormData
|
|
if (options.body instanceof FormData) {
|
|
const {body, contentType} = serializeNativeFormDataBody(options.body);
|
|
this._nativeFormDataBody = {
|
|
form: options.body,
|
|
body,
|
|
contentTypeWasGenerated: noContentType,
|
|
};
|
|
|
|
if (noContentType) {
|
|
headers['content-type'] = contentType;
|
|
}
|
|
|
|
options.body = body;
|
|
} else if (Object.prototype.toString.call(options.body) === '[object FormData]') {
|
|
throw new TypeError('Non-native FormData is not supported. Use globalThis.FormData instead.');
|
|
}
|
|
} else if (isForm) {
|
|
if (noContentType) {
|
|
headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
}
|
|
|
|
const {form} = options;
|
|
options.form = undefined;
|
|
|
|
options.body = (new URLSearchParams(form)).toString();
|
|
} else {
|
|
if (noContentType) {
|
|
headers['content-type'] = 'application/json';
|
|
}
|
|
|
|
const {json} = options;
|
|
options.json = undefined;
|
|
|
|
options.body = options.stringifyJson(json);
|
|
}
|
|
|
|
const uploadBodySize = getBodySize(options.body, headers);
|
|
|
|
// See https://tools.ietf.org/html/rfc7230#section-3.3.2
|
|
// A user agent SHOULD send a Content-Length in a request message when
|
|
// no Transfer-Encoding is sent and the request method defines a meaning
|
|
// for an enclosed payload body. For example, a Content-Length header
|
|
// field is normally sent in a POST request even when the value is 0
|
|
// (indicating an empty payload body). A user agent SHOULD NOT send a
|
|
// Content-Length header field when the request message does not contain
|
|
// a payload body and the method semantics do not anticipate such a
|
|
// body.
|
|
if (is.undefined(headers['content-length']) && is.undefined(headers['transfer-encoding']) && !cannotHaveBody && !is.undefined(uploadBodySize)) {
|
|
headers['content-length'] = String(uploadBodySize);
|
|
}
|
|
}
|
|
|
|
if (options.responseType === 'json' && !('accept' in headers)) {
|
|
headers.accept = 'application/json';
|
|
}
|
|
|
|
this._bodySize = Number(headers['content-length']) || undefined;
|
|
}
|
|
|
|
private async _onResponseBase(response: IncomingMessageWithTimings): Promise<void> {
|
|
// This will be called e.g. when using cache so we need to check if this request has been aborted.
|
|
if (this.isAborted) {
|
|
return;
|
|
}
|
|
|
|
const {options} = this;
|
|
const {url} = options;
|
|
|
|
const nativeResponse = response as IncomingMessageWithTimings & {fromCache?: boolean};
|
|
const statusCode = response.statusCode!;
|
|
const {method} = options;
|
|
const redirectLocationHeader = response.headers.location;
|
|
const redirectLocation = Array.isArray(redirectLocationHeader) ? redirectLocationHeader[0] : redirectLocationHeader;
|
|
const isRedirect = Boolean(redirectLocation && redirectCodes.has(statusCode));
|
|
|
|
// Skip decompression for responses that must not have bodies per RFC 9110:
|
|
// - HEAD responses (any status code)
|
|
// - 1xx (Informational): 100, 101, 102, 103, etc.
|
|
// - 204 (No Content)
|
|
// - 205 (Reset Content)
|
|
// - 304 (Not Modified)
|
|
const hasNoBody = method === 'HEAD'
|
|
|| (statusCode >= 100 && statusCode < 200)
|
|
|| statusCode === 204
|
|
|| statusCode === 205
|
|
|| statusCode === 304;
|
|
|
|
const prepareResponse = (response: PlainResponse): PlainResponse => {
|
|
if (!Object.hasOwn(response, 'headers')) {
|
|
Object.defineProperty(response, 'headers', {
|
|
value: response.headers,
|
|
enumerable: true,
|
|
writable: true,
|
|
configurable: true,
|
|
});
|
|
}
|
|
|
|
response.statusMessage ||= http.STATUS_CODES[statusCode]; // eslint-disable-line @typescript-eslint/prefer-nullish-coalescing -- The status message can be empty.
|
|
response.url = stripUrlAuth(options.url!);
|
|
response.requestUrl = this.requestUrl!;
|
|
response.redirectUrls = this.redirectUrls;
|
|
response.request = this;
|
|
response.isFromCache = nativeResponse.fromCache ?? false;
|
|
response.ip = this.ip;
|
|
response.retryCount = this.retryCount;
|
|
response.ok = isResponseOk(response);
|
|
|
|
return response;
|
|
};
|
|
|
|
let typedResponse = prepareResponse(response as PlainResponse);
|
|
// Redirect responses that will be followed are drained raw. Decompressing them can
|
|
// turn an irrelevant redirect body into a client-side failure or decompression DoS.
|
|
const shouldFollowRedirect = isRedirect && (typeof options.followRedirect === 'function' ? options.followRedirect(typedResponse) : options.followRedirect);
|
|
|
|
if (options.decompress && !hasNoBody && !shouldFollowRedirect) {
|
|
// When strictContentLength is enabled, track compressed bytes by listening to
|
|
// the native response's data events before decompression
|
|
if (options.strictContentLength) {
|
|
this._compressedBytesCount = 0;
|
|
nativeResponse.on('data', (chunk: Uint8Array) => {
|
|
this._compressedBytesCount! += byteLength(chunk);
|
|
});
|
|
}
|
|
|
|
response = decompressResponse(response);
|
|
typedResponse = prepareResponse(response as PlainResponse);
|
|
}
|
|
|
|
// `decompressResponse` wraps the response stream when it decompresses,
|
|
// so `response !== nativeResponse` indicates decompression happened.
|
|
const wasDecompressed = response !== nativeResponse;
|
|
|
|
this._responseSize = Number(response.headers['content-length']) || undefined;
|
|
|
|
this.response = typedResponse;
|
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
this._incrementalDecode = this._shouldIncrementallyDecodeBody() ? {decoder: new globalThis.TextDecoder('utf8', {ignoreBOM: true}), chunks: []} : undefined;
|
|
|
|
// Publish response start event
|
|
publishResponseStart({
|
|
requestId: this._requestId,
|
|
url: typedResponse.url,
|
|
statusCode,
|
|
headers: response.headers,
|
|
isFromCache: typedResponse.isFromCache,
|
|
});
|
|
|
|
response.once('error', (error: Error) => {
|
|
// Node synthesizes ECONNRESET for close-delimited responses after all body
|
|
// bytes have been delivered. Only ignore that late synthetic error on the
|
|
// native response. Wrapped decompression streams surface real checksum and
|
|
// truncation failures after the underlying response has completed.
|
|
if (
|
|
!wasDecompressed
|
|
&& response.complete
|
|
&& this._responseSize === undefined
|
|
&& error.code === 'ECONNRESET'
|
|
) {
|
|
return;
|
|
}
|
|
|
|
this._aborted = true;
|
|
|
|
this._beforeError(new ReadError(error, this));
|
|
});
|
|
|
|
response.once('aborted', () => {
|
|
// Without Content-Length, connection close is the intended EOF signal (RFC 9110 §8.6),
|
|
// not a premature abort. For wrapped decompression streams, rely on the native
|
|
// response completion state because the wrapper strips `content-length`.
|
|
if (this._responseSize === undefined && nativeResponse.complete) {
|
|
return;
|
|
}
|
|
|
|
this._aborted = true;
|
|
|
|
// Check if there's a content-length mismatch to provide a more specific error
|
|
if (!this._checkContentLengthMismatch()) {
|
|
this._beforeError(new ReadError({
|
|
name: 'Error',
|
|
message: 'The server aborted pending request',
|
|
code: 'ECONNRESET',
|
|
}, this));
|
|
}
|
|
});
|
|
|
|
let canFinalizeResponse = false;
|
|
const handleResponseEnd = () => {
|
|
if (
|
|
!canFinalizeResponse
|
|
|| !response.readableEnded
|
|
) {
|
|
return;
|
|
}
|
|
|
|
canFinalizeResponse = false;
|
|
|
|
if (this._stopReading) {
|
|
return;
|
|
}
|
|
|
|
// Validate content-length if it was provided
|
|
// Per RFC 9112: "If the sender closes the connection before the indicated number
|
|
// of octets are received, the recipient MUST consider the message to be incomplete"
|
|
if (this._checkContentLengthMismatch()) {
|
|
return;
|
|
}
|
|
|
|
this._responseSize = this._downloadedSize;
|
|
this.emit('downloadProgress', this.downloadProgress);
|
|
|
|
// Publish response end event
|
|
publishResponseEnd({
|
|
requestId: this._requestId,
|
|
url: typedResponse.url,
|
|
statusCode,
|
|
bodySize: this._downloadedSize,
|
|
timings: this.timings,
|
|
});
|
|
|
|
this.push(null);
|
|
};
|
|
|
|
if (!shouldFollowRedirect) {
|
|
// `set-cookie` handling below awaits the cookie jar. A fast response can fully
|
|
// end during that await, so we need to observe `end` early without completing
|
|
// the outward stream until cookie handling has finished.
|
|
response.once('end', handleResponseEnd);
|
|
}
|
|
|
|
const noPipeCookieJarRawBodyPromise = this._noPipe
|
|
&& is.object(options.cookieJar)
|
|
&& !isRedirect
|
|
? this._setRawBody(response)
|
|
: undefined;
|
|
|
|
const rawCookies = response.headers['set-cookie'];
|
|
if (is.object(options.cookieJar) && rawCookies) {
|
|
let promises: Array<Promise<unknown>> = rawCookies.map(async (rawCookie: string) => (options.cookieJar as PromiseCookieJar).setCookie(rawCookie, url!.toString()));
|
|
|
|
if (options.ignoreInvalidCookies) {
|
|
promises = promises.map(async promise => {
|
|
try {
|
|
await promise;
|
|
} catch {}
|
|
});
|
|
}
|
|
|
|
try {
|
|
await Promise.all(promises);
|
|
} catch (error: unknown) {
|
|
this._beforeError(normalizeError(error));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// The above is running a promise, therefore we need to check if this request has been aborted yet again.
|
|
if (this.isAborted) {
|
|
return;
|
|
}
|
|
|
|
if (shouldFollowRedirect) {
|
|
// We're being redirected, we don't care about the response.
|
|
// It'd be best to abort the request, but we can't because
|
|
// we would have to sacrifice the TCP connection. We don't want that.
|
|
response.resume();
|
|
|
|
this._cancelTimeouts?.();
|
|
this._unproxyEvents?.();
|
|
|
|
if (this.redirectUrls.length >= options.maxRedirects) {
|
|
this._beforeError(new MaxRedirectsError(this));
|
|
return;
|
|
}
|
|
|
|
this._request = undefined;
|
|
|
|
// Reset progress for the new request.
|
|
this._downloadedSize = 0;
|
|
this._uploadedSize = 0;
|
|
|
|
const updatedOptions = new Options(undefined, undefined, this.options);
|
|
|
|
try {
|
|
// We need this in order to support UTF-8
|
|
const redirectBuffer = Buffer.from(redirectLocation, 'binary').toString();
|
|
const redirectUrl = new URL(redirectBuffer, url);
|
|
const currentUnixSocketPath = getUnixSocketPath(url as URL);
|
|
const redirectUnixSocketPath = getUnixSocketPath(redirectUrl);
|
|
|
|
if (redirectUrl.protocol === 'unix:' && redirectUnixSocketPath === undefined) {
|
|
this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
|
|
return;
|
|
}
|
|
|
|
// Relative redirects on the same socket are fine, but a redirect must not switch to a different local socket.
|
|
if (redirectUnixSocketPath !== undefined && currentUnixSocketPath !== redirectUnixSocketPath) {
|
|
this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
|
|
return;
|
|
}
|
|
|
|
// Redirecting to a different site, clear sensitive data.
|
|
// For UNIX sockets, different socket paths are also different origins.
|
|
const isDifferentOrigin = redirectUrl.origin !== (url as URL).origin
|
|
|| currentUnixSocketPath !== redirectUnixSocketPath;
|
|
|
|
const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD';
|
|
// Avoid forwarding a POST body to a different origin on historical 301/302 redirects.
|
|
const crossOriginRequestedGet = isDifferentOrigin
|
|
&& (statusCode === 301 || statusCode === 302)
|
|
&& updatedOptions.method === 'POST';
|
|
const canRewrite = statusCode !== 307 && statusCode !== 308;
|
|
const userRequestedGet = updatedOptions.methodRewriting && canRewrite;
|
|
const shouldDropBody = serverRequestedGet || crossOriginRequestedGet || userRequestedGet;
|
|
|
|
if (shouldDropBody) {
|
|
updatedOptions.method = 'GET';
|
|
this._dropBody(updatedOptions);
|
|
} else if (
|
|
isDifferentOrigin
|
|
&& canRewrite
|
|
&& updatedOptions.method !== 'QUERY'
|
|
&& this._hasBodyForRedirect(updatedOptions)
|
|
) {
|
|
this._dropBody(updatedOptions);
|
|
}
|
|
|
|
if (isDifferentOrigin) {
|
|
// On cross-origin redirects, strip sensitive headers and any credentials
|
|
// embedded in the redirect URL itself to prevent a malicious server from
|
|
// leaking them to a third party. 307/308 redirects preserve the method and replayable body per RFC; QUERY does the same on 301/302.
|
|
updatedOptions.h2session = undefined;
|
|
this._stripCrossOriginState(updatedOptions, redirectUrl);
|
|
} else {
|
|
redirectUrl.username = updatedOptions.username;
|
|
redirectUrl.password = updatedOptions.password;
|
|
}
|
|
|
|
// Redirect URLs are resolved internally. Restore the user option before hooks run so hook mutations still honor it.
|
|
const {allowAbsoluteUrls} = updatedOptions;
|
|
try {
|
|
updatedOptions.allowAbsoluteUrls = true;
|
|
updatedOptions.url = redirectUrl;
|
|
} finally {
|
|
updatedOptions.allowAbsoluteUrls = allowAbsoluteUrls;
|
|
}
|
|
|
|
this.redirectUrls.push(redirectUrl);
|
|
|
|
const boundaryBeforeRedirectHooks = getUrlPrefixBoundary(updatedOptions);
|
|
const bodyBeforeRedirectHooks = updatedOptions.body;
|
|
const h2sessionBeforeRedirectHooks = updatedOptions.h2session;
|
|
const preHookState = isDifferentOrigin
|
|
? undefined
|
|
: {
|
|
...snapshotCrossOriginState(updatedOptions),
|
|
url: new URL(updatedOptions.url),
|
|
};
|
|
|
|
const changedState = await updatedOptions.trackStateMutations(async changedState => {
|
|
for (const hook of updatedOptions.hooks.beforeRedirect) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await hook(updatedOptions as NormalizedOptions, typedResponse);
|
|
}
|
|
|
|
return changedState;
|
|
});
|
|
|
|
if (hasUrlOrPrefixUrlBoundaryChanged(updatedOptions, updatedOptions.url, boundaryBeforeRedirectHooks)) {
|
|
assertUrlHasSameOriginAsPrefixUrlIfNeeded(updatedOptions, updatedOptions.url);
|
|
}
|
|
|
|
updatedOptions.clearUnchangedCookieHeader(preHookState, changedState);
|
|
|
|
const nativeFormDataBody = this._nativeFormDataBody;
|
|
const mustReplayBodyOnRedirect = statusCode === 307 || statusCode === 308 || updatedOptions.method === 'QUERY';
|
|
|
|
if (mustReplayBodyOnRedirect) {
|
|
const bodyUnchangedByHooks = updatedOptions.body === bodyBeforeRedirectHooks;
|
|
const wasNonReplayable = isNonReplayableBody(bodyBeforeRedirectHooks);
|
|
|
|
if (!bodyUnchangedByHooks && wasNonReplayable) {
|
|
// A hook supplied a fresh body, so dispose of the original non-replayable one.
|
|
this._destroyBody(bodyBeforeRedirectHooks);
|
|
} else if (
|
|
bodyUnchangedByHooks
|
|
&& nativeFormDataBody !== undefined
|
|
&& updatedOptions.body === nativeFormDataBody.body
|
|
) {
|
|
// Native FormData generates a fresh stream and boundary, so re-serialize it to replay the upload.
|
|
const {body, contentType} = serializeNativeFormDataBody(nativeFormDataBody.form);
|
|
|
|
nativeFormDataBody.body = body;
|
|
updatedOptions.body = body;
|
|
|
|
if (changedState.has('content-type')) {
|
|
nativeFormDataBody.contentTypeWasGenerated = false;
|
|
} else if (nativeFormDataBody.contentTypeWasGenerated) {
|
|
updatedOptions.setInternalHeader('content-type', contentType);
|
|
}
|
|
} else if (
|
|
bodyUnchangedByHooks
|
|
&& (wasNonReplayable || (is.undefined(updatedOptions.body) && (this._hasWrittenBody || this._hasWritableBody)))
|
|
) {
|
|
// Body-preserving redirects must replay the body, so follow the HTTP spec and other clients by failing for unchanged non-replayable bodies. Hooks may supply a fresh body.
|
|
this._dropBody(updatedOptions);
|
|
this._beforeError(new RequestError('Cannot follow redirect with a non-replayable body', {}, this));
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If a beforeRedirect hook changed the URL to a different origin,
|
|
// strip sensitive headers that were preserved for the original origin.
|
|
// When isDifferentOrigin was already true, headers were already stripped above.
|
|
if (!isDifferentOrigin) {
|
|
const state = preHookState!;
|
|
const hookUrl = updatedOptions.url;
|
|
const hookChangedOrigin = !isSameOrigin(state.url, hookUrl);
|
|
|
|
if (
|
|
hookChangedOrigin
|
|
&& (statusCode === 301 || statusCode === 302)
|
|
&& updatedOptions.method === 'POST'
|
|
) {
|
|
updatedOptions.method = 'GET';
|
|
this._dropBody(updatedOptions);
|
|
}
|
|
|
|
if (hookChangedOrigin) {
|
|
if (updatedOptions.h2session === h2sessionBeforeRedirectHooks) {
|
|
updatedOptions.h2session = undefined;
|
|
}
|
|
|
|
if (
|
|
canRewrite
|
|
&& updatedOptions.method !== 'QUERY'
|
|
&& this._hasUnchangedBodyForRedirect(updatedOptions, state, changedState)
|
|
) {
|
|
this._dropBody(updatedOptions);
|
|
}
|
|
|
|
this._stripUnchangedCrossOriginState(updatedOptions, hookUrl, {
|
|
...state,
|
|
changedState,
|
|
preserveUsername: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'username')
|
|
|| isCrossOriginCredentialChanged(state.url, hookUrl, 'username'),
|
|
preservePassword: hasExplicitCredentialInUrlChange(changedState, hookUrl, 'password')
|
|
|| isCrossOriginCredentialChanged(state.url, hookUrl, 'password'),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Publish redirect event
|
|
publishRedirect({
|
|
requestId: this._requestId,
|
|
fromUrl: url!.toString(),
|
|
toUrl: (updatedOptions.url).toString(),
|
|
statusCode,
|
|
});
|
|
|
|
this.emit('redirect', updatedOptions, typedResponse);
|
|
|
|
this.options = updatedOptions;
|
|
|
|
await this._makeRequest();
|
|
} catch (error: unknown) {
|
|
this._beforeError(normalizeError(error));
|
|
return;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
canFinalizeResponse = true;
|
|
handleResponseEnd();
|
|
|
|
// `HTTPError`s always have `error.response.body` defined.
|
|
// Therefore, we cannot retry if `options.throwHttpErrors` is false.
|
|
// On the last retry, if `options.throwHttpErrors` is false, we would need to return the body,
|
|
// but that wouldn't be possible since the body would be already read in `error.response.body`.
|
|
if (options.isStream && options.throwHttpErrors && !isResponseOk(typedResponse)) {
|
|
this._beforeError(new HTTPError(typedResponse));
|
|
return;
|
|
}
|
|
|
|
// Store the expected content-length from the native response for validation.
|
|
// This is the content-length before decompression, which is what actually gets transferred.
|
|
// Skip storing for responses that shouldn't have bodies per RFC 9110.
|
|
// When decompression occurs, only store if strictContentLength is enabled.
|
|
if (!hasNoBody && (!wasDecompressed || options.strictContentLength)) {
|
|
const contentLengthHeader = nativeResponse.headers['content-length'];
|
|
if (contentLengthHeader !== undefined) {
|
|
const expectedLength = Number(contentLengthHeader);
|
|
if (!Number.isNaN(expectedLength) && expectedLength >= 0) {
|
|
this._expectedContentLength = expectedLength;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.emit('downloadProgress', this.downloadProgress);
|
|
|
|
response.on('readable', () => {
|
|
if (this._triggerRead) {
|
|
this._read();
|
|
}
|
|
});
|
|
|
|
this.on('resume', () => {
|
|
response.resume();
|
|
});
|
|
|
|
this.on('pause', () => {
|
|
response.pause();
|
|
});
|
|
|
|
if (this._noPipe) {
|
|
const captureFromResponse = response.readableEnded || noPipeCookieJarRawBodyPromise !== undefined;
|
|
const success = noPipeCookieJarRawBodyPromise
|
|
? await noPipeCookieJarRawBodyPromise
|
|
: await this._setRawBody(captureFromResponse ? response : this);
|
|
|
|
if (captureFromResponse) {
|
|
handleResponseEnd();
|
|
}
|
|
|
|
if (success) {
|
|
this.emit('response', response);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
this.emit('response', response);
|
|
|
|
for (const destination of this._pipedServerResponses) {
|
|
if (destination.headersSent) {
|
|
continue;
|
|
}
|
|
|
|
for (const key in response.headers) {
|
|
if (Object.hasOwn(response.headers, key)) {
|
|
const value = response.headers[key];
|
|
|
|
// When decompression occurred, skip content-encoding and content-length
|
|
// as they refer to the compressed data, not the decompressed stream.
|
|
if (wasDecompressed && (key === 'content-encoding' || key === 'content-length')) {
|
|
continue;
|
|
}
|
|
|
|
// Skip if value is undefined
|
|
if (value !== undefined) {
|
|
destination.setHeader(key, value);
|
|
}
|
|
}
|
|
}
|
|
|
|
destination.statusCode = statusCode;
|
|
}
|
|
}
|
|
|
|
private async _setRawBody(from: Readable = this): Promise<boolean> {
|
|
try {
|
|
// Errors are emitted via the `error` event
|
|
const fromArray = await from.toArray();
|
|
const hasNonStringChunk = fromArray.some(chunk => typeof chunk !== 'string');
|
|
const rawBody = hasNonStringChunk
|
|
? concatUint8Arrays((fromArray as Array<string | Uint8Array>).map(chunk => typeof chunk === 'string' ? stringToUint8Array(chunk) : chunk))
|
|
: stringToUint8Array((fromArray as string[]).join(''));
|
|
const shouldUseIncrementalDecodedBody = from === this && this._incrementalDecode !== undefined;
|
|
|
|
// On retry Request is destroyed with no error, therefore the above will successfully resolve.
|
|
// So in order to check if this was really successful, we need to check if it has been properly ended.
|
|
if (!this.isAborted && this.response) {
|
|
this.response.rawBody = rawBody;
|
|
if (from !== this) {
|
|
this._downloadedSize = rawBody.byteLength;
|
|
}
|
|
|
|
if (shouldUseIncrementalDecodedBody) {
|
|
try {
|
|
const {decoder, chunks} = this._incrementalDecode!;
|
|
const finalDecodedChunk = decoder.decode();
|
|
if (finalDecodedChunk.length > 0) {
|
|
chunks.push(finalDecodedChunk);
|
|
}
|
|
|
|
cacheDecodedBody(this.response, chunks.join(''));
|
|
} catch {}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
} catch {} finally {
|
|
this._incrementalDecode = undefined;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
private async _onResponse(response: IncomingMessageWithTimings): Promise<void> {
|
|
try {
|
|
await this._onResponseBase(response);
|
|
} catch (error: unknown) {
|
|
/* istanbul ignore next: better safe than sorry */
|
|
this._beforeError(normalizeError(error));
|
|
}
|
|
}
|
|
|
|
private _onRequest(request: ClientRequest): void {
|
|
const {options} = this;
|
|
const {timeout, url} = options;
|
|
|
|
// Publish request start event
|
|
publishRequestStart({
|
|
requestId: this._requestId,
|
|
url: getSanitizedUrl(this.options),
|
|
method: options.method,
|
|
headers: options.headers,
|
|
});
|
|
|
|
timer(request);
|
|
|
|
const {isGotHttp2Request} = request as ClientRequest & {isGotHttp2Request?: boolean};
|
|
let timeoutDelays = timeout;
|
|
if (isGotHttp2Request) {
|
|
const {socket: _socket, ...http2TimeoutDelays} = timeout;
|
|
timeoutDelays = http2TimeoutDelays;
|
|
}
|
|
|
|
this._cancelTimeouts = timedOut(request, timeoutDelays, url as URL);
|
|
|
|
let lastRequestError: Error | undefined;
|
|
const responseEventName = options.cache ? 'cacheableResponse' : 'response';
|
|
|
|
request.once(responseEventName, (response: IncomingMessageWithTimings) => {
|
|
void this._onResponse(response);
|
|
});
|
|
|
|
const emitRequestError = (error: Error): void => {
|
|
this._aborted = true;
|
|
|
|
// Force clean-up, because some packages (e.g. nock) don't do this.
|
|
request.destroy();
|
|
|
|
const wrappedError = error instanceof TimedOutTimeoutError ? new TimeoutError(error, this.timings ?? createPreRequestErrorTimings(), this) : new RequestError(error.message, error, this);
|
|
this._beforeError(wrappedError);
|
|
};
|
|
|
|
request.once('error', (error: Error) => {
|
|
lastRequestError = error;
|
|
|
|
// Ignore errors from requests superseded by a redirect.
|
|
if (this._request !== request) {
|
|
return;
|
|
}
|
|
|
|
/*
|
|
Transient write errors (EPIPE, ECONNRESET) often fire during redirects when the
|
|
server closes the connection after sending the redirect response. Defer by one
|
|
microtask to let the response event make the request stale.
|
|
*/
|
|
if (isTransientWriteError(error)) {
|
|
queueMicrotask(() => {
|
|
if (this._isRequestStale(request)) {
|
|
return;
|
|
}
|
|
|
|
emitRequestError(error);
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
emitRequestError(error);
|
|
});
|
|
|
|
if (!options.cache) {
|
|
request.once('close', () => {
|
|
if (this._request !== request || Boolean((request as ClientRequest & {res?: unknown}).res) || this._stopReading) {
|
|
return;
|
|
}
|
|
|
|
this._beforeError(lastRequestError ?? new ReadError({
|
|
name: 'Error',
|
|
message: 'The server aborted pending request',
|
|
code: 'ECONNRESET',
|
|
}, this));
|
|
});
|
|
}
|
|
|
|
this._unproxyEvents = proxyEvents(request, this, proxiedRequestEvents);
|
|
this._request = request;
|
|
|
|
this.emit('uploadProgress', this.uploadProgress);
|
|
|
|
this._sendBody();
|
|
|
|
this.emit('request', request);
|
|
}
|
|
|
|
private _isRequestStale(request: ClientRequest): boolean {
|
|
return this._request !== request || Boolean((request as ClientRequest & {res?: unknown}).res) || request.destroyed || request.writableEnded;
|
|
}
|
|
|
|
private async _asyncWrite(chunk: any, request: Request | ClientRequest = this): Promise<void> {
|
|
return new Promise((resolve, reject) => {
|
|
if (request === this) {
|
|
super.write(chunk, error => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
return;
|
|
}
|
|
|
|
this._writeRequest(chunk, undefined, error => {
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
resolve();
|
|
}, request as ClientRequest);
|
|
});
|
|
}
|
|
|
|
private _sendBody() {
|
|
// Send body
|
|
const {body} = this.options;
|
|
const currentRequest = this.redirectUrls.length === 0 && !this._discardBodyWrites ? this : this._request ?? this;
|
|
|
|
if (is.nodeStream(body)) {
|
|
body.pipe(currentRequest);
|
|
} else if (is.buffer(body)) {
|
|
// Buffer should be sent directly without conversion
|
|
this._writeBodyInChunks(body, currentRequest);
|
|
} else if (is.typedArray(body)) {
|
|
// Typed arrays should be treated like buffers, not iterated over
|
|
// Create a Uint8Array view over the data (Node.js streams accept Uint8Array)
|
|
const typedArray = body as ArrayBufferView;
|
|
const uint8View = new Uint8Array(typedArray.buffer, typedArray.byteOffset, typedArray.byteLength);
|
|
this._writeBodyInChunks(uint8View, currentRequest);
|
|
} else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
|
|
(async () => {
|
|
const isInitialRequest = currentRequest === this;
|
|
const bodyOptions = this.options;
|
|
|
|
try {
|
|
for await (const chunk of body) {
|
|
if (this.options !== bodyOptions || this.options.body !== body) {
|
|
return;
|
|
}
|
|
|
|
await this._asyncWrite(chunk, currentRequest);
|
|
|
|
if (this.options !== bodyOptions || this.options.body !== body) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (this.options === bodyOptions && this.options.body === body) {
|
|
if (isInitialRequest) {
|
|
super.end();
|
|
return;
|
|
}
|
|
|
|
await this._endWritableRequest(currentRequest as ClientRequest);
|
|
}
|
|
} catch (error: unknown) {
|
|
if (this.options !== bodyOptions || this.options.body !== body) {
|
|
return;
|
|
}
|
|
|
|
this._beforeError(normalizeError(error));
|
|
}
|
|
})();
|
|
} else if (is.undefined(body)) {
|
|
// No body to send, end the request
|
|
if ((this._noPipe ?? false) || !this._methodCanHaveBody || currentRequest !== this) {
|
|
currentRequest.end();
|
|
}
|
|
} else {
|
|
// Handles string bodies (from json/form options).
|
|
this._writeBodyInChunks(stringToUint8Array(body as string), currentRequest);
|
|
}
|
|
}
|
|
|
|
/*
|
|
Write a body buffer in chunks to enable granular `uploadProgress` events.
|
|
|
|
Without chunking, string/Uint8Array/TypedArray bodies are written in a single call, causing `uploadProgress` to only emit 0% and 100% with nothing in between.
|
|
|
|
The 64 KB chunk size matches Node.js fs stream defaults.
|
|
*/
|
|
private _writeBodyInChunks(buffer: Uint8Array, currentRequest: Request | ClientRequest) {
|
|
const isInitialRequest = currentRequest === this;
|
|
|
|
(async () => {
|
|
let request: ClientRequest | undefined;
|
|
|
|
try {
|
|
request = isInitialRequest ? this._request : currentRequest as ClientRequest;
|
|
const activeRequest = request;
|
|
|
|
if (!activeRequest) {
|
|
if (isInitialRequest) {
|
|
super.end();
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (activeRequest.destroyed) {
|
|
return;
|
|
}
|
|
|
|
await this._writeChunksToRequest(buffer, activeRequest);
|
|
|
|
if (this._isRequestStale(activeRequest)) {
|
|
this._finalizeStaleChunkedWrite(activeRequest, isInitialRequest);
|
|
return;
|
|
}
|
|
|
|
if (isInitialRequest) {
|
|
super.end();
|
|
return;
|
|
}
|
|
|
|
await this._endWritableRequest(activeRequest);
|
|
} catch (error: unknown) {
|
|
const normalizedError = normalizeError(error);
|
|
|
|
// Transient write errors (EPIPE, ECONNRESET) are handled by the request-level
|
|
// error and close handlers. For initial redirected writes, still finalize
|
|
// writable state once the stale transition becomes observable.
|
|
if (isTransientWriteError(normalizedError)) {
|
|
if (isInitialRequest && request) {
|
|
const initialRequest = request;
|
|
let didFinalize = false;
|
|
const finalizeIfStale = () => {
|
|
if (didFinalize || !this._isRequestStale(initialRequest)) {
|
|
return;
|
|
}
|
|
|
|
didFinalize = true;
|
|
this._finalizeStaleChunkedWrite(initialRequest, true);
|
|
};
|
|
|
|
finalizeIfStale();
|
|
|
|
if (!didFinalize) {
|
|
initialRequest.once('response', finalizeIfStale);
|
|
queueMicrotask(finalizeIfStale);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (!isInitialRequest && this._isRequestStale(currentRequest as ClientRequest)) {
|
|
return;
|
|
}
|
|
|
|
this._beforeError(normalizedError);
|
|
}
|
|
})();
|
|
}
|
|
|
|
private _finalizeStaleChunkedWrite(request: ClientRequest, isInitialRequest: boolean) {
|
|
if (!request.destroyed && !request.writableEnded) {
|
|
request.destroy();
|
|
}
|
|
|
|
if (isInitialRequest) {
|
|
// Finalize writable state without ending the active redirected request.
|
|
this._skipRequestEndInFinal = true;
|
|
super.end();
|
|
}
|
|
}
|
|
|
|
private _emitUploadComplete(request: ClientRequest) {
|
|
this._bodySize = this._uploadedSize;
|
|
this.emit('uploadProgress', this.uploadProgress);
|
|
request.emit('upload-complete');
|
|
}
|
|
|
|
private async _endWritableRequest(request: ClientRequest): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
request.end((error?: Error | null) => { // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
if (error) {
|
|
reject(error);
|
|
return;
|
|
}
|
|
|
|
if (this._request === request && !request.destroyed) {
|
|
this._emitUploadComplete(request);
|
|
}
|
|
|
|
resolve();
|
|
});
|
|
});
|
|
}
|
|
|
|
private _stripCrossOriginState(options: Options, urlToClear: URL) {
|
|
for (const header of crossOriginStripHeaders) {
|
|
options.deleteInternalHeader(header);
|
|
}
|
|
|
|
options.username = '';
|
|
options.password = '';
|
|
|
|
urlToClear.username = '';
|
|
urlToClear.password = '';
|
|
}
|
|
|
|
private _stripUnchangedCrossOriginState(options: Options, urlToClear: URL, state: CrossOriginState & {
|
|
changedState: Set<string>;
|
|
preserveUsername: boolean;
|
|
preservePassword: boolean;
|
|
}) {
|
|
const headers = options.getInternalHeaders();
|
|
|
|
for (const header of crossOriginStripHeaders) {
|
|
if (!state.changedState.has(header) && headers[header] === state.headers[header]) {
|
|
options.deleteInternalHeader(header);
|
|
}
|
|
}
|
|
|
|
if (!state.preserveUsername) {
|
|
options.username = '';
|
|
urlToClear.username = '';
|
|
}
|
|
|
|
if (!state.preservePassword) {
|
|
options.password = '';
|
|
urlToClear.password = '';
|
|
}
|
|
}
|
|
|
|
private get _methodCanHaveBody(): boolean {
|
|
return !methodsWithoutBody.has(this.options.method) || (this.options.method === 'GET' && this.options.allowGetBody);
|
|
}
|
|
|
|
private _canWriteBody(): boolean {
|
|
return !this._noPipe && !this.isReadonly && this._methodCanHaveBody;
|
|
}
|
|
|
|
private _hasBodyForRedirect(options: Options): boolean {
|
|
return !is.undefined(options.body) || !is.undefined(options.json) || !is.undefined(options.form) || this._hasWrittenBody || this._hasWritableBody;
|
|
}
|
|
|
|
private _hasUnchangedBodyForRedirect(options: Options, state: CrossOriginState, changedState: Set<string>): boolean {
|
|
return !changedState.has('body')
|
|
&& !changedState.has('json')
|
|
&& !changedState.has('form')
|
|
&& this._hasBodyForRedirect(options)
|
|
&& isBodyUnchanged(options, state);
|
|
}
|
|
|
|
private _dropBody(updatedOptions: Options) {
|
|
const {body} = this.options;
|
|
const hadOptionBody = !is.undefined(body) || !is.undefined(this.options.json) || !is.undefined(this.options.form);
|
|
|
|
this.options.clearBody();
|
|
|
|
this._destroyBody(body);
|
|
|
|
if (!hadOptionBody && !this.writableEnded) {
|
|
this._skipRequestEndInFinal = true;
|
|
super.end();
|
|
}
|
|
|
|
updatedOptions.clearBody();
|
|
|
|
this._bodySize = undefined;
|
|
this._hasWrittenBody = false;
|
|
this._hasWritableBody = false;
|
|
}
|
|
|
|
private _destroyBody(body: unknown) {
|
|
if (is.nodeStream(body)) {
|
|
const bodyStream = body as NodeJS.ReadableStream & {destroy(): void};
|
|
bodyStream.off('error', this._onBodyError);
|
|
bodyStream.unpipe();
|
|
bodyStream.on('error', noop);
|
|
bodyStream.destroy();
|
|
} else if (is.asyncIterable(body) || (is.iterable(body) && !is.string(body) && !isBuffer(body))) {
|
|
const iterableBody = body as unknown as Iterator<unknown> | AsyncIterator<unknown>;
|
|
|
|
// Signal the iterator to clean up, but don't await it:
|
|
// the for-await loop in _sendBody exits via the options.body sentinel,
|
|
// and awaiting return() would deadlock when next() is pending.
|
|
if (typeof iterableBody.return === 'function') {
|
|
try {
|
|
const result = iterableBody.return();
|
|
if (result instanceof Promise) {
|
|
// eslint-disable-next-line promise/prefer-await-to-then
|
|
result.catch(noop);
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
}
|
|
|
|
private readonly _onBodyError = (error: Error): void => {
|
|
if (this._flushed) {
|
|
this._beforeError(new UploadError(error, this));
|
|
} else {
|
|
this.flush = async () => {
|
|
this.flush = async () => {};
|
|
|
|
this._beforeError(new UploadError(error, this));
|
|
};
|
|
}
|
|
};
|
|
|
|
private async _writeChunksToRequest(buffer: Uint8Array, request: ClientRequest): Promise<void> {
|
|
const chunkSize = 65_536; // 64 KB
|
|
const isStale = () => this._isRequestStale(request);
|
|
|
|
for (const part of chunk(buffer, chunkSize)) {
|
|
if (isStale()) {
|
|
return;
|
|
}
|
|
|
|
// eslint-disable-next-line no-await-in-loop
|
|
await new Promise<void>((resolve, reject) => {
|
|
this._writeRequest(part, undefined, error => {
|
|
if (isStale()) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
if (error) {
|
|
reject(error);
|
|
} else {
|
|
setImmediate(resolve);
|
|
}
|
|
}, request);
|
|
});
|
|
}
|
|
}
|
|
|
|
private _prepareCache(cache: string | StorageAdapter) {
|
|
if (cacheableStore.has(cache)) {
|
|
return;
|
|
}
|
|
|
|
const cacheableRequest = new CacheableRequest(
|
|
((requestOptions: RequestOptions, handler?: (response: IncomingMessageWithTimings) => void): ClientRequest => {
|
|
/**
|
|
Wraps the cacheable-request handler to run beforeCache hooks.
|
|
These hooks control caching behavior by:
|
|
- Directly mutating the response object (changes apply to what gets cached)
|
|
- Returning `false` to prevent caching
|
|
- Returning `void`/`undefined` to use default caching behavior
|
|
|
|
Hooks use direct mutation - they can modify response.headers, response.statusCode, etc.
|
|
Mutations take effect immediately and determine what gets cached.
|
|
*/
|
|
const wrappedHandler = handler
|
|
? (response: IncomingMessageWithTimings) => {
|
|
const {beforeCacheHooks, gotRequest} = requestOptions as any;
|
|
|
|
// Early return if no hooks - cache the original response
|
|
if (!beforeCacheHooks || beforeCacheHooks.length === 0) {
|
|
handler(response);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Call each beforeCache hook with the response
|
|
// Hooks can directly mutate the response - mutations take effect immediately
|
|
for (const hook of beforeCacheHooks) {
|
|
const result = hook(response);
|
|
|
|
if (result === false) {
|
|
// Prevent caching by adding no-cache headers
|
|
// Mutate the response directly to add headers
|
|
response.headers['cache-control'] = 'no-cache, no-store, must-revalidate';
|
|
response.headers.pragma = 'no-cache';
|
|
response.headers.expires = '0';
|
|
handler(response);
|
|
// Don't call remaining hooks - we've decided not to cache
|
|
return;
|
|
}
|
|
|
|
if (is.promise(result)) {
|
|
// BeforeCache hooks must be synchronous because cacheable-request's handler is synchronous
|
|
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.');
|
|
}
|
|
|
|
if (result !== undefined) {
|
|
// Hooks should return false or undefined only
|
|
// Mutations work directly - no need to return the response
|
|
throw new TypeError('beforeCache hook must return false or undefined. To modify the response, mutate it directly.');
|
|
}
|
|
// Else: void/undefined = continue
|
|
}
|
|
} catch (error: unknown) {
|
|
const normalizedError = normalizeError(error);
|
|
// Convert hook errors to RequestError and propagate
|
|
// This is consistent with how other hooks handle errors
|
|
if (gotRequest) {
|
|
gotRequest._beforeError(normalizedError instanceof RequestError ? normalizedError : new RequestError(normalizedError.message, normalizedError, gotRequest));
|
|
// Don't call handler when error was propagated successfully
|
|
return;
|
|
}
|
|
|
|
// If gotRequest is missing, log the error to aid debugging
|
|
// We still call the handler to prevent the request from hanging
|
|
console.error('Got: beforeCache hook error (request context unavailable):', normalizedError);
|
|
// Call handler with response (potentially partially modified)
|
|
handler(response);
|
|
return;
|
|
}
|
|
|
|
// All hooks ran successfully
|
|
// Cache the response with any mutations applied
|
|
handler(response);
|
|
}
|
|
: handler;
|
|
|
|
const result = (requestOptions as any)._request(requestOptions, wrappedHandler);
|
|
|
|
// TODO: remove this when `cacheable-request` supports async request functions.
|
|
if (is.promise(result)) {
|
|
// We only need to implement the error handler in order to support HTTP/2 caching.
|
|
// The result will be a promise anyway.
|
|
// @ts-expect-error ignore
|
|
result.once = (event: string, handler: (reason: unknown) => void) => {
|
|
if (event === 'error') {
|
|
(async () => {
|
|
try {
|
|
await result;
|
|
} catch (error) {
|
|
handler(error);
|
|
}
|
|
})();
|
|
} else if (event === 'abort' || event === 'destroy') {
|
|
// The empty catch is needed here in case when
|
|
// it rejects before it's `await`ed in `_makeRequest`.
|
|
(async () => {
|
|
try {
|
|
const request = (await result) as ClientRequest;
|
|
request.once(event, handler);
|
|
} catch {}
|
|
})();
|
|
} else {
|
|
/* istanbul ignore next: safety check */
|
|
throw new Error(`Unknown HTTP/2 promise event: ${event}`);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
}
|
|
|
|
return result;
|
|
}) as typeof http.request,
|
|
cache as StorageAdapter,
|
|
);
|
|
cacheableStore.set(cache, cacheableRequest.request());
|
|
}
|
|
|
|
private async _createCacheableRequest(url: URL, options: RequestOptions): Promise<ClientRequest | ResponseLike> {
|
|
return new Promise<ClientRequest | ResponseLike>((resolve, reject) => {
|
|
Object.assign(options, {
|
|
protocol: url.protocol,
|
|
hostname: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
|
|
host: is.string(url.hostname) && url.hostname.startsWith('[') ? url.hostname.slice(1, -1) : url.hostname,
|
|
hash: url.hash === '' ? '' : (url.hash ?? null),
|
|
search: url.search === '' ? '' : (url.search ?? null),
|
|
pathname: url.pathname,
|
|
href: url.href,
|
|
path: `${url.pathname || ''}${url.search || ''}`,
|
|
...(is.string(url.port) && url.port.length > 0 ? {port: Number(url.port)} : {}),
|
|
...(url.username || url.password ? {auth: `${url.username || ''}:${url.password || ''}`} : {}),
|
|
});
|
|
|
|
let request: ClientRequest | Promise<ClientRequest>;
|
|
|
|
// TODO: Fix `cacheable-response`. This is ugly.
|
|
const cacheRequest = cacheableStore.get((options as any).cache)!(options as CacheableOptions, (response: any) => {
|
|
void (async () => {
|
|
response._readableState.autoDestroy = false;
|
|
|
|
if (request) {
|
|
const fix = () => {
|
|
// For ResponseLike objects from cache, set complete to true if not already set.
|
|
// For real HTTP responses, copy from the underlying response.
|
|
if (response.req) {
|
|
response.complete = response.req.res.complete;
|
|
} else if (response.complete === undefined) {
|
|
// ResponseLike from cache should have complete = true
|
|
response.complete = true;
|
|
}
|
|
};
|
|
|
|
response.prependOnceListener('end', fix);
|
|
fix();
|
|
|
|
(await request).emit('cacheableResponse', response);
|
|
}
|
|
|
|
resolve(response);
|
|
})();
|
|
});
|
|
|
|
cacheRequest.once('error', reject);
|
|
cacheRequest.once('request', (requestOrPromise: ClientRequest | Promise<ClientRequest>) => {
|
|
request = requestOrPromise;
|
|
resolve(request);
|
|
});
|
|
});
|
|
}
|
|
|
|
private async _makeRequest(): Promise<void> {
|
|
const {options} = this;
|
|
const shouldDeleteGeneratedHeader = (currentHeader: string | string[] | undefined, generatedHeader: string | undefined) => currentHeader === generatedHeader || is.undefined(currentHeader);
|
|
const syncGeneratedHeader = (name: 'authorization' | 'cookie', {
|
|
currentHeader,
|
|
explicitHeader,
|
|
nextHeader,
|
|
staleGeneratedHeader,
|
|
}: {
|
|
currentHeader: string | string[] | undefined;
|
|
explicitHeader: string | string[] | undefined;
|
|
nextHeader: string | undefined;
|
|
staleGeneratedHeader: string | undefined;
|
|
}) => {
|
|
if (!is.undefined(nextHeader)) {
|
|
options.setInternalHeader(name, nextHeader);
|
|
} else if (!is.undefined(explicitHeader) && currentHeader === staleGeneratedHeader) {
|
|
options.setInternalHeader(name, explicitHeader);
|
|
} else if (shouldDeleteGeneratedHeader(currentHeader, staleGeneratedHeader)) {
|
|
options.deleteInternalHeader(name);
|
|
}
|
|
};
|
|
|
|
const getAuthorizationHeader = (username: string, password: string, isExplicitlyOmitted: boolean) => !isExplicitlyOmitted && (username || password)
|
|
? `Basic ${stringToBase64(`${username}:${password}`)}`
|
|
: undefined;
|
|
const sanitizeHeaders = () => {
|
|
const currentHeaders = options.getInternalHeaders();
|
|
|
|
for (const key in currentHeaders) {
|
|
if (is.undefined(currentHeaders[key])) {
|
|
options.deleteInternalHeader(key);
|
|
} else if (is.null(currentHeaders[key])) {
|
|
throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
|
|
} else if (Array.isArray(currentHeaders[key]) && key === 'transfer-encoding') {
|
|
// Node serializes request header arrays as repeated field lines. Keep framing
|
|
// unambiguous by allowing only one transfer-encoding value here.
|
|
if (currentHeaders[key].length !== 1) {
|
|
throw new TypeError(`The \`${key}\` header must be a single value`);
|
|
}
|
|
|
|
options.setInternalHeader(key, currentHeaders[key][0]);
|
|
} else if (Array.isArray(currentHeaders[key]) && singleValueRequestHeaders.has(key)) {
|
|
// Duplicate credential and content-length lines are not allowed on requests.
|
|
// Normalize a single-element array to match the long-supported string path.
|
|
if (currentHeaders[key].length !== 1) {
|
|
throw new TypeError(`The \`${key}\` header must be a single value`);
|
|
}
|
|
|
|
options.setInternalHeader(key, currentHeaders[key][0]);
|
|
}
|
|
}
|
|
|
|
return currentHeaders;
|
|
};
|
|
|
|
const getCookieHeader = async (cookieJar: PromiseCookieJar | undefined) => {
|
|
if (!cookieJar) {
|
|
return undefined;
|
|
}
|
|
|
|
const cookieString = await cookieJar.getCookieString(options.url!.toString());
|
|
return is.nonEmptyString(cookieString) ? cookieString : undefined;
|
|
};
|
|
|
|
const headers = sanitizeHeaders();
|
|
const initialHeaders = options.getInternalHeaders();
|
|
const authorizationWasInitiallyExplicit = options.isHeaderExplicitlySet('authorization');
|
|
const explicitAuthorizationHeader = authorizationWasInitiallyExplicit ? initialHeaders.authorization : undefined;
|
|
const explicitCookieHeader = options.isHeaderExplicitlySet('cookie') ? initialHeaders.cookie : undefined;
|
|
const authorizationWasInitiallyOmitted = options.isHeaderExplicitlySet('authorization') && is.undefined(initialHeaders.authorization);
|
|
const cookieWasInitiallyOmitted = options.isHeaderExplicitlySet('cookie') && is.undefined(initialHeaders.cookie);
|
|
|
|
if (options.decompress && is.undefined(headers['accept-encoding'])) {
|
|
const encodings = ['gzip', 'deflate'];
|
|
if (supportsBrotli) {
|
|
encodings.push('br');
|
|
}
|
|
|
|
if (supportsZstd) {
|
|
encodings.push('zstd');
|
|
}
|
|
|
|
options.setInternalHeader('accept-encoding', encodings.join(', '));
|
|
}
|
|
|
|
const {username, password} = options;
|
|
const cookieJar = options.cookieJar as PromiseCookieJar | undefined;
|
|
// Preserve an explicit Authorization header over URL-derived Basic auth. This keeps
|
|
// normalized single-element arrays aligned with the long-supported string behavior.
|
|
const generatedAuthorizationHeader = is.undefined(explicitAuthorizationHeader)
|
|
? getAuthorizationHeader(username, password, authorizationWasInitiallyOmitted)
|
|
: undefined;
|
|
let generatedCookieHeader: string | undefined;
|
|
|
|
if (!is.undefined(generatedAuthorizationHeader)) {
|
|
options.setInternalHeader('authorization', generatedAuthorizationHeader);
|
|
}
|
|
|
|
if (!cookieWasInitiallyOmitted) {
|
|
generatedCookieHeader = await getCookieHeader(cookieJar);
|
|
|
|
if (!is.undefined(generatedCookieHeader)) {
|
|
options.setInternalHeader('cookie', generatedCookieHeader);
|
|
}
|
|
}
|
|
|
|
let request: ReturnType<Options['getRequestFunction']> | undefined;
|
|
let shouldOmitRequestUrlCredentials = false;
|
|
const urlBeforeRequestHooks = options.url instanceof URL ? new URL(options.url) : undefined;
|
|
const boundaryBeforeRequestHooks = getUrlPrefixBoundary(options);
|
|
const stateBeforeRequestHooks = urlBeforeRequestHooks ? snapshotCrossOriginState(options) : undefined;
|
|
const crossOriginHookStrippedHeaders = new Set<string>();
|
|
const changedState = await options.trackStateMutations(async changedState => {
|
|
for (const hook of options.hooks.beforeRequest) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
const result = await hook(options as NormalizedOptions, {retryCount: this.retryCount});
|
|
|
|
if (!is.undefined(result)) {
|
|
// @ts-expect-error Skip the type mismatch to support abstract responses
|
|
request = () => result;
|
|
break;
|
|
}
|
|
}
|
|
|
|
return changedState;
|
|
});
|
|
|
|
if (
|
|
urlBeforeRequestHooks
|
|
&& options.url instanceof URL
|
|
&& hasUrlOrPrefixUrlBoundaryChanged(options, options.url, boundaryBeforeRequestHooks)
|
|
) {
|
|
assertUrlHasSameOriginAsPrefixUrlIfNeeded(options, options.url);
|
|
}
|
|
|
|
if (
|
|
urlBeforeRequestHooks
|
|
&& options.url instanceof URL
|
|
&& !isSameOrigin(urlBeforeRequestHooks, options.url)
|
|
) {
|
|
const hookChangedState = new Set(changedState);
|
|
const currentHeaders = options.getInternalHeaders();
|
|
const changedHeaders: Record<string, string | string[] | undefined> = {};
|
|
|
|
for (const header of crossOriginStripHeaders) {
|
|
if (hookChangedState.has(header)) {
|
|
changedHeaders[header] = currentHeaders[header];
|
|
} else {
|
|
options.deleteInternalHeader(header);
|
|
crossOriginHookStrippedHeaders.add(header);
|
|
}
|
|
}
|
|
|
|
const changedOptions: OptionsInit = {headers: changedHeaders};
|
|
|
|
if (hookChangedState.has('url')) {
|
|
changedOptions.url = options.url;
|
|
}
|
|
|
|
if (hookChangedState.has('prefixUrl')) {
|
|
changedOptions.prefixUrl = options.prefixUrl;
|
|
}
|
|
|
|
if (hookChangedState.has('username')) {
|
|
changedOptions.username = options.username;
|
|
}
|
|
|
|
if (hookChangedState.has('password')) {
|
|
changedOptions.password = options.password;
|
|
}
|
|
|
|
options.stripSensitiveHeaders(urlBeforeRequestHooks, options.url, changedOptions);
|
|
|
|
this._discardBodyWrites = true;
|
|
this._hasWrittenBody = false;
|
|
this._hasWritableBody = false;
|
|
|
|
if (
|
|
!hookChangedState.has('body')
|
|
&& !hookChangedState.has('json')
|
|
&& !hookChangedState.has('form')
|
|
&& isBodyUnchanged(options, stateBeforeRequestHooks!)
|
|
) {
|
|
options.clearBody();
|
|
this._bodySize = undefined;
|
|
}
|
|
}
|
|
|
|
if (request === undefined) {
|
|
const currentHeaders = options.getInternalHeaders();
|
|
// `headers.authorization = undefined` / `headers.cookie = undefined` is an
|
|
// explicit opt-out. Respect that instead of regenerating values from URL
|
|
// credentials or the cookie jar later in request setup.
|
|
const isHeaderExplicitlyOmitted = (header: 'authorization' | 'cookie') => options.isHeaderExplicitlySet(header)
|
|
&& (Object.hasOwn(currentHeaders, header) || changedState.has(header))
|
|
&& is.undefined(currentHeaders[header]);
|
|
const currentAuthorizationHeader = currentHeaders.authorization;
|
|
const currentCookieHeader = currentHeaders.cookie;
|
|
// Authorization follows a small contract:
|
|
// - A concrete Authorization header is sent as-is.
|
|
// - `authorization = undefined` means omit Authorization entirely, including URL auth.
|
|
// - Deleting an Authorization header that started explicit also means omit it.
|
|
// - Otherwise, if the request did not start with explicit Authorization, Got may
|
|
// generate Basic auth from the current username/password.
|
|
const authorizationWasExplicitlyOmitted = isHeaderExplicitlyOmitted('authorization')
|
|
|| (
|
|
authorizationWasInitiallyExplicit
|
|
&& !crossOriginHookStrippedHeaders.has('authorization')
|
|
&& is.undefined(currentAuthorizationHeader)
|
|
);
|
|
const cookieWasExplicitlyOmitted = is.undefined(currentCookieHeader)
|
|
&& (cookieWasInitiallyOmitted || isHeaderExplicitlyOmitted('cookie'));
|
|
|
|
sanitizeHeaders();
|
|
|
|
if (!is.undefined(currentHeaders['transfer-encoding']) && !is.undefined(currentHeaders['content-length'])) {
|
|
options.deleteInternalHeader('content-length');
|
|
}
|
|
|
|
if (authorizationWasExplicitlyOmitted) {
|
|
shouldOmitRequestUrlCredentials = true;
|
|
options.deleteInternalHeader('authorization');
|
|
|
|
if (changedState.has('authorization') && is.undefined(explicitAuthorizationHeader) && !authorizationWasInitiallyOmitted) {
|
|
delete options.headers.authorization;
|
|
}
|
|
}
|
|
|
|
const authorizationHeader = !authorizationWasInitiallyExplicit
|
|
&& !authorizationWasInitiallyOmitted
|
|
&& !authorizationWasExplicitlyOmitted
|
|
? getAuthorizationHeader(options.username, options.password, authorizationWasExplicitlyOmitted)
|
|
: undefined;
|
|
const cookieJar = options.cookieJar as PromiseCookieJar | undefined;
|
|
if (changedState.has('authorization') && !is.undefined(currentAuthorizationHeader)) {
|
|
// A beforeRequest hook intentionally set the outgoing Authorization header.
|
|
} else {
|
|
const restorableAuthorizationHeader = crossOriginHookStrippedHeaders.has('authorization') || (changedState.has('authorization') && is.undefined(currentAuthorizationHeader))
|
|
? undefined
|
|
: explicitAuthorizationHeader;
|
|
syncGeneratedHeader('authorization', {
|
|
currentHeader: currentAuthorizationHeader,
|
|
explicitHeader: restorableAuthorizationHeader,
|
|
nextHeader: authorizationHeader,
|
|
staleGeneratedHeader: generatedAuthorizationHeader,
|
|
});
|
|
}
|
|
|
|
if (cookieWasExplicitlyOmitted) {
|
|
options.deleteInternalHeader('cookie');
|
|
|
|
if (changedState.has('cookie') && is.undefined(explicitCookieHeader) && !cookieWasInitiallyOmitted) {
|
|
delete options.headers.cookie;
|
|
}
|
|
} else if (changedState.has('cookie')) {
|
|
// A beforeRequest hook intentionally set the outgoing Cookie header.
|
|
} else {
|
|
const cookieHeader = !cookieWasInitiallyOmitted && !cookieWasExplicitlyOmitted
|
|
? await getCookieHeader(cookieJar)
|
|
: undefined;
|
|
const restorableCookieHeader = crossOriginHookStrippedHeaders.has('cookie')
|
|
? undefined
|
|
: explicitCookieHeader;
|
|
|
|
syncGeneratedHeader('cookie', {
|
|
currentHeader: currentCookieHeader,
|
|
explicitHeader: restorableCookieHeader,
|
|
nextHeader: cookieHeader,
|
|
staleGeneratedHeader: generatedCookieHeader,
|
|
});
|
|
}
|
|
}
|
|
|
|
request ??= options.getRequestFunction();
|
|
|
|
const url = shouldOmitRequestUrlCredentials
|
|
? new URL(stripUrlAuth(options.url!))
|
|
: options.url as URL;
|
|
|
|
this._requestOptions = options.createNativeRequestOptions() as NativeRequestOptions;
|
|
|
|
if (shouldOmitRequestUrlCredentials) {
|
|
this._requestOptions.auth = undefined;
|
|
}
|
|
|
|
if (options.cache) {
|
|
(this._requestOptions as any)._request = request;
|
|
(this._requestOptions as any).cache = options.cache;
|
|
(this._requestOptions as any).body = options.body;
|
|
(this._requestOptions as any).beforeCacheHooks = options.hooks.beforeCache;
|
|
(this._requestOptions as any).gotRequest = this;
|
|
|
|
try {
|
|
this._prepareCache(options.cache as StorageAdapter);
|
|
} catch (error: unknown) {
|
|
throw new CacheError(normalizeError(error), this);
|
|
}
|
|
}
|
|
|
|
// Cache support
|
|
const function_ = options.cache ? this._createCacheableRequest : request;
|
|
|
|
try {
|
|
// We can't do `await fn(...)`,
|
|
// because stream `error` event can be emitted before `Promise.resolve()`.
|
|
const requestFunctionStartedAt = Date.now();
|
|
const originalRequestTimeout = options.timeout.request;
|
|
let shouldRestoreRequestTimeout = false;
|
|
let requestOrResponse = function_!(url, this._requestOptions);
|
|
|
|
if (is.promise(requestOrResponse)) {
|
|
requestOrResponse = await requestOrResponse;
|
|
|
|
if (options.timeout.request !== undefined) {
|
|
const remainingRequestTimeout = options.timeout.request - (Date.now() - requestFunctionStartedAt);
|
|
options.timeout.request = Math.max(0, remainingRequestTimeout);
|
|
shouldRestoreRequestTimeout = true;
|
|
}
|
|
}
|
|
|
|
try {
|
|
if (isClientRequest(requestOrResponse!)) {
|
|
this._onRequest(requestOrResponse);
|
|
} else if (this.writableEnded) {
|
|
void this._onResponse(requestOrResponse as IncomingMessageWithTimings);
|
|
} else {
|
|
this.once('finish', () => {
|
|
void this._onResponse(requestOrResponse as IncomingMessageWithTimings);
|
|
});
|
|
|
|
this._sendBody();
|
|
}
|
|
} finally {
|
|
if (shouldRestoreRequestTimeout) {
|
|
options.timeout.request = originalRequestTimeout;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof CacheableCacheError) {
|
|
throw new CacheError(error, this);
|
|
}
|
|
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async _error(error: RequestError): Promise<void> {
|
|
try {
|
|
// Skip calling hooks for HTTP errors when throwHttpErrors is false (Promise API only).
|
|
// See https://github.com/sindresorhus/got/issues/2103
|
|
if (this.options && (!(error instanceof HTTPError) || this.options.throwHttpErrors)) {
|
|
const hooks = this.options.hooks.beforeError;
|
|
if (hooks.length > 0) {
|
|
for (const hook of hooks) {
|
|
// eslint-disable-next-line no-await-in-loop
|
|
error = await hook(error) as RequestError;
|
|
|
|
// Validate hook return value
|
|
if (!(error instanceof Error)) {
|
|
throw new TypeError(`The \`beforeError\` hook must return an Error instance. Received ${is.string(error) ? 'string' : String(typeof error)}.`);
|
|
}
|
|
}
|
|
|
|
// Mark this error as processed by hooks so _destroy preserves custom error types.
|
|
// Only mark non-RequestError errors, since RequestErrors are already preserved
|
|
// by the instanceof check in _destroy (line 642).
|
|
if (!(error instanceof RequestError)) {
|
|
errorsProcessedByHooks.add(error);
|
|
}
|
|
}
|
|
}
|
|
} catch (error_: unknown) {
|
|
const normalizedError = normalizeError(error_);
|
|
error = new RequestError(normalizedError.message, normalizedError, this);
|
|
}
|
|
|
|
// Publish error event
|
|
publishError({
|
|
requestId: this._requestId,
|
|
url: getSanitizedUrl(this.options),
|
|
error,
|
|
timings: this.timings,
|
|
});
|
|
|
|
this.destroy(error);
|
|
|
|
// Manually emit error for Promise API to ensure it receives it.
|
|
// Node.js streams may not re-emit if an error was already emitted during retry attempts.
|
|
// Only emit for Promise API (_noPipe = true) to avoid double emissions in stream mode.
|
|
// Use process.nextTick to defer emission and allow destroy() to complete first.
|
|
// See https://github.com/sindresorhus/got/issues/1995
|
|
if (this._noPipe) {
|
|
process.nextTick(() => {
|
|
this.emit('error', error);
|
|
});
|
|
}
|
|
}
|
|
|
|
private _writeRequest(chunk: any, encoding: BufferEncoding | undefined, callback: (error?: Error | null) => void, request: ClientRequest | undefined = this._request): void { // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
if (!request || request.destroyed) {
|
|
// When there's no request (e.g., using cached response from beforeRequest hook),
|
|
// we still need to call the callback to allow the stream to finish properly.
|
|
callback();
|
|
return;
|
|
}
|
|
|
|
request.write(chunk, encoding as any, (error?: Error | null) => { // eslint-disable-line @typescript-eslint/no-restricted-types
|
|
// The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed.
|
|
// The `this._request === request` check prevents stale write callbacks from a pre-redirect request from incrementing `_uploadedSize` after it's been reset.
|
|
if (!error && !request.destroyed && this._request === request) {
|
|
// For strings, encode them first to measure the actual bytes that will be sent
|
|
const bytes = typeof chunk === 'string' ? Buffer.from(chunk, encoding) : chunk;
|
|
this._uploadedSize += byteLength(bytes);
|
|
|
|
const progress = this.uploadProgress;
|
|
|
|
if (progress.percent < 1) {
|
|
this.emit('uploadProgress', progress);
|
|
}
|
|
}
|
|
|
|
callback(error);
|
|
});
|
|
}
|
|
|
|
/**
|
|
The remote IP address.
|
|
*/
|
|
get ip(): string | undefined {
|
|
return this.socket?.remoteAddress;
|
|
}
|
|
|
|
/**
|
|
Indicates whether the request has been aborted or not.
|
|
*/
|
|
get isAborted(): boolean {
|
|
return this._aborted;
|
|
}
|
|
|
|
get socket(): Socket | undefined {
|
|
return this._request?.socket ?? undefined;
|
|
}
|
|
|
|
/**
|
|
Progress event for downloading (receiving a response).
|
|
*/
|
|
get downloadProgress(): Progress {
|
|
return makeProgress(this._downloadedSize, this._responseSize);
|
|
}
|
|
|
|
/**
|
|
Progress event for uploading (sending a request).
|
|
*/
|
|
get uploadProgress(): Progress {
|
|
return makeProgress(this._uploadedSize, this._bodySize);
|
|
}
|
|
|
|
/**
|
|
The object contains the following properties:
|
|
|
|
- `start` - Time when the request started.
|
|
- `socket` - Time when a socket was assigned to the request.
|
|
- `lookup` - Time when the DNS lookup finished.
|
|
- `connect` - Time when the socket successfully connected.
|
|
- `secureConnect` - Time when the socket securely connected.
|
|
- `upload` - Time when the request finished uploading.
|
|
- `response` - Time when the request fired `response` event.
|
|
- `end` - Time when the response fired `end` event.
|
|
- `error` - Time when the request fired `error` event.
|
|
- `abort` - Time when the request fired `abort` event.
|
|
- `phases`
|
|
- `wait` - `timings.socket - timings.start`
|
|
- `dns` - `timings.lookup - timings.socket`
|
|
- `tcp` - `timings.connect - timings.lookup`
|
|
- `tls` - `timings.secureConnect - timings.connect`
|
|
- `request` - `timings.upload - (timings.secureConnect || timings.connect)`
|
|
- `firstByte` - `timings.response - timings.upload`
|
|
- `download` - `timings.end - timings.response`
|
|
- `total` - `(timings.end || timings.error || timings.abort) - timings.start`
|
|
|
|
If something has not been measured yet, it will be `undefined`.
|
|
|
|
__Note__: The time is a `number` representing the milliseconds elapsed since the UNIX epoch.
|
|
*/
|
|
get timings(): Timings | undefined {
|
|
return (this._request as ClientRequestWithTimings)?.timings;
|
|
}
|
|
|
|
/**
|
|
Whether the response was retrieved from the cache.
|
|
*/
|
|
get isFromCache(): boolean | undefined {
|
|
return this.response?.isFromCache;
|
|
}
|
|
|
|
get reusedSocket(): boolean | undefined {
|
|
return this._request?.reusedSocket;
|
|
}
|
|
|
|
/**
|
|
Whether the stream is read-only. Returns `true` when `body`, `json`, or `form` options are provided.
|
|
*/
|
|
get isReadonly(): boolean {
|
|
return !is.undefined(this.options?.body) || !is.undefined(this.options?.json) || !is.undefined(this.options?.form);
|
|
}
|
|
}
|