aboutsummaryrefslogtreecommitdiff
path: root/src/cdp
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/cdp
parent52fd9523d1e4739d9a9e082e11686f244c767cc1 (diff)
feat: add CDP session and BrowserClient factory
Diffstat (limited to 'src/cdp')
-rw-r--r--src/cdp/session.ts123
1 files changed, 123 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;
+ },
+ };
+};