diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-02 15:52:10 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-05-02 15:52:10 +0530 |
| commit | 71228dea254b5e817477b7c0f6a95213a3b84ad3 (patch) | |
| tree | f9cee1619a9aaa5e76e275fb3c5b0b1b4b97ed2e /src/cdp | |
| parent | 52fd9523d1e4739d9a9e082e11686f244c767cc1 (diff) | |
feat: add CDP session and BrowserClient factory
Diffstat (limited to 'src/cdp')
| -rw-r--r-- | src/cdp/session.ts | 123 |
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; + }, + }; +}; |
