You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

448 lines
12 KiB
TypeScript

import type { ConnectionManager, IConnection } from './connection'
import { IMessage, MessageType } from './message'
import { arrayBufferToBase64, bufHexView, getSize, isTextBody } from './utils'
export type Header = Record<string, string[]>
export interface IRequest {
method: string
url: string
proto: string
header: Header
body?: ArrayBuffer
}
export interface IFlowRequest {
connId: string
request: IRequest
}
export interface IResponse {
statusCode: number
header: Header
body?: ArrayBuffer
}
export interface IPreviewBody {
type: 'image' | 'json' | 'binary'
data: string | null
}
export interface IFlowPreview {
no: number
id: string
waitIntercept: boolean
host: string
path: string
method: string
statusCode: string
size: string
costTime: string
contentType: string
}
export class Flow {
public no: number
public id: string
public connId: string
public waitIntercept: boolean
public request: IRequest
public response: IResponse | null = null
public url: URL
private path: string
private _size = 0
private size = '0'
private headerContentLengthExist = false
private contentType = ''
private startTime = Date.now()
private endTime = 0
private costTime = '(pending)'
public static curNo = 0
private status: MessageType = MessageType.REQUEST
private _isTextRequest: boolean | null
private _isTextResponse: boolean | null
private _requestBody: string | null
private _hexviewRequestBody: string | null = null
private _responseBody: string | null
private _previewResponseBody: IPreviewBody | null = null
private _previewRequestBody: IPreviewBody | null = null
private _hexviewResponseBody: string | null = null
private connMgr: ConnectionManager
private conn: IConnection | undefined
constructor(msg: IMessage, connMgr: ConnectionManager) {
this.no = ++Flow.curNo
this.id = msg.id
this.waitIntercept = msg.waitIntercept
const flowRequestMsg = msg.content as IFlowRequest
this.connId = flowRequestMsg.connId
this.request = flowRequestMsg.request
this.url = new URL(this.request.url)
this.path = this.url.pathname + this.url.search
this._isTextRequest = null
this._isTextResponse = null
this._requestBody = null
this._responseBody = null
this.connMgr = connMgr
}
public addRequestBody(msg: IMessage): Flow {
this.status = MessageType.REQUEST_BODY
this.waitIntercept = msg.waitIntercept
this.request.body = msg.content as ArrayBuffer
return this
}
public addResponse(msg: IMessage): Flow {
this.status = MessageType.RESPONSE
this.waitIntercept = msg.waitIntercept
this.response = msg.content as IResponse
if (this.response && this.response.header) {
if (this.response.header['Content-Type'] != null) {
this.contentType = this.response.header['Content-Type'][0].split(';')[0]
if (this.contentType.includes('javascript')) this.contentType = 'javascript'
}
if (this.response.header['Content-Length'] != null) {
this.headerContentLengthExist = true
this._size = parseInt(this.response.header['Content-Length'][0])
this.size = getSize(this._size)
}
}
return this
}
public addResponseBody(msg: IMessage): Flow {
this.status = MessageType.RESPONSE_BODY
this.waitIntercept = msg.waitIntercept
if (this.response) this.response.body = msg.content as ArrayBuffer
this.endTime = Date.now()
this.costTime = String(this.endTime - this.startTime) + ' ms'
if (!this.headerContentLengthExist && this.response && this.response.body) {
this._size = this.response.body.byteLength
this.size = getSize(this._size)
}
return this
}
public preview(): IFlowPreview {
return {
no: this.no,
id: this.id,
waitIntercept: this.waitIntercept,
host: this.url.host,
path: this.path,
method: this.request.method,
statusCode: this.response ? String(this.response.statusCode) : '(pending)',
size: this.size,
costTime: this.costTime,
contentType: this.contentType,
}
}
public isTextRequest(): boolean {
if (this._isTextRequest !== null) return this._isTextRequest
this._isTextRequest = isTextBody(this.request)
return this._isTextRequest
}
public requestBody(): string {
if (this._requestBody !== null) return this._requestBody
if (!this.isTextRequest()) {
this._requestBody = ''
return this._requestBody
}
if (this.status < MessageType.REQUEST_BODY) return ''
this._requestBody = new TextDecoder().decode(this.request.body)
return this._requestBody
}
public hexviewRequestBody(): string | null {
if (this._hexviewRequestBody !== null) return this._hexviewRequestBody
if (this.status < MessageType.REQUEST_BODY) return null
if (!(this.request?.body?.byteLength)) return null
this._hexviewRequestBody = bufHexView(this.request.body)
return this._hexviewRequestBody
}
public isTextResponse(): boolean | null {
if (this.status < MessageType.RESPONSE) return null
if (this._isTextResponse !== null) return this._isTextResponse
this._isTextResponse = isTextBody(this.response as IResponse)
return this._isTextResponse
}
public responseBody(): string {
if (this._responseBody !== null) return this._responseBody
if (this.status < MessageType.RESPONSE) return ''
if (!this.isTextResponse()) {
this._responseBody = ''
return this._responseBody
}
if (this.status < MessageType.RESPONSE_BODY) return ''
this._responseBody = new TextDecoder().decode(this.response?.body)
return this._responseBody
}
public previewResponseBody(): IPreviewBody | null {
if (this._previewResponseBody) return this._previewResponseBody
if (this.status < MessageType.RESPONSE_BODY) return null
if (!(this.response?.body?.byteLength)) return null
let contentType: string | undefined
if (this.response.header['Content-Type']) contentType = this.response.header['Content-Type'][0]
if (!contentType) return null
if (contentType.startsWith('image/')) {
this._previewResponseBody = {
type: 'image',
data: arrayBufferToBase64(this.response.body),
}
}
else if (contentType.includes('application/json')) {
this._previewResponseBody = {
type: 'json',
data: this.responseBody(),
}
}
return this._previewResponseBody
}
public previewRequestBody(): IPreviewBody | null {
if (this._previewRequestBody) return this._previewRequestBody
if (this.status < MessageType.REQUEST_BODY) return null
if (!(this.request.body?.byteLength)) return null
if (!this.isTextRequest()) {
this._previewRequestBody = {
type: 'binary',
data: this.hexviewRequestBody(),
}
} else if (/json/.test(this.request.header['Content-Type'].join(''))) {
this._previewRequestBody = {
type: 'json',
data: this.requestBody(),
}
}
return this._previewRequestBody
}
public hexviewResponseBody(): string | null {
if (this._hexviewResponseBody !== null) return this._hexviewResponseBody
if (this.status < MessageType.RESPONSE_BODY) return null
if (!(this.response?.body?.byteLength)) return null
this._hexviewResponseBody = bufHexView(this.response.body)
return this._hexviewResponseBody
}
public getConn(): IConnection | undefined {
if (this.conn) return this.conn
this.conn = this.connMgr.get(this.connId)
return this.conn
}
}
const FLOW_FILTER_SCOPES = ['url', 'method', 'code', 'header', 'reqheader', 'resheader', 'body', 'reqbody', 'resbody', 'all'] as const
type FlowFilterScope = typeof FLOW_FILTER_SCOPES[number]
class FlowFilter {
private keyword: string | RegExp | undefined
private scope: FlowFilterScope = 'url'
constructor(text: string) {
text = text.trim()
if (!text) return
for (const scope of FLOW_FILTER_SCOPES) {
if (text.startsWith(`${scope}:`)) {
this.scope = scope
text = text.replace(`${scope}:`, '').trim()
break
}
}
if (!text) return
// regexp
if (text.startsWith('/') && (text.endsWith('/') || text.endsWith('/i'))) {
let flags: string | undefined
if (text.endsWith('i')) {
flags = 'i'
text = text.slice(0, -1)
}
text = text.slice(1, -1).trim()
if (!text) return
this.keyword = new RegExp(text, flags)
}
// string
else {
this.keyword = text
}
}
public match(flow: Flow): boolean {
switch (this.scope) {
case 'url':
return this.matchUrl(flow)
case 'method':
return this.matchMethod(flow)
case 'code':
return this.matchCode(flow)
case 'reqheader':
return this.matchReqHeader(flow)
case 'resheader':
return this.matchResHeader(flow)
case 'header':
return this.matchHeader(flow)
case 'reqbody':
return this.matchReqBody(flow)
case 'resbody':
return this.matchResBody(flow)
case 'body':
return this.matchBody(flow)
case 'all':
return this.matchAll(flow)
default:
throw new Error(`invalid scope ${this.scope}`)
}
}
private matchUrl(flow: Flow): boolean {
return this.matchKeyword(flow.request.url)
}
private matchMethod(flow: Flow): boolean {
return this.matchKeyword(flow.request.method) || this.matchKeyword(flow.request.method.toLowerCase())
}
private matchCode(flow: Flow): boolean {
if (!flow.response) return false
return this.matchKeyword(flow.response.statusCode.toString())
}
private _matchHeader(header: Header): boolean {
return Object.entries(header).some(([key, vals]) => {
return [key].concat(vals).some(text => this.matchKeyword(text))
})
}
private matchReqHeader(flow: Flow): boolean {
return this._matchHeader(flow.request.header)
}
private matchResHeader(flow: Flow): boolean {
if (!flow.response) return false
return this._matchHeader(flow.response.header)
}
private matchHeader(flow: Flow): boolean {
return this.matchReqHeader(flow) || this.matchResHeader(flow)
}
private matchReqBody(flow: Flow): boolean {
const body = flow.requestBody()
if (!body) return false
return this.matchKeyword(body)
}
private matchResBody(flow: Flow): boolean {
const body = flow.responseBody()
if (!body) return false
return this.matchKeyword(body)
}
private matchBody(flow: Flow): boolean {
return this.matchReqBody(flow) || this.matchResBody(flow)
}
private matchAll(flow: Flow): boolean {
return this.matchUrl(flow) || this.matchMethod(flow) || this.matchHeader(flow) || this.matchBody(flow)
}
private matchKeyword(text: string): boolean {
if (!this.keyword) return true
if (!text) return false
if (this.keyword instanceof RegExp) return this.keyword.test(text)
return text.includes(this.keyword)
}
}
export class FlowManager {
private items: Flow[]
private _map: Map<string, Flow>
private flowFilter: FlowFilter | undefined
private filterTimer: number | null
private num: number
private max: number
constructor() {
this.items = []
this._map = new Map()
this.filterTimer = null
this.num = 0
this.max = 1000
}
showList() {
if (!this.flowFilter) return this.items
return this.items.filter(item => (this.flowFilter as FlowFilter).match(item))
}
add(item: Flow) {
item.no = ++this.num
this.items.push(item)
this._map.set(item.id, item)
if (this.items.length > this.max) {
const oldest = this.items.shift()
if (oldest) this._map.delete(oldest.id)
}
}
get(id: string) {
return this._map.get(id)
}
changeFilterLazy(text: string, callback: (err: any) => void) {
if (this.filterTimer) {
clearTimeout(this.filterTimer)
this.filterTimer = null
}
this.filterTimer = setTimeout(() => {
try {
this.flowFilter = new FlowFilter(text)
callback(null)
} catch (err) {
this.flowFilter = undefined
callback(err)
}
}, 300) as any
}
clear() {
this.items = []
this._map = new Map()
}
}