Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion skills/dev-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
10 changes: 9 additions & 1 deletion skills/dev-browser/scripts/start-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
199 changes: 168 additions & 31 deletions skills/dev-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -53,9 +55,11 @@ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promi

export async function serve(options: ServeOptions = {}): Promise<DevBrowserServer> {
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) {
Expand All @@ -77,20 +81,62 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
mkdirSync(userDataDir, { recursive: true });
console.log(`Using persistent browser profile: ${userDataDir}`);

console.log("Launching browser with persistent context...");
// Browser state - lazily initialized if lazy=true
let context: BrowserContext | null = null;
let wsEndpoint: string | null = null;
let internalWsEndpoint: string | null = null;
let browserLaunching: Promise<void> | 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<void> {
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://<host>: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<void> {
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 {
Expand All @@ -103,7 +149,7 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe

// Helper to get CDP targetId for a page
async function getTargetId(page: Page): Promise<string> {
const cdpSession = await context.newCDPSession(page);
const cdpSession = await context!.newCDPSession(page);
try {
const { targetInfo } = await cdpSession.send("Target.getTargetInfo");
return targetInfo.targetId;
Expand All @@ -116,9 +162,10 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
const app: Express = express();
app.use(express.json());

// GET / - server info
app.get("/", (_req: Request, res: Response) => {
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);
});

Expand All @@ -130,7 +177,7 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
res.json(response);
});

// POST /pages - get or create page
// POST /pages - get or create page (triggers browser launch if lazy)
app.post("/pages", async (req: Request, res: Response) => {
const body = req.body as GetPageRequest;
const { name } = body;
Expand All @@ -150,11 +197,14 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
return;
}

// Ensure browser is launched
await ensureBrowser();

// 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");
const page = await withTimeout(context!.newPage(), 30000, "Page creation timed out after 30s");
const targetId = await getTargetId(page);
entry = { page, targetId };
registry.set(name, entry);
Expand All @@ -165,7 +215,7 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
});
}

const response: GetPageResponse = { wsEndpoint, name, targetId: entry.targetId };
const response: GetPageResponse = { wsEndpoint: wsEndpoint!, name, targetId: entry.targetId };
res.json(response);
});

Expand All @@ -184,9 +234,90 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
res.status(404).json({ error: "page not found" });
});

// Create HTTP server
const httpServer = createHttpServer(app);

// Create WebSocket server for proxying CDP connections
// This works around Chrome ignoring --remote-debugging-address on macOS
const wss = new WebSocketServer({ noServer: true });

// Handle WebSocket connections by proxying to Chrome's CDP
wss.on("connection", (clientWs, req) => {
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
Expand Down Expand Up @@ -222,11 +353,13 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
}
registry.clear();

// Close context (this also closes the browser)
try {
await context.close();
} catch {
// Context might already be closed
// Close context (this also closes the browser) - only if launched
if (context) {
try {
await context.close();
} catch {
// Context might already be closed
}
}

server.close();
Expand All @@ -235,10 +368,12 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe

// Synchronous cleanup for forced exits
const syncCleanup = () => {
try {
context.close();
} catch {
// Best effort
if (context) {
try {
context.close();
} catch {
// Best effort
}
}
};

Expand Down Expand Up @@ -271,7 +406,9 @@ export async function serve(options: ServeOptions = {}): Promise<DevBrowserServe
};

return {
wsEndpoint,
// In lazy mode, wsEndpoint is null until browser launches
// Callers should use the HTTP API to get wsEndpoint
wsEndpoint: wsEndpoint ?? `ws://${host === "0.0.0.0" ? "127.0.0.1" : host}:${port}/devtools/browser/pending`,
port,
async stop() {
removeHandlers();
Expand Down
4 changes: 4 additions & 0 deletions skills/dev-browser/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,14 @@

export interface ServeOptions {
port?: number;
/** Host to bind the server to. Defaults to "localhost". Use "0.0.0.0" for remote access. */
host?: string;
headless?: boolean;
cdpPort?: number;
/** Directory to store persistent browser profiles (cookies, localStorage, etc.) */
profileDir?: string;
/** If true, Chrome is not launched until first client request. Defaults to false. */
lazy?: boolean;
}

export interface GetPageRequest {
Expand Down