import express, { type Express, type Request, type Response } from "express"; import { chromium, type BrowserContext, type Page } from "playwright"; import { mkdirSync } from "fs"; import { join } from "path"; import type { Socket } from "net"; import type { ServeOptions, GetPageRequest, GetPageResponse, ListPagesResponse, ServerInfoResponse, } from "./types"; export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse }; export interface DevBrowserServer { wsEndpoint: string; port: number; stop: () => Promise; } // Helper to retry fetch with exponential backoff async function fetchWithRetry( url: string, maxRetries = 5, delayMs = 500 ): Promise { let lastError: Error | null = null; for (let i = 0; i < maxRetries; i++) { try { const res = await fetch(url); if (res.ok) return res; throw new Error(`HTTP ${res.status}: ${res.statusText}`); } catch (err) { lastError = err instanceof Error ? err : new Error(String(err)); if (i < maxRetries - 1) { await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1))); } } } throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`); } // Helper to add timeout to promises function withTimeout(promise: Promise, ms: number, message: string): Promise { return Promise.race([ promise, new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms) ), ]); } export async function serve(options: ServeOptions = {}): Promise { const port = options.port ?? 9222; const headless = options.headless ?? false; const cdpPort = options.cdpPort ?? 9223; const profileDir = options.profileDir; // Validate port numbers if (port < 1 || port > 65535) { throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`); } if (cdpPort < 1 || cdpPort > 65535) { throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`); } if (port === cdpPort) { throw new Error("port and cdpPort must be different"); } // Determine user data directory for persistent context const userDataDir = profileDir ? join(profileDir, "browser-data") : join(process.cwd(), ".browser-data"); // Create directory if it doesn't exist mkdirSync(userDataDir, { recursive: true }); console.log(`Using persistent browser profile: ${userDataDir}`); console.log("Launching browser with persistent context..."); // Launch persistent context - this persists cookies, localStorage, cache, etc. const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, { headless, args: [`--remote-debugging-port=${cdpPort}`], }); console.log("Browser launched with persistent profile..."); // Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup) const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`); const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string }; const wsEndpoint = cdpInfo.webSocketDebuggerUrl; console.log(`CDP WebSocket endpoint: ${wsEndpoint}`); // Registry entry type for page tracking interface PageEntry { page: Page; targetId: string; } // Registry: name -> PageEntry const registry = new Map(); // Helper to get CDP targetId for a page async function getTargetId(page: Page): Promise { const cdpSession = await context.newCDPSession(page); try { const { targetInfo } = await cdpSession.send("Target.getTargetInfo"); return targetInfo.targetId; } finally { await cdpSession.detach(); } } // Express server for page management const app: Express = express(); app.use(express.json()); // GET / - server info app.get("/", (_req: Request, res: Response) => { const response: ServerInfoResponse = { wsEndpoint }; res.json(response); }); // GET /pages - list all pages app.get("/pages", (_req: Request, res: Response) => { const response: ListPagesResponse = { pages: Array.from(registry.keys()), }; res.json(response); }); // POST /pages - get or create page app.post("/pages", async (req: Request, res: Response) => { const body = req.body as GetPageRequest; const { name, viewport } = body; if (!name || typeof name !== "string") { res.status(400).json({ error: "name is required and must be a string" }); return; } if (name.length === 0) { res.status(400).json({ error: "name cannot be empty" }); return; } if (name.length > 256) { res.status(400).json({ error: "name must be 256 characters or less" }); return; } // Check if page already exists let entry = registry.get(name); if (!entry) { // Create new page in the persistent context (with timeout to prevent hangs) const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s"); // Apply viewport if provided if (viewport) { await page.setViewportSize(viewport); } const targetId = await getTargetId(page); entry = { page, targetId }; registry.set(name, entry); // Clean up registry when page is closed (e.g., user clicks X) page.on("close", () => { registry.delete(name); }); } const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId }; res.json(response); }); // DELETE /pages/:name - close a page app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => { const name = decodeURIComponent(req.params.name); const entry = registry.get(name); if (entry) { await entry.page.close(); registry.delete(name); res.json({ success: true }); return; } res.status(404).json({ error: "page not found" }); }); // Start the server const server = app.listen(port, () => { console.log(`HTTP API server running on port ${port}`); }); // Track active connections for clean shutdown const connections = new Set(); server.on("connection", (socket: Socket) => { connections.add(socket); socket.on("close", () => connections.delete(socket)); }); // Track if cleanup has been called to avoid double cleanup let cleaningUp = false; // Cleanup function const cleanup = async () => { if (cleaningUp) return; cleaningUp = true; console.log("\nShutting down..."); // Close all active HTTP connections for (const socket of connections) { socket.destroy(); } connections.clear(); // Close all pages for (const entry of registry.values()) { try { await entry.page.close(); } catch { // Page might already be closed } } registry.clear(); // Close context (this also closes the browser) try { await context.close(); } catch { // Context might already be closed } server.close(); console.log("Server stopped."); }; // Synchronous cleanup for forced exits const syncCleanup = () => { try { context.close(); } catch { // Best effort } }; // Signal handlers (consolidated to reduce duplication) const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; const signalHandler = async () => { await cleanup(); process.exit(0); }; const errorHandler = async (err: unknown) => { console.error("Unhandled error:", err); await cleanup(); process.exit(1); }; // Register handlers signals.forEach((sig) => process.on(sig, signalHandler)); process.on("uncaughtException", errorHandler); process.on("unhandledRejection", errorHandler); process.on("exit", syncCleanup); // Helper to remove all handlers const removeHandlers = () => { signals.forEach((sig) => process.off(sig, signalHandler)); process.off("uncaughtException", errorHandler); process.off("unhandledRejection", errorHandler); process.off("exit", syncCleanup); }; return { wsEndpoint, port, async stop() { removeHandlers(); await cleanup(); }, }; }