thenavisapp
Version:
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
1,314 lines (1,058 loc) • 37.2 kB
text/typescript
import process from 'node:process';
import {Buffer} from 'node:buffer';
import {Duplex, type Readable} from 'node:stream';
import http, {ServerResponse, type ClientRequest, type RequestOptions} from 'node:http';
import type {Socket} from 'node:net';
import timer, {type ClientRequestWithTimings, type Timings, type IncomingMessageWithTimings} from '@szmarczak/http-timer';
import CacheableRequest, {
CacheError as CacheableCacheError,
type StorageAdapter,
type CacheableRequestFunction,
type CacheableOptions,
} from 'cacheable-request';
import decompressResponse from 'decompress-response';
import is from '@sindresorhus/is';
import {getStreamAsBuffer} from 'get-stream';
import {FormDataEncoder, isFormData as isFormDataLike} from 'form-data-encoder';
import type ResponseLike from 'responselike';
import getBodySize from './utils/get-body-size.js';
import isFormData from './utils/is-form-data.js';
import proxyEvents from './utils/proxy-events.js';
import timedOut, {TimeoutError as TimedOutTimeoutError} from './timed-out.js';
import urlToOptions from './utils/url-to-options.js';
import WeakableMap from './utils/weakable-map.js';
import calculateRetryDelay from './calculate-retry-delay.js';
import Options, {
type PromiseCookieJar,
type NativeRequestOptions,
type RetryOptions,
type OptionsError,
type OptionsInit,
} from './options.js';
import {isResponseOk, type PlainResponse, type Response} from './response.js';
import isClientRequest from './utils/is-client-request.js';
import isUnixSocketURL from './utils/is-unix-socket-url.js';
import {
RequestError,
ReadError,
MaxRedirectsError,
HTTPError,
TimeoutError,
UploadError,
CacheError,
AbortError,
} from './errors.js';
type Error = NodeJS.ErrnoException;
export type Progress = {
percent: number;
transferred: number;
total?: number;
};
const supportsBrotli = is.string(process.versions.brotli);
const methodsWithoutBody: ReadonlySet<string> = new Set(['GET', 'HEAD']);
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) => void) => T);
export type RequestEvents<T> = {
on: GotEventFunction<T>;
once: GotEventFunction<T>;
off: GotEventFunction<T>;
};
const cacheableStore = new WeakableMap<string | StorageAdapter, CacheableRequestFunction>();
const redirectCodes: ReadonlySet<number> = new Set([300, 301, 302, 303, 304, 307, 308]);
const proxiedRequestEvents = [
'socket',
'connect',
'continue',
'information',
'upgrade',
] as const;
const noop = (): void => {};
type UrlType = ConstructorParameters<typeof Options>[0];
type OptionsType = ConstructorParameters<typeof Options>[1];
type DefaultsType = ConstructorParameters<typeof Options>[2];
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: number;
declare private _requestOptions: NativeRequestOptions;
private _stopRetry: () => void;
private _downloadedSize: number;
private _uploadedSize: number;
private _stopReading: boolean;
private readonly _pipedServerResponses: Set<ServerResponse>;
private _request?: ClientRequest;
private _responseSize?: number;
private _bodySize?: number;
private _unproxyEvents: () => void;
private _isFromCache?: boolean;
private _cannotHaveBody: boolean;
private _triggerRead: boolean;
declare private readonly _jobs: Array<() => void>;
private _cancelTimeouts: () => void;
private readonly _removeListeners: () => void;
private _nativeResponse?: IncomingMessageWithTimings;
private _flushed: boolean;
private _aborted: boolean;
// We need this because `this._request` if `undefined` when using cache
private _requestInitialized: boolean;
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._downloadedSize = 0;
this._uploadedSize = 0;
this._stopReading = false;
this._pipedServerResponses = new Set<ServerResponse>();
this._cannotHaveBody = false;
this._unproxyEvents = noop;
this._triggerRead = false;
this._cancelTimeouts = noop;
this._removeListeners = noop;
this._jobs = [];
this._flushed = false;
this._requestInitialized = false;
this._aborted = false;
this.redirectUrls = [];
this.retryCount = 0;
this._stopRetry = noop;
this.on('pipe', (source: any) => {
if (source?.headers) {
Object.assign(this.options.headers, source.headers);
}
});
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;
} catch (error: any) {
const {options} = error as OptionsError;
if (options) {
this.options = options;
}
this.flush = async () => {
this.flush = async () => {};
this.destroy(error);
};
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', error => {
if (this._flushed) {
this._beforeError(new UploadError(error, this));
} else {
this.flush = async () => {
this.flush = async () => {};
this._beforeError(new UploadError(error, this));
};
}
});
}
if (this.options.signal) {
const abort = () => {
this.destroy(new AbortError(this));
};
if (this.options.signal.aborted) {
abort();
} else {
this.options.signal.addEventListener('abort', abort);
this._removeListeners = () => {
this.options.signal?.removeEventListener('abort', abort);
};
}
}
}
async flush() {
if (this._flushed) {
return;
}
this._flushed = true;
try {
await this._finalizeBody();
if (this.destroyed) {
return;
}
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: any) {
this._beforeError(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 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 = response.rawBody!.toString();
}
}
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;
}
}
const retryOptions = options.retry as RetryOptions;
backoff = await retryOptions.calculateDelay({
attemptCount,
retryOptions,
error: typedError,
retryAfter,
computedValue: calculateRetryDelay({
attemptCount,
retryOptions,
error: typedError,
retryAfter,
computedValue: retryOptions.maxRetryAfter ?? options.timeout.request ?? Number.POSITIVE_INFINITY,
}),
});
} catch (error_: any) {
void this._error(new RequestError(error_.message, error_, 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;
}
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_: any) {
void this._error(new RequestError(error_.message, error, this));
return;
}
// Something forced us to abort the retry
if (this.destroyed) {
return;
}
this.destroy();
this.emit('retry', this.retryCount + 1, error, (updatedOptions?: OptionsInit) => {
const request = new Request(options.url, 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
const progress = this.downloadProgress;
if (progress.percent < 1) {
this.emit('downloadProgress', progress);
}
this.push(data);
}
}
}
override _write(chunk: unknown, encoding: BufferEncoding | undefined, callback: (error?: Error | null) => void): void { // eslint-disable-line @typescript-eslint/ban-types
const write = (): void => {
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/ban-types
const endRequest = (): void => {
// We need to check if `this._request` is present,
// because it isn't when we use cache.
if (!this._request || this._request.destroyed) {
callback();
return;
}
this._request.end((error?: Error | null) => { // eslint-disable-line @typescript-eslint/ban-types
// The request has been destroyed before `_final` finished.
// See https://github.com/nodejs/node/issues/39356
if ((this._request as any)._writableState?.errored) {
return;
}
if (!error) {
this._bodySize = this._uploadedSize;
this.emit('uploadProgress', this.uploadProgress);
this._request!.emit('upload-complete');
}
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/ban-types
this._stopReading = true;
this.flush = async () => {};
// Prevent further retries
this._stopRetry();
this._cancelTimeouts();
this._removeListeners();
if (this.options) {
const {body} = this.options;
if (is.nodeStream(body)) {
body.destroy();
}
}
if (this._request) {
this._request.destroy();
}
if (error !== null && !is.undefined(error) && !(error instanceof RequestError)) {
error = new RequestError(error.message, 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 async _finalizeBody(): Promise<void> {
const {options} = this;
const {headers} = options;
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 = methodsWithoutBody.has(options.method) && !(options.method === 'GET' && options.allowGetBody);
this._cannotHaveBody = cannotHaveBody;
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) {
// Body is spec-compliant FormData
if (isFormDataLike(options.body)) {
const encoder = new FormDataEncoder(options.body);
if (noContentType) {
headers['content-type'] = encoder.headers['Content-Type'];
}
if ('Content-Length' in encoder.headers) {
headers['content-length'] = encoder.headers['Content-Length'];
}
options.body = encoder.encode();
}
// Special case for https://github.com/form-data/form-data
if (isFormData(options.body) && noContentType) {
headers['content-type'] = `multipart/form-data; boundary=${options.body.getBoundary()}`;
}
} else if (isForm) {
if (noContentType) {
headers['content-type'] = 'application/x-www-form-urlencoded';
}
const {form} = options;
options.form = undefined;
options.body = (new URLSearchParams(form as Record<string, string>)).toString();
} else {
if (noContentType) {
headers['content-type'] = 'application/json';
}
const {json} = options;
options.json = undefined;
options.body = options.stringifyJson(json);
}
const uploadBodySize = await getBodySize(options.body, options.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 options.headers)) {
options.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;
this._nativeResponse = response;
if (options.decompress) {
response = decompressResponse(response);
}
const statusCode = response.statusCode!;
const typedResponse = response as PlainResponse;
typedResponse.statusMessage = typedResponse.statusMessage ?? http.STATUS_CODES[statusCode];
typedResponse.url = options.url!.toString();
typedResponse.requestUrl = this.requestUrl!;
typedResponse.redirectUrls = this.redirectUrls;
typedResponse.request = this;
typedResponse.isFromCache = (this._nativeResponse as any).fromCache ?? false;
typedResponse.ip = this.ip;
typedResponse.retryCount = this.retryCount;
typedResponse.ok = isResponseOk(typedResponse);
this._isFromCache = typedResponse.isFromCache;
this._responseSize = Number(response.headers['content-length']) || undefined;
this.response = typedResponse;
response.once('end', () => {
this._responseSize = this._downloadedSize;
this.emit('downloadProgress', this.downloadProgress);
});
response.once('error', (error: Error) => {
this._aborted = true;
// Force clean-up, because some packages don't do this.
// TODO: Fix decompress-response
response.destroy();
this._beforeError(new ReadError(error, this));
});
response.once('aborted', () => {
this._aborted = true;
this._beforeError(new ReadError({
name: 'Error',
message: 'The server aborted pending request',
code: 'ECONNRESET',
}, this));
});
this.emit('downloadProgress', this.downloadProgress);
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) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
promises = promises.map(async promise => {
try {
await promise;
} catch {}
});
}
try {
await Promise.all(promises);
} catch (error: any) {
this._beforeError(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 (response.headers.location && redirectCodes.has(statusCode)) {
// 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.
const shouldFollow = typeof options.followRedirect === 'function' ? options.followRedirect(typedResponse) : options.followRedirect;
if (shouldFollow) {
response.resume();
this._cancelTimeouts();
this._unproxyEvents();
if (this.redirectUrls.length >= options.maxRedirects) {
this._beforeError(new MaxRedirectsError(this));
return;
}
this._request = undefined;
const updatedOptions = new Options(undefined, undefined, this.options);
const serverRequestedGet = statusCode === 303 && updatedOptions.method !== 'GET' && updatedOptions.method !== 'HEAD';
const canRewrite = statusCode !== 307 && statusCode !== 308;
const userRequestedGet = updatedOptions.methodRewriting && canRewrite;
if (serverRequestedGet || userRequestedGet) {
updatedOptions.method = 'GET';
updatedOptions.body = undefined;
updatedOptions.json = undefined;
updatedOptions.form = undefined;
delete updatedOptions.headers['content-length'];
}
try {
// We need this in order to support UTF-8
const redirectBuffer = Buffer.from(response.headers.location, 'binary').toString();
const redirectUrl = new URL(redirectBuffer, url);
if (!isUnixSocketURL(url as URL) && isUnixSocketURL(redirectUrl)) {
this._beforeError(new RequestError('Cannot redirect to UNIX socket', {}, this));
return;
}
// Redirecting to a different site, clear sensitive data.
if (redirectUrl.hostname !== (url as URL).hostname || redirectUrl.port !== (url as URL).port) {
if ('host' in updatedOptions.headers) {
delete updatedOptions.headers.host;
}
if ('cookie' in updatedOptions.headers) {
delete updatedOptions.headers.cookie;
}
if ('authorization' in updatedOptions.headers) {
delete updatedOptions.headers.authorization;
}
if (updatedOptions.username || updatedOptions.password) {
updatedOptions.username = '';
updatedOptions.password = '';
}
} else {
redirectUrl.username = updatedOptions.username;
redirectUrl.password = updatedOptions.password;
}
this.redirectUrls.push(redirectUrl);
updatedOptions.prefixUrl = '';
updatedOptions.url = redirectUrl;
for (const hook of updatedOptions.hooks.beforeRedirect) {
// eslint-disable-next-line no-await-in-loop
await hook(updatedOptions, typedResponse);
}
this.emit('redirect', updatedOptions, typedResponse);
this.options = updatedOptions;
await this._makeRequest();
} catch (error: any) {
this._beforeError(error);
return;
}
return;
}
}
// `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;
}
response.on('readable', () => {
if (this._triggerRead) {
this._read();
}
});
this.on('resume', () => {
response.resume();
});
this.on('pause', () => {
response.pause();
});
response.once('end', () => {
this.push(null);
});
if (this._noPipe) {
const success = await this._setRawBody();
if (success) {
this.emit('response', response);
}
return;
}
this.emit('response', response);
for (const destination of this._pipedServerResponses) {
if (destination.headersSent) {
continue;
}
// eslint-disable-next-line guard-for-in
for (const key in response.headers) {
const isAllowed = options.decompress ? key !== 'content-encoding' : true;
const value = response.headers[key];
if (isAllowed) {
destination.setHeader(key, value!);
}
}
destination.statusCode = statusCode;
}
}
private async _setRawBody(from: Readable = this): Promise<boolean> {
if (from.readableEnded) {
return false;
}
try {
// Errors are emitted via the `error` event
const rawBody = await getStreamAsBuffer(from);
// TODO: Switch to this:
// let rawBody = await from.toArray();
// rawBody = Buffer.concat(rawBody);
// On retry Request is destroyed with no error, therefore the above will successfully resolve.
// So in order to check if this was really successfull, we need to check if it has been properly ended.
if (!this.isAborted) {
this.response!.rawBody = rawBody;
return true;
}
} catch {}
return false;
}
private async _onResponse(response: IncomingMessageWithTimings): Promise<void> {
try {
await this._onResponseBase(response);
} catch (error: any) {
/* istanbul ignore next: better safe than sorry */
this._beforeError(error);
}
}
private _onRequest(request: ClientRequest): void {
const {options} = this;
const {timeout, url} = options;
timer(request);
if (this.options.http2) {
// Unset stream timeout, as the `timeout` option was used only for connection timeout.
request.setTimeout(0);
}
this._cancelTimeouts = timedOut(request, timeout, url as URL);
const responseEventName = options.cache ? 'cacheableResponse' : 'response';
request.once(responseEventName, (response: IncomingMessageWithTimings) => {
void this._onResponse(response);
});
request.once('error', (error: Error) => {
this._aborted = true;
// Force clean-up, because some packages (e.g. nock) don't do this.
request.destroy();
error = error instanceof TimedOutTimeoutError ? new TimeoutError(error, this.timings!, this) : new RequestError(error.message, error, this);
this._beforeError(error);
});
this._unproxyEvents = proxyEvents(request, this, proxiedRequestEvents);
this._request = request;
this.emit('uploadProgress', this.uploadProgress);
this._sendBody();
this.emit('request', request);
}
private async _asyncWrite(chunk: any): Promise<void> {
return new Promise((resolve, reject) => {
super.write(chunk, error => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
private _sendBody() {
// Send body
const {body} = this.options;
const currentRequest = this.redirectUrls.length === 0 ? this : this._request ?? this;
if (is.nodeStream(body)) {
body.pipe(currentRequest);
} else if (is.generator(body) || is.asyncGenerator(body)) {
(async () => {
try {
for await (const chunk of body) {
await this._asyncWrite(chunk);
}
super.end();
} catch (error: any) {
this._beforeError(error);
}
})();
} else if (!is.undefined(body)) {
this._writeRequest(body, undefined, () => {});
currentRequest.end();
} else if (this._cannotHaveBody || this._noPipe) {
currentRequest.end();
}
}
private _prepareCache(cache: string | StorageAdapter) {
if (!cacheableStore.has(cache)) {
const cacheableRequest = new CacheableRequest(
((requestOptions: RequestOptions, handler?: (response: IncomingMessageWithTimings) => void): ClientRequest => {
const result = (requestOptions as any)._request(requestOptions, handler);
// 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 HTTP2 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') {
// 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('abort', handler);
} catch {}
})();
} else {
/* istanbul ignore next: safety check */
throw new Error(`Unknown HTTP2 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) => {
// TODO: Remove `utils/url-to-options.ts` when `cacheable-request` is fixed
Object.assign(options, urlToOptions(url));
let request: ClientRequest | Promise<ClientRequest>;
// TODO: Fix `cacheable-response`. This is ugly.
const cacheRequest = cacheableStore.get((options as any).cache)!(options as CacheableOptions, async (response: any) => {
response._readableState.autoDestroy = false;
if (request) {
const fix = () => {
if (response.req) {
response.complete = response.req.res.complete;
}
};
response.prependOnceListener('end', fix);
fix();
(await request).emit('cacheableResponse', response);
}
resolve(response);
});
cacheRequest.once('error', reject);
cacheRequest.once('request', async (requestOrPromise: ClientRequest | Promise<ClientRequest>) => {
request = requestOrPromise;
resolve(request);
});
});
}
private async _makeRequest(): Promise<void> {
const {options} = this;
const {headers, username, password} = options;
const cookieJar = options.cookieJar as PromiseCookieJar | undefined;
for (const key in headers) {
if (is.undefined(headers[key])) {
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
delete headers[key];
} else if (is.null_(headers[key])) {
throw new TypeError(`Use \`undefined\` instead of \`null\` to delete the \`${key}\` header`);
}
}
if (options.decompress && is.undefined(headers['accept-encoding'])) {
headers['accept-encoding'] = supportsBrotli ? 'gzip, deflate, br' : 'gzip, deflate';
}
if (username || password) {
const credentials = Buffer.from(`${username}:${password}`).toString('base64');
headers.authorization = `Basic ${credentials}`;
}
// Set cookies
if (cookieJar) {
const cookieString: string = await cookieJar.getCookieString(options.url!.toString());
if (is.nonEmptyString(cookieString)) {
headers.cookie = cookieString;
}
}
// Reset `prefixUrl`
options.prefixUrl = '';
let request: ReturnType<Options['getRequestFunction']> | undefined;
for (const hook of options.hooks.beforeRequest) {
// eslint-disable-next-line no-await-in-loop
const result = await hook(options);
if (!is.undefined(result)) {
// @ts-expect-error Skip the type mismatch to support abstract responses
request = () => result;
break;
}
}
if (!request) {
request = options.getRequestFunction();
}
const url = options.url as URL;
this._requestOptions = options.createNativeRequestOptions() as NativeRequestOptions;
if (options.cache) {
(this._requestOptions as any)._request = request;
(this._requestOptions as any).cache = options.cache;
(this._requestOptions as any).body = options.body;
this._prepareCache(options.cache as StorageAdapter);
}
// Cache support
const fn = options.cache ? this._createCacheableRequest : request;
try {
// We can't do `await fn(...)`,
// because stream `error` event can be emitted before `Promise.resolve()`.
let requestOrResponse = fn!(url, this._requestOptions);
if (is.promise(requestOrResponse)) {
requestOrResponse = await requestOrResponse;
}
// Fallback
if (is.undefined(requestOrResponse)) {
requestOrResponse = options.getFallbackRequestFunction()!(url, this._requestOptions);
if (is.promise(requestOrResponse)) {
requestOrResponse = await requestOrResponse;
}
}
if (isClientRequest(requestOrResponse!)) {
this._onRequest(requestOrResponse);
} else if (this.writable) {
this.once('finish', () => {
void this._onResponse(requestOrResponse as IncomingMessageWithTimings);
});
this._sendBody();
} else {
void this._onResponse(requestOrResponse as IncomingMessageWithTimings);
}
} catch (error) {
if (error instanceof CacheableCacheError) {
throw new CacheError(error, this);
}
throw error;
}
}
private async _error(error: RequestError): Promise<void> {
try {
if (error instanceof HTTPError && !this.options.throwHttpErrors) {
// This branch can be reached only when using the Promise API
// Skip calling the hooks on purpose.
// See https://github.com/sindresorhus/got/issues/2103
} else {
for (const hook of this.options.hooks.beforeError) {
// eslint-disable-next-line no-await-in-loop
error = await hook(error);
}
}
} catch (error_: any) {
error = new RequestError(error_.message, error_, this);
}
this.destroy(error);
}
private _writeRequest(chunk: any, encoding: BufferEncoding | undefined, callback: (error?: Error | null) => void): void { // eslint-disable-line @typescript-eslint/ban-types
if (!this._request || this._request.destroyed) {
// Probably the `ClientRequest` instance will throw
return;
}
this._request.write(chunk, encoding!, (error?: Error | null) => { // eslint-disable-line @typescript-eslint/ban-types
// The `!destroyed` check is required to prevent `uploadProgress` being emitted after the stream was destroyed
if (!error && !this._request!.destroyed) {
this._uploadedSize += Buffer.byteLength(chunk, encoding);
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 {
let percent;
if (this._responseSize) {
percent = this._downloadedSize / this._responseSize;
} else if (this._responseSize === this._downloadedSize) {
percent = 1;
} else {
percent = 0;
}
return {
percent,
transferred: this._downloadedSize,
total: this._responseSize,
};
}
/**
Progress event for uploading (sending a request).
*/
get uploadProgress(): Progress {
let percent;
if (this._bodySize) {
percent = this._uploadedSize / this._bodySize;
} else if (this._bodySize === this._uploadedSize) {
percent = 1;
} else {
percent = 0;
}
return {
percent,
transferred: this._uploadedSize,
total: 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._isFromCache;
}
get reusedSocket(): boolean | undefined {
return this._request?.reusedSocket;
}
}