got
Version:
Human-friendly and powerful HTTP request library for Node.js
258 lines (257 loc) • 14 kB
JavaScript
import { EventEmitter } from 'node:events';
import is from '@sindresorhus/is';
import { normalizeError, HTTPError, RetryError, SchemaValidationError, } from '../core/errors.js';
import Request from '../core/index.js';
import isNonReplayableBody from '../core/utils/is-non-replayable-body.js';
import { decodeUint8Array, parseBody, isResponseOk, ParseError, } from '../core/response.js';
import proxyEvents from '../core/utils/proxy-events.js';
import { assertUrlHasSameOriginAsPrefixUrlIfNeeded, getUrlPrefixBoundary, hasBodyOptionChanged, hasUrlOrPrefixUrlBoundaryChanged, isSameOrigin, snapshotCrossOriginState, } from '../core/options.js';
const compressedEncodings = new Set(['gzip', 'x-gzip', 'deflate', 'br', 'zstd']);
const proxiedRequestEvents = [
'request',
'redirect',
'uploadProgress',
'downloadProgress',
];
const validateWithSchema = async (value, schema) => {
// JavaScript users can pass anything, so check the shape before calling into the schema.
const standardSchema = is.object(schema) ? schema['~standard'] : undefined;
if (!is.object(standardSchema) || typeof standardSchema.validate !== 'function') {
throw new TypeError('The `schema` argument must follow the Standard Schema specification');
}
const validationResult = await standardSchema.validate(value);
if (validationResult.issues) {
throw new SchemaValidationError(validationResult.issues);
}
return validationResult.value;
};
export default function asPromise(firstRequest) {
let globalRequest;
let globalResponse;
const emitter = new EventEmitter();
let promiseSettled = false;
const promise = new Promise((resolve, reject) => {
const makeRequest = (retryCount, defaultOptions, retrySource) => {
const request = firstRequest ?? new Request(undefined, undefined, defaultOptions, retrySource);
request.retryCount = retryCount;
request._noPipe = true;
globalRequest = request;
const setResponse = (response) => {
globalResponse = response;
request.response = response;
response.ok = isResponseOk(response);
};
// Settles the promise and emits `response`. The event carries the response the promise settles
// with, not the response of an attempt that is about to be retried or thrown.
const resolveWithResponse = (response) => {
request.destroy();
promiseSettled = true;
// The listener runs outside any request error handling, so a throw would otherwise
// escape as an unhandled error and leave the promise pending. The later `resolve()` is
// then a no-op.
try {
emitter.emit('response', response);
}
catch (error) {
reject(normalizeError(error));
}
// eslint-disable-next-line unicorn/no-multiple-promise-resolver-calls
resolve(request.options.resolveBodyOnly ? response.body : response);
};
request.once('response', (response) => {
void (async () => {
// Parse body
const contentEncoding = (response.headers['content-encoding'] ?? '').toLowerCase();
const isCompressed = contentEncoding.split(',').some(encoding => compressedEncodings.has(encoding.trim()));
const { options } = request;
// Content-Encoding on a bodyless response describes the representation, not transferred bytes.
const hasNoBody = options.method === 'HEAD' || response.statusCode === 204 || response.statusCode === 205 || response.statusCode === 304;
if (isCompressed && !options.decompress && !hasNoBody) {
response.body = response.rawBody;
}
else {
try {
response.body = parseBody(response, options.responseType, options.parseJson, options.encoding);
}
catch (error) {
// Preserve the response text when parsing fails.
try {
response.body = decodeUint8Array(response.rawBody, options.encoding);
}
catch (error) {
request._beforeError(new ParseError(normalizeError(error), response));
return;
}
if (isResponseOk(response)) {
request._beforeError(error);
return;
}
}
}
try {
const hooks = options.hooks.afterResponse;
for (const hook of hooks) {
const previousUrl = options.url ? new URL(options.url) : undefined;
const previousBoundary = getUrlPrefixBoundary(options);
const previousState = previousUrl ? snapshotCrossOriginState(options) : undefined;
const requestOptions = response.request.options;
const responseSnapshot = response;
// @ts-expect-error TS doesn't notice that RequestPromise is a Promise
// eslint-disable-next-line no-await-in-loop
response = await requestOptions.trackStateMutations(async (changedState) => hook(responseSnapshot, async (updatedOptions) => {
const preserveHooks = updatedOptions.preserveHooks ?? false;
if (updatedOptions === requestOptions) {
const hasExplicitBody = hasBodyOptionChanged(changedState);
options.clearUnchangedCookieHeader(previousState, changedState);
const currentUrl = options.url;
if (previousUrl
&& currentUrl instanceof URL
&& hasUrlOrPrefixUrlBoundaryChanged(options, currentUrl, previousBoundary)) {
assertUrlHasSameOriginAsPrefixUrlIfNeeded(options, currentUrl);
}
if (updatedOptions.url !== undefined
&& previousUrl
&& !isSameOrigin(previousUrl, currentUrl)) {
options.stripUnchangedCrossOriginState(previousState, changedState, { clearBody: !hasExplicitBody });
}
}
else {
options.mergeNextRequestOptions(updatedOptions, {
previousUrl,
previousState,
previousBoundary,
});
}
// RetryError interrupts this loop, so skip all response hooks on the retry by default.
// preserveHooks explicitly opts into running the complete hook list again.
if (!preserveHooks) {
options.hooks.afterResponse = [];
}
throw new RetryError(request);
}));
if (!(is.object(response) && is.number(response.statusCode) && 'body' in response)) {
throw new TypeError('The `afterResponse` hook returned an invalid value');
}
}
}
catch (error) {
request._beforeError(error);
return;
}
setResponse(response);
if (!response.ok) {
request._beforeError(new HTTPError(response));
return;
}
resolveWithResponse(response);
})();
});
let handledFinalError = false;
const onError = (error) => {
// Route errors emitted directly on the stream (e.g., EPIPE from Node.js)
// through retry logic first, then handle them here after retries are exhausted.
// See https://github.com/sindresorhus/got/issues/1995
if (!request._stopReading) {
request._beforeError(error);
return;
}
// Allow the manual re-emission from Request to land only once.
if (handledFinalError) {
return;
}
handledFinalError = true;
promiseSettled = true;
if (error instanceof HTTPError && !request.options.throwHttpErrors) {
setResponse(error.response);
resolveWithResponse(error.response);
return;
}
reject(error);
};
// Use .on() instead of .once() to keep the listener active across retries.
// When _stopReading is false, we return early and the error gets re-emitted
// after retry logic completes, so we need this listener to remain active.
// See https://github.com/sindresorhus/got/issues/1995
request.on('error', onError);
let previousBody = request.options?.body;
request.on('request', () => {
// Async handlers and beforeRequest hooks can supply the body after this Promise was created.
previousBody = request.options.body;
});
request.once('retry', (newRetryCount, error) => {
firstRequest = undefined;
// If promise already settled, don't retry
// This prevents the race condition in #1489 where a late error
// (e.g., ECONNRESET after successful response) triggers retry
// after the promise has already resolved/rejected
if (promiseSettled) {
return;
}
const newBody = request.options.body;
if (previousBody === newBody && isNonReplayableBody(newBody)) {
error.message = 'Cannot retry with consumed body stream';
onError(error);
return;
}
// This is needed! We need to reuse `request.options` because they can get modified!
// For example, by calling `promise.json()`.
makeRequest(newRetryCount, request.options, request);
});
proxyEvents(request, emitter, proxiedRequestEvents);
if (is.undefined(firstRequest)) {
void request.flush();
}
};
makeRequest(0);
});
for (const method of ['on', 'once', 'off']) {
promise[method] = function (event, function_) {
emitter[method](event, function_);
return this;
};
}
const shortcut = (promiseToAwait, responseType, schema) => {
const newPromise = (async () => {
// Wait until downloading has ended
const response = await promiseToAwait;
// Handlers may replace the response or recover one from a request error.
if (!globalRequest.options.resolveBodyOnly && response?.request instanceof Request && response.rawBody instanceof Uint8Array) {
globalResponse = response;
}
const { options } = globalResponse.request;
// `parseBody` wraps a decode failure in a `ParseError`, so an unusable encoding is
// reported with the response attached instead of as a bare `TypeError`.
const body = parseBody(globalResponse, responseType, options.parseJson, options.encoding);
if (schema === undefined) {
return body;
}
// Awaiting instead of returning the validation promise keeps the caller's frame in the
// async stack trace of a `SchemaValidationError`.
const validatedBody = await validateWithSchema(body, schema);
return validatedBody;
})();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
Object.defineProperties(newPromise, Object.getOwnPropertyDescriptors(promiseToAwait));
return newPromise;
};
// Note: These use `function` syntax (not arrows) to access `this` context.
// When custom handlers wrap the promise to transform errors, these methods
// are copied to the handler's promise. Using `this` ensures we await the
// handler's wrapped promise, not the original, so errors propagate correctly.
promise.json = function (schema) {
if (globalRequest.options) {
const { headers } = globalRequest.options;
if (!globalRequest.writableFinished && !('accept' in headers)) {
headers.accept = 'application/json';
}
}
return shortcut(this, 'json', schema);
};
promise.buffer = function () {
return shortcut(this, 'buffer');
};
promise.text = function () {
return shortcut(this, 'text');
};
return promise;
}