import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; import { randomBytes } from 'node:crypto'; import { logger } from './logger.js'; interface LiveViewSession { id: string; browser: Browser; context: BrowserContext; page: Page; appUrl: string; lastFrame: string | null; // base64 JPEG streaming: boolean; frameInterval: ReturnType | null; createdAt: number; } const sessions = new Map(); const SESSION_TTL_MS = 15 * 60 * 1_000; // 15 min function generateId(): string { return randomBytes(16).toString('hex'); } /** * Start a live view session: launches a headless browser, navigates to appUrl, * begins capturing frames. Returns the session ID. */ export async function startLiveView(appUrl: string, storageState?: string): Promise { const id = generateId(); const isDocker = !!process.env.PLAYWRIGHT_IN_DOCKER || process.platform === 'linux'; const browser = await chromium.launch({ headless: true, args: isDocker ? ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] : [], }); const context = await browser.newContext({ viewport: { width: 1280, height: 800 } }); // Restore session if provided if (storageState) { try { const state = JSON.parse(storageState); if (state.cookies?.length) await context.addCookies(state.cookies); } catch { /* ignore */ } } const page = await context.newPage(); await page.goto(appUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }); const session: LiveViewSession = { id, browser, context, page, appUrl, lastFrame: null, streaming: true, frameInterval: null, createdAt: Date.now(), }; // Start capturing frames (every 500ms) session.frameInterval = setInterval(async () => { if (!session.streaming) return; try { const buf = await page.screenshot({ type: 'jpeg', quality: 60, fullPage: false }); session.lastFrame = buf.toString('base64'); } catch { session.streaming = false; } }, 500); sessions.set(id, session); // Auto-cleanup after TTL setTimeout(() => closeLiveView(id).catch(() => {}), SESSION_TTL_MS); logger.info({ id, appUrl }, '[live-view] Session started'); return id; } /** * Get the latest frame for a session (base64 JPEG). */ export function getFrame(id: string): string | null { return sessions.get(id)?.lastFrame ?? null; } /** * Navigate the live view browser to a URL. */ export async function liveViewNavigate(id: string, url: string): Promise { const session = sessions.get(id); if (!session) throw new Error(`Live view session ${id} not found`); await session.page.goto(url, { waitUntil: 'domcontentloaded' }); } /** * Capture storageState from the live view session, close the browser, return JSON string. */ export async function completeLiveView(id: string): Promise { const session = sessions.get(id); if (!session) throw new Error(`Live view session ${id} not found or expired`); const state = await session.context.storageState(); const json = JSON.stringify(state); await closeLiveView(id); return json; } /** * Send a mouse or keyboard input event to the live view browser. */ export async function sendInput(id: string, event: { type: 'click' | 'type' | 'key'; x?: number; y?: number; button?: 'left' | 'right' | 'middle'; text?: string; key?: string; }): Promise { const session = sessions.get(id); if (!session) throw new Error(`Live view session ${id} not found`); if (event.type === 'click' && event.x !== undefined && event.y !== undefined) { await session.page.mouse.click(event.x, event.y, { button: event.button ?? 'left' }); } else if (event.type === 'type' && event.text) { await session.page.keyboard.type(event.text); } else if (event.type === 'key' && event.key) { await session.page.keyboard.press(event.key); } } /** * Close a live view session without capturing. */ export async function closeLiveView(id: string): Promise { const session = sessions.get(id); if (!session) return; session.streaming = false; if (session.frameInterval) clearInterval(session.frameInterval); sessions.delete(id); await session.browser.close().catch(() => {}); logger.info({ id }, '[live-view] Session closed'); } /** * List all active session IDs (for health checks / cleanup). */ export function listLiveViews(): string[] { return [...sessions.keys()]; }