import { until } from '@open-draft/until' import { invariant } from 'outvariant' import type { Logger } from '../../utils/logger' import type { HttpResponseType } from '../../events/http' import { concatArrayBuffer } from './utils/concat-array-buffer' import { createEvent } from './utils/create-event' import { decodeBuffer, encodeBuffer, toArrayBuffer, } from '../../utils/buffer-utils' import { createProxy } from '../../utils/create-proxy' import { isDomParserSupportedType } from './utils/is-dom-parser-supported-type' import { parseJson } from '../../utils/parse-json' import { createResponse } from './utils/create-response' import { createRequestId } from '../../create-request-id' import { getBodyByteLength } from './utils/get-body-byte-length' import { FetchRequest, FetchResponse } from '../../utils/fetch-utils' import { isResponseError } from '../../utils/response-utils' const kIsRequestHandled = Symbol('kIsRequestHandled') const kFetchRequest = Symbol('kFetchRequest') const MAX_REDIRECTS = 20 /** * 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 responseType: HttpResponseType request: Request requestId: string } ) => void; [kIsRequestHandled]: boolean; [kFetchRequest]?: Request private sync: boolean = false private method: string = 'GET' private url: URL = null as any private requestHeaders: Headers private responseBuffer: Uint8Array private redirectCount: number private events: Map> private uploadEvents: Map< keyof XMLHttpRequestEventTargetEventMap, Array > constructor( readonly initialRequest: XMLHttpRequest, public logger: Logger ) { this[kIsRequestHandled] = false this.redirectCount = 0 this.events = new Map() this.uploadEvents = new Map() this.requestId = createRequestId() this.requestHeaders = new Headers() this.responseBuffer = new Uint8Array() this.request = createProxy(initialRequest, { methodCall: ([methodName, args], invoke) => { switch (methodName) { case 'open': { const [method, url, async] = args as [ string, string | undefined, boolean | undefined, ] this.sync = !(async ?? true) if (typeof url === 'undefined') { this.method = 'GET' this.url = toAbsoluteUrl(method) } else { this.method = method this.url = toAbsoluteUrl(url) } this.logger.verbose('open %s %s', this.method, this.url.href) return invoke() } case 'addEventListener': { const [eventName, listener] = args as [ keyof XMLHttpRequestEventTargetEventMap, Function, ] this.registerEvent(eventName, listener) this.logger.verbose('addEventListener', eventName, listener) return invoke() } case 'setRequestHeader': { const [name, value] = args as [string, string] this.requestHeaders.set(name, value) this.logger.verbose('setRequestHeader', name, value) return invoke() } case 'send': { const [body] = args as [ body?: XMLHttpRequestBodyInit | Document | null, ] if (this.sync) { console.warn( `Failed to intercept an XMLHttpRequest (${this.method} ${this.url}): synchronous requests are not supported. This request will be performed as-is.` ) return invoke() } 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, responseType: this[kIsRequestHandled] ? 'mock' : 'original', 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() /** * @note Start request handling on the next tick so that the user * could add event listeners for "loadend" before the interceptor fires it. */ queueMicrotask(() => { 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.verbose( 'request callback settled but request has not been handled (readystate %d), performing as-is...', this.request.readyState ) 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.verbose('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.verbose('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.verbose('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 this.logger.verbose( 'responding with a mocked response: %d %s', response.status, response.statusText ) FetchResponse.setUrl(this.url.href, response) // 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, }, }) // 1. Fire a progress event named loadstart at this with 0 and 0. this.trigger('loadstart', this.request, { loaded: 0, total: 0 }) // 2. Let requestBodyTransmitted be 0. let requestBodyTransmitted = 0 let uploadComplete = false if (this[kFetchRequest]) { const requestBodyLength = await getBodyByteLength( this[kFetchRequest].clone() ) // 5. If this’s upload complete flag is unset and this’s upload listener flag is set, then fire a progress event named loadstart at this’s upload object with requestBodyTransmitted and requestBodyLength. if (!uploadComplete) { this.trigger('loadstart', this.request.upload, { loaded: 0, total: requestBodyLength, }) } const processRequestBodyChunkLength = (bytesLength: number) => { requestBodyTransmitted += bytesLength if (requestBodyTransmitted < requestBodyLength) { this.trigger('progress', this.request.upload, { loaded: requestBodyTransmitted, total: requestBodyLength, }) } } const processRequestEndOfBody = () => { uploadComplete = true this.trigger('progress', this.request.upload, { loaded: requestBodyTransmitted, total: requestBodyLength, }) this.trigger('load', this.request.upload, { loaded: requestBodyTransmitted, total: requestBodyLength, }) this.trigger('loadend', this.request.upload, { loaded: requestBodyTransmitted, total: requestBodyLength, }) } if (this[kFetchRequest]?.body != null) { const reader = this[kFetchRequest].body.getReader() while (true) { const { value, done } = await reader.read() if (done) { processRequestEndOfBody() break } processRequestBodyChunkLength(value.byteLength) } } else { processRequestEndOfBody() } } let timedOut = false const responseReadController = new AbortController() const requestErrorSteps = ( event: keyof XMLHttpRequestEventTargetEventMap ) => { this.setReadyState(this.request.DONE) if (!uploadComplete) { this.trigger(event, this.request.upload, { loaded: 0, total: 0, }) this.trigger('loadend', this.request.upload, { loaded: 0, total: 0, }) } this.trigger(event, this.request, { loaded: 0, total: 0 }) this.trigger('loadend', this.request, { loaded: 0, total: 0 }) } const processResponse = async (response: Response) => { const handleErrors = () => { if (timedOut) { requestErrorSteps('timeout') } else if (responseReadController.signal.aborted) { requestErrorSteps('abort') } else if (isResponseError(response)) { requestErrorSteps('error') } } handleErrors() define(this.request, 'status', response.status) define(this.request, 'statusText', response.statusText) if (!this.request.responseURL) { define(this.request, 'responseURL', response.url) } if (isResponseError(response)) { return } /** * @note The response body length is derived ONLY from the "content-length" header. * If that response header is not set, the "total" in all progress events must be 0. */ const responseBodyLength = Number( response.headers.get('content-length') ?? '0' ) this.setReadyState(this.request.HEADERS_RECEIVED) let receivedBytes = 0 let lastReceivedResponseBytesAt = performance.now() const processResponseBodyChunk = (bytesLength: number) => { receivedBytes += bytesLength const now = performance.now() const shouldBuffer = now - lastReceivedResponseBytesAt <= 60 && receivedBytes < responseBodyLength lastReceivedResponseBytesAt = now if (shouldBuffer) { return } if (this.request.readyState === this.request.HEADERS_RECEIVED) { this.setReadyState(this.request.LOADING, false) } this.trigger('readystatechange', this.request) this.trigger('progress', this.request, { loaded: receivedBytes, total: responseBodyLength, }) } const processResponseBodyError = () => { requestErrorSteps('error') } const processResponseEndOfBody = async () => { handleErrors() if (isResponseError(response)) { return } // 3. Let transmitted be xhr’s received bytes’s length. let transmitted = receivedBytes // 9. Fire an event named readystatechange at xhr. this.setReadyState(this.request.DONE) // 10. Fire a progress event named load at xhr with transmitted and length. this.trigger('load', this.request, { loaded: transmitted, total: responseBodyLength, }) // 11. Fire a progress event named loadend at xhr with transmitted and length. this.trigger('loadend', this.request, { loaded: transmitted, total: responseBodyLength, }) } // 7. If this’s response’s body is null, then run handle response end-of-body for this and return. if (response.body == null) { processResponseEndOfBody() } else { const reader = response.body.getReader() while (true) { if (responseReadController.signal.aborted) { break } try { const { value, done } = await reader.read() if (done) { processResponseEndOfBody() return } processResponseBodyChunk(value.byteLength) this.responseBuffer = concatArrayBuffer(this.responseBuffer, value) } catch { processResponseBodyError() } } } } // Redirects are followed as a part of the fetch controller. Since we don't have one, // retrieve the final response and then continue with processing it instead of the mocked one. const [redirectError, finalResponse] = await until(() => { return this.followRedirects(response) }) if (redirectError) { return } processResponse(finalResponse) // 12.1, 12.2. if (this.request.timeout) { setTimeout(() => { if (this.request.readyState !== this.request.DONE) { timedOut = true responseReadController.abort() } }, this.request.timeout) } this.request.getResponseHeader = new Proxy(this.request.getResponseHeader, { apply: (_, __, args: [name: string]) => { this.logger.verbose('getResponseHeader', args[0]) if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.verbose('headers not received yet, returning null') return null } const headerValue = finalResponse.headers.get(args[0]) this.logger.verbose( 'resolved response header "%s" to', args[0], headerValue ) return headerValue }, }) this.request.getAllResponseHeaders = new Proxy( this.request.getAllResponseHeaders, { apply: () => { this.logger.verbose('getAllResponseHeaders') if (this.request.readyState < this.request.HEADERS_RECEIVED) { this.logger.verbose('headers not received yet, returning empty string') return '' } const headersList = Array.from(finalResponse.headers) const allHeaders = headersList .map(([headerName, headerValue]) => { return `${headerName}: ${headerValue}` }) .join('\r\n') this.logger.verbose('resolved all response headers to', allHeaders) return allHeaders }, } ) } private responseBufferToText(): string { return decodeBuffer(this.responseBuffer) } get response(): unknown { this.logger.verbose( '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.verbose('resolved response JSON', responseJson) return responseJson } case 'arraybuffer': { const arrayBuffer = toArrayBuffer(this.responseBuffer) this.logger.verbose('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.verbose( 'resolved response Blob (mime type: %s)', responseBlob, mimeType ) return responseBlob } default: { const responseText = this.responseBufferToText() this.logger.verbose( '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.verbose('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 } private async followRedirects(response: Response): Promise { const redirectLocation = response.headers.get('location') if ( !redirectLocation || !FetchResponse.isRedirectResponse(response.status) ) { return response } this.redirectCount++ if (this.redirectCount > MAX_REDIRECTS) { throw new Error('Too many redirects') } const redirectUrl = new URL(redirectLocation, location.href) const redirectMethod = FetchResponse.isResponseWithBody(response.status) ? this.method : 'GET' const redirectResponse = await new Promise((resolve, reject) => { const request = new XMLHttpRequest() request.responseType = this.request.responseType request.addEventListener('load', () => { this.url = new URL(request.responseURL) resolve(createResponse(request, request.response)) }) request.addEventListener('error', () => { this.errorWith() reject(new Error('Redirect request failed')) }) request.open(redirectMethod, redirectUrl.href) request.send() }) return this.followRedirects(redirectResponse) } 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.verbose('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, triggerReadyStateChangeEvent = true ): void { this.logger.verbose( 'setReadyState: %d -> %d', this.request.readyState, nextReadyState ) if (this.request.readyState === nextReadyState) { this.logger.verbose('ready state identical, skipping transition...') return } define(this.request, 'readyState', nextReadyState) this.logger.verbose('set readyState to: %d', nextReadyState) if (!triggerReadyStateChangeEvent) { return } if (nextReadyState !== this.request.UNSENT) { this.logger.verbose('triggering "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.verbose('trigger "%s"', eventName, options || '') // Invoke direct callbacks. if (typeof callback === 'function') { this.logger.verbose('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.verbose( '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.verbose('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 FetchRequest(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: resolvedBody, }) const headers = fetchRequest.headers const proxyHeaders = createProxy(headers, { methodCall: ([methodName, args], invoke) => { const result = 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] /** * @note Forward only the headers the Fetch API Request accepted. * Forbidden request headers (e.g. "Cookie") are dropped silently * by the request's headers guard, and forwarding them makes * the browser refuse them with an error in the console. */ if (headers.has(headerName)) { 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 result }, }) define(fetchRequest, 'headers', proxyHeaders) // setRawRequest(fetchRequest, this.request) this.logger.verbose('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, }) }