/ skills / dev-browser / src / index.ts
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  }