UNPKG

got

Version:

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

231 lines (230 loc) 12.5 kB
import { EventEmitter } from 'node:events'; import is from '@sindresorhus/is'; import { HTTPError, RetryError, } from '../core/errors.js'; import Request, { normalizeError } from '../core/index.js'; import { decodeUint8Array, isUtf8Encoding, parseBody, isResponseOk, ParseError, } from '../core/response.js'; import proxyEvents from '../core/utils/proxy-events.js'; import { applyUrlOverride, isSameOrigin, snapshotCrossOriginState, } from '../core/options.js'; const compressedEncodings = new Set(['gzip', 'deflate', 'br', 'zstd']); const proxiedRequestEvents = [ 'request', 'response', 'redirect', 'uploadProgress', 'downloadProgress', ]; 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) => { const request = firstRequest ?? new Request(undefined, undefined, defaultOptions); request.retryCount = retryCount; request._noPipe = true; globalRequest = request; request.once('response', (response) => { void (async () => { // Parse body const contentEncoding = (response.headers['content-encoding'] ?? '').toLowerCase(); const isCompressed = compressedEncodings.has(contentEncoding); const { options } = request; if (isCompressed && !options.decompress) { response.body = response.rawBody; } else { try { response.body = parseBody(response, options.responseType, options.parseJson, options.encoding); } catch (error) { // Fall back to `utf8` try { response.body = decodeUint8Array(response.rawBody); } catch (error) { request._beforeError(new ParseError(normalizeError(error), response)); return; } if (isResponseOk(response)) { request._beforeError(normalizeError(error)); return; } } } try { const hooks = options.hooks.afterResponse; for (const [index, hook] of hooks.entries()) { const previousUrl = options.url ? new URL(options.url) : undefined; 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; const reusesRequestOptions = updatedOptions === requestOptions; const hasExplicitBody = reusesRequestOptions ? changedState.has('body') || changedState.has('json') || changedState.has('form') : (Object.hasOwn(updatedOptions, 'body') && updatedOptions.body !== undefined) || (Object.hasOwn(updatedOptions, 'json') && updatedOptions.json !== undefined) || (Object.hasOwn(updatedOptions, 'form') && updatedOptions.form !== undefined); const clearsCookieJar = Object.hasOwn(updatedOptions, 'cookieJar') && updatedOptions.cookieJar === undefined; if (hasExplicitBody && !reusesRequestOptions) { options.clearBody(); } if (!reusesRequestOptions && clearsCookieJar) { options.cookieJar = undefined; } if (!reusesRequestOptions) { options.merge(updatedOptions); options.syncCookieHeaderAfterMerge(previousState, updatedOptions.headers); } options.clearUnchangedCookieHeader(previousState, reusesRequestOptions ? changedState : undefined); if (updatedOptions.url) { const nextUrl = reusesRequestOptions ? options.url : applyUrlOverride(options, updatedOptions.url, updatedOptions); if (previousUrl) { if (reusesRequestOptions && !isSameOrigin(previousUrl, nextUrl)) { options.stripUnchangedCrossOriginState(previousState, changedState, { clearBody: !hasExplicitBody }); } else { options.stripSensitiveHeaders(previousUrl, nextUrl, updatedOptions); if (!isSameOrigin(previousUrl, nextUrl) && !hasExplicitBody) { options.clearBody(); } } } } // Remove any further hooks for that request, because we'll call them anyway. // The loop continues. We don't want duplicates (asPromise recursion). // Unless preserveHooks is true, in which case we keep the remaining hooks. if (!preserveHooks) { options.hooks.afterResponse = options.hooks.afterResponse.slice(0, index); } 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(normalizeError(error)); return; } globalResponse = response; if (!isResponseOk(response)) { request._beforeError(new HTTPError(response)); return; } request.destroy(); promiseSettled = true; resolve(request.options.resolveBodyOnly ? response.body : 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; const { options } = request; if (error instanceof HTTPError && !options.throwHttpErrors) { const { response } = error; request.destroy(); resolve(request.options.resolveBodyOnly ? response.body : 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); const 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 && (is.nodeStream(newBody) || newBody instanceof ReadableStream)) { 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); }); proxyEvents(request, emitter, proxiedRequestEvents); if (is.undefined(firstRequest)) { void request.flush(); } }; makeRequest(0); }); promise.on = function (event, function_) { emitter.on(event, function_); return this; }; promise.once = function (event, function_) { emitter.once(event, function_); return this; }; promise.off = function (event, function_) { emitter.off(event, function_); return this; }; const shortcut = (promiseToAwait, responseType) => { const newPromise = (async () => { // Wait until downloading has ended await promiseToAwait; const { options } = globalResponse.request; if (responseType === 'text') { const text = decodeUint8Array(globalResponse.rawBody, options.encoding); return (isUtf8Encoding(options.encoding) ? text.replace(/^\u{FEFF}/v, '') : text); } return parseBody(globalResponse, responseType, options.parseJson, options.encoding); })(); // 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 () { if (globalRequest.options) { const { headers } = globalRequest.options; if (!globalRequest.writableFinished && !('accept' in headers)) { headers.accept = 'application/json'; } } return shortcut(this, 'json'); }; promise.buffer = function () { return shortcut(this, 'buffer'); }; promise.text = function () { return shortcut(this, 'text'); }; return promise; }