import { map, type Result, err, ok } from "../util/result"; import { type CdpError, cdpError } from "./errors"; import type { DialogInfo } from "./types"; import type { OwnershipRegistry } from "./ownership"; import type { CdpTransport } from "./types"; import { type CdpMethod, type ParamsOf, type ResultOf, decodeResult } from "./commands"; import { decodeEvent } from "./events"; import { createNetworkBuffer, type DrainResult, type NetworkFilter } from "./network-buffer"; import { createConsoleBuffer, type ConsoleDrainResult, type ConsoleFilter } from "./console-buffer"; type TabSession = { sessionId: string; targetId: string; dialog: DialogInfo | null; pageInfoDirty: boolean; networkBuffer: ReturnType; consoleBuffer: ReturnType; refMap: Map; refSig: Map; }; const dialogType = (raw: string): DialogInfo["type"] => raw === "confirm" || raw === "prompt" || raw === "beforeunload" ? raw : "alert"; export type CdpSession = { attachFirstPage(): Promise>; attach(targetId: string): Promise>; windowId(targetId: string): Promise>; switchTo(targetId: string): Promise>; current(): { readonly sessionId: string; readonly targetId: string } | null; setRefMap(refMap: Map, refSig: Map): void; resolveRef(ref: string): number | undefined; refSignatures(): ReadonlyMap; refMappings(): ReadonlyMap; call(method: M, params?: ParamsOf, opts?: { timeoutMs?: number }): Promise, CdpError>>; callOnTarget(method: M, params: ParamsOf, sessionId: string, opts?: { timeoutMs?: number }): Promise, CdpError>>; callBrowser(method: M, params?: ParamsOf, opts?: { timeoutMs?: number }): Promise, CdpError>>; takeDialog(): DialogInfo | null; drainPageInfoInvalidations(): boolean; drainNetworkBuffer(filter: NetworkFilter): DrainResult; drainConsoleBuffer(filter: ConsoleFilter): ConsoleDrainResult; }; // Injected so seeding goes through the target factory; attachFirstPage's own `Target.createTarget` would land in whichever profile has focus. export type SeedTargetProvider = () => Promise>; export const createCdpSession = ( transport: CdpTransport, ownership?: OwnershipRegistry, createSeedTarget?: SeedTargetProvider, ): CdpSession => { let sessionId: string | null = null; let targetId: string | null = null; const tabs = new Map(); const sessionIdToTargetId = new Map(); let activeConsumer: Promise = Promise.resolve(); const resolveTab = (evSessionId?: string): TabSession | undefined => { const tid = evSessionId ? sessionIdToTargetId.get(evSessionId) : targetId; return tid ? tabs.get(tid) : undefined; }; const consumeEvents = async (): Promise => { for await (const ev of transport.events()) { if (ev.method !== "Target.targetDestroyed") { if (ev.sessionId && !sessionIdToTargetId.has(ev.sessionId)) continue; } switch (ev.method) { case "Page.javascriptDialogOpening": { const tab = resolveTab(ev.sessionId); if (!tab) break; const decoded = decodeEvent(ev.method, ev.params); tab.dialog = decoded.success ? { type: dialogType(decoded.data.type), message: decoded.data.message, ...(decoded.data.defaultPrompt !== undefined ? { defaultPrompt: decoded.data.defaultPrompt } : {}), } : { type: "alert", message: "" }; break; } // Intentionally NOT cleared on javascriptDialogClosed: a fast dismiss would otherwise drop a dialog the agent was about to read. case "Page.frameNavigated": case "Page.loadEventFired": { const tab = resolveTab(ev.sessionId); if (tab) tab.pageInfoDirty = true; break; } case "Target.targetCreated": { if (!ownership) break; const decoded = decodeEvent(ev.method, ev.params); if (!decoded.success) break; const info = decoded.data.targetInfo; if (info.type === "page" && info.targetId && info.openerId && ownership.has(info.openerId)) { ownership.add(info.targetId); } break; } case "Target.targetDestroyed": { if (!ownership) break; const decoded = decodeEvent(ev.method, ev.params); if (!decoded.success || !decoded.data.targetId) break; const destroyed = decoded.data.targetId; ownership.remove(destroyed); const tab = tabs.get(destroyed); if (tab) { sessionIdToTargetId.delete(tab.sessionId); tabs.delete(destroyed); } break; } case "Network.requestWillBeSent": { const tab = resolveTab(ev.sessionId); if (tab) tab.networkBuffer.ingestRequestWillBeSent(ev.params); break; } case "Network.responseReceived": { const tab = resolveTab(ev.sessionId); if (tab) tab.networkBuffer.ingestResponseReceived(ev.params); break; } case "Network.loadingFinished": { const tab = resolveTab(ev.sessionId); if (tab) tab.networkBuffer.ingestLoadingFinished(ev.params); break; } case "Network.loadingFailed": { const tab = resolveTab(ev.sessionId); if (tab) tab.networkBuffer.ingestLoadingFailed(ev.params); break; } case "Runtime.consoleAPICalled": { const tab = resolveTab(ev.sessionId); if (tab) tab.consoleBuffer.ingestConsoleApi(ev.params); break; } case "Log.entryAdded": { const tab = resolveTab(ev.sessionId); if (tab) tab.consoleBuffer.ingestLogEntry(ev.params); break; } default: break; } } }; const restartConsumer = (): void => { activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => { console.warn("[pi-browser-harness] CDP event consumer crashed:", e); }); }; restartConsumer(); transport.onClose(() => { sessionId = null; targetId = null; tabs.clear(); sessionIdToTargetId.clear(); // Do NOT clear `dialog` here: a pending takeDialog() must still see it. restartConsumer(); }); const enableDomains = async (sid: string): Promise => { for (const d of ["Page", "DOM", "Runtime", "Network", "Accessibility", "Log"]) { await transport.request(`${d}.enable`, {}, { sessionId: sid }); } }; const req = async ( method: M, params: ParamsOf | undefined, sid: string | null, opts: { timeoutMs?: number } = {}, ): Promise, CdpError>> => { const raw = await transport.request(method, { ...(params ?? {}) }, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}), }); if (!raw.success) return raw; return decodeResult(method, raw.data); }; const attach = async (tid: string): Promise> => map(await req("Target.attachToTarget", { targetId: tid, flatten: true }, null), (d) => d.sessionId); const windowId = async (tid: string): Promise> => map(await req("Browser.getWindowForTarget", { targetId: tid }, null), (d) => d.windowId); const currentTab = (): TabSession | undefined => (targetId ? tabs.get(targetId) : undefined); const session: CdpSession = { async attachFirstPage() { await req("Target.setDiscoverTargets", { discover: true }, null); const targets = await req("Target.getTargets", {}, null); if (!targets.success) return targets; const allPages = targets.data.targetInfos.filter((t) => t.type === "page"); if (ownership) { const live = new Set(allPages.map((p) => p.targetId)); const survivors = ownership.list().filter((id) => live.has(id)); if (survivors.length !== ownership.list().length) ownership.replaceAll(survivors); const hw = ownership.harnessWindow(); if (hw && !live.has(hw)) ownership.setHarnessWindow(undefined); const anchorId = survivors[0]; if (anchorId === undefined) { ownership.setHarnessWindowId(undefined); } else { const anchorWin = await windowId(anchorId); if (anchorWin.success) { const anchorWindowId = anchorWin.data; ownership.setHarnessWindowId(anchorWindowId); const windows = await Promise.all( allPages.map((p) => p.targetId === anchorId ? Promise.resolve(anchorWin) : windowId(p.targetId), ), ); allPages.forEach((p, i) => { const r = windows[i]; if (r?.success && r.data === anchorWindowId) { ownership.add(p.targetId); } }); } if (!ownership.harnessWindow()) ownership.setHarnessWindow(anchorId); } } const liveTargetIds = new Set(allPages.map((p) => p.targetId)); for (const tid of tabs.keys()) { if (!liveTargetIds.has(tid)) { const tab = tabs.get(tid); if (tab) sessionIdToTargetId.delete(tab.sessionId); tabs.delete(tid); } } let pickTargetId: string | undefined; if (ownership) { const ownedLive = ownership.list().filter((id) => allPages.some((p) => p.targetId === id)); pickTargetId = ownedLive[0]; } if (!pickTargetId && createSeedTarget) { const seeded = await createSeedTarget(); if (!seeded.success) return seeded; pickTargetId = seeded.data; } if (!pickTargetId) { const created = await req( "Target.createTarget", { url: "about:blank", ...(ownership ? { newWindow: true } : {}) }, null, ); if (!created.success) return created; pickTargetId = created.data.targetId; if (ownership) { ownership.setHarnessWindow(created.data.targetId); ownership.add(created.data.targetId); const win = await windowId(created.data.targetId); if (win.success) ownership.setHarnessWindowId(win.data); } } const attached = await attach(pickTargetId); if (!attached.success) return attached; const attachedSessionId = attached.data; sessionId = attachedSessionId; targetId = pickTargetId; tabs.set(pickTargetId, { sessionId: attachedSessionId, targetId: pickTargetId, dialog: null, pageInfoDirty: false, networkBuffer: createNetworkBuffer(), consoleBuffer: createConsoleBuffer(), refMap: new Map(), refSig: new Map(), }); sessionIdToTargetId.set(attachedSessionId, pickTargetId); await enableDomains(attachedSessionId); return ok({ targetId: pickTargetId, sessionId: attachedSessionId }); }, async switchTo(tid) { const activated = await req("Target.activateTarget", { targetId: tid }, null); if (!activated.success) return activated; const attached = await attach(tid); if (!attached.success) return attached; const attachedSessionId = attached.data; const existing = tabs.get(tid); const tab: TabSession = existing ?? { sessionId: attachedSessionId, targetId: tid, dialog: null, pageInfoDirty: true, networkBuffer: createNetworkBuffer(), consoleBuffer: createConsoleBuffer(), refMap: new Map(), refSig: new Map(), }; if (existing) { sessionIdToTargetId.delete(existing.sessionId); existing.sessionId = attachedSessionId; } else { tabs.set(tid, tab); } sessionIdToTargetId.set(attachedSessionId, tid); await enableDomains(attachedSessionId); sessionId = tab.sessionId; targetId = tid; return ok(undefined); }, attach, windowId, current() { return sessionId && targetId ? { sessionId, targetId } : null; }, setRefMap(refMap, refSig) { const tab = currentTab(); if (!tab) return; tab.refMap = refMap; tab.refSig = refSig; }, resolveRef(ref) { const tab = currentTab(); return tab?.refMap.get(ref); }, refSignatures() { const tab = currentTab(); return tab?.refSig ?? new Map(); }, refMappings() { const tab = currentTab(); return tab?.refMap ?? new Map(); }, call(method, params, opts) { return req(method, params, sessionId, opts); }, callOnTarget(method, params, sid, opts) { return req(method, params, sid, opts); }, callBrowser(method, params, opts) { return req(method, params, null, opts); }, takeDialog() { const tab = currentTab(); if (!tab) return null; const d = tab.dialog; tab.dialog = null; return d; }, drainPageInfoInvalidations() { const tab = currentTab(); if (!tab) return false; const dirty = tab.pageInfoDirty; tab.pageInfoDirty = false; return dirty; }, drainNetworkBuffer(filter) { const tab = currentTab(); if (!tab) return { records: [], total: 0, bufferOverflowed: false }; return tab.networkBuffer.drain(filter); }, drainConsoleBuffer(filter) { const tab = currentTab(); if (!tab) return { records: [], total: 0, bufferOverflowed: false }; return tab.consoleBuffer.drain(filter); }, }; return session; }; export const evaluateJson = async ( session: CdpSession, expression: string, check: (v: unknown) => v is T, opts?: { sessionId?: string | undefined; timeoutMs?: number | undefined }, ): Promise> => { const params = { expression, returnByValue: true, awaitPromise: true, }; const callOpts = opts?.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}; const r = opts?.sessionId !== undefined ? await session.callOnTarget("Runtime.evaluate", params, opts.sessionId, callOpts) : await session.call("Runtime.evaluate", params, callOpts); if (!r.success) return r; const { result, exceptionDetails } = r.data; if (exceptionDetails !== undefined) { return err(cdpError("remote_error", exceptionDetails.text, "Runtime.evaluate")); } const value = result.value; if (!check(value)) { return err(cdpError("invalid_response", `unexpected evaluate shape (type: ${result.type}) for: ${expression.slice(0, 60)}`, "Runtime.evaluate")); } return ok(value); };