/** * CDP Session Management * * Wraps Playwright's CDP session access to provide a typed, injectable * interface for sending Chrome DevTools Protocol commands. * * Design: dependency injection over optional imports. The extractor receives * a session factory rather than importing Playwright directly. */ /** * Minimal CDP session interface. Any object that can send CDP commands * and return JSON-like responses satisfies this contract. */ export interface CDPSession { send(method: string, params?: Record): Promise on(event: string, handler: (payload: unknown) => void): void off(event: string, handler: (payload: unknown) => void): void detach(): Promise } /** * Factory that creates a CDP session for a given Playwright page. * This is injected so the package does not hard-depend on Playwright. */ export type CDPSessionFactory = (pageRef: unknown) => Promise /** * Playwright-backed session factory implementation. * * Usage: * const session = await createPlaywrightCDPSession(page) * * Where `page` is a Playwright Page object. */ export async function createPlaywrightCDPSession( page: unknown ): Promise { // Playwright pages expose context() and a CDP session method. // We access it dynamically to avoid a hard import dependency. const pwPage = page as { context: () => { newCDPSession: (p: unknown) => Promise } } const context = pwPage.context() const session = await context.newCDPSession(page) return session } /** * Session manager that owns the lifecycle of a CDP session. * * Handles attach, detach, and basic error recovery. * Caches document root nodeId and enabled domains to minimize round-trips. */ export class CDPSessionManager { private session: CDPSession | null = null private attachPromise: Promise | null = null private readonly factory: CDPSessionFactory private readonly pageRef: unknown private documentRootNodeId: number | null = null private enabledDomains = new Set() constructor(factory: CDPSessionFactory, pageRef: unknown) { this.factory = factory this.pageRef = pageRef } /** * Ensure a session is attached. Idempotent and concurrency-safe. */ async attach(): Promise { if (this.session) return this.session if (this.attachPromise) return this.attachPromise this.attachPromise = this.factory(this.pageRef).then((s) => { this.session = s this.documentRootNodeId = null this.enabledDomains.clear() return s }) return this.attachPromise } /** * Get cached document root nodeId, or fetch and cache it. */ async getDocumentRootNodeId(): Promise { if (this.documentRootNodeId !== null) { return this.documentRootNodeId } const s = await this.attach() const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument') this.documentRootNodeId = result.root.nodeId return this.documentRootNodeId } /** * Enable a CDP domain if not already enabled. */ async enableDomain(domain: string): Promise { if (this.enabledDomains.has(domain)) { return } const s = await this.attach() await s.send(`${domain}.enable`) this.enabledDomains.add(domain) } /** * Get the current session, throwing if not attached. */ getSession(): CDPSession { if (!this.session) { throw new Error('CDP session not attached. Call attach() first.') } return this.session } /** * Send a CDP command, automatically attaching if needed. */ async send( method: string, params?: Record ): Promise { const s = await this.attach() return s.send(method, params) } /** * Detach and clean up the session. */ async detach(): Promise { if (this.session) { await this.session.detach() this.session = null } this.attachPromise = null } } /** * Convenient builder for a manager using the Playwright factory. */ export function createSessionManager(page: unknown): CDPSessionManager { return new CDPSessionManager(createPlaywrightCDPSession, page) }