@mswjs/interceptors
Version:
Low-level HTTP/HTTPS/XHR/fetch request interception library.
204 lines (171 loc) • 6.98 kB
text/typescript
import { invariant } from 'outvariant'
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'
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)
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')
)
const isRequestHandled = await handleRequest({
request,
requestId,
emitter: this.emitter,
controller,
onResponse: async (rawResponse) => {
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)
},
onRequestError: (response) => {
this.logger.info('request has errored!', { response })
responsePromise.reject(createNetworkError(response))
},
onError: (error) => {
this.logger.info('request has been aborted!', { error })
responsePromise.reject(error)
},
})
if (isRequestHandled) {
this.logger.info('request has been handled, returning mock promise...')
return responsePromise
}
this.logger.info(
'no mocked response received, performing request as-is...'
)
/**
* @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()
return pureFetch(request).then(async (response) => {
this.logger.info('original fetch performed', response)
if (this.emitter.listenerCount('response') > 0) {
this.logger.info('emitting the "response" event...')
const responseClone = response.clone()
await emitAsync(this.emitter, 'response', {
response: responseClone,
isMockedResponse: false,
request: requestCloneForResponseEvent,
requestId,
})
}
return response
})
}
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
)
})
}
}