/** * I took inspiration in few stack exchange posts * and Tencent vConsole library (MIT) * by wrapping the XMLHttpRequest object in a Proxy * we can intercept the network requests * in not-so-hacky way * */ import NetworkMessage from './networkMessage' import { RequestState, INetworkMessage, RequestResponseData } from './types'; import { formatByteSize, genStringBody, getStringResponseByType, getURL } from './utils' export class ResponseProxyHandler implements ProxyHandler { public resp: Response public item: NetworkMessage constructor(resp: T, item: NetworkMessage) { this.resp = resp this.item = item this.mockReader() } public set(target: T, key: string, value: (args: any[]) => any) { return Reflect.set(target, key, value) } public get(target: T, key: string) { const value = Reflect.get(target, key) switch (key) { case 'arrayBuffer': case 'blob': case 'formData': case 'json': case 'text': return () => { this.item.responseType = key.toLowerCase() // @ts-ignore return value.apply(target).then((resp: any) => { this.item.response = getStringResponseByType(this.item.responseType, resp) return resp }) } } if (typeof value === 'function') { return value.bind(target) } else { return value } } protected mockReader() { let readerReceivedValue: Uint8Array if (!this.resp.body) { // some browsers do not return `body` in some cases, like `OPTIONS` method return } if (typeof this.resp.body.getReader !== 'function') { return } const clonedResp = this.resp.clone(); const _getReader = clonedResp.body.getReader // @ts-ignore clonedResp.body.getReader = () => { const reader = >_getReader.apply(this.resp.body) // when readyState is already 4, // it's not a chunked stream, or it had already been read. // so should not update status. if (this.item.readyState === RequestState.DONE) { return reader } const _read = reader.read const _cancel = reader.cancel this.item.responseType = 'arraybuffer' // @ts-ignore reader.read = () => { return (>_read.apply(reader)).then( (result: ReadableStreamReadResult) => { if (!readerReceivedValue) { // @ts-ignore readerReceivedValue = new Uint8Array(result.value) } else { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const newValue = new Uint8Array(readerReceivedValue.length + result.value!.length) newValue.set(readerReceivedValue) // eslint-disable-next-line @typescript-eslint/no-non-null-assertion newValue.set(result.value!, readerReceivedValue.length) readerReceivedValue = newValue } this.item.endTime = performance.now() this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime) this.item.readyState = result.done ? 4 : 3 this.item.statusText = result.done ? String(this.item.status) : 'Loading' this.item.responseSize = readerReceivedValue.length this.item.responseSizeText = formatByteSize(this.item.responseSize) if (result.done) { this.item.response = getStringResponseByType( this.item.responseType, readerReceivedValue, ) } return result }, ) } reader.cancel = (...args) => { this.item.cancelState = 2 this.item.statusText = 'Cancel' this.item.endTime = performance.now() this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime) this.item.response = getStringResponseByType(this.item.responseType, readerReceivedValue) return _cancel.apply(reader, args) } return reader } } } export class FetchProxyHandler implements ProxyHandler { constructor( private readonly ignoredHeaders: boolean | string[], private readonly setSessionTokenHeader: (cb: (name: string, value: string) => void) => void, private readonly sanitize: (data: RequestResponseData) => RequestResponseData | null, private readonly sendMessage: (item: INetworkMessage) => void, private readonly isServiceUrl: (url: string) => boolean, private readonly tokenUrlMatcher?: (url: string) => boolean, ) {} public apply(target: T, _: typeof window, argsList: [RequestInfo | URL, RequestInit]) { const input = argsList[0] const init = argsList[1] if ( !input || // @ts-ignore (typeof input !== 'string' && !input?.url) ) { return >target.apply(window, argsList) } const isORUrl = input instanceof URL || typeof input === 'string' ? this.isServiceUrl(String(input)) : this.isServiceUrl(String(input.url)) if (isORUrl) { return target.apply(window, argsList) } const item = new NetworkMessage(this.ignoredHeaders, this.setSessionTokenHeader, this.sanitize) this.beforeFetch(item, input as RequestInfo, init) this.setSessionTokenHeader((name, value) => { if (this.tokenUrlMatcher !== undefined) { if (!this.tokenUrlMatcher(item.url)) { return } } if (argsList[1] === undefined && argsList[0] instanceof Request) { return argsList[0].headers.append(name, value) } else { if (!argsList[1]) argsList[1] = {} if (argsList[1].headers === undefined) { argsList[1] = { ...argsList[1], headers: {} } } if (argsList[1].headers instanceof Headers) { argsList[1].headers.append(name, value) } else if (Array.isArray(argsList[1].headers)) { argsList[1].headers.push([name, value]) } else { // @ts-ignore argsList[1].headers[name] = value } } }) return (>target.apply(window, argsList)) .then(this.afterFetch(item)) .catch((e) => { // mock finally item.endTime = performance.now() item.duration = item.endTime - (item.startTime || item.endTime) throw e }) } protected beforeFetch(item: NetworkMessage, input: RequestInfo | string, init?: RequestInit) { let url: URL, method = 'GET', requestHeader: HeadersInit = {} // handle `input` content if (typeof input === 'string') { // when `input` is a string method = init?.method || 'GET' url = getURL(input) requestHeader = init?.headers || {} } else { // when `input` is a `Request` object method = input.method || 'GET' url = getURL(input.url) requestHeader = input.headers } item.method = method item.requestType = 'fetch' item.requestHeader = requestHeader item.url = url.toString() item.name = (url.pathname.split('/').pop() || '') + url.search item.status = 0 item.statusText = 'Pending' item.readyState = 1 if (!item.startTime) { // UNSENT item.startTime = performance.now() } if (Object.prototype.toString.call(requestHeader) === '[object Headers]') { item.requestHeader = {} for (const [key, value] of requestHeader) { item.requestHeader[key] = value } } else { item.requestHeader = requestHeader } // save GET data if (url.search && url.searchParams) { item.getData = {} for (const [key, value] of url.searchParams) { item.getData[key] = value } } // save POST data if (init?.body) { item.requestData = genStringBody(init.body) } } protected afterFetch(item: NetworkMessage) { return (resp: Response) => { item.endTime = performance.now() item.duration = item.endTime - (item.startTime || item.endTime) item.status = resp.status item.statusText = String(resp.status) let isChunked = false item.header = {} for (const [key, value] of resp.headers) { item.header[key] = value isChunked = value.toLowerCase().indexOf('chunked') > -1 ? true : isChunked } if (isChunked) { // when `transfer-encoding` is chunked, the response is a stream which is under loading, // so the `readyState` should be 3 (Loading), // and the response should NOT be `clone()` which will affect stream reading. item.readyState = 3 } else { // Otherwise, not chunked, the response is not a stream, // so it's completed and can be cloned for `text()` calling. item.readyState = 4 this.handleResponseBody(resp.clone(), item) .then((responseValue: string | ArrayBuffer) => { item.responseSize = typeof responseValue === 'string' ? responseValue.length : responseValue.byteLength item.responseSizeText = formatByteSize(item.responseSize) item.response = getStringResponseByType(item.responseType, responseValue) const msg = item.getMessage() if (msg) { this.sendMessage(msg) } }) .catch((e) => { if (e.name !== 'AbortError') { throw e } else { // ignore AbortError } }) } return new Proxy(resp, new ResponseProxyHandler(resp, item)) } } protected handleResponseBody(resp: Response, item: NetworkMessage) { // parse response body by Content-Type const contentType = resp.headers.get('content-type') if (contentType && contentType.includes('application/json')) { item.responseType = 'json' return resp.text() } else if ( contentType && (contentType.includes('text/html') || contentType.includes('text/plain')) ) { item.responseType = 'text' return resp.text() } else { item.responseType = 'arraybuffer' return resp.arrayBuffer() } } } export default class FetchProxy { public static create( ignoredHeaders: boolean | string[], setSessionTokenHeader: (cb: (name: string, value: string) => void) => void, sanitize: (data: RequestResponseData) => RequestResponseData | null, sendMessage: (item: INetworkMessage) => void, isServiceUrl: (url: string) => boolean, tokenUrlMatcher?: (url: string) => boolean, ) { return new Proxy( fetch, new FetchProxyHandler( ignoredHeaders, setSessionTokenHeader, sanitize, sendMessage, isServiceUrl, tokenUrlMatcher, ), ) } }