aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 15:52:10 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 15:52:10 +0530
commit71228dea254b5e817477b7c0f6a95213a3b84ad3 (patch)
treef9cee1619a9aaa5e76e275fb3c5b0b1b4b97ed2e /src
parent52fd9523d1e4739d9a9e082e11686f244c767cc1 (diff)
feat: add CDP session and BrowserClient factory
Diffstat (limited to 'src')
-rw-r--r--src/cdp/session.ts123
-rw-r--r--src/client.ts180
2 files changed, 303 insertions, 0 deletions
diff --git a/src/cdp/session.ts b/src/cdp/session.ts
new file mode 100644
index 0000000..71f7a76
--- /dev/null
+++ b/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;
+ },
+ };
+};
diff --git a/src/client.ts b/src/client.ts
new file mode 100644
index 0000000..637114b
--- /dev/null
+++ b/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,
+ };
+};