mirror of
https://github.com/yaklang/yaklang-chrome-extension.git
synced 2026-09-25 04:31:52 +08:00
619 lines
26 KiB
TypeScript
619 lines
26 KiB
TypeScript
import type { BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction } from '@/types/models'
|
|
import { callableExecutionPolicy, settleCallableResult } from './execution'
|
|
|
|
const MAX_BODY_BYTES = 8 * 1024 * 1024
|
|
const MAX_CONTROLS = 2_000
|
|
const MAX_FIELDS = 64
|
|
const MAX_MUTATIONS = 2_000
|
|
const DEFAULT_TIMEOUT_MS = 4_000
|
|
|
|
interface CapturedRequest {
|
|
boundary: 'fetch' | 'xhr' | 'beacon' | 'form'
|
|
method: string
|
|
url: string
|
|
headers: Record<string, string>
|
|
bodyText: string
|
|
}
|
|
|
|
interface TransactionContext {
|
|
domInputCount: number
|
|
}
|
|
|
|
export interface RequestTransactionInvocation {
|
|
transaction: BrowserPageCallableTransaction
|
|
logicalInput: unknown
|
|
invoke(context: TransactionContext): unknown
|
|
timeoutMs?: number
|
|
}
|
|
|
|
interface RollbackController {
|
|
finish(): number
|
|
}
|
|
|
|
interface MutableControl extends Element {
|
|
value?: string
|
|
checked?: boolean
|
|
selectedIndex?: number
|
|
name?: string
|
|
id: string
|
|
type?: string
|
|
}
|
|
|
|
function error(message: string): Error {
|
|
return new Error(`请求事务失败:${message}`)
|
|
}
|
|
|
|
function delay(milliseconds: number): Promise<void> {
|
|
return new Promise((resolve) => window.setTimeout(resolve, milliseconds))
|
|
}
|
|
|
|
function absoluteUrl(value: string): string {
|
|
try { return new URL(value, location.href).toString() } catch { return value }
|
|
}
|
|
|
|
function runtimeBaseUrl(): string {
|
|
return typeof location === 'undefined' ? 'http://localhost/' : location.href
|
|
}
|
|
|
|
function comparableUrl(value: string, baseUrl: string): string {
|
|
try {
|
|
const url = new URL(value, baseUrl)
|
|
return `${url.origin}${url.pathname}${url.search}`
|
|
} catch {
|
|
return value
|
|
}
|
|
}
|
|
|
|
export function resolveRequestTransactionFetchInput(
|
|
input: RequestInfo | URL,
|
|
baseUrl = runtimeBaseUrl(),
|
|
): RequestInfo | URL {
|
|
if (typeof Request !== 'undefined' && input instanceof Request) return input
|
|
try { return new URL(String(input), baseUrl).toString() } catch { return input }
|
|
}
|
|
|
|
export function requestMatchesTransaction(
|
|
transaction: BrowserPageCallableTransaction,
|
|
method: string,
|
|
url: string,
|
|
baseUrl = runtimeBaseUrl(),
|
|
): boolean {
|
|
return transaction.request.method.toUpperCase() === method.toUpperCase()
|
|
&& comparableUrl(transaction.request.url, baseUrl) === comparableUrl(url, baseUrl)
|
|
}
|
|
|
|
function requestMatchesStep(
|
|
step: { boundary: CapturedRequest['boundary']; method: string; url: string },
|
|
request: CapturedRequest,
|
|
baseUrl = runtimeBaseUrl(),
|
|
): boolean {
|
|
return step.boundary === request.boundary
|
|
&& step.method.toUpperCase() === request.method.toUpperCase()
|
|
&& comparableUrl(step.url, baseUrl) === comparableUrl(request.url, baseUrl)
|
|
}
|
|
|
|
function byteLength(value: string): number {
|
|
return new TextEncoder().encode(value).byteLength
|
|
}
|
|
|
|
async function bodyText(value: unknown): Promise<string> {
|
|
if (value === undefined || value === null) return ''
|
|
if (typeof value === 'string') return value
|
|
if (typeof URLSearchParams !== 'undefined' && value instanceof URLSearchParams) return value.toString()
|
|
if (typeof Blob !== 'undefined' && value instanceof Blob) return value.text()
|
|
if (typeof FormData !== 'undefined' && value instanceof FormData) {
|
|
const form = new URLSearchParams()
|
|
for (const [key, item] of value.entries()) {
|
|
if (typeof item !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
|
form.append(key, item)
|
|
}
|
|
return form.toString()
|
|
}
|
|
if (value instanceof ArrayBuffer) return new TextDecoder().decode(new Uint8Array(value))
|
|
if (ArrayBuffer.isView(value)) return new TextDecoder().decode(new Uint8Array(value.buffer, value.byteOffset, value.byteLength))
|
|
throw error(`不支持的请求 Body 类型 ${Object.prototype.toString.call(value)}`)
|
|
}
|
|
|
|
function headerRecord(headers: Headers): Record<string, string> {
|
|
const output: Record<string, string> = Object.create(null) as Record<string, string>
|
|
headers.forEach((value, key) => { output[key.toLowerCase()] = value })
|
|
return output
|
|
}
|
|
|
|
function parseForm(value: string): Record<string, string | string[]> {
|
|
const output: Record<string, string | string[]> = Object.create(null) as Record<string, string | string[]>
|
|
for (const [key, item] of new URLSearchParams(value)) {
|
|
const previous = output[key]
|
|
output[key] = previous === undefined ? item : Array.isArray(previous) ? [...previous, item] : [previous, item]
|
|
}
|
|
return output
|
|
}
|
|
|
|
function capturedBodyFormat(request: CapturedRequest): BrowserPageCallableTransaction['request']['bodyFormat'] {
|
|
const contentType = request.headers['content-type']?.toLowerCase() || ''
|
|
if (contentType.includes('application/x-www-form-urlencoded')) return 'form'
|
|
if (contentType.includes('application/json') || /^[\s\n\r]*[\[{]/.test(request.bodyText)) return 'json'
|
|
return 'raw'
|
|
}
|
|
|
|
function capturedBody(request: CapturedRequest): unknown {
|
|
const format = capturedBodyFormat(request)
|
|
if (format === 'json') {
|
|
try { return JSON.parse(request.bodyText) as unknown } catch { throw error('页面生成的请求 Body 不是有效 JSON') }
|
|
}
|
|
if (format === 'form') return parseForm(request.bodyText)
|
|
return request.bodyText
|
|
}
|
|
|
|
function bodyValue(text: string, format: BrowserPageCallableTransaction['request']['bodyFormat']): unknown {
|
|
if (format === 'json') {
|
|
try { return JSON.parse(text) as unknown } catch { throw error('在线前置请求返回的 Body 不是有效 JSON') }
|
|
}
|
|
if (format === 'form') return parseForm(text)
|
|
return text
|
|
}
|
|
|
|
function validatePrerequisiteRequest(
|
|
step: BrowserPageCallableTransaction['prerequisites'][number],
|
|
request: CapturedRequest,
|
|
): void {
|
|
const length = byteLength(request.bodyText)
|
|
if (length > step.maxRequestBodyBytes) {
|
|
throw error(`在线前置请求 Body 超过计划上限 ${step.maxRequestBodyBytes} B`)
|
|
}
|
|
if (step.requestBodyFormat === 'none') {
|
|
if (length !== 0) throw error('在线前置请求意外携带了 Body')
|
|
return
|
|
}
|
|
const actual = capturedBodyFormat(request)
|
|
if (actual !== step.requestBodyFormat) {
|
|
throw error(`在线前置请求生成了 ${actual} Body,但录制证据要求 ${step.requestBodyFormat} Body`)
|
|
}
|
|
}
|
|
|
|
async function readBoundedResponseText(response: Response, maximumBytes: number): Promise<string> {
|
|
const declaredLength = Number(response.headers.get('content-length'))
|
|
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
|
throw error(`在线前置响应超过计划上限 ${maximumBytes} B`)
|
|
}
|
|
const clone = response.clone()
|
|
if (!clone.body) return ''
|
|
const reader = clone.body.getReader()
|
|
const chunks: Uint8Array[] = []
|
|
let length = 0
|
|
try {
|
|
while (true) {
|
|
const result = await reader.read()
|
|
if (result.done) break
|
|
if (!result.value) continue
|
|
length += result.value.byteLength
|
|
if (length > maximumBytes) {
|
|
await reader.cancel().catch(() => undefined)
|
|
throw error(`在线前置响应超过计划上限 ${maximumBytes} B`)
|
|
}
|
|
chunks.push(result.value)
|
|
}
|
|
} finally {
|
|
reader.releaseLock()
|
|
}
|
|
const bytes = new Uint8Array(length)
|
|
let offset = 0
|
|
for (const chunk of chunks) {
|
|
bytes.set(chunk, offset)
|
|
offset += chunk.byteLength
|
|
}
|
|
return new TextDecoder().decode(bytes)
|
|
}
|
|
|
|
async function validatePrerequisiteResponse(
|
|
step: BrowserPageCallableTransaction['prerequisites'][number],
|
|
response: Response,
|
|
): Promise<void> {
|
|
if (response.status !== step.response.statusCode) {
|
|
throw error(`在线前置响应状态为 ${response.status},录制证据要求 ${step.response.statusCode}`)
|
|
}
|
|
if (comparableUrl(response.url, runtimeBaseUrl()) !== comparableUrl(step.response.url, runtimeBaseUrl())) {
|
|
throw error(`在线前置响应到达未计划 URL ${response.url || '(empty)'}`)
|
|
}
|
|
const text = await readBoundedResponseText(response, step.response.maxBodyBytes)
|
|
const value = bodyValue(text, step.response.bodyFormat)
|
|
validateRequestTransactionOutput(value, step.response.requiredPaths)
|
|
}
|
|
|
|
function readOwnPath(input: unknown, path: string): unknown {
|
|
let current = input
|
|
for (const segment of path.split('.').filter(Boolean)) {
|
|
if (!current || typeof current !== 'object' || !Object.prototype.hasOwnProperty.call(current, segment)) return undefined
|
|
current = (current as Record<string, unknown>)[segment]
|
|
}
|
|
return current
|
|
}
|
|
|
|
export function validateRequestTransactionOutput(value: unknown, destinations: string[]): void {
|
|
const missing = destinations.filter((destination) => {
|
|
const path = destination === 'body' ? '' : destination.startsWith('body.') ? destination.slice(5) : destination
|
|
return path ? readOwnPath(value, path) === undefined : value === undefined
|
|
})
|
|
if (missing.length) throw error(`截获的请求缺少目标字段:${missing.join('、')}`)
|
|
}
|
|
|
|
function logicalObject(value: unknown): Record<string, unknown> | undefined {
|
|
let current = value
|
|
if (typeof current === 'string') {
|
|
try { current = JSON.parse(current) as unknown } catch { return undefined }
|
|
}
|
|
return current && typeof current === 'object' && !Array.isArray(current)
|
|
? current as Record<string, unknown>
|
|
: undefined
|
|
}
|
|
|
|
interface LogicalField {
|
|
path: string
|
|
key: string
|
|
value: unknown
|
|
}
|
|
|
|
function logicalFields(value: unknown): LogicalField[] {
|
|
const root = logicalObject(value)
|
|
if (!root) return []
|
|
const output: LogicalField[] = []
|
|
const visit = (current: Record<string, unknown>, prefix: string, depth: number) => {
|
|
if (depth > 4 || output.length >= MAX_FIELDS) return
|
|
for (const [key, item] of Object.entries(current)) {
|
|
if (output.length >= MAX_FIELDS) break
|
|
const path = prefix ? `${prefix}.${key}` : key
|
|
if (item && typeof item === 'object' && !Array.isArray(item)) visit(item as Record<string, unknown>, path, depth + 1)
|
|
else output.push({ path, key, value: item })
|
|
}
|
|
}
|
|
visit(root, '', 0)
|
|
return output
|
|
}
|
|
|
|
function controlNames(control: MutableControl): string[] {
|
|
const name = typeof control.name === 'string' ? control.name : ''
|
|
return [name, control.id, name.replace(/\[([^\]]+)\]/g, '.$1')].filter(Boolean)
|
|
}
|
|
|
|
function setControlValue(control: MutableControl, value: unknown): void {
|
|
const type = String(control.type || '').toLowerCase()
|
|
if ((type === 'checkbox' || type === 'radio') && typeof control.checked === 'boolean') {
|
|
if (type === 'radio') control.checked = String(control.value ?? '') === String(value)
|
|
else control.checked = typeof value === 'boolean' ? value : Array.isArray(value)
|
|
? value.map(String).includes(String(control.value ?? ''))
|
|
: Boolean(value)
|
|
return
|
|
}
|
|
if ('value' in control) {
|
|
control.value = value === undefined || value === null ? ''
|
|
: typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
}
|
|
}
|
|
|
|
function bindLogicalInput(value: unknown): number {
|
|
const fields = logicalFields(value)
|
|
if (!fields.length) return 0
|
|
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
|
const missing: string[] = []
|
|
let matched = 0
|
|
for (const field of fields) {
|
|
const candidates = controls.filter((control) => controlNames(control).some((name) => (
|
|
name === field.path || name === field.key || name.endsWith(`.${field.path}`) || name.endsWith(`.${field.key}`)
|
|
)))
|
|
if (!candidates.length) {
|
|
missing.push(field.path)
|
|
continue
|
|
}
|
|
candidates.forEach((control) => setControlValue(control, field.value))
|
|
matched += 1
|
|
}
|
|
if (matched && missing.length) throw error(`无法把明文字段映射到页面输入:${missing.join('、')}`)
|
|
return matched
|
|
}
|
|
|
|
function beginDomRollback(): RollbackController {
|
|
const controls = [...document.querySelectorAll('input, textarea, select')].slice(0, MAX_CONTROLS) as MutableControl[]
|
|
const controlSnapshots = controls.map((control) => ({
|
|
control,
|
|
value: control.value,
|
|
checked: control.checked,
|
|
selectedIndex: control.selectedIndex,
|
|
}))
|
|
const mutations: MutationRecord[] = []
|
|
const root = document.documentElement
|
|
const observer = root && typeof MutationObserver !== 'undefined'
|
|
? new MutationObserver((records) => {
|
|
if (mutations.length < MAX_MUTATIONS) mutations.push(...records.slice(0, MAX_MUTATIONS - mutations.length))
|
|
})
|
|
: undefined
|
|
observer?.observe(root, {
|
|
subtree: true,
|
|
childList: true,
|
|
attributes: true,
|
|
attributeOldValue: true,
|
|
characterData: true,
|
|
characterDataOldValue: true,
|
|
})
|
|
let finished = false
|
|
return {
|
|
finish() {
|
|
if (finished) return mutations.length
|
|
finished = true
|
|
if (observer) mutations.push(...observer.takeRecords().slice(0, Math.max(0, MAX_MUTATIONS - mutations.length)))
|
|
observer?.disconnect()
|
|
for (const snapshot of controlSnapshots) {
|
|
if (snapshot.value !== undefined) snapshot.control.value = snapshot.value
|
|
if (snapshot.checked !== undefined) snapshot.control.checked = snapshot.checked
|
|
if (snapshot.selectedIndex !== undefined) snapshot.control.selectedIndex = snapshot.selectedIndex
|
|
}
|
|
for (const mutation of [...mutations].reverse()) {
|
|
try {
|
|
if (mutation.type === 'attributes') {
|
|
if (!mutation.attributeName) continue
|
|
if (mutation.oldValue === null) (mutation.target as Element).removeAttributeNS(mutation.attributeNamespace, mutation.attributeName)
|
|
else (mutation.target as Element).setAttributeNS(mutation.attributeNamespace, mutation.attributeName, mutation.oldValue)
|
|
} else if (mutation.type === 'characterData') {
|
|
mutation.target.nodeValue = mutation.oldValue
|
|
} else {
|
|
mutation.addedNodes.forEach((node) => { if (node.parentNode === mutation.target) mutation.target.removeChild(node) })
|
|
const before = mutation.nextSibling?.parentNode === mutation.target ? mutation.nextSibling : null
|
|
mutation.removedNodes.forEach((node) => mutation.target.insertBefore(node, before))
|
|
}
|
|
} catch {
|
|
// Best-effort rollback is followed by fail-closed validation at the request boundary.
|
|
}
|
|
}
|
|
return mutations.length
|
|
},
|
|
}
|
|
}
|
|
|
|
function setMethod<T extends object, K extends keyof T>(target: T, key: K, value: T[K], restorers: Array<() => void>): void {
|
|
const previous = target[key]
|
|
try {
|
|
target[key] = value
|
|
restorers.push(() => { target[key] = previous })
|
|
} catch {
|
|
// A non-writable optional boundary remains protected by the other installed boundaries.
|
|
}
|
|
}
|
|
|
|
function formRequest(form: HTMLFormElement, submitter?: HTMLElement | null): CapturedRequest {
|
|
const method = (form.method || 'GET').toUpperCase()
|
|
const url = absoluteUrl(form.action || location.href)
|
|
const formData = new FormData(form, submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement ? submitter : undefined)
|
|
const encoded = new URLSearchParams()
|
|
for (const [key, value] of formData.entries()) {
|
|
if (typeof value !== 'string') throw error(`表单字段 ${key} 包含文件,暂不允许自动回放`)
|
|
encoded.append(key, value)
|
|
}
|
|
return {
|
|
boundary: 'form', method, url,
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
bodyText: encoded.toString(),
|
|
}
|
|
}
|
|
|
|
export async function executeRequestTransaction(input: RequestTransactionInvocation): Promise<unknown> {
|
|
const timeoutMs = callableExecutionPolicy('auto', input.timeoutMs ?? DEFAULT_TIMEOUT_MS).timeoutMs
|
|
const rollback = beginDomRollback()
|
|
const restorers: Array<() => void> = []
|
|
let captured: CapturedRequest | undefined
|
|
let captureFailure: Error | undefined
|
|
let prerequisiteIndex = 0
|
|
let prerequisiteInFlight = false
|
|
let resolveCapture!: () => void
|
|
const captureSignal = new Promise<void>((resolve) => { resolveCapture = resolve })
|
|
const transactionAbort = new AbortController()
|
|
|
|
const fail = (reason: unknown): Error => {
|
|
const message = reason instanceof Error ? reason.message : String(reason)
|
|
const failure = reason instanceof Error && message.startsWith('请求事务失败:') ? reason : error(message)
|
|
captureFailure = failure
|
|
if (!transactionAbort.signal.aborted) transactionAbort.abort(failure)
|
|
resolveCapture()
|
|
return failure
|
|
}
|
|
|
|
const capture = async (request: CapturedRequest): Promise<void> => {
|
|
if (captured || captureFailure) {
|
|
throw fail('页面流程产生了目标请求之外的额外网络请求')
|
|
}
|
|
if (prerequisiteInFlight || prerequisiteIndex !== input.transaction.prerequisites.length) {
|
|
throw fail('页面在在线前置请求完成前尝试生成最终业务请求')
|
|
}
|
|
if (!requestMatchesStep(input.transaction.request, request)) {
|
|
throw fail(`页面尝试访问未授权请求 ${request.method} ${request.url}`)
|
|
}
|
|
if (byteLength(request.bodyText) > MAX_BODY_BYTES) {
|
|
throw fail('页面生成的请求 Body 超过 8 MiB')
|
|
}
|
|
captured = request
|
|
resolveCapture()
|
|
}
|
|
|
|
const previousFetch = window.fetch
|
|
setMethod(window, 'fetch', (async function transactionFetch(this: Window, requestInput: RequestInfo | URL, init?: RequestInit) {
|
|
const request = new Request(resolveRequestTransactionFetchInput(requestInput), init)
|
|
const observed: CapturedRequest = {
|
|
boundary: 'fetch',
|
|
method: request.method.toUpperCase(),
|
|
url: request.url,
|
|
headers: headerRecord(request.headers),
|
|
bodyText: await request.clone().text(),
|
|
}
|
|
const prerequisite = input.transaction.prerequisites[prerequisiteIndex]
|
|
if (prerequisite) {
|
|
if (prerequisiteInFlight) throw fail('页面并发发起了多个在线前置请求,无法证明执行顺序')
|
|
if (!requestMatchesStep(prerequisite, observed)) {
|
|
throw fail(`页面尝试访问未授权请求 ${observed.method} ${observed.url}`)
|
|
}
|
|
let forwardAbort: (() => void) | undefined
|
|
try {
|
|
validatePrerequisiteRequest(prerequisite, observed)
|
|
prerequisiteInFlight = true
|
|
if (request.signal.aborted) transactionAbort.abort(request.signal.reason)
|
|
else {
|
|
forwardAbort = () => transactionAbort.abort(request.signal.reason)
|
|
request.signal.addEventListener('abort', forwardAbort, { once: true })
|
|
}
|
|
const expectedRedirect = comparableUrl(prerequisite.url, runtimeBaseUrl())
|
|
=== comparableUrl(prerequisite.response.url, runtimeBaseUrl()) ? 'error' : 'follow'
|
|
const guardedRequest = new Request(request, {
|
|
redirect: expectedRedirect,
|
|
signal: transactionAbort.signal,
|
|
})
|
|
const response = await Reflect.apply(previousFetch, this, [guardedRequest])
|
|
await validatePrerequisiteResponse(prerequisite, response)
|
|
prerequisiteIndex += 1
|
|
return response
|
|
} catch (reason) {
|
|
throw fail(reason)
|
|
} finally {
|
|
if (forwardAbort) request.signal.removeEventListener('abort', forwardAbort)
|
|
prerequisiteInFlight = false
|
|
}
|
|
}
|
|
await capture(observed)
|
|
return await new Promise<Response>(() => undefined)
|
|
}) as typeof previousFetch, restorers)
|
|
|
|
const xhrMetadata = new WeakMap<XMLHttpRequest, { method: string; url: string; headers: Record<string, string> }>()
|
|
const xhrPrototype = XMLHttpRequest.prototype
|
|
const previousOpen = xhrPrototype.open
|
|
const previousSetHeader = xhrPrototype.setRequestHeader
|
|
const previousSend = xhrPrototype.send
|
|
setMethod(xhrPrototype, 'open', (function transactionOpen(this: XMLHttpRequest, method: string, url: string | URL, ...rest: unknown[]) {
|
|
xhrMetadata.set(this, { method: method.toUpperCase(), url: absoluteUrl(String(url)), headers: Object.create(null) as Record<string, string> })
|
|
return Reflect.apply(previousOpen, this, [method, url, ...rest] as never)
|
|
}) as typeof previousOpen, restorers)
|
|
setMethod(xhrPrototype, 'setRequestHeader', (function transactionSetHeader(this: XMLHttpRequest, name: string, value: string) {
|
|
const metadata = xhrMetadata.get(this)
|
|
if (metadata) metadata.headers[name.toLowerCase()] = value
|
|
return Reflect.apply(previousSetHeader, this, [name, value])
|
|
}) as typeof previousSetHeader, restorers)
|
|
setMethod(xhrPrototype, 'send', (function transactionSend(this: XMLHttpRequest, body?: Document | XMLHttpRequestBodyInit | null) {
|
|
const metadata = xhrMetadata.get(this)
|
|
if (!metadata) throw error('XHR 没有可验证的 open 边界')
|
|
void bodyText(body).then((text) => capture({ boundary: 'xhr', ...metadata, bodyText: text })).catch((reason) => {
|
|
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
|
resolveCapture()
|
|
})
|
|
}) as typeof previousSend, restorers)
|
|
|
|
if (typeof navigator.sendBeacon === 'function') {
|
|
setMethod(navigator, 'sendBeacon', (function transactionBeacon(url: string | URL, data?: BodyInit | null) {
|
|
void bodyText(data).then((text) => capture({
|
|
boundary: 'beacon', method: 'POST', url: absoluteUrl(String(url)), headers: {}, bodyText: text,
|
|
})).catch((reason) => {
|
|
captureFailure = reason instanceof Error ? reason : error(String(reason))
|
|
resolveCapture()
|
|
})
|
|
return true
|
|
}) as typeof navigator.sendBeacon, restorers)
|
|
}
|
|
|
|
const formPrototype = HTMLFormElement.prototype
|
|
const previousSubmit = formPrototype.submit
|
|
const previousRequestSubmit = formPrototype.requestSubmit
|
|
setMethod(formPrototype, 'submit', (function transactionSubmit(this: HTMLFormElement) {
|
|
void capture(formRequest(this)).catch(() => undefined)
|
|
}) as typeof previousSubmit, restorers)
|
|
setMethod(formPrototype, 'requestSubmit', (function transactionRequestSubmit(this: HTMLFormElement, submitter?: HTMLElement | null) {
|
|
void capture(formRequest(this, submitter)).catch(() => undefined)
|
|
}) as typeof previousRequestSubmit, restorers)
|
|
const submitListener = (event: SubmitEvent) => {
|
|
event.preventDefault()
|
|
event.stopImmediatePropagation()
|
|
if (event.target instanceof HTMLFormElement) void capture(formRequest(event.target, event.submitter)).catch(() => undefined)
|
|
}
|
|
document.addEventListener('submit', submitListener, true)
|
|
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
|
|
|
setMethod(window, 'alert', (() => undefined) as typeof window.alert, restorers)
|
|
setMethod(window, 'confirm', (() => false) as typeof window.confirm, restorers)
|
|
setMethod(window, 'prompt', (() => null) as typeof window.prompt, restorers)
|
|
setMethod(window, 'open', (() => null) as typeof window.open, restorers)
|
|
|
|
let invocationFailure: unknown
|
|
let returned: unknown
|
|
try {
|
|
const domInputCount = bindLogicalInput(input.logicalInput)
|
|
try { returned = input.invoke({ domInputCount }) } catch (reason) {
|
|
invocationFailure = reason
|
|
resolveCapture()
|
|
}
|
|
void Promise.resolve(returned).catch((reason) => {
|
|
invocationFailure = reason
|
|
if (!captured) resolveCapture()
|
|
})
|
|
await Promise.race([
|
|
captureSignal,
|
|
delay(timeoutMs).then(() => {
|
|
if (!captured && !captureFailure) fail('等待页面完成在线依赖并生成目标请求超时')
|
|
}),
|
|
])
|
|
if (captureFailure) throw captureFailure
|
|
if (!captured) {
|
|
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
|
throw error('页面函数没有产生目标请求')
|
|
}
|
|
await delay(0)
|
|
if (captureFailure) throw captureFailure
|
|
if (invocationFailure instanceof Error) throw error(invocationFailure.message)
|
|
const actualBodyFormat = capturedBodyFormat(captured)
|
|
if (actualBodyFormat !== input.transaction.request.bodyFormat) {
|
|
throw error(`页面生成了 ${actualBodyFormat} Body,但录制证据要求 ${input.transaction.request.bodyFormat} Body`)
|
|
}
|
|
const value = capturedBody(captured)
|
|
validateRequestTransactionOutput(value, input.transaction.request.expectedDestinations)
|
|
return value
|
|
} finally {
|
|
if (!transactionAbort.signal.aborted) transactionAbort.abort(error('请求事务已经结束'))
|
|
for (const restore of restorers.reverse()) {
|
|
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
|
}
|
|
rollback.finish()
|
|
}
|
|
}
|
|
|
|
export async function executeSideEffectFreeCallable(
|
|
invoke: () => unknown,
|
|
execution: BrowserPageCallableExecutionPolicy,
|
|
): Promise<unknown> {
|
|
const rollback = beginDomRollback()
|
|
const restorers: Array<() => void> = []
|
|
let attemptedBoundary = ''
|
|
const block = (boundary: string): never => {
|
|
attemptedBoundary = boundary
|
|
throw error(`普通页面函数尝试触发 ${boundary},必须改用请求事务`)
|
|
}
|
|
setMethod(window, 'fetch', (() => block('Fetch')) as typeof window.fetch, restorers)
|
|
setMethod(XMLHttpRequest.prototype, 'send', (function blockedXhrSend() { return block('XHR') }) as typeof XMLHttpRequest.prototype.send, restorers)
|
|
if (typeof navigator.sendBeacon === 'function') {
|
|
setMethod(navigator, 'sendBeacon', (() => block('Beacon')) as typeof navigator.sendBeacon, restorers)
|
|
}
|
|
setMethod(HTMLFormElement.prototype, 'submit', (function blockedSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.submit, restorers)
|
|
setMethod(HTMLFormElement.prototype, 'requestSubmit', (function blockedRequestSubmit() { return block('Form Submit') }) as typeof HTMLFormElement.prototype.requestSubmit, restorers)
|
|
const submitListener = (event: SubmitEvent) => {
|
|
event.preventDefault()
|
|
event.stopImmediatePropagation()
|
|
attemptedBoundary = 'Form Submit'
|
|
}
|
|
document.addEventListener('submit', submitListener, true)
|
|
restorers.push(() => document.removeEventListener('submit', submitListener, true))
|
|
try {
|
|
const value = await settleCallableResult(invoke(), execution)
|
|
await Promise.resolve()
|
|
if (attemptedBoundary) throw error(`普通页面函数尝试触发 ${attemptedBoundary},必须改用请求事务`)
|
|
const mutationCount = rollback.finish()
|
|
if (mutationCount) throw error('普通页面函数修改了页面 DOM,必须改用请求事务')
|
|
return value
|
|
} finally {
|
|
for (const restore of restorers.reverse()) {
|
|
try { restore() } catch { /* The document may have been replaced while fail-closing. */ }
|
|
}
|
|
rollback.finish()
|
|
}
|
|
}
|