2025-08-15 10:00:00 -07:00
|
|
|
/**
|
|
|
|
|
* 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<T = unknown>(method: string, params?: Record<string, unknown>): Promise<T>
|
|
|
|
|
on(event: string, handler: (payload: unknown) => void): void
|
|
|
|
|
off(event: string, handler: (payload: unknown) => void): void
|
|
|
|
|
detach(): Promise<void>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<CDPSession>
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<CDPSession> {
|
|
|
|
|
// 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<CDPSession>
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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<CDPSession> | null = null
|
|
|
|
|
private readonly factory: CDPSessionFactory
|
|
|
|
|
private readonly pageRef: unknown
|
|
|
|
|
private documentRootNodeId: number | null = null
|
|
|
|
|
private enabledDomains = new Set<string>()
|
|
|
|
|
|
|
|
|
|
constructor(factory: CDPSessionFactory, pageRef: unknown) {
|
|
|
|
|
this.factory = factory
|
|
|
|
|
this.pageRef = pageRef
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Ensure a session is attached. Idempotent and concurrency-safe.
|
|
|
|
|
*/
|
|
|
|
|
async attach(): Promise<CDPSession> {
|
|
|
|
|
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<number> {
|
|
|
|
|
if (this.documentRootNodeId !== null) {
|
|
|
|
|
return this.documentRootNodeId
|
|
|
|
|
}
|
|
|
|
|
const s = await this.attach()
|
2026-05-22 10:20:06 -07:00
|
|
|
const result = await s.send<{ root: { nodeId: number } }>('DOM.getDocument', { depth: -1 })
|
2025-08-15 10:00:00 -07:00
|
|
|
this.documentRootNodeId = result.root.nodeId
|
|
|
|
|
return this.documentRootNodeId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Enable a CDP domain if not already enabled.
|
|
|
|
|
*/
|
|
|
|
|
async enableDomain(domain: string): Promise<void> {
|
|
|
|
|
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<T = unknown>(
|
|
|
|
|
method: string,
|
|
|
|
|
params?: Record<string, unknown>
|
|
|
|
|
): Promise<T> {
|
|
|
|
|
const s = await this.attach()
|
|
|
|
|
return s.send<T>(method, params)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Detach and clean up the session.
|
|
|
|
|
*/
|
|
|
|
|
async detach(): Promise<void> {
|
|
|
|
|
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)
|
|
|
|
|
}
|