all repos — sz-chrome-remote-cdp @ 71228dea254b5e817477b7c0f6a95213a3b84ad3

pi-browser-harness, patched to drive a Chrome on another host over CDP, with castle_cdp_search web search

feat: add CDP session and BrowserClient factory
amankumarsingh77 amankumarsingh7702@gmal.com
Sat, 02 May 2026 15:52:10 +0530
commit

71228dea254b5e817477b7c0f6a95213a3b84ad3

parent

52fd9523d1e4739d9a9e082e11686f244c767cc1

2 files changed, 303 insertions(+), 0 deletions(-)

jump to
A src/cdp/session.ts

@@ -0,0 +1,123 @@

+import { type Result, err, ok } from "../util/result"; +import { type CdpError, cdpError } from "./errors"; +import type { DialogInfo } from "./types"; +import { isInternalUrl } from "./types"; +import type { CdpTransport } from "./transport"; + +export type CdpSession = { + attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>; + switchTo(targetId: string): Promise<Result<void, CdpError>>; + current(): { readonly sessionId: string; readonly targetId: string } | null; + call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>; + callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>; + callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>; + takeDialog(): DialogInfo | null; + drainPageInfoInvalidations(): boolean; +}; + +export const createCdpSession = (transport: CdpTransport): CdpSession => { + let sessionId: string | null = null; + let targetId: string | null = null; + let dialog: DialogInfo | null = null; + let pageInfoDirty = false; + + const consumeEvents = async (): Promise<void> => { + for await (const ev of transport.events()) { + if (ev.method === "Page.javascriptDialogOpening") { + const params = ev.params as Partial<DialogInfo> | undefined; + dialog = { + type: (params?.type as DialogInfo["type"]) ?? "alert", + message: params?.message ?? "", + ...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}), + }; + continue; + } + if (ev.method === "Page.javascriptDialogClosed") { + dialog = null; + continue; + } + if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") { + pageInfoDirty = true; + } + } + }; + + void consumeEvents(); + transport.onClose(() => { + sessionId = null; + targetId = null; + dialog = null; + pageInfoDirty = false; + void consumeEvents(); + }); + + const enableDomains = async (sid: string): Promise<void> => { + for (const d of ["Page", "DOM", "Runtime", "Network"]) { + await transport.request(`${d}.enable`, {}, { sessionId: sid }); + } + }; + + // CDP response shapes are documented in chromedevtools.github.io but not + // available as TypeScript types. We cast `as` from `unknown` only in this + // file (the CDP boundary). Each cast is paired with the CDP method that + // produced the response. Adding runtime guards for every shape would be + // noise — Chrome's protocol is stable enough that a wrong cast surfaces + // as a clear Error in normal use. + return { + async attachFirstPage() { + const targets = await transport.request("Target.getTargets", {}, { sessionId: null }); + if (!targets.success) return targets; + const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> }; + let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url)); + if (pages.length === 0) { + const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null }); + if (!created.success) return created; + const c = created.data as { targetId: string }; + pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }]; + } + const first = pages[0]; + if (!first) return err(cdpError("invalid_response", "no page targets after creation")); + const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null }); + if (!attached.success) return attached; + const a = attached.data as { sessionId: string }; + sessionId = a.sessionId; + targetId = first.targetId; + await enableDomains(a.sessionId); + return ok({ targetId: first.targetId, sessionId: a.sessionId }); + }, + async switchTo(tid) { + const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null }); + if (!activated.success) return activated; + const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null }); + if (!attached.success) return attached; + const a = attached.data as { sessionId: string }; + sessionId = a.sessionId; + targetId = tid; + pageInfoDirty = true; + await enableDomains(a.sessionId); + return ok(undefined); + }, + current() { + return sessionId && targetId ? { sessionId, targetId } : null; + }, + call(method, params = {}, opts = {}) { + return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) }); + }, + callOnTarget(method, params, sid, opts = {}) { + return transport.request(method, params, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) }); + }, + callBrowser(method, params = {}, opts = {}) { + return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) }); + }, + takeDialog() { + const d = dialog; + dialog = null; + return d; + }, + drainPageInfoInvalidations() { + const dirty = pageInfoDirty; + pageInfoDirty = false; + return dirty; + }, + }; +};
A src/client.ts

@@ -0,0 +1,180 @@

+import { type Result, err, ok } from "./util/result"; +import { safeJs } from "./util/js-template"; +import { discoverWsUrl } from "./cdp/discovery"; +import { type CdpError, cdpError } from "./cdp/errors"; +import type { CdpTransport } from "./cdp/transport"; +import { createCdpTransport } from "./cdp/transport"; +import { type CdpSession, createCdpSession } from "./cdp/session"; +import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types"; + +export type BrowserClientOptions = { + readonly namespace: string; + readonly remote?: { readonly cdpUrl: string; readonly browserId: string }; +}; + +export type BrowserClient = { + readonly namespace: string; + ensureAlive(): Promise<Result<void, CdpError>>; + status(): DaemonStatus; + start(): Promise<Result<void, CdpError>>; + stop(): Promise<void>; + evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>; + pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>; + takeDialog(): DialogInfo | null; + listTabs(includeInternal?: boolean): Promise<Result<ReadonlyArray<TabInfo>, CdpError>>; + switchTab(targetId: string): Promise<Result<void, CdpError>>; + newTab(url?: string): Promise<Result<string, CdpError>>; + current(): { readonly sessionId: string; readonly targetId: string } | null; + session(): CdpSession; + transport(): CdpTransport; +}; + +const HEALTH_TTL_MS = 30_000; +const PAGE_INFO_TTL_MS = 1_000; + +export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => { + const transport = createCdpTransport(); + const session = createCdpSession(transport); + let lastHealth = 0; + let pageCache: { readonly info: PageInfo; readonly at: number } | null = null; + let remote: BrowserClientOptions["remote"] | null = opts.remote ?? null; + + const start = async (): Promise<Result<void, CdpError>> => { + if (transport.state() === "open" && session.current()) return ok(undefined); + const envUrl = process.env["BU_CDP_WS"]; + let wsUrl: string; + if (remote?.cdpUrl) { + wsUrl = remote.cdpUrl; + } else if (envUrl) { + wsUrl = envUrl; + } else { + const discovered = await discoverWsUrl(); + if (!discovered.success) return discovered; + wsUrl = discovered.data; + } + const connected = await transport.connect(wsUrl, { timeoutMs: 10_000 }); + if (!connected.success) return connected; + if (!remote) { + remote = { cdpUrl: wsUrl, browserId: wsUrl.split("/").pop() ?? "unknown" }; + } + const attached = await session.attachFirstPage(); + if (!attached.success) { + await transport.close(); + return attached; + } + lastHealth = Date.now(); + pageCache = null; + return ok(undefined); + }; + + const stop = async (): Promise<void> => { + await transport.close(); + pageCache = null; + lastHealth = 0; + }; + + const ensureAlive = async (): Promise<Result<void, CdpError>> => { + if (transport.state() !== "open" || !session.current()) { + await stop(); + return start(); + } + if (Date.now() - lastHealth < HEALTH_TTL_MS) return ok(undefined); + const probe = await transport.request("Target.getTargets", {}, { sessionId: null, timeoutMs: 2_000 }); + if (probe.success) { + lastHealth = Date.now(); + return ok(undefined); + } + await stop(); + return start(); + }; + + const evaluateJs = async (expression: string, sessionId?: string): Promise<Result<unknown, CdpError>> => { + const wrapped = expression.includes("return ") && !expression.trim().startsWith("(") + ? `(function(){${expression}})()` + : expression; + const r = sessionId + ? await session.callOnTarget("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }, sessionId) + : await session.call("Runtime.evaluate", { expression: wrapped, returnByValue: true, awaitPromise: true }); + if (!r.success) return r; + const data = r.data as { result?: { value?: unknown }; exceptionDetails?: unknown }; + if (data.exceptionDetails) { + return err(cdpError("remote_error", `JS evaluation failed: ${JSON.stringify(data.exceptionDetails)}`, "Runtime.evaluate")); + } + return ok(data.result?.value); + }; + + const readPageInfo = async (): Promise<Result<PageInfo, CdpError>> => { + const dirty = session.drainPageInfoInvalidations(); + if (pageCache && !dirty && Date.now() - pageCache.at < PAGE_INFO_TTL_MS) return ok(pageCache.info); + const expr = safeJs`JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`; + const raw = await evaluateJs(expr); + if (!raw.success) return raw; + if (typeof raw.data !== "string") return err(cdpError("invalid_response", "page info evaluation did not return a string")); + const parsedRaw = JSON.parse(raw.data) as { url: string; title: string; w: number; h: number; sx: number; sy: number; pw: number; ph: number }; + const info: PageInfo = { + url: parsedRaw.url, title: parsedRaw.title, + width: parsedRaw.w, height: parsedRaw.h, + scrollX: parsedRaw.sx, scrollY: parsedRaw.sy, + pageWidth: parsedRaw.pw, pageHeight: parsedRaw.ph, + }; + pageCache = { info, at: Date.now() }; + return ok(info); + }; + + const pageInfo = async (): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>> => { + const d = session.takeDialog(); + if (d) return ok({ dialog: d }); + return readPageInfo(); + }; + + const listTabs = async (includeInternal = true): Promise<Result<ReadonlyArray<TabInfo>, CdpError>> => { + const r = await session.callBrowser("Target.getTargets"); + if (!r.success) return r; + const data = r.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; title: string; url: string }> }; + const tabs = data.targetInfos + .filter((t) => t.type === "page") + .filter((t) => includeInternal || !t.url.startsWith("chrome://")) + .map((t): TabInfo => ({ targetId: t.targetId, title: t.title, url: t.url })); + return ok(tabs); + }; + + const switchTab = async (targetId: string): Promise<Result<void, CdpError>> => { + const r = await session.switchTo(targetId); + if (!r.success) return r; + pageCache = null; + await session.call("Runtime.evaluate", { expression: safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title` }); + return ok(undefined); + }; + + const newTab = async (url?: string): Promise<Result<string, CdpError>> => { + const created = await session.callBrowser("Target.createTarget", { url: "about:blank" }); + if (!created.success) return created; + const c = created.data as { targetId: string }; + const switched = await switchTab(c.targetId); + if (!switched.success) return switched; + if (url && url !== "about:blank") { + const nav = await session.call("Page.navigate", { url }); + if (!nav.success) return nav; + pageCache = null; + } + return ok(c.targetId); + }; + + const status = (): DaemonStatus => ({ + alive: transport.state() === "open" && session.current() !== null, + sessionId: session.current()?.sessionId ?? null, + namespace: opts.namespace, + ...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}), + }); + + return { + namespace: opts.namespace, + ensureAlive, status, start, stop, + evaluateJs, pageInfo, + takeDialog: () => session.takeDialog(), + listTabs, switchTab, newTab, + current: () => session.current(), + session: () => session, + transport: () => transport, + }; +};