import { PAGE_RECORDER_PROTOCOL_VERSION, PAGE_RECORDER_REGISTRY_KEY } from '@/features/browser-recording/constants'; import { PAGE_RECORDER_REQUEST_EVENT, PAGE_RECORDER_RESPONSE_EVENT, type PageRecorderBridgeCommand, type PageRecorderBridgeRequest, type PageRecorderBridgeResponse, } from '@/features/browser-recording/bridge-protocol'; import { PAGE_CALLABLE_REGISTRY_KEY } from '@/features/page-callable/constants'; import { executeRequestTransaction, executeSideEffectFreeCallable } from '@/features/page-callable/request-transaction'; import { callableExecutionPolicy, settleCallableResult } from '@/features/page-callable/execution'; import { createCryptoAdapterRuntime, PAGE_CRYPTO_ADAPTERS, type CallableOperationKind, type CryptoAdapterInvocationPlan, type CryptoAdapterOperation, type CryptoAdapterRuntime, type CryptoAdapterToolkit, } from '@/features/browser-crypto/adapters'; import { executeTransformDirection } from '@/features/browser-transform/mapping'; import { createCommunicationBoundaryRuntime, type CommunicationBoundaryRuntime, } from '@/features/browser-recording/main-world/boundaries/communication'; import { createNetworkBoundaryRuntime, type NetworkBoundaryRuntime, } from '@/features/browser-recording/main-world/boundaries/network'; import { createRequestPreparationRuntime, type RequestPreparationRuntime, } from '@/features/browser-recording/main-world/boundaries/request-preparation'; import { createEncodingTransformRuntime, type EncodingTransformRuntime, } from '@/features/browser-recording/main-world/transforms/encoding'; import { createLibraryTransformRuntime, type LibraryTransformRuntime, } from '@/features/browser-recording/main-world/transforms/library-transform'; import { createRecordingEvidenceRuntime, type RecordingEvidenceRuntime, } from '@/features/browser-recording/main-world/evidence'; import { createRecordingTraceRuntime, type RecordingTraceContext, type RecordingTraceRuntime, } from '@/features/browser-recording/main-world/trace'; import { RetainedCallBudget } from '@/features/browser-recording/main-world/retained-call-budget'; import { estimateRetainedCallBytes } from '@/features/browser-recording/main-world/retained-value-size'; import { ExtensionError } from '@/shared/errors'; import type { BrowserPageCallableExecution, BrowserPageCallableExecutionPolicy, BrowserPageCallableTransaction, BrowserRecordingCrypto, BrowserRecordingNavigation, BrowserRecordingTransform, BrowserTransformDirection, BrowserTransformDirectionName, BrowserTransformPacket, } from '@/types/models'; type RecordingKind = 'interaction' | 'fetch' | 'xhr' | 'form' | 'beacon' | 'worker' | 'message' | 'websocket' | 'crypto' | 'transform' | 'navigation'; interface RecorderOptions { captureValues: boolean; maxEntries: number; maxValueBytes: number; expiresAt?: number; } interface ValueEvidence { path: string; fingerprint: string; encoding: 'text' | 'bytes' | 'hex' | 'base64' | 'json'; byteLength: number; preview?: string; } type CallArgumentRole = 'data' | 'key' | 'iv' | 'algorithm' | 'options' | 'signature' | 'salt' | 'nonce' | 'aad' | 'unknown'; interface CallArgumentEvidence { index: number; role: CallArgumentRole; dataType: string; byteLength?: number; replaceable: boolean; retained: boolean; summary?: string; } interface RecordingEvent { id: string; sequence: number; timestamp: number; durationMs?: number; recordingId: string; traceId: string; interactionId?: string; parentEventId?: string; kind: RecordingKind; source?: 'page' | 'browser'; documentId?: string; operation: string; label?: string; url?: string; method?: string; crypto?: BrowserRecordingCrypto; transform?: BrowserRecordingTransform; direction?: 'send' | 'receive'; socketId?: string; channelId?: string; byteLength?: number; resultByteLength?: number; dataType?: string; stack?: string; scriptUrl?: string; wrapperHandleId?: string; callHandleId?: string; callableCapable?: boolean; arguments?: CallArgumentEvidence[]; inputs: ValueEvidence[]; outputs: ValueEvidence[]; sensitiveCaptured: boolean; inputPreview?: string; outputPreview?: string; error?: string; navigation?: BrowserRecordingNavigation; } interface PageCallableMetadata { id: string; name: string; kind: 'recorded-call' | 'business-closure' | 'request-transaction' | 'global-function'; operation: string; algorithm?: string; crypto?: BrowserRecordingCrypto; origin: string; lifecycle: 'document'; execution: BrowserPageCallableExecutionPolicy; inputSlots: Array<{ id: string; name: string; index: number; role: CallArgumentRole; dataType: string; required: boolean; retained: boolean; }>; output: { dataType: string; encoding: 'auto' | 'utf8' | 'hex' | 'base64' | 'json'; shape: 'value' | 'envelope'; paths: string[]; }; transaction?: BrowserPageCallableTransaction; provenance: { recordingId?: string; traceId?: string; eventId?: string; sourceUrl?: string; lineNumber?: number; functionName?: string; }; createdAt: number; } interface PageCallableRegistryEntry { metadata: PageCallableMetadata; invoke(args: unknown[], context?: { domInputCount: number }): unknown; } interface RecorderSnapshot { version: typeof PAGE_RECORDER_PROTOCOL_VERSION; active: boolean; recordingId?: string; startedAt?: number; count: number; droppedCount: number; retainedCallCount: number; retainedCallBytes: number; retainedCallDroppedCount: number; options?: RecorderOptions; events: RecordingEvent[]; callables: PageCallableMetadata[]; } interface RecordedCallHandle { id: string; retainedBytes: number; kind: CallableOperationKind; operation: string; crypto: BrowserRecordingCrypto; original: Function; thisArg: unknown; args: unknown[]; inputIndex: number; originalInput: unknown; eventId?: string; traceId?: string; recordingId?: string; sourceUrl?: string; outputDataType?: string; outputEncoding?: PageCallableMetadata['output']['encoding']; resultMode: 'sync' | 'promise'; adaptInput(value: unknown): unknown; } interface RecorderController { version: typeof PAGE_RECORDER_PROTOCOL_VERSION; command(command: string, input?: Record): unknown; } interface DeepBreakMatcher { wrapperHandleId: string; operation: string; scriptUrl?: string; } type RecordingEventInput = Omit & { inputs?: ValueEvidence[]; outputs?: ValueEvidence[] }; export default defineUnlistedScript(() => { const REGISTRY_KEY = PAGE_RECORDER_REGISTRY_KEY; const CALLABLE_REGISTRY_KEY = PAGE_CALLABLE_REGISTRY_KEY; const registry = window as unknown as Record; const bridgeScript = document.currentScript; if (bridgeScript instanceof HTMLScriptElement) { const bridgeParse = JSON.parse.bind(JSON); const bridgeStringify = JSON.stringify.bind(JSON); const allowedCommands = new Set([ 'start', 'resume', 'navigation.record', 'stop', 'clear', 'status', 'get', 'callable.create', 'callable.list', 'callable.execute', 'callable.delete', 'transform.execute', ]); bridgeScript.addEventListener(PAGE_RECORDER_REQUEST_EVENT, (rawEvent) => { if (!(rawEvent instanceof CustomEvent) || typeof rawEvent.detail !== 'string') return; void (async () => { let request: PageRecorderBridgeRequest; try { request = bridgeParse(rawEvent.detail) as PageRecorderBridgeRequest; } catch { return; } if (!request?.id || !allowedCommands.has(request.command)) return; let response: PageRecorderBridgeResponse; try { const activeController = registry[REGISTRY_KEY] as RecorderController | undefined; if (activeController?.version !== PAGE_RECORDER_PROTOCOL_VERSION || typeof activeController.command !== 'function') { throw new Error('页面录制器尚未就绪'); } response = { id: request.id, ok: true, result: await Promise.resolve(activeController.command(request.command, request.input || {})), }; } catch (error) { response = { id: request.id, ok: false, error: error instanceof Error ? error.message : String(error), }; } try { bridgeScript.dispatchEvent(new CustomEvent(PAGE_RECORDER_RESPONSE_EVENT, { detail: bridgeStringify(response) })); } catch (error) { const fallback: PageRecorderBridgeResponse = { id: request.id, ok: false, error: `页面录制器结果无法序列化:${error instanceof Error ? error.message : String(error)}`, }; bridgeScript.dispatchEvent(new CustomEvent(PAGE_RECORDER_RESPONSE_EVENT, { detail: bridgeStringify(fallback) })); } })(); }); } const existing = registry[REGISTRY_KEY] as RecorderController | undefined; if (existing?.version === PAGE_RECORDER_PROTOCOL_VERSION) return; const nativeStringify = JSON.stringify.bind(JSON); const nativeAtob = window.atob.bind(window); const encoder = new TextEncoder(); const decoder = new TextDecoder(); const restorers: Array<() => void> = []; const handles = new RetainedCallBudget(); const activeEventStack: string[] = []; let expiryTimer: number | undefined; let active = false; let recordingId: string | undefined; let startedAt: number | undefined; let uniqueSequence = 0; let deepBreakMatcher: DeepBreakMatcher | undefined; let restoreAfterDeepBreak = false; let options: RecorderOptions = { captureValues: false, maxEntries: 200, maxValueBytes: 2_048 }; const evidenceRuntime: RecordingEvidenceRuntime = createRecordingEvidenceRuntime(window, () => options); const traceRuntime: RecordingTraceRuntime = createRecordingTraceRuntime({ active: () => active, recordingId: () => recordingId, captureValues: () => options.captureValues, maxEntries: () => options.maxEntries, parentEventId: () => activeEventStack.at(-1), unique, }); function pageCallableRegistry(): Map { const current = registry[CALLABLE_REGISTRY_KEY]; if (current instanceof Map) return current as Map; const created = new Map(); Object.defineProperty(registry, CALLABLE_REGISTRY_KEY, { value: created, configurable: true, enumerable: false, writable: false, }); return created; } function callableMetadata(): PageCallableMetadata[] { return [...pageCallableRegistry().values()].slice(-128).map((entry) => entry.metadata); } function clearRecordedCallables(): void { const callables = pageCallableRegistry(); for (const [id, entry] of callables) { if (entry.metadata.kind === 'recorded-call') callables.delete(id); } } function unique(prefix: string): string { uniqueSequence += 1; return `${prefix}-${Date.now().toString(36)}-${Math.floor(performance.now() * 1000).toString(36)}-${uniqueSequence.toString(36)}`; } function dataType(value: unknown): string { return evidenceRuntime.dataType(value); } function asBytes(value: unknown): Uint8Array | undefined { return evidenceRuntime.asBytes(value); } function bytesToBase64(bytes: Uint8Array): string { return evidenceRuntime.bytesToBase64(bytes); } function fingerprint(value: string): string { return evidenceRuntime.fingerprint(value); } function reseedFingerprints(): void { evidenceRuntime.reseed(); } function collectEvidence( value: unknown, path = '$', depth = 0, output: ValueEvidence[] = [], parseStringContainers = true, ): ValueEvidence[] { return evidenceRuntime.collect(value, path, depth, output, parseStringContainers); } function byteLength(value: unknown): number | undefined { return evidenceRuntime.byteLength(value); } function preview(value: unknown): string | undefined { return evidenceRuntime.preview(value); } function stackInfo(): { stack?: string; scriptUrl?: string } { try { const stack = new Error().stack?.split('\n').slice(2, 10).join('\n').slice(0, 4_096); const scriptUrl = stack?.match(/https?:\/\/[^\s)]+/)?.[0]?.slice(0, 2_048); return { stack, scriptUrl }; } catch { return {}; } } function pauseForDeepCapture(wrapperHandleId: string, scriptUrl?: string): void { const matcher = deepBreakMatcher; if (!matcher || matcher.wrapperHandleId !== wrapperHandleId) return; if (matcher.scriptUrl && scriptUrl && !scriptUrl.startsWith(matcher.scriptUrl)) return; const restoreAfterResume = restoreAfterDeepBreak; deepBreakMatcher = undefined; restoreAfterDeepBreak = false; if (restoreAfterResume) stop(); } function deepCaptureFunction(wrapperHandleId: string): Function | undefined { return cryptoAdapterRuntime.wrapperFunction(wrapperHandleId) || communicationBoundaryRuntime.wrapperFunction(wrapperHandleId); } function record(input: RecordingEventInput, context?: RecordingTraceContext): RecordingEvent | undefined { return traceRuntime.record(input, context) as RecordingEvent | undefined; } function observe(factory: () => RecordingEventInput, context?: RecordingTraceContext): RecordingEvent | undefined { return traceRuntime.observe(factory, context) as RecordingEvent | undefined; } function bestEffort(operation: () => void): void { try { operation(); } catch { /* Recording must not change page behavior. */ } } function errorMessage(error: unknown): string { try { return (error instanceof Error ? error.message : String(error)).slice(0, 512); } catch { return 'Unknown error'; } } function argumentEvidence( index: number, role: CallArgumentRole, value: unknown, replaceable: boolean, retained: boolean, summary?: string, ): CallArgumentEvidence { const type = dataType(value); const sizeEligible = ['data', 'key', 'iv', 'signature', 'salt', 'nonce', 'aad'].includes(role) && type !== 'CryptoKey'; const size = sizeEligible ? byteLength(value) : undefined; return { index, role, dataType: type.slice(0, 120), byteLength: size === undefined ? undefined : Math.max(0, size), replaceable, retained, summary: summary?.slice(0, 240), }; } function interactionLabel(target: EventTarget | null): string { if (!(target instanceof Element)) return '页面操作'; const element = target.closest('button, a, input, select, textarea, [role]') || target; const text = [element.getAttribute('aria-label'), element.getAttribute('name'), element.getAttribute('title'), element.textContent] .find((value) => value?.trim())?.trim().replace(/\s+/g, ' ').slice(0, 120); return text || element.tagName.toLowerCase(); } function beginInteraction(operation: string, target: EventTarget | null): void { if (!active) return; const interactionId = unique('interaction'); const context = { traceId: unique('trace'), interactionId }; traceRuntime.bindContext(context); observe(() => ({ kind: 'interaction', operation, label: interactionLabel(target) }), context); } function patchInteractions(): void { const onClick = (event: MouseEvent) => { if (event.button === 0) beginInteraction('click', event.target); }; const onSubmit = (event: SubmitEvent) => beginInteraction('submit', event.target); document.addEventListener('click', onClick, true); document.addEventListener('submit', onSubmit, true); restorers.push(() => { document.removeEventListener('click', onClick, true); document.removeEventListener('submit', onSubmit, true); }); } function registerHandle(input: Omit): string | undefined { const id = unique('handle'); const retainedBytes = estimateRetainedCallBytes(input.args); return handles.add({ id, retainedBytes, ...input }) ? id : undefined; } function invokeCryptoAdapter( operation: CryptoAdapterOperation, original: Function, thisArg: unknown, args: unknown[], wrapperHandleId: string, installDynamic: (operations: CryptoAdapterOperation[]) => void, ): unknown { const invokeOriginal = (): unknown => operation.invocationMode === 'construct' ? Reflect.construct(original, args) : Reflect.apply(original, thisArg, args); let plan: CryptoAdapterInvocationPlan; try { plan = operation.describe(thisArg, args, cryptoAdapterToolkit); } catch { return invokeOriginal(); } const started = performance.now(); const inputIndex = plan.inputIndex; const callHandleId = plan.callableKind && inputIndex >= 0 ? registerHandle({ kind: plan.callableKind, operation: `${plan.crypto.adapterId}.${plan.crypto.operation}`, crypto: plan.crypto, original, thisArg, args: [...args], inputIndex, originalInput: args[inputIndex], outputEncoding: plan.outputEncoding || plan.crypto.outputEncoding, resultMode: operation.resultMode, adaptInput: plan.adaptInput || ((value) => defaultAdaptInput(value, args[inputIndex])), }) : undefined; const item = observe(() => ({ kind: 'crypto', operation: plan.crypto.operation, crypto: plan.crypto, wrapperHandleId, callHandleId, callableCapable: Boolean(callHandleId), arguments: plan.arguments, byteLength: inputIndex >= 0 ? byteLength(args[inputIndex]) : undefined, dataType: inputIndex >= 0 ? dataType(args[inputIndex]) : undefined, inputPreview: inputIndex >= 0 ? preview(args[inputIndex]) : undefined, inputs: inputIndex >= 0 ? plan.inputEvidence?.(args[inputIndex]) || collectEvidence(args[inputIndex], '$input') : [], ...stackInfo(), })); if (item && callHandleId) { const handle = handles.get(callHandleId); if (handle) Object.assign(handle, { eventId: item.id, traceId: item.traceId, recordingId: item.recordingId, sourceUrl: item.scriptUrl, }); } pauseForDeepCapture(wrapperHandleId, item?.scriptUrl); if (item) activeEventStack.push(item.id); const complete = (output: unknown): void => { if (plan.discoverResult) { try { installDynamic(plan.discoverResult(output)); } catch { /* Runtime session discovery is optional. */ } } if (!item) return; item.durationMs = Math.max(0, performance.now() - started); item.resultByteLength = byteLength(output); item.outputPreview = preview(output); item.outputs = plan.outputEvidence?.(output) || collectEvidence(output, '$output'); item.error = plan.outputError?.(output) || item.error; const handle = callHandleId ? handles.get(callHandleId) : undefined; if (handle) handle.outputDataType = dataType(output); }; const fail = (error: unknown): void => { if (item) item.error = errorMessage(error); }; try { const output = invokeOriginal(); if (operation.resultMode === 'promise') { if (item) activeEventStack.pop(); if (output && typeof (output as { then?: unknown }).then === 'function') { void (output as Promise).then(complete, fail); } else { complete(output); } } else { if (item) activeEventStack.pop(); complete(output); } return output; } catch (error) { if (item) activeEventStack.pop(); fail(error); throw error; } } const cryptoAdapterToolkit: CryptoAdapterToolkit = { unique, byteLength, dataType, fingerprint, argument: argumentEvidence, collectEvidence: (value, path) => collectEvidence(value, path), defaultOutputEvidence: (value) => collectEvidence(value, '$output'), defaultAdaptInput, bytesForInput, bytesToBase64, }; const cryptoAdapterRuntime: CryptoAdapterRuntime = createCryptoAdapterRuntime( PAGE_CRYPTO_ADAPTERS, { window, crypto: globalThis.crypto }, cryptoAdapterToolkit, { unique, invoke: invokeCryptoAdapter, }, ); const communicationBoundaryRuntime: CommunicationBoundaryRuntime = createCommunicationBoundaryRuntime(window, { unique, describe(value, path) { return { byteLength: byteLength(value), dataType: dataType(value), preview: preview(value), evidence: collectEvidence(value, path), }; }, stackInfo, emit: (input, context) => { if (context) traceRuntime.bindContext(context); return observe(() => input, context); }, afterWrapperInvoke: pauseForDeepCapture, }); const networkBoundaryRuntime: NetworkBoundaryRuntime = createNetworkBoundaryRuntime(window, { unique, byteLength, dataType, asBytes, preview, collectEvidence: (value, path) => collectEvidence(value, path), stackInfo, context: () => traceRuntime.context(), emit: (event, context) => { observe(() => event, context); }, }); const encodingTransformRuntime: EncodingTransformRuntime = createEncodingTransformRuntime(window, { byteLength, preview, collectEvidence: (value, path) => collectEvidence(value, path), stackInfo, emit: (event) => { observe(() => ({ kind: 'transform', ...event })); }, }); const libraryTransformRuntime: LibraryTransformRuntime = createLibraryTransformRuntime(window, { currentTrace: () => traceRuntime.currentContext(), collectEvidence: (value, path) => collectEvidence(value, path), byteLength, dataType, preview, stackInfo, emit: (event, context) => { observe(() => ({ kind: 'transform', ...event }), context); }, }); const requestPreparationRuntime: RequestPreparationRuntime = createRequestPreparationRuntime(window, { currentTrace: () => traceRuntime.currentContext(), collectEvidence: (value, path) => collectEvidence(value, path), byteLength, dataType, preview, stackInfo, emit: (event, context) => { observe(() => ({ kind: 'transform', ...event }), context); }, }); function installObservers(): void { bestEffort(patchInteractions); cryptoAdapterRuntime.start(); communicationBoundaryRuntime.start(); networkBoundaryRuntime.start(); requestPreparationRuntime.start(); encodingTransformRuntime.start(); libraryTransformRuntime.start(); } function stop(): void { active = false; if (expiryTimer !== undefined) window.clearTimeout(expiryTimer); expiryTimer = undefined; cryptoAdapterRuntime.stop(); communicationBoundaryRuntime.stop(); networkBoundaryRuntime.stop(); requestPreparationRuntime.stop(); encodingTransformRuntime.stop(); libraryTransformRuntime.stop(); while (restorers.length) bestEffort(restorers.pop()!); activeEventStack.length = 0; traceRuntime.releaseContext(); deepBreakMatcher = undefined; restoreAfterDeepBreak = false; } function resumeRecording(): void { if (active || !startedAt) return; active = true; installObservers(); if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now())); } function snapshot(limit = options.maxEntries): RecorderSnapshot { const trace = traceRuntime.snapshot(limit); return { version: PAGE_RECORDER_PROTOCOL_VERSION, active, recordingId, startedAt, count: trace.count, droppedCount: trace.droppedCount, retainedCallCount: handles.size, retainedCallBytes: handles.retainedBytes, retainedCallDroppedCount: handles.droppedCount, options: startedAt ? { ...options } : undefined, events: trace.events as RecordingEvent[], callables: callableMetadata(), }; } function normalizedBytes(value: unknown): Uint8Array | undefined { const direct = asBytes(value); if (direct) return direct; if (!value || typeof value !== 'object') return undefined; const record = value as Record; if (record.type !== 'bytes' || typeof record.base64 !== 'string') return undefined; const binary = nativeAtob(record.base64); return Uint8Array.from(binary, (character) => character.charCodeAt(0)); } function bytesForInput(value: unknown): Uint8Array | undefined { return normalizedBytes(value) || (typeof value === 'string' ? encoder.encode(value) : undefined); } function defaultAdaptInput(value: unknown, originalInput: unknown): unknown { if (typeof originalInput === 'string') { const bytes = normalizedBytes(value); return bytes ? decoder.decode(bytes) : typeof value === 'string' ? value : nativeStringify(value); } const bytes = bytesForInput(value); if (!bytes) return value; if (originalInput instanceof ArrayBuffer) return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); if (ArrayBuffer.isView(originalInput)) return bytes; return value; } function createRecordedCallable(handle: RecordedCallHandle, name: string): PageCallableMetadata { const id = unique('callable'); const metadata: PageCallableMetadata = { id, name: name.trim().slice(0, 120) || handle.operation, kind: 'recorded-call', operation: handle.operation, algorithm: handle.crypto.algorithm, crypto: handle.crypto, origin: location.origin, lifecycle: 'document', execution: callableExecutionPolicy(handle.resultMode), inputSlots: [{ id: 'data', name: 'data', index: 0, role: 'data', dataType: dataType(handle.originalInput), required: true, retained: false, }], output: { dataType: handle.outputDataType || 'unknown', encoding: handle.outputEncoding || 'auto', shape: 'value', paths: [], }, provenance: { recordingId: handle.recordingId, traceId: handle.traceId, eventId: handle.eventId, sourceUrl: handle.sourceUrl, functionName: handle.original.name || undefined, }, createdAt: Date.now(), }; pageCallableRegistry().set(id, { metadata, invoke(values) { if (!values.length) throw new Error('页面函数缺少 data 参数'); const args = [...handle.args]; args[handle.inputIndex] = handle.adaptInput(values[0]); return Reflect.apply(handle.original, handle.thisArg, args); }, }); return metadata; } async function executePageCallable(callableId: string, values: unknown[]): Promise { const entry = pageCallableRegistry().get(callableId); if (!entry) throw new Error('页面函数已经失效,页面可能已经刷新'); const started = performance.now(); const result = entry.metadata.kind === 'request-transaction' ? await executeRequestTransaction({ transaction: entry.metadata.transaction || (() => { throw new Error('请求事务缺少边界配置'); })(), logicalInput: values[0], invoke: (context) => entry.invoke(values, context), timeoutMs: entry.metadata.execution.timeoutMs, }) : entry.metadata.kind === 'business-closure' || entry.metadata.kind === 'global-function' ? await executeSideEffectFreeCallable(() => entry.invoke(values), entry.metadata.execution) : await settleCallableResult(entry.invoke(values), entry.metadata.execution); const seen = new WeakSet(); let nodes = 0; const maxBytes = 8 * 1024 * 1024; const normalize = (value: unknown, depth = 0): unknown => { nodes += 1; if (nodes > 100_000) throw new Error('页面函数结果包含过多节点'); if (value === null || value === undefined || typeof value === 'boolean' || typeof value === 'number') return value; if (typeof value === 'string') { if (encoder.encode(value).byteLength > maxBytes) throw new Error('页面函数字符串结果超过 8 MiB'); return value; } if (typeof value === 'bigint') return value.toString(); if (typeof value === 'function' || typeof value === 'symbol') throw new Error(`页面函数返回了不可序列化的 ${typeof value}`); if (depth >= 32) throw new Error('页面函数结果嵌套超过 32 层'); const bytes = asBytes(value); if (bytes) { if (bytes.byteLength > maxBytes) throw new Error('页面函数字节结果超过 8 MiB'); return { type: 'bytes', byteLength: bytes.byteLength, base64: bytesToBase64(bytes) }; } if (value instanceof Date) return value.toISOString(); if (value instanceof URLSearchParams) return value.toString(); if (typeof Response !== 'undefined' && value instanceof Response) { return { type: 'Response', status: value.status, statusText: value.statusText, url: value.url, headers: Object.fromEntries(value.headers) }; } if (value && typeof value === 'object') { const cryptoValue = value as { sigBytes?: unknown; ciphertext?: unknown; toString?: unknown }; if ((typeof cryptoValue.sigBytes === 'number' || cryptoValue.ciphertext) && typeof cryptoValue.toString === 'function') { const text = Reflect.apply(cryptoValue.toString as Function, value, []); if (typeof text === 'string' && text !== '[object Object]') return text; } if (seen.has(value)) throw new Error('页面函数结果包含循环引用'); seen.add(value); try { if (Array.isArray(value)) return value.map((item) => normalize(item, depth + 1)); const output: Record = {}; for (const [key, item] of Object.entries(value as Record)) output[key] = normalize(item, depth + 1); return output; } finally { seen.delete(value); } } throw new Error(`页面函数返回了不可序列化的 ${typeof value}`); }; const value = normalize(result); let preview: string; try { preview = typeof value === 'string' ? value : nativeStringify(value); } catch { preview = String(value); } return { callableId, type: dataType(result).toLowerCase(), preview: preview.slice(0, 8_192), value, byteLength: encoder.encode(preview).byteLength, durationMs: Math.max(0, performance.now() - started), }; } const controller: RecorderController = { version: PAGE_RECORDER_PROTOCOL_VERSION, command(command, input = {}) { if (command === 'start') { stop(); options = { captureValues: input.captureValues === true, maxEntries: Math.max(20, Math.min(Number(input.maxEntries) || 200, 500)), maxValueBytes: Math.max(256, Math.min(Number(input.maxValueBytes) || 2_048, 8_192)), expiresAt: typeof input.expiresAt === 'number' ? input.expiresAt : undefined, }; handles.clear(); clearRecordedCallables(); traceRuntime.reset(Number.isSafeInteger(input.sequenceStart) && Number(input.sequenceStart) >= 0 ? Number(input.sequenceStart) : 0); recordingId = typeof input.recordingId === 'string' && input.recordingId.trim() ? input.recordingId.trim().slice(0, 160) : unique('recording'); startedAt = typeof input.startedAt === 'number' && Number.isFinite(input.startedAt) ? input.startedAt : Date.now(); reseedFingerprints(); active = true; installObservers(); if (options.expiresAt) expiryTimer = window.setTimeout(stop, Math.max(0, options.expiresAt - Date.now())); return snapshot(); } if (command === 'resume') { if (Number.isSafeInteger(input.sequenceStart)) traceRuntime.advanceSequenceStart(Number(input.sequenceStart)); resumeRecording(); return snapshot(); } if (command === 'navigation.record') { const navigation = input.navigation as BrowserRecordingNavigation | undefined; if (!navigation || typeof navigation.toUrl !== 'string') throw new Error('页面跳转事件无效'); record({ kind: 'navigation', source: 'browser', documentId: typeof input.documentId === 'string' ? input.documentId.slice(0, 160) : undefined, operation: String(input.operation || 'navigate').slice(0, 160), label: String(input.label || '页面跳转').slice(0, 240), url: navigation.toUrl.slice(0, 8_192), navigation, }); return snapshot(); } if (command === 'stop') { stop(); return snapshot(); } if (command === 'deep.arm') { if (!startedAt) throw new Error('请先录制一次页面操作,再进入深度捕获'); restoreAfterDeepBreak = !active; resumeRecording(); const matcherKind = input.kind === 'boundary' ? 'boundary' : 'crypto'; const adapterId = String(input.adapterId || '').trim().slice(0, 64); const eventKind = String(input.eventKind || '').trim().slice(0, 32); const operation = String(input.operation || '').trim().slice(0, 240); const wrapperHandleId = String(input.wrapperHandleId || '').trim().slice(0, 160); if (!operation || !wrapperHandleId || (matcherKind === 'crypto' ? !adapterId : !['beacon', 'worker', 'message'].includes(eventKind))) { throw new Error('深度捕获目标、操作或函数句柄不完整'); } if (!deepCaptureFunction(wrapperHandleId)) { const shouldRestore = restoreAfterDeepBreak; restoreAfterDeepBreak = false; if (shouldRestore) stop(); throw new Error('目标密码函数已经失效,请重新录制一次当前页面操作'); } deepBreakMatcher = { wrapperHandleId, operation, scriptUrl: typeof input.scriptUrl === 'string' ? input.scriptUrl.slice(0, 2_048) : undefined, }; return { armed: true, kind: matcherKind, adapterId: adapterId || undefined, eventKind: eventKind || undefined, operation, wrapperHandleId }; } if (command === 'deep.function') return deepCaptureFunction(String(input.wrapperHandleId || '')); if (command === 'deep.disarm') { const shouldRestore = restoreAfterDeepBreak; deepBreakMatcher = undefined; restoreAfterDeepBreak = false; if (shouldRestore) stop(); return { armed: false }; } if (command === 'clear') { stop(); traceRuntime.reset(); handles.clear(); clearRecordedCallables(); recordingId = undefined; startedAt = undefined; return snapshot(); } if (command === 'status' || command === 'get') return snapshot(typeof input.limit === 'number' ? input.limit : options.maxEntries); if (command === 'callable.create') { const callHandleId = String(input.callHandleId || ''); const handle = handles.get(callHandleId); if (!handle) throw new Error('加解密调用句柄不存在或已经失效'); return createRecordedCallable(handle, String(input.name || handle.operation)); } if (command === 'callable.list') return callableMetadata(); if (command === 'callable.execute') { return executePageCallable(String(input.callableId || ''), Array.isArray(input.args) ? input.args : []); } if (command === 'callable.delete') { pageCallableRegistry().delete(String(input.callableId || '')); return callableMetadata(); } if (command === 'transform.execute') { const directionName = String(input.directionName) as BrowserTransformDirectionName; return executeTransformDirection( String(input.profileId || ''), directionName, input.direction as unknown as BrowserTransformDirection, input.packet as unknown as BrowserTransformPacket, async (callableId, args) => await executePageCallable(callableId, args) as BrowserPageCallableExecution, ).then( (value) => ({ ok: true, value }), (error: unknown) => ({ ok: false, error: { code: error instanceof ExtensionError ? error.code : 'transform_page_execution_failed', message: error instanceof Error ? error.message : String(error), }, }), ); } throw new Error(`不支持的录制命令: ${command}`); }, }; Object.defineProperty(registry, REGISTRY_KEY, { value: controller, configurable: true, enumerable: false, writable: false }); });