| 1 | import { randomToken, type DocumentRegistry, type FrameBinding } from "./documents.js"; |
| 2 | import type { GuestFrame, GuestPage } from "./guestView.js"; |
| 3 | import { scriptCall, SNAPSHOT_SCRIPT_SOURCE } from "./pageScripts.js"; |
| 4 | import type { SnapshotOutput } from "./snapshotScript.js"; |
| 5 | import { FrameRuntime, runChildFrame } from "./frameRuntime.js"; |
| 6 | import { browserFailure } from "./errors.js"; |
| 7 | |
| 8 | export const REGISTRY_KEY = "__reasonixBrowserRegistry"; |
| 9 | export const ISOLATED_WORLD = 1; |
| 10 | export const MAX_SNAPSHOT_NODES = 4000; |
| 11 | |
| 12 | export interface SnapshotResult { |
| 13 | documentToken: string; |
| 14 | url: string; |
| 15 | title: string; |
| 16 | tree: string; |
| 17 | refs: number; |
| 18 | } |
| 19 | |
| 20 | // The main frame uses Electron's isolated world; children use a verified CDP |
| 21 | // isolated context, with an owned session for out-of-process iframes. |
| 22 | export function runInFrame(page: GuestPage, frame: GuestFrame, code: string, runtime?: FrameRuntime): Promise<unknown> { |
| 23 | if (frame.frameTreeNodeId === page.mainFrame.frameTreeNodeId) return page.executeJavaScriptInIsolatedWorld(ISOLATED_WORLD, [{ code }]); |
| 24 | return runtime ? runtime.run(frame, code) : runChildFrame(page, frame, code); |
| 25 | } |
| 26 | |
| 27 | export function findFrame(page: GuestPage, frameTreeNodeId: number): GuestFrame | null { |
| 28 | const main = page.mainFrame; |
| 29 | if (main.frameTreeNodeId === frameTreeNodeId) return main; |
| 30 | for (const frame of main.framesInSubtree) { |
| 31 | if (frame.frameTreeNodeId === frameTreeNodeId && !frame.detached) return frame; |
| 32 | } |
| 33 | return null; |
| 34 | } |
| 35 | |
| 36 | function isSnapshotOutput(value: unknown): value is SnapshotOutput { |
| 37 | if (typeof value !== "object" || value === null) return false; |
| 38 | const out = value as Record<string, unknown>; |
| 39 | return typeof out.docId === "string" && typeof out.tree === "string" && typeof out.refs === "number" && typeof out.nodes === "number"; |
| 40 | } |
| 41 | |
| 42 | function clipURL(url: string): string { |
| 43 | return url.length > 120 ? `${url.slice(0, 119)}…` : url; |
| 44 | } |
| 45 | |
| 46 | function indent(tree: string): string { |
| 47 | return tree.split("\n").map((line) => ` ${line}`).join("\n"); |
| 48 | } |
| 49 | |
| 50 | async function within<T>(promise: Promise<T>, milliseconds: number): Promise<T> { |
| 51 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 52 | try { return await Promise.race([promise, new Promise<never>((_resolve, reject) => { timer = setTimeout(() => reject(browserFailure("page_not_ready", "DOM observation deadline exceeded")), Math.max(1, milliseconds)); })]); } |
| 53 | finally { if (timer) clearTimeout(timer); } |
| 54 | } |
| 55 | |
| 56 | export async function takeSnapshot(page: GuestPage, tabId: string, epoch: number, selector: string, documents: DocumentRegistry, verify: () => void = () => {}): Promise<SnapshotResult> { |
| 57 | const snapshotId = randomToken(8); |
| 58 | const main = page.mainFrame; |
| 59 | let mainRaw: unknown; |
| 60 | try { mainRaw = await within(runInFrame(page, main, scriptCall(SNAPSHOT_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId, prefix: "", selector, budget: MAX_SNAPSHOT_NODES })), 3000); } |
| 61 | catch (error) { if (typeof error === "object" && error !== null && "code" in error) throw error; throw browserFailure("script_runtime_error", String(error).slice(0, 500)); } |
| 62 | if (!isSnapshotOutput(mainRaw)) throw new Error("snapshot script returned no tree"); |
| 63 | const frames: FrameBinding[] = [{ prefix: "", frameTreeNodeId: main.frameTreeNodeId, docId: mainRaw.docId }]; |
| 64 | const sections = [mainRaw.tree]; |
| 65 | let refs = mainRaw.refs; |
| 66 | let budget = MAX_SNAPSHOT_NODES - mainRaw.nodes; |
| 67 | let index = 0; |
| 68 | const childrenDeadline = Date.now() + 1000; |
| 69 | const children = main.framesInSubtree.filter(frame => frame.frameTreeNodeId !== main.frameTreeNodeId && !frame.detached); |
| 70 | const runtime = children.length ? new FrameRuntime(page, childrenDeadline) : undefined; |
| 71 | try { |
| 72 | for (const frame of children) { |
| 73 | if (budget <= 0 || Date.now() >= childrenDeadline) break; |
| 74 | index += 1; |
| 75 | const prefix = `f${index}`; |
| 76 | let raw: unknown; |
| 77 | try { |
| 78 | raw = await runInFrame(page, frame, scriptCall(SNAPSHOT_SCRIPT_SOURCE, { key: REGISTRY_KEY, snapshotId, prefix, selector: "", budget }), runtime); |
| 79 | } catch (error) { |
| 80 | sections.push(`frame ${prefix} unavailable: ${error instanceof Error ? error.message.slice(0, 200) : "isolated runtime unavailable"}`); |
| 81 | continue; |
| 82 | } |
| 83 | if (!isSnapshotOutput(raw)) continue; |
| 84 | frames.push({ prefix, frameTreeNodeId: frame.frameTreeNodeId, docId: raw.docId }); |
| 85 | refs += raw.refs; |
| 86 | budget -= raw.nodes; |
| 87 | if (raw.tree === "") continue; |
| 88 | sections.push(`frame ${prefix} ${JSON.stringify(clipURL(frame.url))}\n${indent(raw.tree)}`); |
| 89 | } |
| 90 | } finally { await runtime?.close(); } |
| 91 | verify(); |
| 92 | const documentToken = documents.issue({ tabId, epoch, snapshotId, frames }); |
| 93 | return { documentToken, url: page.getURL(), title: page.getTitle(), tree: sections.join("\n"), refs }; |
| 94 | } |
| 95 |