UNPKG

@mswjs/interceptors

Version:

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

215 lines (180 loc) 7.53 kB
import { invariant } from 'outvariant' import { until } from '@open-draft/until' import { DeferredPromise } from '@open-draft/deferred-promise' import { HttpRequestEventMap, IS_PATCHED_MODULE } from '../../glossary' import { Interceptor } from '../../Interceptor' import { RequestController } from '../../RequestController' import { emitAsync } from '../../utils/emitAsync' import { handleRequest } from '../../utils/handleRequest' import { canParseUrl } from '../../utils/canParseUrl' import { createRequestId } from '../../createRequestId' import { createNetworkError } from './utils/createNetworkError' import { followFetchRedirect } from './utils/followRedirect' import { decompressResponse } from './utils/decompression' import { hasConfigurableGlobal } from '../../utils/hasConfigurableGlobal' import { FetchResponse } from '../../utils/fetchUtils' import { setRawRequest } from '../../getRawRequest' import { isResponseError } from '../../utils/responseUtils' export class FetchInterceptor extends Interceptor<HttpRequestEventMap> { static symbol = Symbol('fetch') constructor() { super(FetchInterceptor.symbol) } protected checkEnvironment() { return hasConfigurableGlobal('fetch') } protected async setup() { const pureFetch = globalThis.fetch invariant( !(pureFetch as any)[IS_PATCHED_MODULE], 'Failed to patch the "fetch" module: already patched.' ) globalThis.fetch = async (input, init) => { const requestId = createRequestId() /** * @note Resolve potentially relative request URL * against the present `location`. This is mainly * for native `fetch` in JSDOM. * @see https://github.com/mswjs/msw/issues/1625 */ const resolvedInput = typeof input === 'string' && typeof location !== 'undefined' && !canParseUrl(input) ? new URL(input, location.href) : input const request = new Request(resolvedInput, init) /** * @note Set the raw request only if a Request instance was provided to fetch. */ if (input instanceof Request) { setRawRequest(request, input) } const responsePromise = new DeferredPromise<Response>() const controller = new RequestController(request, { passthrough: async () => { this.logger.info('request has not been handled, passthrough...') /** * @note Clone the request instance right before performing it. * This preserves any modifications made to the intercepted request * in the "request" listener. This also allows the user to read the * request body in the "response" listener (otherwise "unusable"). */ const requestCloneForResponseEvent = request.clone() // Perform the intercepted request as-is. const { error: responseError, data: originalResponse } = await until( () => pureFetch(request) ) if (responseError) { return responsePromise.reject(responseError) } this.logger.info('original fetch performed', originalResponse) if (this.emitter.listenerCount('response') > 0) { this.logger.info('emitting the "response" event...') const responseClone = originalResponse.clone() await emitAsync(this.emitter, 'response', { response: responseClone, isMockedResponse: false, request: requestCloneForResponseEvent, requestId, }) } // Resolve the response promise with the original response // since the `fetch()` return this internal promise. responsePromise.resolve(originalResponse) }, respondWith: async (rawResponse) => { // Handle mocked `Response.error()` (i.e. request errors). if (isResponseError(rawResponse)) { this.logger.info('request has errored!', { response: rawResponse }) responsePromise.reject(createNetworkError(rawResponse)) return } this.logger.info('received mocked response!', { rawResponse, }) // Decompress the mocked response body, if applicable. const decompressedStream = decompressResponse(rawResponse) const response = decompressedStream === null ? rawResponse : new FetchResponse(decompressedStream, rawResponse) FetchResponse.setUrl(request.url, response) /** * Undici's handling of following redirect responses. * Treat the "manual" redirect mode as a regular mocked response. * This way, the client can manually follow the redirect it receives. * @see https://github.com/nodejs/undici/blob/a6dac3149c505b58d2e6d068b97f4dc993da55f0/lib/web/fetch/index.js#L1173 */ if (FetchResponse.isRedirectResponse(response.status)) { // Reject the request promise if its `redirect` is set to `error` // and it receives a mocked redirect response. if (request.redirect === 'error') { responsePromise.reject(createNetworkError('unexpected redirect')) return } if (request.redirect === 'follow') { followFetchRedirect(request, response).then( (response) => { responsePromise.resolve(response) }, (reason) => { responsePromise.reject(reason) } ) return } } if (this.emitter.listenerCount('response') > 0) { this.logger.info('emitting the "response" event...') // Await the response listeners to finish before resolving // the response promise. This ensures all your logic finishes // before the interceptor resolves the pending response. await emitAsync(this.emitter, 'response', { // Clone the mocked response for the "response" event listener. // This way, the listener can read the response and not lock its body // for the actual fetch consumer. response: response.clone(), isMockedResponse: true, request, requestId, }) } responsePromise.resolve(response) }, errorWith: (reason) => { this.logger.info('request has been aborted!', { reason }) responsePromise.reject(reason) }, }) this.logger.info('[%s] %s', request.method, request.url) this.logger.info('awaiting for the mocked response...') this.logger.info( 'emitting the "request" event for %s listener(s)...', this.emitter.listenerCount('request') ) await handleRequest({ request, requestId, emitter: this.emitter, controller, }) return responsePromise } Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { enumerable: true, configurable: true, value: true, }) this.subscriptions.push(() => { Object.defineProperty(globalThis.fetch, IS_PATCHED_MODULE, { value: undefined, }) globalThis.fetch = pureFetch this.logger.info( 'restored native "globalThis.fetch"!', globalThis.fetch.name ) }) } }