diff --git a/skills/dev-browser/package.json b/skills/dev-browser/package.json index 9826ac9..3415535 100644 --- a/skills/dev-browser/package.json +++ b/skills/dev-browser/package.json @@ -13,10 +13,12 @@ }, "dependencies": { "express": "^4.21.0", - "playwright": "^1.49.0" + "playwright": "^1.49.0", + "ws": "^8.18.0" }, "devDependencies": { "@types/express": "^5.0.0", + "@types/ws": "^8.5.13", "tsx": "^4.21.0", "vitest": "^2.1.0" } diff --git a/skills/dev-browser/scripts/start-server.ts b/skills/dev-browser/scripts/start-server.ts index e130a27..4d8af57 100644 --- a/skills/dev-browser/scripts/start-server.ts +++ b/skills/dev-browser/scripts/start-server.ts @@ -100,14 +100,22 @@ try { console.log("Starting dev browser server..."); const headless = process.env.HEADLESS === "true"; +const host = process.env.HOST ?? "localhost"; +const lazy = process.env.LAZY === "true"; const server = await serve({ port: 9222, + host, headless, + lazy, profileDir, }); console.log(`Dev browser server started`); -console.log(` WebSocket: ${server.wsEndpoint}`); +if (lazy) { + console.log(` Mode: lazy (browser launches on first request)`); +} else { + console.log(` WebSocket: ${server.wsEndpoint}`); +} console.log(` Tmp directory: ${tmpDir}`); console.log(` Profile directory: ${profileDir}`); console.log(`\nReady`); diff --git a/skills/dev-browser/src/index.ts b/skills/dev-browser/src/index.ts index 24fd619..49574a2 100644 --- a/skills/dev-browser/src/index.ts +++ b/skills/dev-browser/src/index.ts @@ -3,6 +3,8 @@ import { chromium, type BrowserContext, type Page } from "playwright"; import { mkdirSync } from "fs"; import { join } from "path"; import type { Socket } from "net"; +import { createServer as createHttpServer } from "http"; +import WebSocket, { WebSocketServer } from "ws"; import type { ServeOptions, GetPageRequest, @@ -53,9 +55,11 @@ function withTimeout(promise: Promise, ms: number, message: string): Promi export async function serve(options: ServeOptions = {}): Promise { const port = options.port ?? 9222; + const host = options.host ?? "localhost"; const headless = options.headless ?? false; const cdpPort = options.cdpPort ?? 9223; const profileDir = options.profileDir; + const lazy = options.lazy ?? false; // Validate port numbers if (port < 1 || port > 65535) { @@ -77,20 +81,62 @@ export async function serve(options: ServeOptions = {}): Promise | null = null; - // 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..."); + // Function to launch the browser (called immediately or on first request) + async function launchBrowser(): Promise { + if (context) return; // Already launched + + console.log("Launching browser with persistent context..."); + + // Launch persistent context - this persists cookies, localStorage, cache, etc. + // When host is 0.0.0.0, also bind Chrome's debugging port to all interfaces + const cdpArgs = [`--remote-debugging-port=${cdpPort}`]; + if (host === "0.0.0.0") { + cdpArgs.push("--remote-debugging-address=0.0.0.0"); + } + context = await chromium.launchPersistentContext(userDataDir, { + headless, + args: cdpArgs, + }); + 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 }; + internalWsEndpoint = cdpInfo.webSocketDebuggerUrl; + console.log(`Internal CDP WebSocket endpoint: ${internalWsEndpoint}`); + + // Create proxied WebSocket endpoint that goes through our server + // This works around Chrome ignoring --remote-debugging-address on macOS + // Original: ws://127.0.0.1:9223/devtools/browser/xxx + // Proxied: ws://:9222/devtools/browser/xxx + const wsPath = new URL(internalWsEndpoint).pathname; + wsEndpoint = `ws://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}${wsPath}`; + console.log(`Proxied CDP WebSocket endpoint: ${wsEndpoint}`); + } - // 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}`); + // Ensure browser is launched (with deduplication for concurrent requests) + async function ensureBrowser(): Promise { + if (context) return; + if (browserLaunching) { + await browserLaunching; + return; + } + browserLaunching = launchBrowser(); + await browserLaunching; + } + + // Launch immediately unless lazy mode + if (!lazy) { + await launchBrowser(); + } else { + console.log("Lazy mode: Browser will launch on first request"); + } // Registry entry type for page tracking interface PageEntry { @@ -103,7 +149,7 @@ export async function serve(options: ServeOptions = {}): Promise { - const cdpSession = await context.newCDPSession(page); + const cdpSession = await context!.newCDPSession(page); try { const { targetInfo } = await cdpSession.send("Target.getTargetInfo"); return targetInfo.targetId; @@ -116,9 +162,10 @@ export async function serve(options: ServeOptions = {}): Promise { - const response: ServerInfoResponse = { wsEndpoint }; + // GET / - server info (triggers browser launch if lazy) + app.get("/", async (_req: Request, res: Response) => { + await ensureBrowser(); + const response: ServerInfoResponse = { wsEndpoint: wsEndpoint! }; res.json(response); }); @@ -130,7 +177,7 @@ export async function serve(options: ServeOptions = {}): Promise { const body = req.body as GetPageRequest; const { name } = body; @@ -150,11 +197,14 @@ export async function serve(options: ServeOptions = {}): Promise { + const targetUrl = `ws://127.0.0.1:${cdpPort}${req.url}`; + console.log(`Proxying WebSocket to: ${targetUrl}`); + + // Queue messages until Chrome connection is open + const messageQueue: (Buffer | ArrayBuffer | Buffer[])[] = []; + let chromeReady = false; + + const chromeWs = new WebSocket(targetUrl); + + chromeWs.on("open", () => { + console.log("Connected to Chrome CDP"); + chromeReady = true; + // Send any queued messages + for (const msg of messageQueue) { + chromeWs.send(msg); + } + messageQueue.length = 0; + }); + + chromeWs.on("message", (data, isBinary) => { + if (clientWs.readyState === WebSocket.OPEN) { + clientWs.send(data, { binary: isBinary }); + } + }); + + chromeWs.on("close", (code, reason) => { + console.log(`Chrome WebSocket closed: code=${code} reason=${reason.toString()}`); + clientWs.close(code, reason); + }); + + chromeWs.on("error", (err) => { + console.error("Chrome WebSocket error:", err); + clientWs.close(); + }); + + clientWs.on("message", (data, isBinary) => { + if (chromeReady && chromeWs.readyState === WebSocket.OPEN) { + chromeWs.send(data, { binary: isBinary }); + } else { + messageQueue.push(data); + } + }); + + clientWs.on("close", (code, reason) => { + console.log(`Client WebSocket closed: code=${code} reason=${reason.toString()}`); + chromeWs.close(); + }); + + clientWs.on("error", (err) => { + console.error("Client WebSocket error:", err); + chromeWs.close(); + }); + }); + + // Handle upgrade requests (triggers browser launch if lazy) + httpServer.on("upgrade", async (req, socket, head) => { + if (req.url?.startsWith("/devtools")) { + console.log(`WebSocket upgrade request: ${req.url}`); + try { + await ensureBrowser(); + wss.handleUpgrade(req, socket, head, (ws) => { + wss.emit("connection", ws, req); + }); + } catch (err) { + console.error("Failed to launch browser for WebSocket:", err); + socket.destroy(); + } + } else { + socket.destroy(); + } + }); + // Start the server - const server = app.listen(port, () => { - console.log(`HTTP API server running on port ${port}`); + const server = httpServer.listen(port, host, () => { + console.log(`HTTP API server running on ${host}:${port}`); }); // Track active connections for clean shutdown @@ -222,11 +353,13 @@ export async function serve(options: ServeOptions = {}): Promise { - try { - context.close(); - } catch { - // Best effort + if (context) { + try { + context.close(); + } catch { + // Best effort + } } }; @@ -271,7 +406,9 @@ export async function serve(options: ServeOptions = {}): Promise