UNPKG

got

Version:

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

1,061 lines 86.1 kB
import process from 'node:process'; import { Buffer } from 'node:buffer'; import { Duplex } from 'node:stream'; import { addAbortListener } from 'node:events'; import http, { ServerResponse } from 'node:http'; import { byteLength } from 'byte-counter'; import { chunk } from 'chunk-data'; import { concatUint8Arrays, stringToBase64, stringToUint8Array } from 'uint8array-extras'; import CacheableRequest, { CacheError as CacheableCacheError, } from 'cacheable-request'; import decompressResponse from 'decompress-response'; import is, { isBuffer } from '@sindresorhus/is'; import timer 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, { crossOriginStripHeaders, hasExplicitCredentialInUrlChange, isCrossOriginCredentialChanged, isBodyUnchanged, isSameOrigin, snapshotCrossOriginState, } from './options.js'; import { cacheDecodedBody, decodeUint8Array, isResponseOk, isUtf8Encoding, } 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'; const supportsBrotli = is.string(process.versions.brotli); const supportsZstd = is.string(process.versions.zstd); const methodsWithoutBody = new Set(['GET', 'HEAD']); const cacheableStore = new WeakableMap(); const redirectCodes = new Set([301, 302, 303, 307, 308]); export { crossOriginStripHeaders } from './options.js'; const transientWriteErrorCodes = 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(); const proxiedRequestEvents = [ 'socket', 'connect', 'continue', 'information', 'upgrade', ]; const noop = () => { }; const isTransientWriteError = (error) => { const { code } = error; return typeof code === 'string' && transientWriteErrorCodes.has(code); }; const getConnectionListedHeaders = (headers) => { const connectionListedHeaders = new Set(); 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) => { if (error instanceof globalThis.Error) { return error; } if (is.object(error)) { const errorLike = error; const message = typeof errorLike.message === 'string' ? errorLike.message : 'Non-error object thrown'; const normalizedError = new globalThis.Error(message, { cause: error }); 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)); }; const getSanitizedUrl = (options) => options?.url ? stripUrlAuth(options.url) : ''; const makeProgress = (transferred, total) => { let percent = 0; if (total) { percent = transferred / total; } else if (total === transferred) { percent = 1; } return { percent, transferred, total }; }; export default class Request extends Duplex { // @ts-expect-error - Ignoring for now. ['constructor']; _noPipe; // @ts-expect-error https://github.com/microsoft/TypeScript/issues/9568 options; response; requestUrl; redirectUrls = []; retryCount = 0; _stopReading = false; _stopRetry; _downloadedSize = 0; _uploadedSize = 0; _pipedServerResponses = new Set(); _request; _responseSize; _bodySize; _unproxyEvents; _triggerRead = false; _jobs = []; _cancelTimeouts; _abortListenerDisposer; _flushed = false; _aborted = false; _expectedContentLength; _compressedBytesCount; _skipRequestEndInFinal = false; _incrementalDecode; _requestId = generateRequestId(); // We need this because `this._request` if `undefined` when using cache _requestInitialized = false; constructor(url, options, defaults) { 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) => { 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; // Publish request creation event publishRequestCreate({ requestId: this._requestId, url: getSanitizedUrl(this.options), method: this.options.method, }); } catch (error) { const { options } = error; 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); } if (this.options.signal) { const abort = () => { // See https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static#return_value if (this.options.signal?.reason?.name === 'TimeoutError') { this.destroy(new TimeoutError(this.options.signal.reason, this.timings, this)); } else { this.destroy(new AbortError(this)); } }; if (this.options.signal.aborted) { abort(); } else { const abortListenerDisposer = addAbortListener(this.options.signal, abort); this._abortListenerDisposer = abortListenerDisposer; } } } 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) { this._beforeError(normalizeError(error)); } } _beforeError(error) { 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; 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; 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; 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_) { const normalizedError = normalizeError(error_); void this._error(new RequestError(normalizedError.message, normalizedError, this)); return; } if (backoff) { await new Promise(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_) { 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_) { 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) => { 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); })(); } _read() { 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); } this.push(data); } } } _write(chunk, encoding, callback) { const write = () => { this._writeRequest(chunk, encoding, callback); }; if (this._requestInitialized) { write(); } else { this._jobs.push(write); } } _final(callback) { const endRequest = () => { 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) { callback(); return; } request.end((error) => { // The request has been destroyed before `_final` finished. // See https://github.com/nodejs/node/issues/39356 if (request?._writableState?.errored) { return; } if (!error) { this._emitUploadComplete(request); } callback(error); }); }; if (this._requestInitialized) { endRequest(); } else { this._jobs.push(endRequest); } } _destroy(error, callback) { this._stopReading = true; this.flush = async () => { }; // Prevent further retries this._stopRetry?.(); this._cancelTimeouts?.(); this._abortListenerDisposer?.[Symbol.dispose](); 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?.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); } pipe(destination, options) { if (destination instanceof ServerResponse) { this._pipedServerResponses.add(destination); } return super.pipe(destination, options); } unpipe(destination) { if (destination instanceof ServerResponse) { this._pipedServerResponses.delete(destination); } super.unpipe(destination); return this; } _shouldIncrementallyDecodeBody() { const { responseType, encoding } = this.options; return Boolean(this._noPipe) && (responseType === 'text' || responseType === 'json') && isUtf8Encoding(encoding) && typeof globalThis.TextDecoder === 'function'; } _checkContentLengthMismatch() { 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; } async _finalizeBody() { 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 = methodsWithoutBody.has(options.method) && !(options.method === 'GET' && options.allowGetBody); 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 response = new Response(options.body); if (noContentType) { headers['content-type'] = response.headers.get('content-type') ?? 'multipart/form-data'; } options.body = response.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; } async _onResponseBase(response) { // 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; 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) => { 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); // 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) => { this._compressedBytesCount += byteLength(chunk); }); } response = decompressResponse(response); typedResponse = prepareResponse(response); } 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) => { this._aborted = true; this._beforeError(new ReadError(error, this)); }); response.once('aborted', () => { 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 = rawCookies.map(async (rawCookie) => options.cookieJar.setCookie(rawCookie, url.toString())); if (options.ignoreInvalidCookies) { promises = promises.map(async (promise) => { try { await promise; } catch { } }); } try { await Promise.all(promises); } catch (error) { 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); 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.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); } if (isDifferentOrigin) { // Also strip body on cross-origin redirects to prevent data leakage. // 301/302 POST already drops the body (converted to GET above). // 307/308 preserve the method per RFC, but the body must not be // forwarded to a different origin. // Strip credentials embedded in the redirect URL itself // to prevent a malicious server from injecting auth to third parties. this._stripCrossOriginState(updatedOptions, redirectUrl, shouldDropBody); } else { redirectUrl.username = updatedOptions.username; redirectUrl.password = updatedOptions.password; } updatedOptions.url = redirectUrl; this.redirectUrls.push(redirectUrl); 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, typedResponse); } return changedState; }); updatedOptions.clearUnchangedCookieHeader(preHookState, changedState); // 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; if (!isSameOrigin(state.url, hookUrl)) { this._stripUnchangedCrossOriginState(updatedOptions, hookUrl, shouldDropBody, { ...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) { 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; } // `decompressResponse` wraps the response stream when it decompresses, // so `response !== nativeResponse` indicates decompression happened. const wasDecompressed = response !== nativeResponse; // 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; } } async _setRawBody(from = this) { 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.map(chunk => typeof chunk === 'string' ? stringToUint8Array(chunk) : chunk)) : stringToUint8Array(fromArray.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; } async _onResponse(response) { try { await this._onResponseBase(response); } catch (error) { /* istanbul ignore next: better safe than sorry */ this._beforeError(normalizeError(error)); } } _onRequest(request) { 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); this._cancelTimeouts = timedOut(request, timeout, url); if (this.options.http2) { // Unset stream timeout, as the `timeout` option was used only for connection timeout. // We remove all 'timeout' listeners instead of calling setTimeout(0) because: // 1. setTimeout(0) causes a memory leak (see https://github.com/sindresorhus/got/issues/690) // 2. With HTTP/2 connection reuse, setTimeout(0) accumulates listeners on the socket // 3. removeAllListeners('timeout') properly cleans up without the memory leak request.removeAllListeners('timeout'); // For HTTP/2, wait for socket and remove timeout listeners from it request.once('socket', (socket) => { socket.removeAllListeners('timeout'); }); } let lastRequestError; const responseEventName = options.cache ? 'cacheableResponse' : 'response'; request.once(responseEventName, (response) => { void this._onResponse(response); }); const emitRequestError = (error) => { 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, this) : new RequestError(error.message, error, this); this._beforeError(wrappedError); }; request.once('error', (error) => { lastRequestError = error; // Ignore errors from requests superseded by a redirect. if (this._request !== request) { return; } /* Tra