UNPKG

got

Version:

Human-friendly and powerful HTTP request library for Node.js

258 lines (257 loc) • 14 kB
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; }