| 1 | import { randomUUID } from "node:crypto"; |
| 2 | import { AsyncLocalStorage } from "node:async_hooks"; |
| 3 | import type { GuestFrame, GuestPage } from "./guestView.js"; |
| 4 | import { acquireDebugger, type DebuggerRelease, type DebuggerSender } from "./debuggerLease.js"; |
| 5 | import { browserFailure } from "./errors.js"; |
| 6 | import { abortable } from "./captureQueue.js"; |
| 7 | import { FrameSessionPool, type FrameSessionLease } from "./frameSessionPool.js"; |
| 8 | |
| 9 | const operationSignals = new AsyncLocalStorage<AbortSignal>(); |
| 10 | export function frameOperationSignal(): AbortSignal | undefined { return operationSignals.getStore(); } |
| 11 | export function withFrameOperationSignal<T>(signal: AbortSignal, work: () => Promise<T>): Promise<T> { |
| 12 | return operationSignals.run(signal, work); |
| 13 | } |
| 14 | |
| 15 | interface Context { frameId: string; sessionId?: string; executionContextId: number; sessionFrame?: GuestFrame } |
| 16 | type Execute = (contextId: number, sessionId: string | undefined, send: DebuggerSender, sessionFrame?: GuestFrame) => Promise<unknown>; |
| 17 | |
| 18 | // One operation owns its frame contexts and object groups. Root connections |
| 19 | // and target sessions have a single page-level owner; contexts are never shared. |
| 20 | export class FrameRuntime { |
| 21 | private readonly release: DebuggerRelease; |
| 22 | private readonly pool: FrameSessionPool; |
| 23 | private readonly main: GuestFrame; |
| 24 | private readonly sessions = new Map<string, string>(); |
| 25 | private readonly borrowed = new Map<string, FrameSessionLease>(); |
| 26 | private readonly contexts = new Map<string, Promise<Context>>(); |
| 27 | private readonly objectSessions = new Set<string | undefined>(); |
| 28 | private readonly group = `reasonix-frame-owner-${randomUUID()}`; |
| 29 | private root?: Promise<Context>; |
| 30 | private closed = false; |
| 31 | private failed = false; |
| 32 | private readonly signal = operationSignals.getStore(); |
| 33 | |
| 34 | constructor(private readonly page: GuestPage, private readonly deadline = Date.now() + 1000) { |
| 35 | this.main = page.mainFrame; |
| 36 | this.release = acquireDebugger(page.debugger); |
| 37 | this.pool = FrameSessionPool.for(page.debugger, this.release); |
| 38 | } |
| 39 | |
| 40 | private assertLive(): void { |
| 41 | this.signal?.throwIfAborted(); |
| 42 | if (this.closed || this.page.isDestroyed() || this.main.detached || this.page.mainFrame !== this.main) throw browserFailure("stale_document", "frame operation no longer owns the page"); |
| 43 | if (Date.now() >= this.deadline) throw browserFailure("page_not_ready", "child frame protocol deadline exceeded"); |
| 44 | } |
| 45 | |
| 46 | private async send(method: string, params?: unknown, sessionId?: string): Promise<unknown> { |
| 47 | this.assertLive(); |
| 48 | return this.wait(this.release.send(method, params, sessionId), method); |
| 49 | } |
| 50 | |
| 51 | private async wait<T>(command: Promise<T>, method: string): Promise<T> { |
| 52 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 53 | try { |
| 54 | const result = await Promise.race([this.signal ? abortable(command, this.signal) : command, new Promise<never>((_resolve, reject) => { |
| 55 | timer = setTimeout(() => reject(browserFailure("page_not_ready", `child frame protocol deadline exceeded at ${method}`)), Math.max(1, this.deadline - Date.now())); |
| 56 | })]); |
| 57 | this.assertLive(); |
| 58 | return result; |
| 59 | } finally { clearTimeout(timer); } |
| 60 | } |
| 61 | |
| 62 | private context(frameId: string, sessionId?: string, sessionFrame?: GuestFrame): Promise<Context> { |
| 63 | const key = `${sessionId ?? "root"}:${frameId}`; |
| 64 | let existing = this.contexts.get(key); |
| 65 | if (!existing) { |
| 66 | existing = this.send("Page.createIsolatedWorld", { frameId, worldName: "reasonix-browser-v1", grantUniveralAccess: false }, sessionId).then(world => { |
| 67 | const { executionContextId } = world as { executionContextId: number }; |
| 68 | if (!Number.isInteger(executionContextId)) throw browserFailure("script_runtime_error", "isolated world returned no context"); |
| 69 | return { frameId, sessionId, executionContextId, sessionFrame }; |
| 70 | }); |
| 71 | this.contexts.set(key, existing); |
| 72 | } |
| 73 | return existing; |
| 74 | } |
| 75 | |
| 76 | private async attach(frameId: string): Promise<string> { |
| 77 | this.assertLive(); |
| 78 | let lease = this.borrowed.get(frameId); |
| 79 | if (!lease) { lease = this.pool.borrow(frameId); this.borrowed.set(frameId, lease); } |
| 80 | return this.wait(lease.ready, "Target.attachToTarget/domain initialization"); |
| 81 | } |
| 82 | |
| 83 | private rootContext(): Promise<Context> { |
| 84 | return this.root ??= (async () => { |
| 85 | await this.send("Page.enable"); |
| 86 | const tree = await this.send("Page.getFrameTree") as { frameTree: { frame: { id: string } } }; |
| 87 | return this.context(tree.frameTree.frame.id); |
| 88 | })(); |
| 89 | } |
| 90 | |
| 91 | private async child(parent: Context, nativeParent: GuestFrame, nativeChild: GuestFrame): Promise<Context> { |
| 92 | const url = nativeChild.url; |
| 93 | const siblings = () => nativeParent.frames ?? nativeParent.framesInSubtree.filter(frame => frame.parent?.frameTreeNodeId === nativeParent.frameTreeNodeId); |
| 94 | const index = siblings().findIndex(frame => frame.frameTreeNodeId === nativeChild.frameTreeNodeId); |
| 95 | const verify = () => { |
| 96 | this.assertLive(); |
| 97 | if (index < 0 || siblings()[index]?.frameTreeNodeId !== nativeChild.frameTreeNodeId || nativeChild.detached || nativeParent.detached || nativeChild.url !== url || nativeChild.parent?.frameTreeNodeId !== nativeParent.frameTreeNodeId) throw browserFailure("stale_document", "frame changed during resolution"); |
| 98 | }; |
| 99 | verify(); |
| 100 | const owner = await this.send("Runtime.evaluate", { contextId: parent.executionContextId, expression: `(() => { const target = window.frames[${index}]; const find = root => { for (const el of root.querySelectorAll('*')) { if ((el.tagName === 'IFRAME' || el.tagName === 'FRAME') && el.contentWindow === target) return el; if (el.shadowRoot) { const nested = find(el.shadowRoot); if (nested) return nested; } } }; return find(document); })()`, returnByValue: false, objectGroup: this.group, timeout: 500 }, parent.sessionId) as { result?: { objectId?: string } }; |
| 101 | if (!owner.result?.objectId) throw browserFailure("stale_document", "frame owner disappeared"); |
| 102 | this.objectSessions.add(parent.sessionId); |
| 103 | const describe = async () => { |
| 104 | verify(); |
| 105 | const result = await this.send("DOM.describeNode", { objectId: owner.result!.objectId }, parent.sessionId) as { node: { frameId?: string; backendNodeId?: number } }; |
| 106 | if (!result.node.frameId) throw browserFailure("stale_document", "frame owner has no document"); |
| 107 | return result.node as { frameId: string; backendNodeId?: number }; |
| 108 | }; |
| 109 | const original = await describe(); |
| 110 | let frameId = original.frameId; |
| 111 | const targets = await this.send("Target.getTargets") as { targetInfos: { targetId: string; type: string }[] }; |
| 112 | if (!targets.targetInfos.some(target => target.targetId === frameId && target.type === "iframe")) return this.context(frameId, parent.sessionId, parent.sessionFrame); |
| 113 | let sessionId = this.sessions.get(frameId); |
| 114 | if (!sessionId) { |
| 115 | let attached: string; |
| 116 | try { attached = await this.attach(frameId); } |
| 117 | catch (error) { |
| 118 | // Refresh only a failed read-only target attachment. The same retained |
| 119 | // DOM object and native frame must still identify the original owner. |
| 120 | verify(); |
| 121 | await this.send("Target.getTargets"); |
| 122 | const refreshed = await describe(); |
| 123 | if (original.backendNodeId === undefined || original.backendNodeId !== refreshed.backendNodeId || refreshed.frameId === frameId) throw error; |
| 124 | frameId = refreshed.frameId; |
| 125 | attached = await this.attach(frameId); |
| 126 | } |
| 127 | sessionId = attached; |
| 128 | this.sessions.set(frameId, sessionId); |
| 129 | } |
| 130 | verify(); |
| 131 | return this.context(frameId, sessionId, nativeChild); |
| 132 | } |
| 133 | |
| 134 | async run(frame: GuestFrame, code: string, execute?: Execute): Promise<unknown> { |
| 135 | try { return await this.runFrame(frame, code, execute); } |
| 136 | catch (error) { this.failed = true; throw error; } |
| 137 | } |
| 138 | |
| 139 | private async runFrame(frame: GuestFrame, code: string, execute?: Execute): Promise<unknown> { |
| 140 | this.assertLive(); |
| 141 | const url = frame.url; |
| 142 | const path: GuestFrame[] = []; |
| 143 | for (let current = frame; current.frameTreeNodeId !== this.main.frameTreeNodeId;) { |
| 144 | path.unshift(current); |
| 145 | if (!current.parent) throw browserFailure("stale_document", "frame no longer has a parent"); |
| 146 | current = current.parent; |
| 147 | } |
| 148 | let context = await this.rootContext(); |
| 149 | let parent = this.main; |
| 150 | for (const child of path) { context = await this.child(context, parent, child); parent = child; } |
| 151 | const assertFrame = () => { |
| 152 | this.assertLive(); |
| 153 | if (frame.detached || frame.url !== url) throw browserFailure("stale_document", "frame navigated during evaluation"); |
| 154 | }; |
| 155 | assertFrame(); |
| 156 | if (execute) { |
| 157 | // Custom operations (file uploads) must use the same deadline and |
| 158 | // cancellation fence as script evaluation. Never expose the raw lease |
| 159 | // sender: a late lookup could otherwise dispatch into a departed frame. |
| 160 | const send: DebuggerSender = async (method, params, sessionId) => { |
| 161 | assertFrame(); |
| 162 | const result = await this.send(method, params, sessionId); |
| 163 | assertFrame(); |
| 164 | return result; |
| 165 | }; |
| 166 | const result = await execute(context.executionContextId, context.sessionId, send, context.sessionFrame); |
| 167 | assertFrame(); |
| 168 | return result; |
| 169 | } |
| 170 | const result = await this.send("Runtime.evaluate", { expression: code, contextId: context.executionContextId, returnByValue: true, awaitPromise: true, timeout: Math.max(1, this.deadline - Date.now()) }, context.sessionId) as { result?: { value?: unknown }; exceptionDetails?: unknown }; |
| 171 | if (result.exceptionDetails) throw browserFailure("script_runtime_error", "isolated child-frame runtime failed"); |
| 172 | assertFrame(); |
| 173 | return result.result?.value; |
| 174 | } |
| 175 | |
| 176 | async close(failed = false): Promise<void> { |
| 177 | if (this.closed) return; |
| 178 | this.failed ||= failed; |
| 179 | this.closed = true; |
| 180 | // Cleanup commands are tracked by the connection owner even after this |
| 181 | // operation's deadline. Their eventual replies cannot resurrect contexts. |
| 182 | const cleanup = (async () => { |
| 183 | const targets: Array<string | undefined> = [...this.sessions.values()].reverse(); |
| 184 | targets.push(undefined); |
| 185 | for (const sessionId of targets) { |
| 186 | // The shared target stays attached for adjacent operations. Only this |
| 187 | // operation's object group is released here. |
| 188 | if (this.objectSessions.has(sessionId)) await this.release.send("Runtime.releaseObjectGroup", { objectGroup: this.group }, sessionId).catch(() => {}); |
| 189 | } |
| 190 | })(); |
| 191 | let timer: ReturnType<typeof setTimeout> | undefined; |
| 192 | try { await Promise.race([cleanup, new Promise<void>(resolve => { timer = setTimeout(resolve, 100); })]); } |
| 193 | finally { |
| 194 | clearTimeout(timer); |
| 195 | // Cleanup may still be using the target's objects. Release borrowed |
| 196 | // sessions only after those commands finish, even when our waiter expires. |
| 197 | void cleanup.finally(() => { |
| 198 | for (const lease of this.borrowed.values()) lease.release(this.failed || Boolean(this.signal?.aborted)); |
| 199 | this.borrowed.clear(); this.release(); |
| 200 | }); |
| 201 | this.contexts.clear(); this.sessions.clear(); |
| 202 | } |
| 203 | } |
| 204 | } |
| 205 | |
| 206 | export async function runChildFrame(page: GuestPage, frame: GuestFrame, code: string, execute?: Execute): Promise<unknown> { |
| 207 | const runtime = new FrameRuntime(page); |
| 208 | try { return await runtime.run(frame, code, execute); } |
| 209 | finally { await runtime.close(); } |
| 210 | } |
| 211 |