import { getGlobalSingleton } from "../instances"; import { HostedPage, type PageState, type ScreencastFrame } from "./hosted-page"; import { playwrightManager } from "./playwright"; export type { PageState, ScreencastFrame }; const TEXT_CAP_BYTES = 500 * 1024; const HTML_CAP_BYTES = 1024 * 1024; const NAVIGATION_TIMEOUT_MS = 8_000; const normalizeUrl = (value: string): string => /^[a-z][a-z0-9+.-]*:/i.test(value) ? value : `https://${value}`; const capString = (value: string, maximum: number): string => value.length > maximum ? value.slice(0, maximum) : value; class BrowserHost { private pages = new Map(); private activeId: string | null = null; isAvailable(): boolean { return playwrightManager.isAvailable(); } async page(pageId?: string): Promise { const targetId = pageId ?? this.activeId; const cached = targetId ? this.pages.get(targetId) : undefined; if (cached && !cached.closed) { this.activeId = cached.id; return cached; } if (cached) this.pages.delete(cached.id); const context = await playwrightManager.ensure(); const rawPage = context .pages() .find((candidate) => Array.from(this.pages.values()).every((hosted) => !hosted.matches(candidate)), ) ?? (await context.newPage()); const hosted = HostedPage.attach(rawPage); this.pages.set(hosted.id, hosted); this.activeId = hosted.id; return hosted; } async navigate(url: string, pageId?: string): Promise<{ url: string; title: string }> { const page = await this.page(pageId); await page.navigate(normalizeUrl(url), NAVIGATION_TIMEOUT_MS); const state = await page.readState(); return { url: state.url, title: state.title }; } async getUrl(pageId?: string): Promise<{ url: string; title: string }> { const state = await (await this.page(pageId)).readState(); return { url: state.url, title: state.title }; } async getState(pageId?: string): Promise { return (await this.page(pageId)).readState(); } async peekState(): Promise { const page = this.activeId ? this.pages.get(this.activeId) : undefined; if (!page || page.closed) return null; return page.readState(); } async goBack(pageId?: string): Promise { await (await this.page(pageId)).goBack(NAVIGATION_TIMEOUT_MS); } async goForward(pageId?: string): Promise { await (await this.page(pageId)).goForward(NAVIGATION_TIMEOUT_MS); } async reload(pageId?: string): Promise { await (await this.page(pageId)).reload(NAVIGATION_TIMEOUT_MS); } async getText(pageId?: string): Promise { return capString(await (await this.page(pageId)).text(), TEXT_CAP_BYTES); } async getHtml(pageId?: string): Promise { return capString(await (await this.page(pageId)).html(), HTML_CAP_BYTES); } async click(args: { selector: string }, pageId?: string): Promise<{ found: boolean }> { const page = await this.page(pageId); return { found: await page.click(args.selector) }; } async fill( args: { selector: string; value: string }, pageId?: string, ): Promise<{ found: boolean }> { const page = await this.page(pageId); return { found: await page.fill(args.selector, args.value) }; } async scroll( args: { deltaY: number; deltaX?: number }, pageId?: string, ): Promise<{ deltaX: number; deltaY: number; scrollY: number }> { const deltaY = clampDelta(args.deltaY); const deltaX = clampDelta(args.deltaX ?? 0); const scrollY = await (await this.page(pageId)).scroll(deltaX, deltaY); return { deltaX, deltaY, scrollY }; } async screenshot(pageId?: string): Promise { const data = await (await this.page(pageId)).screenshot("png"); return `data:image/png;base64,${data}`; } async setViewport(width: number, height: number, pageId?: string): Promise { await (await this.page(pageId)).setViewport(width, height); } async pollFrame(pageId?: string): Promise<{ frame: ScreencastFrame | null; state: PageState }> { const page = await this.page(pageId); const [frame, state] = await Promise.all([page.captureFrame(), page.readState()]); return { frame, state }; } async dispatchMouse(args: MouseInput, pageId?: string): Promise { await (await this.page(pageId)).dispatchMouse(args); } async dispatchKey(args: KeyInput, pageId?: string): Promise { await (await this.page(pageId)).dispatchKey(args); } stop(): void { for (const page of this.pages.values()) page.close(); this.pages.clear(); this.activeId = null; playwrightManager.stop(); } } export type MouseInput = { type: "down" | "up" | "move" | "wheel"; x: number; y: number; button?: "left" | "right" | "middle"; clickCount?: number; deltaX?: number; deltaY?: number; }; export type KeyInput = { type: "down" | "up"; key: string; code: string }; const clampDelta = (value: number): number => { if (!Number.isFinite(value)) return 0; return Math.max(-10_000, Math.min(10_000, Math.trunc(value))); }; export const browserHost = getGlobalSingleton("browserHost", () => new BrowserHost());