openreplay/networkProxy/src/fetchProxy.ts

330 lines
11 KiB
TypeScript

/**
* 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<T extends Response> implements ProxyHandler<T> {
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 = <any>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 = <ReturnType<typeof _getReader>>_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 (<ReturnType<typeof _read>>_read.apply(reader)).then(
(result: ReadableStreamReadResult<Uint8Array>) => {
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<T extends typeof fetch> implements ProxyHandler<T> {
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 <ReturnType<T>>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 (<ReturnType<T>>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 = <NetworkMessage['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 <Headers>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,
),
)
}
}