openreplay/networkProxy/src/xhrProxy.ts
Delirium e66423dcf4
Spot network refactoring (#2617)
* start refactoring network

* separate network module, refactor spot network capture

Signed-off-by: nick-delirium <nikita@openreplay.com>

* some console refactoring, display network results in ui

* detect gql error param

* fix proxy ignore file, fix network tracking, fix tab tracking

* some code quality improvements...

* handle graphql in network lib (.2 ver), update tracker to use last version of lib

* remove debug logs, change request type to gql (if its gql!) in lib, display gql in ui

---------

Signed-off-by: nick-delirium <nikita@openreplay.com>
2024-09-30 09:47:27 +02:00

263 lines
8.4 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 { genGetDataByUrl, formatByteSize, genStringBody, getStringResponseByType } from './utils'
export class XHRProxyHandler<T extends XMLHttpRequest> implements ProxyHandler<T> {
public XMLReq: XMLHttpRequest
public item: NetworkMessage
constructor(
XMLReq: XMLHttpRequest,
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: (message: INetworkMessage) => void,
private readonly isServiceUrl: (url: string) => boolean,
private readonly tokenUrlMatcher?: (url: string) => boolean,
) {
this.XMLReq = XMLReq
this.XMLReq.onreadystatechange = () => {
this.onReadyStateChange()
}
this.XMLReq.onabort = () => {
this.onAbort()
}
this.XMLReq.ontimeout = () => {
this.onTimeout()
}
this.item = new NetworkMessage(ignoredHeaders, setSessionTokenHeader, sanitize)
this.item.requestType = 'xhr'
}
public get(target: T, key: string) {
switch (key) {
case 'open':
return this.getOpen(target)
case 'send':
this.setSessionTokenHeader((name: string, value: string) => {
if (this.tokenUrlMatcher !== undefined) {
if (!this.tokenUrlMatcher(this.item.url)) {
return
}
}
target.setRequestHeader(name, value)
})
return this.getSend(target)
case 'setRequestHeader':
return this.getSetRequestHeader(target)
default:
// eslint-disable-next-line no-case-declarations
const value = Reflect.get(target, key)
if (typeof value === 'function') {
return value.bind(target)
} else {
return value
}
}
}
public set(target: T, key: string, value: (args: any[]) => any) {
switch (key) {
case 'onreadystatechange':
return this.setOnReadyStateChange(target, key, value)
case 'onabort':
return this.setOnAbort(target, key, value)
case 'ontimeout':
return this.setOnTimeout(target, key, value)
default:
// not tracked methods
}
return Reflect.set(target, key, value)
}
public onReadyStateChange() {
if (this.item.url && this.isServiceUrl(this.item.url)) return
this.item.readyState = this.XMLReq.readyState
this.item.responseType = this.XMLReq.responseType
this.item.endTime = performance.now()
this.item.duration = this.item.endTime - this.item.startTime
this.updateItemByReadyState()
setTimeout(() => {
this.item.response = getStringResponseByType(this.item.responseType, this.item.response)
}, 0)
if (this.XMLReq.readyState === RequestState.DONE) {
const msg = this.item.getMessage()
if (msg) {
this.sendMessage(msg)
}
}
}
public onAbort() {
this.item.cancelState = 1
this.item.statusText = 'Abort'
const msg = this.item.getMessage()
if (msg) {
this.sendMessage(msg)
}
}
public onTimeout() {
this.item.cancelState = 3
this.item.statusText = 'Timeout'
const msg = this.item.getMessage()
if (msg) {
this.sendMessage(msg)
}
}
protected getOpen(target: T) {
const targetFunction = Reflect.get(target, 'open')
return (...args: any[]) => {
const method = args[0]
const url = args[1]
this.item.method = method ? method.toUpperCase() : 'GET'
this.item.url = url.toString?.() || ''
this.item.name = this.item.url?.replace(new RegExp('/*$'), '').split('/').pop() ?? ''
this.item.getData = genGetDataByUrl(this.item.url, {})
return targetFunction.apply(target, args)
}
}
protected getSend(target: T) {
const targetFunction = Reflect.get(target, 'send')
return (...args: any[]) => {
const data: XMLHttpRequestBodyInit = args[0]
this.item.requestData = genStringBody(data)
return targetFunction.apply(target, args)
}
}
protected getSetRequestHeader(target: T) {
const targetFunction = Reflect.get(target, 'setRequestHeader')
return (...args: any[]) => {
if (!this.item.requestHeader) {
this.item.requestHeader = {}
}
// @ts-ignore
this.item.requestHeader[args[0]] = args[1]
return targetFunction.apply(target, args)
}
}
protected setOnReadyStateChange(target: T, key: string, orscFunction: (args: any[]) => any) {
return Reflect.set(target, key, (...args: any[]) => {
this.onReadyStateChange()
orscFunction?.apply(target, args)
})
}
protected setOnAbort(target: T, key: string, oaFunction: (args: any[]) => any) {
return Reflect.set(target, key, (...args: any[]) => {
this.onAbort()
oaFunction.apply(target, args)
})
}
protected setOnTimeout(target: T, key: string, otFunction: (args: any[]) => any) {
return Reflect.set(target, key, (...args: any[]) => {
this.onTimeout()
otFunction.apply(target, args)
})
}
/**
* Update item's properties according to readyState.
*/
protected updateItemByReadyState() {
switch (this.XMLReq.readyState) {
case RequestState.UNSENT:
case RequestState.OPENED:
this.item.status = RequestState.UNSENT
this.item.statusText = 'Pending'
if (!this.item.startTime) {
this.item.startTime = performance.now()
}
break
case RequestState.HEADERS_RECEIVED:
this.item.status = this.XMLReq.status
this.item.statusText = 'Loading'
this.item.header = {}
// eslint-disable-next-line no-case-declarations
const header = this.XMLReq.getAllResponseHeaders() || '',
headerArr = header.split('\n')
// extract plain text to key-value format
for (let i = 0; i < headerArr.length; i++) {
const line = headerArr[i]
if (!line) {
continue
}
const arr = line.split(': ')
const key = arr[0]
this.item.header[key] = arr.slice(1).join(': ')
}
break
case RequestState.LOADING:
this.item.status = this.XMLReq.status
this.item.statusText = 'Loading'
if (!!this.XMLReq.response && this.XMLReq.response.length) {
this.item.responseSize = this.XMLReq.response.length
this.item.responseSizeText = formatByteSize(this.item.responseSize)
}
break
case RequestState.DONE:
// `XMLReq.abort()` will change `status` from 200 to 0, so use previous value in this case
this.item.status = this.XMLReq.status || this.item.status || 0
// show status code when request completed
this.item.statusText = String(this.item.status)
this.item.endTime = performance.now()
this.item.duration = this.item.endTime - (this.item.startTime || this.item.endTime)
this.item.response = this.XMLReq.response
if (!!this.XMLReq.response && this.XMLReq.response.length) {
this.item.responseSize = this.XMLReq.response.length
this.item.responseSizeText = formatByteSize(this.item.responseSize)
}
break
default:
this.item.status = this.XMLReq.status
this.item.statusText = 'Unknown'
break
}
}
}
export default class XHRProxy {
public static create(
ignoredHeaders: boolean | string[],
setSessionTokenHeader: (cb: (name: string, value: string) => void) => void,
sanitize: (data: RequestResponseData) => RequestResponseData | null,
sendMessage: (data: INetworkMessage) => void,
isServiceUrl: (url: string) => boolean,
tokenUrlMatcher?: (url: string) => boolean,
) {
return new Proxy(XMLHttpRequest, {
construct(original: any) {
const XMLReq = new original()
return new Proxy(
XMLReq,
new XHRProxyHandler(
XMLReq as XMLHttpRequest,
ignoredHeaders,
setSessionTokenHeader,
sanitize,
sendMessage,
isServiceUrl,
tokenUrlMatcher,
),
)
},
})
}
}