diff --git a/src/browser/cdp.test.ts b/src/browser/cdp.test.ts new file mode 100644 index 000000000..65836d288 --- /dev/null +++ b/src/browser/cdp.test.ts @@ -0,0 +1,73 @@ +import { createServer } from "node:http"; + +import { afterEach, describe, expect, it } from "vitest"; +import { WebSocketServer } from "ws"; + +import { createTargetViaCdp } from "./cdp.js"; + +describe("cdp", () => { + let httpServer: ReturnType | null = null; + let wsServer: WebSocketServer | null = null; + + afterEach(async () => { + await new Promise((resolve) => { + if (!httpServer) return resolve(); + httpServer.close(() => resolve()); + httpServer = null; + }); + await new Promise((resolve) => { + if (!wsServer) return resolve(); + wsServer.close(() => resolve()); + wsServer = null; + }); + }); + + it("creates a target via the browser websocket", async () => { + wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" }); + await new Promise((resolve) => wsServer?.once("listening", resolve)); + const wsPort = (wsServer.address() as { port: number }).port; + + wsServer.on("connection", (socket) => { + socket.on("message", (data) => { + const msg = JSON.parse(String(data)) as { + id?: number; + method?: string; + params?: { url?: string }; + }; + if (msg.method !== "Target.createTarget") return; + socket.send( + JSON.stringify({ + id: msg.id, + result: { targetId: "TARGET_123" }, + }), + ); + }); + }); + + httpServer = createServer((req, res) => { + if (req.url === "/json/version") { + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + webSocketDebuggerUrl: `ws://127.0.0.1:${wsPort}/devtools/browser/TEST`, + }), + ); + return; + } + res.statusCode = 404; + res.end("not found"); + }); + + await new Promise((resolve) => + httpServer?.listen(0, "127.0.0.1", resolve), + ); + const httpPort = (httpServer.address() as { port: number }).port; + + const created = await createTargetViaCdp({ + cdpPort: httpPort, + url: "https://example.com", + }); + + expect(created.targetId).toBe("TARGET_123"); + }); +}); diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 6b51cfd20..a85471a52 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -11,16 +11,19 @@ type Pending = { reject: (err: Error) => void; }; -export async function captureScreenshotPng(opts: { - wsUrl: string; - fullPage?: boolean; -}): Promise { - const ws = new WebSocket(opts.wsUrl, { handshakeTimeout: 5000 }); +type CdpSendFn = ( + method: string, + params?: Record, +) => Promise; +function createCdpSender(ws: WebSocket) { let nextId = 1; const pending = new Map(); - const send = (method: string, params?: Record) => { + const send: CdpSendFn = ( + method: string, + params?: Record, + ) => { const id = nextId++; const msg = { id, method, params }; ws.send(JSON.stringify(msg)); @@ -39,11 +42,6 @@ export async function captureScreenshotPng(opts: { } }; - const openPromise = new Promise((resolve, reject) => { - ws.once("open", () => resolve()); - ws.once("error", (err) => reject(err)); - }); - ws.on("message", (data) => { try { const parsed = JSON.parse(String(data)) as CdpResponse; @@ -65,6 +63,33 @@ export async function captureScreenshotPng(opts: { closeWithError(new Error("CDP socket closed")); }); + return { send, closeWithError }; +} + +async function fetchJson(url: string, timeoutMs = 1500): Promise { + const ctrl = new AbortController(); + const t = setTimeout(() => ctrl.abort(), timeoutMs); + try { + const res = await fetch(url, { signal: ctrl.signal }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return (await res.json()) as T; + } finally { + clearTimeout(t); + } +} + +export async function captureScreenshotPng(opts: { + wsUrl: string; + fullPage?: boolean; +}): Promise { + const ws = new WebSocket(opts.wsUrl, { handshakeTimeout: 5000 }); + const { send, closeWithError } = createCdpSender(ws); + + const openPromise = new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", (err) => reject(err)); + }); + await openPromise; await send("Page.enable"); @@ -106,3 +131,42 @@ export async function captureScreenshotPng(opts: { return Buffer.from(base64, "base64"); } + +export async function createTargetViaCdp(opts: { + cdpPort: number; + url: string; +}): Promise<{ targetId: string }> { + const version = await fetchJson<{ webSocketDebuggerUrl?: string }>( + `http://127.0.0.1:${opts.cdpPort}/json/version`, + 1500, + ); + const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim(); + if (!wsUrl) throw new Error("CDP /json/version missing webSocketDebuggerUrl"); + + const ws = new WebSocket(wsUrl, { handshakeTimeout: 5000 }); + const { send, closeWithError } = createCdpSender(ws); + + const openPromise = new Promise((resolve, reject) => { + ws.once("open", () => resolve()); + ws.once("error", (err) => reject(err)); + }); + + await openPromise; + + const created = (await send("Target.createTarget", { url: opts.url })) as { + targetId?: string; + }; + const targetId = String(created?.targetId ?? "").trim(); + if (!targetId) { + closeWithError(new Error("CDP Target.createTarget returned no targetId")); + throw new Error("CDP Target.createTarget returned no targetId"); + } + + try { + ws.close(); + } catch { + // ignore + } + + return { targetId }; +} diff --git a/src/browser/server.ts b/src/browser/server.ts index 9638a9bab..9f7178722 100644 --- a/src/browser/server.ts +++ b/src/browser/server.ts @@ -6,7 +6,7 @@ import { loadConfig } from "../config/config.js"; import { logError, logInfo, logWarn } from "../logger.js"; import { ensureMediaDir, saveMediaBuffer } from "../media/store.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; -import { captureScreenshotPng } from "./cdp.js"; +import { captureScreenshotPng, createTargetViaCdp } from "./cdp.js"; import { isChromeReachable, launchClawdChrome, @@ -93,9 +93,25 @@ async function listTabs(cdpPort: number): Promise { } async function openTab(cdpPort: number, url: string): Promise { + // Prefer CDP websocket Target.createTarget (more stable across Chrome versions), + // then fall back to /json/new for older/quirky builds. + const createdViaCdp = await createTargetViaCdp({ cdpPort, url }) + .then((r) => r.targetId) + .catch(() => null); + + if (createdViaCdp) { + const deadline = Date.now() + 2000; + while (Date.now() < deadline) { + const tabs = await listTabs(cdpPort).catch(() => [] as BrowserTab[]); + const found = tabs.find((t) => t.targetId === createdViaCdp); + if (found) return found; + await new Promise((r) => setTimeout(r, 100)); + } + return { targetId: createdViaCdp, title: "", url, type: "page" }; + } + const encoded = encodeURIComponent(url); - // Chrome changed /json/new to require PUT (older versions allowed GET). type CdpTarget = { id?: string; title?: string; @@ -103,6 +119,8 @@ async function openTab(cdpPort: number, url: string): Promise { webSocketDebuggerUrl?: string; type?: string; }; + + // Chrome changed /json/new to require PUT (older versions allowed GET). const endpoint = `http://127.0.0.1:${cdpPort}/json/new?${encoded}`; const created = await fetchJson(endpoint, 1500, { method: "PUT",