UNPKG

@mswjs/interceptors

Version:

Low-level HTTP/HTTPS/XHR/fetch request interception library.

292 lines (250 loc) 9.97 kB
import { urlToHttpOptions } from 'node:url' import { Agent as HttpAgent, globalAgent as httpGlobalAgent, IncomingMessage, } from 'node:http' import { RequestOptions, Agent as HttpsAgent, globalAgent as httpsGlobalAgent, } from 'node:https' import { /** * @note Use the Node.js URL instead of the global URL * because environments like JSDOM may override the global, * breaking the compatibility with Node.js. * @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 */ URL, Url as LegacyURL, parse as parseUrl, } from 'node:url' import { Logger } from '@open-draft/logger' import { ResolvedRequestOptions, getUrlByRequestOptions, } from '../../../utils/getUrlByRequestOptions' import { cloneObject } from '../../../utils/cloneObject' import { isObject } from '../../../utils/isObject' const logger = new Logger('http normalizeClientRequestArgs') export type HttpRequestCallback = (response: IncomingMessage) => void export type ClientRequestArgs = // Request without any arguments is also possible. | [] | [string | URL | LegacyURL, HttpRequestCallback?] | [string | URL | LegacyURL, RequestOptions, HttpRequestCallback?] | [RequestOptions, HttpRequestCallback?] function resolveRequestOptions( args: ClientRequestArgs, url: URL ): RequestOptions { // Calling `fetch` provides only URL to `ClientRequest` // without any `RequestOptions` or callback. if (typeof args[1] === 'undefined' || typeof args[1] === 'function') { logger.info('request options not provided, deriving from the url', url) return urlToHttpOptions(url) } if (args[1]) { logger.info('has custom RequestOptions!', args[1]) const requestOptionsFromUrl = urlToHttpOptions(url) logger.info('derived RequestOptions from the URL:', requestOptionsFromUrl) /** * Clone the request options to lock their state * at the moment they are provided to `ClientRequest`. * @see https://github.com/mswjs/interceptors/issues/86 */ logger.info('cloning RequestOptions...') const clonedRequestOptions = cloneObject(args[1]) logger.info('successfully cloned RequestOptions!', clonedRequestOptions) return { ...requestOptionsFromUrl, ...clonedRequestOptions, } } logger.info('using an empty object as request options') return {} as RequestOptions } /** * Overrides the given `URL` instance with the explicit properties provided * on the `RequestOptions` object. The options object takes precedence, * and will replace URL properties like "host", "path", and "port", if specified. */ function overrideUrlByRequestOptions(url: URL, options: RequestOptions): URL { url.host = options.host || url.host url.hostname = options.hostname || url.hostname url.port = options.port ? options.port.toString() : url.port if (options.path) { const parsedOptionsPath = parseUrl(options.path, false) url.pathname = parsedOptionsPath.pathname || '' url.search = parsedOptionsPath.search || '' } return url } function resolveCallback( args: ClientRequestArgs ): HttpRequestCallback | undefined { return typeof args[1] === 'function' ? args[1] : args[2] } export type NormalizedClientRequestArgs = [ url: URL, options: ResolvedRequestOptions, callback?: HttpRequestCallback ] /** * Normalizes parameters given to a `http.request` call * so it always has a `URL` and `RequestOptions`. */ export function normalizeClientRequestArgs( defaultProtocol: string, args: ClientRequestArgs ): NormalizedClientRequestArgs { let url: URL let options: ResolvedRequestOptions let callback: HttpRequestCallback | undefined logger.info('arguments', args) logger.info('using default protocol:', defaultProtocol) // Support "http.request()" calls without any arguments. // That call results in a "GET http://localhost" request. if (args.length === 0) { const url = new URL('http://localhost') const options = resolveRequestOptions(args, url) return [url, options] } // Convert a url string into a URL instance // and derive request options from it. if (typeof args[0] === 'string') { logger.info('first argument is a location string:', args[0]) url = new URL(args[0]) logger.info('created a url:', url) const requestOptionsFromUrl = urlToHttpOptions(url) logger.info('request options from url:', requestOptionsFromUrl) options = resolveRequestOptions(args, url) logger.info('resolved request options:', options) callback = resolveCallback(args) } // Handle a given URL instance as-is // and derive request options from it. else if (args[0] instanceof URL) { url = args[0] logger.info('first argument is a URL:', url) // Check if the second provided argument is RequestOptions. // If it is, check if "options.path" was set and rewrite it // on the input URL. // Do this before resolving options from the URL below // to prevent query string from being duplicated in the path. if (typeof args[1] !== 'undefined' && isObject<RequestOptions>(args[1])) { url = overrideUrlByRequestOptions(url, args[1]) } options = resolveRequestOptions(args, url) logger.info('derived request options:', options) callback = resolveCallback(args) } // Handle a legacy URL instance and re-normalize from either a RequestOptions object // or a WHATWG URL. else if ('hash' in args[0] && !('method' in args[0])) { const [legacyUrl] = args logger.info('first argument is a legacy URL:', legacyUrl) if (legacyUrl.hostname === null) { /** * We are dealing with a relative url, so use the path as an "option" and * merge in any existing options, giving priority to exising options -- i.e. a path in any * existing options will take precedence over the one contained in the url. This is consistent * with the behaviour in ClientRequest. * @see https://github.com/nodejs/node/blob/d84f1312915fe45fe0febe888db692c74894c382/lib/_http_client.js#L122 */ logger.info('given legacy URL is relative (no hostname)') return isObject(args[1]) ? normalizeClientRequestArgs(defaultProtocol, [ { path: legacyUrl.path, ...args[1] }, args[2], ]) : normalizeClientRequestArgs(defaultProtocol, [ { path: legacyUrl.path }, args[1] as HttpRequestCallback, ]) } logger.info('given legacy url is absolute') // We are dealing with an absolute URL, so convert to WHATWG and try again. const resolvedUrl = new URL(legacyUrl.href) return args[1] === undefined ? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl]) : typeof args[1] === 'function' ? normalizeClientRequestArgs(defaultProtocol, [resolvedUrl, args[1]]) : normalizeClientRequestArgs(defaultProtocol, [ resolvedUrl, args[1], args[2], ]) } // Handle a given "RequestOptions" object as-is // and derive the URL instance from it. else if (isObject(args[0])) { options = { ...(args[0] as any) } logger.info('first argument is RequestOptions:', options) // When handling a "RequestOptions" object without an explicit "protocol", // infer the protocol from the request issuing module (http/https). options.protocol = options.protocol || defaultProtocol logger.info('normalized request options:', options) url = getUrlByRequestOptions(options) logger.info('created a URL from RequestOptions:', url.href) callback = resolveCallback(args) } else { throw new Error( `Failed to construct ClientRequest with these parameters: ${args}` ) } options.protocol = options.protocol || url.protocol options.method = options.method || 'GET' /** * Infer a fallback agent from the URL protocol. * The interception is done on the "ClientRequest" level ("NodeClientRequest") * and it may miss the correct agent. Always align the agent * with the URL protocol, if not provided. * * @note Respect the "agent: false" value. */ if (typeof options.agent === 'undefined') { const agent = options.protocol === 'https:' ? new HttpsAgent({ // Any other value other than false is considered as true, so we don't add this property if undefined. ...('rejectUnauthorized' in options && { rejectUnauthorized: options.rejectUnauthorized, }), }) : new HttpAgent() options.agent = agent logger.info('resolved fallback agent:', agent) } /** * Ensure that the default Agent is always set. * This prevents the protocol mismatch for requests with { agent: false }, * where the global Agent is inferred. * @see https://github.com/mswjs/msw/issues/1150 * @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L130 * @see https://github.com/nodejs/node/blob/418ff70b810f0e7112d48baaa72932a56cfa213b/lib/_http_client.js#L157-L159 */ if (!options._defaultAgent) { logger.info( 'has no default agent, setting the default agent for "%s"', options.protocol ) options._defaultAgent = options.protocol === 'https:' ? httpsGlobalAgent : httpGlobalAgent } logger.info('successfully resolved url:', url.href) logger.info('successfully resolved options:', options) logger.info('successfully resolved callback:', callback) /** * @note If the user-provided URL is not a valid URL in Node.js, * (e.g. the one provided by the JSDOM polyfills), case it to * string. Otherwise, this throws on Node.js incompatibility * (`ERR_INVALID_ARG_TYPE` on the connection listener) * @see https://github.com/node-fetch/node-fetch/issues/1376#issuecomment-966435555 */ if (!(url instanceof URL)) { url = (url as any).toString() } return [url, options, callback] }