import { invariant } from 'outvariant' import { isNodeProcess } from 'is-node-process' import type { Logger } from '@open-draft/logger' import { concatArrayBuffer } from './utils/concatArrayBuffer' import { createEvent } from './utils/createEvent' import { decodeBuffer, encodeBuffer, toArrayBuffer, } from '../../utils/bufferUtils' import { createProxy } from '../../utils/createProxy' import { isDomParserSupportedType } from './utils/isDomParserSupportedType' import { parseJson } from '../../utils/parseJson' import { createResponse } from './utils/createResponse' import { INTERNAL_REQUEST_ID_HEADER_NAME } from '../../Interceptor' import { createRequestId } from '../../createRequestId' import { getBodyByteLength } from './utils/getBodyByteLength' const kIsRequestHandled = Symbol('kIsRequestHandled') const IS_NODE = isNodeProcess() const kFetchRequest = Symbol('kFetchRequest') /** * An `XMLHttpRequest` instance controller that allows us * to handle any given request instance (e.g. responding to it). */ export class XMLHttpRequestController { public request: XMLHttpRequest public requestId: string public onRequest?: ( this: XMLHttpRequestController, args: { request: Request requestId: string } ) => Promise public onResponse?: ( this: XMLHttpRequestController, args: { response: Response isMockedResponse: boolean request: Request requestId: string } ) => void; [kIsRequestHandled]: boolean; [kFetchRequest]?: Request private method: string = 'GET' private url: URL = null as any private requestHeaders: Headers private responseBuffer: Uint8Array private events: Map> private uploadEvents: Map< keyof XMLHttpRequestEventTargetEventMap, Array > constructor(readonly initialRequest: XMLHttpRequest, public logger: Logger) { this[kIsRequestHandled] = false this.events = new Map() this.uploadEvents = new Map() this.requestId = createRequestId() this.requestHeaders = new Headers() this.responseBuffer = new Uint8Array() this.request = createProxy(initialRequest, { setProperty: ([propertyName, nextValue], invoke) => { switch (propertyName) { case 'ontimeout': { const eventName = propertyName.slice( 2 ) as keyof XMLHttpRequestEventTargetEventMap /** * @note Proxy callbacks to event listeners because JSDOM has trouble * translating these properties to callbacks. It seemed to be operating * on events exclusively. */ this.request.addEventListener(eventName, nextValue as any) return invoke() } default: { return invoke() } } }, methodCall: ([methodName, args], invoke) => { switch (methodName) { case 'open': { const [method, url] = args as [string, string | undefined] if (typeof url === 'undefined') { this.method = 'GET' this.url = toAbsoluteUrl(method) } else { this.method = method this.url = toAbsoluteUrl(url) } this.logger = this.logger.extend(`${this.method} ${this.url.href}`) this.logger.info('open', this.method, this.url.href) return invoke() } case 'addEventListener': { const [eventName, listener] = args as [ keyof XMLHttpRequestEventTargetEventMap, Function ] this.registerEvent(eventName, listener) this.logger.info('addEventListener', eventName, listener) return invoke() } case 'setRequestHeader': { const [name, value] = args as [string, string] this.requestHeaders.set(name, value) this.logger.info('setRequestHeader', name, value) return invoke() } case 'send': { const [body] = args as [ body?: XMLHttpRequestBodyInit | Document | null ] this.request.addEventListener('load', () => { if (typeof this.onResponse !== 'undefined') { // Create a Fetch API Response representation of whichever // response this XMLHttpRequest received. Note those may // be either a mocked and the original response. const fetchResponse = createResponse( this.request, /** * The `response` property is the right way to read * the ambiguous response body, as the request's "responseType" may differ. * @see https://xhr.spec.whatwg.org/#the-response-attribute */ this.request.response ) // Notify the consumer about the response. this.onResponse.call(this, { response: fetchResponse, isMockedResponse: this[kIsRequestHandled], request: fetchRequest, requestId: this.requestId!, }) } }) const requestBody = typeof body === 'string' ? encodeBuffer(body) : body // Delegate request handling to the consumer. const fetchRequest = this.toFetchApiRequest(requestBody) this[kFetchRequest] = fetchRequest.clone() const onceRequestSettled = this.onRequest?.call(this, { request: fetchRequest, requestId: this.requestId!, }) || Promise.resolve() onceRequestSettled.finally(() => { // If the consumer didn't handle the request (called `.respondWith()`) perform it as-is. if (!this[kIsRequestHandled]) { this.logger.info( 'request callback settled but request has not been handled (readystate %d), performing as-is...', this.request.readyState ) /** * @note Set the intercepted request ID on the original request in Node.js * so that if it triggers any other interceptors, they don't attempt * to process it once again. * * For instance, XMLHttpRequest is often implemented via "http.ClientRequest" * and we don't want for both XHR and ClientRequest interceptors to * handle the same request at the same time (e.g. emit the "response" event twice). */ if (IS_NODE) { this.request.setRequestHeader( INTERNAL_REQUEST_ID_HEADER_NAME, this.requestId! ) } return invoke() } }) break } default: { return invoke() } } }, }) /** * Proxy the `.upload` property to gather the event listeners/callbacks. */ define( this.request, 'upload', createProxy(this.request.upload, { setProperty: ([propertyName, nextValue], invoke) => { switch (propertyName) { case 'onloadstart': case 'onprogress': case 'onaboart': case 'onerror': case 'onload': case 'ontimeout': case 'onloadend': { const eventName = propertyName.slice( 2 ) as keyof XMLHttpRequestEventTargetEventMap this.registerUploadEvent(eventName, nextValue as Function) } } return invoke() }, methodCall: ([methodName, args], invoke) => { switch (methodName) { case 'addEventListener': { const [eventName, listener] = args as [ keyof XMLHttpRequestEventTargetEventMap, Function ] this.registerUploadEvent(eventName, listener) this.logger.info('upload.addEventListener', eventName, listener) return invoke() } } }, }) ) } private registerEvent( eventName: keyof XMLHttpRequestEventTargetEventMap, listener: Function ): void { const prevEvents = this.events.get(eventName) || [] const nextEvents = prevEvents.concat(listener) this.events.set(eventName, nextEvents) this.logger.info('registered event "%s"', eventName, listener) } private registerUploadEvent( eventName: keyof XMLHttpRequestEventTargetEventMap, listener: Function ): void { const prevEvents = this.uploadEvents.get(eventName) || [] const nextEvents = prevEvents.concat(listener) this.uploadEvents.set(eventName, nextEvents) this.logger.info('registered upload event "%s"', eventName, listener) } /** * Responds to the current request with the given * Fetch API `Response` instance. */ public async respondWith(response: Response): Promise { /** * @note Since `XMLHttpRequestController` delegates the handling of the responses * to the "load" event listener that doesn't distinguish between the mocked and original * responses, mark the request that had a mocked response with a corresponding symbol. * * Mark this request as having a mocked response immediately since * calculating request/response total body length is asynchronous. */ this[kIsRequestHandled] = true /** * Dispatch request upload events for requests with a body. * @see https://github.com/mswjs/interceptors/issues/573 */ if (this[kFetchRequest]) { const totalRequestBodyLength = await getBodyByteLength( this[kFetchRequest] ) this.trigger('loadstart', this.request.upload, { loaded: 0, total: totalRequestBodyLength, }) this.trigger('progress', this.request.upload, { loaded: totalRequestBodyLength, total: totalRequestBodyLength, }) this.trigger('load', this.request.upload, { loaded: totalRequestBodyLength, total: totalRequestBodyLength, }) this.trigger('loadend', this.request.upload, { loaded: totalRequestBodyLength, total: totalRequestBodyLength, }) } this.logger.info( 'responding with a mocked response: %d %s', response.status, response.statusText ) define(this.request, 'status', response.status) define(this.request, 'statusText', response.statusText) define(this.request, 'responseURL', this.url.href) this.request.getResponseHeader = new Proxy(this.request.getResponseHeader, { apply: (_, __, args: [name: string]) => { this.logger.info('getResponseHeader', args[0]) if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.info('headers not received yet, returning null') // Headers not received yet, nothing to return. return null } const headerValue = response.headers.get(args[0]) this.logger.info( 'resolved response header "%s" to', args[0], headerValue ) return headerValue }, }) this.request.getAllResponseHeaders = new Proxy( this.request.getAllResponseHeaders, { apply: () => { this.logger.info('getAllResponseHeaders') if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.info('headers not received yet, returning empty string') // Headers not received yet, nothing to return. return '' } const headersList = Array.from(response.headers.entries()) const allHeaders = headersList .map(([headerName, headerValue]) => { return `${headerName}: ${headerValue}` }) .join('\r\n') this.logger.info('resolved all response headers to', allHeaders) return allHeaders }, } ) // Update the response getters to resolve against the mocked response. Object.defineProperties(this.request, { response: { enumerable: true, configurable: false, get: () => this.response, }, responseText: { enumerable: true, configurable: false, get: () => this.responseText, }, responseXML: { enumerable: true, configurable: false, get: () => this.responseXML, }, }) const totalResponseBodyLength = await getBodyByteLength(response.clone()) this.logger.info('calculated response body length', totalResponseBodyLength) this.trigger('loadstart', this.request, { loaded: 0, total: totalResponseBodyLength, }) this.setReadyState(this.request.HEADERS_RECEIVED) this.setReadyState(this.request.LOADING) const finalizeResponse = () => { this.logger.info('finalizing the mocked response...') this.setReadyState(this.request.DONE) this.trigger('load', this.request, { loaded: this.responseBuffer.byteLength, total: totalResponseBodyLength, }) this.trigger('loadend', this.request, { loaded: this.responseBuffer.byteLength, total: totalResponseBodyLength, }) } if (response.body) { this.logger.info('mocked response has body, streaming...') const reader = response.body.getReader() const readNextResponseBodyChunk = async () => { const { value, done } = await reader.read() if (done) { this.logger.info('response body stream done!') finalizeResponse() return } if (value) { this.logger.info('read response body chunk:', value) this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) this.trigger('progress', this.request, { loaded: this.responseBuffer.byteLength, total: totalResponseBodyLength, }) } readNextResponseBodyChunk() } readNextResponseBodyChunk() } else { finalizeResponse() } } private responseBufferToText(): string { return decodeBuffer(this.responseBuffer) } get response(): unknown { this.logger.info( 'getResponse (responseType: %s)', this.request.responseType ) if (this.request.readyState !== this.request.DONE) { return null } switch (this.request.responseType) { case 'json': { const responseJson = parseJson(this.responseBufferToText()) this.logger.info('resolved response JSON', responseJson) return responseJson } case 'arraybuffer': { const arrayBuffer = toArrayBuffer(this.responseBuffer) this.logger.info('resolved response ArrayBuffer', arrayBuffer) return arrayBuffer } case 'blob': { const mimeType = this.request.getResponseHeader('Content-Type') || 'text/plain' const responseBlob = new Blob([this.responseBufferToText()], { type: mimeType, }) this.logger.info( 'resolved response Blob (mime type: %s)', responseBlob, mimeType ) return responseBlob } default: { const responseText = this.responseBufferToText() this.logger.info( 'resolving "%s" response type as text', this.request.responseType, responseText ) return responseText } } } get responseText(): string { /** * Throw when trying to read the response body as text when the * "responseType" doesn't expect text. This just respects the spec better. * @see https://xhr.spec.whatwg.org/#the-responsetext-attribute */ invariant( this.request.responseType === '' || this.request.responseType === 'text', 'InvalidStateError: The object is in invalid state.' ) if ( this.request.readyState !== this.request.LOADING && this.request.readyState !== this.request.DONE ) { return '' } const responseText = this.responseBufferToText() this.logger.info('getResponseText: "%s"', responseText) return responseText } get responseXML(): Document | null { invariant( this.request.responseType === '' || this.request.responseType === 'document', 'InvalidStateError: The object is in invalid state.' ) if (this.request.readyState !== this.request.DONE) { return null } const contentType = this.request.getResponseHeader('Content-Type') || '' if (typeof DOMParser === 'undefined') { console.warn( 'Cannot retrieve XMLHttpRequest response body as XML: DOMParser is not defined. You are likely using an environment that is not browser or does not polyfill browser globals correctly.' ) return null } if (isDomParserSupportedType(contentType)) { return new DOMParser().parseFromString( this.responseBufferToText(), contentType ) } return null } public errorWith(error?: Error): void { /** * @note Mark this request as handled even if it received a mock error. * This prevents the controller from trying to perform this request as-is. */ this[kIsRequestHandled] = true this.logger.info('responding with an error') this.setReadyState(this.request.DONE) this.trigger('error', this.request) this.trigger('loadend', this.request) } /** * Transitions this request's `readyState` to the given one. */ private setReadyState(nextReadyState: number): void { this.logger.info( 'setReadyState: %d -> %d', this.request.readyState, nextReadyState ) if (this.request.readyState === nextReadyState) { this.logger.info('ready state identical, skipping transition...') return } define(this.request, 'readyState', nextReadyState) this.logger.info('set readyState to: %d', nextReadyState) if (nextReadyState !== this.request.UNSENT) { this.logger.info('triggerring "readystatechange" event...') this.trigger('readystatechange', this.request) } } /** * Triggers given event on the `XMLHttpRequest` instance. */ private trigger< EventName extends keyof (XMLHttpRequestEventTargetEventMap & { readystatechange: ProgressEvent }) >( eventName: EventName, target: XMLHttpRequest | XMLHttpRequestUpload, options?: ProgressEventInit ): void { const callback = (target as XMLHttpRequest)[`on${eventName}`] const event = createEvent(target, eventName, options) this.logger.info('trigger "%s"', eventName, options || '') // Invoke direct callbacks. if (typeof callback === 'function') { this.logger.info('found a direct "%s" callback, calling...', eventName) callback.call(target as XMLHttpRequest, event) } // Invoke event listeners. const events = target instanceof XMLHttpRequestUpload ? this.uploadEvents : this.events for (const [registeredEventName, listeners] of events) { if (registeredEventName === eventName) { this.logger.info( 'found %d listener(s) for "%s" event, calling...', listeners.length, eventName ) listeners.forEach((listener) => listener.call(target, event)) } } } /** * Converts this `XMLHttpRequest` instance into a Fetch API `Request` instance. */ private toFetchApiRequest( body: XMLHttpRequestBodyInit | Document | null | undefined ): Request { this.logger.info('converting request to a Fetch API Request...') // If the `Document` is used as the body of this XMLHttpRequest, // set its inner text as the Fetch API Request body. const resolvedBody = body instanceof Document ? body.documentElement.innerText : body const fetchRequest = new Request(this.url.href, { method: this.method, headers: this.requestHeaders, /** * @see https://xhr.spec.whatwg.org/#cross-origin-credentials */ credentials: this.request.withCredentials ? 'include' : 'same-origin', body: ['GET', 'HEAD'].includes(this.method.toUpperCase()) ? null : resolvedBody, }) const proxyHeaders = createProxy(fetchRequest.headers, { methodCall: ([methodName, args], invoke) => { // Forward the latest state of the internal request headers // because the interceptor might have modified them // without responding to the request. switch (methodName) { case 'append': case 'set': { const [headerName, headerValue] = args as [string, string] this.request.setRequestHeader(headerName, headerValue) break } case 'delete': { const [headerName] = args as [string] console.warn( `XMLHttpRequest: Cannot remove a "${headerName}" header from the Fetch API representation of the "${fetchRequest.method} ${fetchRequest.url}" request. XMLHttpRequest headers cannot be removed.` ) break } } return invoke() }, }) define(fetchRequest, 'headers', proxyHeaders) this.logger.info('converted request to a Fetch API Request!', fetchRequest) return fetchRequest } } function toAbsoluteUrl(url: string | URL): URL { /** * @note XMLHttpRequest interceptor may run in environments * that implement XMLHttpRequest but don't implement "location" * (for example, React Native). If that's the case, return the * input URL as-is (nothing to be relative to). * @see https://github.com/mswjs/msw/issues/1777 */ if (typeof location === 'undefined') { return new URL(url) } return new URL(url.toString(), location.href) } function define( target: object, property: string | symbol, value: unknown ): void { Reflect.defineProperty(target, property, { // Ensure writable properties to allow redefining readonly properties. writable: true, enumerable: true, value, }) }