Files
Imhotep/packages/imhotep-cdp/src/session.ts
T

150 lines
4.1 KiB
TypeScript

/**
* 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()
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<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)
}