index.ts
1 import express, { type Express, type Request, type Response } from "express"; 2 import { chromium, type BrowserContext, type Page } from "playwright"; 3 import { mkdirSync } from "fs"; 4 import { join } from "path"; 5 import type { Socket } from "net"; 6 import type { 7 ServeOptions, 8 GetPageRequest, 9 GetPageResponse, 10 ListPagesResponse, 11 ServerInfoResponse, 12 } from "./types"; 13 14 export type { ServeOptions, GetPageResponse, ListPagesResponse, ServerInfoResponse }; 15 16 export interface DevBrowserServer { 17 wsEndpoint: string; 18 port: number; 19 stop: () => Promise<void>; 20 } 21 22 // Helper to retry fetch with exponential backoff 23 async function fetchWithRetry( 24 url: string, 25 maxRetries = 5, 26 delayMs = 500 27 ): Promise<globalThis.Response> { 28 let lastError: Error | null = null; 29 for (let i = 0; i < maxRetries; i++) { 30 try { 31 const res = await fetch(url); 32 if (res.ok) return res; 33 throw new Error(`HTTP ${res.status}: ${res.statusText}`); 34 } catch (err) { 35 lastError = err instanceof Error ? err : new Error(String(err)); 36 if (i < maxRetries - 1) { 37 await new Promise((resolve) => setTimeout(resolve, delayMs * (i + 1))); 38 } 39 } 40 } 41 throw new Error(`Failed after ${maxRetries} retries: ${lastError?.message}`); 42 } 43 44 // Helper to add timeout to promises 45 function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> { 46 return Promise.race([ 47 promise, 48 new Promise<never>((_, reject) => 49 setTimeout(() => reject(new Error(`Timeout: ${message}`)), ms) 50 ), 51 ]); 52 } 53 54 export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> { 55 const port = options.port ?? 9222; 56 const headless = options.headless ?? false; 57 const cdpPort = options.cdpPort ?? 9223; 58 const profileDir = options.profileDir; 59 60 // Validate port numbers 61 if (port < 1 || port > 65535) { 62 throw new Error(`Invalid port: ${port}. Must be between 1 and 65535`); 63 } 64 if (cdpPort < 1 || cdpPort > 65535) { 65 throw new Error(`Invalid cdpPort: ${cdpPort}. Must be between 1 and 65535`); 66 } 67 if (port === cdpPort) { 68 throw new Error("port and cdpPort must be different"); 69 } 70 71 // Determine user data directory for persistent context 72 const userDataDir = profileDir 73 ? join(profileDir, "browser-data") 74 : join(process.cwd(), ".browser-data"); 75 76 // Create directory if it doesn't exist 77 mkdirSync(userDataDir, { recursive: true }); 78 console.log(`Using persistent browser profile: ${userDataDir}`); 79 80 console.log("Launching browser with persistent context..."); 81 82 // Launch persistent context - this persists cookies, localStorage, cache, etc. 83 const context: BrowserContext = await chromium.launchPersistentContext(userDataDir, { 84 headless, 85 args: [`--remote-debugging-port=${cdpPort}`], 86 }); 87 console.log("Browser launched with persistent profile..."); 88 89 // Get the CDP WebSocket endpoint from Chrome's JSON API (with retry for slow startup) 90 const cdpResponse = await fetchWithRetry(`http://127.0.0.1:${cdpPort}/json/version`); 91 const cdpInfo = (await cdpResponse.json()) as { webSocketDebuggerUrl: string }; 92 const wsEndpoint = cdpInfo.webSocketDebuggerUrl; 93 console.log(`CDP WebSocket endpoint: ${wsEndpoint}`); 94 95 // Registry entry type for page tracking 96 interface PageEntry { 97 page: Page; 98 targetId: string; 99 } 100 101 // Registry: name -> PageEntry 102 const registry = new Map<string, PageEntry>(); 103 104 // Helper to get CDP targetId for a page 105 async function getTargetId(page: Page): Promise<string> { 106 const cdpSession = await context.newCDPSession(page); 107 try { 108 const { targetInfo } = await cdpSession.send("Target.getTargetInfo"); 109 return targetInfo.targetId; 110 } finally { 111 await cdpSession.detach(); 112 } 113 } 114 115 // Express server for page management 116 const app: Express = express(); 117 app.use(express.json()); 118 119 // GET / - server info 120 app.get("/", (_req: Request, res: Response) => { 121 const response: ServerInfoResponse = { wsEndpoint }; 122 res.json(response); 123 }); 124 125 // GET /pages - list all pages 126 app.get("/pages", (_req: Request, res: Response) => { 127 const response: ListPagesResponse = { 128 pages: Array.from(registry.keys()), 129 }; 130 res.json(response); 131 }); 132 133 // POST /pages - get or create page 134 app.post("/pages", async (req: Request, res: Response) => { 135 const body = req.body as GetPageRequest; 136 const { name, viewport } = body; 137 138 if (!name || typeof name !== "string") { 139 res.status(400).json({ error: "name is required and must be a string" }); 140 return; 141 } 142 143 if (name.length === 0) { 144 res.status(400).json({ error: "name cannot be empty" }); 145 return; 146 } 147 148 if (name.length > 256) { 149 res.status(400).json({ error: "name must be 256 characters or less" }); 150 return; 151 } 152 153 // Check if page already exists 154 let entry = registry.get(name); 155 if (!entry) { 156 // Create new page in the persistent context (with timeout to prevent hangs) 157 const page = await withTimeout(context.newPage(), 30000, "Page creation timed out after 30s"); 158 159 // Apply viewport if provided 160 if (viewport) { 161 await page.setViewportSize(viewport); 162 } 163 164 const targetId = await getTargetId(page); 165 entry = { page, targetId }; 166 registry.set(name, entry); 167 168 // Clean up registry when page is closed (e.g., user clicks X) 169 page.on("close", () => { 170 registry.delete(name); 171 }); 172 } 173 174 const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId }; 175 res.json(response); 176 }); 177 178 // DELETE /pages/:name - close a page 179 app.delete("/pages/:name", async (req: Request<{ name: string }>, res: Response) => { 180 const name = decodeURIComponent(req.params.name); 181 const entry = registry.get(name); 182 183 if (entry) { 184 await entry.page.close(); 185 registry.delete(name); 186 res.json({ success: true }); 187 return; 188 } 189 190 res.status(404).json({ error: "page not found" }); 191 }); 192 193 // Start the server 194 const server = app.listen(port, () => { 195 console.log(`HTTP API server running on port ${port}`); 196 }); 197 198 // Track active connections for clean shutdown 199 const connections = new Set<Socket>(); 200 server.on("connection", (socket: Socket) => { 201 connections.add(socket); 202 socket.on("close", () => connections.delete(socket)); 203 }); 204 205 // Track if cleanup has been called to avoid double cleanup 206 let cleaningUp = false; 207 208 // Cleanup function 209 const cleanup = async () => { 210 if (cleaningUp) return; 211 cleaningUp = true; 212 213 console.log("\nShutting down..."); 214 215 // Close all active HTTP connections 216 for (const socket of connections) { 217 socket.destroy(); 218 } 219 connections.clear(); 220 221 // Close all pages 222 for (const entry of registry.values()) { 223 try { 224 await entry.page.close(); 225 } catch { 226 // Page might already be closed 227 } 228 } 229 registry.clear(); 230 231 // Close context (this also closes the browser) 232 try { 233 await context.close(); 234 } catch { 235 // Context might already be closed 236 } 237 238 server.close(); 239 console.log("Server stopped."); 240 }; 241 242 // Synchronous cleanup for forced exits 243 const syncCleanup = () => { 244 try { 245 context.close(); 246 } catch { 247 // Best effort 248 } 249 }; 250 251 // Signal handlers (consolidated to reduce duplication) 252 const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const; 253 254 const signalHandler = async () => { 255 await cleanup(); 256 process.exit(0); 257 }; 258 259 const errorHandler = async (err: unknown) => { 260 console.error("Unhandled error:", err); 261 await cleanup(); 262 process.exit(1); 263 }; 264 265 // Register handlers 266 signals.forEach((sig) => process.on(sig, signalHandler)); 267 process.on("uncaughtException", errorHandler); 268 process.on("unhandledRejection", errorHandler); 269 process.on("exit", syncCleanup); 270 271 // Helper to remove all handlers 272 const removeHandlers = () => { 273 signals.forEach((sig) => process.off(sig, signalHandler)); 274 process.off("uncaughtException", errorHandler); 275 process.off("unhandledRejection", errorHandler); 276 process.off("exit", syncCleanup); 277 }; 278 279 return { 280 wsEndpoint, 281 port, 282 async stop() { 283 removeHandlers(); 284 await cleanup(); 285 }, 286 }; 287 }