From 564599e58bc28ff5e7a27e789c19b689be09ceaf Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 11 Mar 2026 14:23:00 -0700 Subject: [PATCH] feat: persistent Chromium daemon with CLI wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bun-powered HTTP server on localhost keeps headless Chromium alive between commands. CLI auto-starts server on first call (~3s), subsequent commands ~100-200ms. Bearer token auth, 30 min idle shutdown, auto-restart on Chromium crash. Architecture: compiled CLI binary → HTTP POST → Bun.serve → Playwright Co-Authored-By: Claude Opus 4.6 --- .gitignore | 4 + package.json | 34 ++++++ src/browser-manager.ts | 212 +++++++++++++++++++++++++++++++++++ src/buffers.ts | 37 ++++++ src/cli.ts | 211 +++++++++++++++++++++++++++++++++++ src/server.ts | 247 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 745 insertions(+) create mode 100644 .gitignore create mode 100644 package.json create mode 100644 src/browser-manager.ts create mode 100644 src/buffers.ts create mode 100644 src/cli.ts create mode 100644 src/server.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..b930f21e --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +/tmp/ +*.log diff --git a/package.json b/package.json new file mode 100644 index 00000000..d6e51079 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "gstack-browse", + "version": "2.0.0", + "description": "Fast headless browser CLI for AI coding agents. Persistent Chromium daemon with sub-200ms commands.", + "license": "MIT", + "type": "module", + "bin": { + "browse": "./dist/browse" + }, + "scripts": { + "build": "bun build --compile src/cli.ts --outfile dist/browse", + "dev": "bun run src/cli.ts", + "server": "bun run src/server.ts", + "test": "bun test", + "start": "bun run src/server.ts" + }, + "dependencies": { + "playwright": "^1.58.2", + "diff": "^7.0.0" + }, + "engines": { + "bun": ">=1.0.0" + }, + "keywords": [ + "browser", + "automation", + "playwright", + "headless", + "cli", + "claude", + "ai-agent", + "devtools" + ] +} diff --git a/src/browser-manager.ts b/src/browser-manager.ts new file mode 100644 index 00000000..6d9c75f0 --- /dev/null +++ b/src/browser-manager.ts @@ -0,0 +1,212 @@ +/** + * Browser lifecycle manager + * + * Chromium crash handling: + * browser.on('disconnected') → log error → process.exit(1) + * CLI detects dead server → auto-restarts on next command + * We do NOT try to self-heal — don't hide failure. + */ + +import { chromium, type Browser, type BrowserContext, type Page } from 'playwright'; +import { addConsoleEntry, addNetworkEntry, networkBuffer, type LogEntry, type NetworkEntry } from './buffers'; + +export class BrowserManager { + private browser: Browser | null = null; + private context: BrowserContext | null = null; + private pages: Map = new Map(); + private activeTabId: number = 0; + private nextTabId: number = 1; + private extraHeaders: Record = {}; + private customUserAgent: string | null = null; + + async launch() { + this.browser = await chromium.launch({ headless: true }); + + // Chromium crash → exit with clear message + this.browser.on('disconnected', () => { + console.error('[browse] FATAL: Chromium process crashed or was killed. Server exiting.'); + console.error('[browse] Console/network logs flushed to /tmp/browse-*.log'); + process.exit(1); + }); + + this.context = await this.browser.newContext({ + viewport: { width: 1280, height: 720 }, + }); + + // Create first tab + await this.newTab(); + } + + async close() { + if (this.browser) { + // Remove disconnect handler to avoid exit during intentional close + this.browser.removeAllListeners('disconnected'); + await this.browser.close(); + this.browser = null; + } + } + + isHealthy(): boolean { + return this.browser !== null && this.browser.isConnected(); + } + + // ─── Tab Management ──────────────────────────────────────── + async newTab(url?: string): Promise { + if (!this.context) throw new Error('Browser not launched'); + + const page = await this.context.newPage(); + const id = this.nextTabId++; + this.pages.set(id, page); + this.activeTabId = id; + + // Wire up console/network capture + this.wirePageEvents(page); + + if (url) { + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + } + + return id; + } + + async closeTab(id?: number): Promise { + const tabId = id ?? this.activeTabId; + const page = this.pages.get(tabId); + if (!page) throw new Error(`Tab ${tabId} not found`); + + await page.close(); + this.pages.delete(tabId); + + // Switch to another tab if we closed the active one + if (tabId === this.activeTabId) { + const remaining = [...this.pages.keys()]; + if (remaining.length > 0) { + this.activeTabId = remaining[remaining.length - 1]; + } else { + // No tabs left — create a new blank one + await this.newTab(); + } + } + } + + switchTab(id: number): void { + if (!this.pages.has(id)) throw new Error(`Tab ${id} not found`); + this.activeTabId = id; + } + + getTabCount(): number { + return this.pages.size; + } + + getTabList(): Array<{ id: number; url: string; title: string; active: boolean }> { + const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; + for (const [id, page] of this.pages) { + tabs.push({ + id, + url: page.url(), + title: '', // title requires await, populated by caller + active: id === this.activeTabId, + }); + } + return tabs; + } + + async getTabListWithTitles(): Promise> { + const tabs: Array<{ id: number; url: string; title: string; active: boolean }> = []; + for (const [id, page] of this.pages) { + tabs.push({ + id, + url: page.url(), + title: await page.title().catch(() => ''), + active: id === this.activeTabId, + }); + } + return tabs; + } + + // ─── Page Access ─────────────────────────────────────────── + getPage(): Page { + const page = this.pages.get(this.activeTabId); + if (!page) throw new Error('No active page. Use "browse goto " first.'); + return page; + } + + getCurrentUrl(): string { + try { + return this.getPage().url(); + } catch { + return 'about:blank'; + } + } + + // ─── Viewport ────────────────────────────────────────────── + async setViewport(width: number, height: number) { + await this.getPage().setViewportSize({ width, height }); + } + + // ─── Extra Headers ───────────────────────────────────────── + async setExtraHeader(name: string, value: string) { + this.extraHeaders[name] = value; + if (this.context) { + await this.context.setExtraHTTPHeaders(this.extraHeaders); + } + } + + // ─── User Agent ──────────────────────────────────────────── + // Note: user agent changes require a new context in Playwright + // For simplicity, we just store it and apply on next "restart" + setUserAgent(ua: string) { + this.customUserAgent = ua; + } + + // ─── Console/Network Wiring ──────────────────────────────── + private wirePageEvents(page: Page) { + page.on('console', (msg) => { + addConsoleEntry({ + timestamp: Date.now(), + level: msg.type(), + text: msg.text(), + }); + }); + + page.on('request', (req) => { + addNetworkEntry({ + timestamp: Date.now(), + method: req.method(), + url: req.url(), + }); + }); + + page.on('response', (res) => { + // Find matching request entry and update it + const url = res.url(); + const status = res.status(); + for (let i = networkBuffer.length - 1; i >= 0; i--) { + if (networkBuffer[i].url === url && !networkBuffer[i].status) { + networkBuffer[i].status = status; + networkBuffer[i].duration = Date.now() - networkBuffer[i].timestamp; + break; + } + } + }); + + // Capture response sizes via response finished + page.on('requestfinished', async (req) => { + try { + const res = await req.response(); + if (res) { + const url = req.url(); + const body = await res.body().catch(() => null); + const size = body ? body.length : 0; + for (let i = networkBuffer.length - 1; i >= 0; i--) { + if (networkBuffer[i].url === url && !networkBuffer[i].size) { + networkBuffer[i].size = size; + break; + } + } + } + } catch {} + }); + } +} + diff --git a/src/buffers.ts b/src/buffers.ts new file mode 100644 index 00000000..5bf1c2df --- /dev/null +++ b/src/buffers.ts @@ -0,0 +1,37 @@ +/** + * Shared buffers and types — extracted to break circular dependency + * between server.ts and browser-manager.ts + */ + +export interface LogEntry { + timestamp: number; + level: string; + text: string; +} + +export interface NetworkEntry { + timestamp: number; + method: string; + url: string; + status?: number; + duration?: number; + size?: number; +} + +export const consoleBuffer: LogEntry[] = []; +export const networkBuffer: NetworkEntry[] = []; +const HIGH_WATER_MARK = 50_000; + +export function addConsoleEntry(entry: LogEntry) { + consoleBuffer.push(entry); + if (consoleBuffer.length === HIGH_WATER_MARK) { + console.warn(`[browse] Console buffer reached ${HIGH_WATER_MARK} entries`); + } +} + +export function addNetworkEntry(entry: NetworkEntry) { + networkBuffer.push(entry); + if (networkBuffer.length === HIGH_WATER_MARK) { + console.warn(`[browse] Network buffer reached ${HIGH_WATER_MARK} entries`); + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 00000000..811e806b --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,211 @@ +/** + * gstack-browse CLI — thin wrapper that talks to the persistent server + * + * Flow: + * 1. Read /tmp/browse-server.json for port + token + * 2. If missing or stale PID → start server in background + * 3. Health check + * 4. Send command via HTTP POST + * 5. Print response to stdout (or stderr for errors) + */ + +import * as fs from 'fs'; +import * as path from 'path'; + +const STATE_FILE = process.env.BROWSE_STATE_FILE || '/tmp/browse-server.json'; +// When compiled, import.meta.dir is virtual. Use env var or well-known path. +const SERVER_SCRIPT = process.env.BROWSE_SERVER_SCRIPT + || (import.meta.dir.startsWith('/') && !import.meta.dir.includes('$bunfs') + ? path.resolve(import.meta.dir, 'server.ts') + : path.resolve(process.env.HOME || '/tmp', '.claude/skills/browse/src/server.ts')); +const MAX_START_WAIT = 8000; // 8 seconds to start + +interface ServerState { + pid: number; + port: number; + token: string; + startedAt: string; + serverPath: string; +} + +// ─── State File ──────────────────────────────────────────────── +function readState(): ServerState | null { + try { + const data = fs.readFileSync(STATE_FILE, 'utf-8'); + return JSON.parse(data); + } catch { + return null; + } +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +// ─── Server Lifecycle ────────────────────────────────────────── +async function startServer(): Promise { + // Clean up stale state file + try { fs.unlinkSync(STATE_FILE); } catch {} + + // Start server as detached background process + const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }); + + // Don't hold the CLI open + proc.unref(); + + // Wait for state file to appear + const start = Date.now(); + while (Date.now() - start < MAX_START_WAIT) { + const state = readState(); + if (state && isProcessAlive(state.pid)) { + return state; + } + await Bun.sleep(100); + } + + // If we get here, server didn't start in time + // Try to read stderr for error message + const stderr = proc.stderr; + if (stderr) { + const reader = stderr.getReader(); + const { value } = await reader.read(); + if (value) { + const errText = new TextDecoder().decode(value); + throw new Error(`Server failed to start:\n${errText}`); + } + } + throw new Error(`Server failed to start within ${MAX_START_WAIT / 1000}s`); +} + +async function ensureServer(): Promise { + const state = readState(); + + if (state && isProcessAlive(state.pid)) { + // Server appears alive — do a health check + try { + const resp = await fetch(`http://127.0.0.1:${state.port}/health`, { + signal: AbortSignal.timeout(2000), + }); + if (resp.ok) { + const health = await resp.json() as any; + if (health.status === 'healthy') { + return state; + } + } + } catch { + // Health check failed — server is dead or unhealthy + } + } + + // Need to (re)start + console.error('[browse] Starting server...'); + return startServer(); +} + +// ─── Command Dispatch ────────────────────────────────────────── +async function sendCommand(state: ServerState, command: string, args: string[]): Promise { + const body = JSON.stringify({ command, args }); + + try { + const resp = await fetch(`http://127.0.0.1:${state.port}/command`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${state.token}`, + }, + body, + signal: AbortSignal.timeout(30000), + }); + + if (resp.status === 401) { + // Token mismatch — server may have restarted + console.error('[browse] Auth failed — server may have restarted. Retrying...'); + const newState = readState(); + if (newState && newState.token !== state.token) { + return sendCommand(newState, command, args); + } + throw new Error('Authentication failed'); + } + + const text = await resp.text(); + + if (resp.ok) { + process.stdout.write(text); + if (!text.endsWith('\n')) process.stdout.write('\n'); + } else { + // Try to parse as JSON error + try { + const err = JSON.parse(text); + console.error(err.error || text); + if (err.hint) console.error(err.hint); + } catch { + console.error(text); + } + process.exit(1); + } + } catch (err: any) { + if (err.name === 'AbortError') { + console.error('[browse] Command timed out after 30s'); + process.exit(1); + } + // Connection error — server may have crashed + if (err.code === 'ECONNREFUSED' || err.code === 'ECONNRESET' || err.message?.includes('fetch failed')) { + console.error('[browse] Server connection lost. Restarting...'); + const newState = await startServer(); + return sendCommand(newState, command, args); + } + throw err; + } +} + +// ─── Main ────────────────────────────────────────────────────── +async function main() { + const args = process.argv.slice(2); + + if (args.length === 0 || args[0] === '--help' || args[0] === '-h') { + console.log(`gstack-browse — Fast headless browser for AI coding agents + +Usage: browse [args...] + +Navigation: goto | back | forward | reload | url +Content: text | html [sel] | links | forms | accessibility +Interaction: click | fill | select + hover | type | press + scroll [sel] | wait | viewport +Inspection: js | eval | css | attrs + console [--clear] | network [--clear] + cookies | storage [set ] | perf +Visual: screenshot [path] | pdf [path] | responsive [prefix] +Compare: diff +Multi-step: chain (reads JSON from stdin) +Tabs: tabs | tab | newtab [url] | closetab [id] +Server: status | cookie = | header : + useragent | stop | restart`); + process.exit(0); + } + + const command = args[0]; + const commandArgs = args.slice(1); + + // Special case: chain reads from stdin + if (command === 'chain' && commandArgs.length === 0) { + const stdin = await Bun.stdin.text(); + commandArgs.push(stdin.trim()); + } + + const state = await ensureServer(); + await sendCommand(state, command, commandArgs); +} + +main().catch((err) => { + console.error(`[browse] ${err.message}`); + process.exit(1); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 00000000..d0d082f7 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,247 @@ +/** + * gstack-browse server — persistent Chromium daemon + * + * Architecture: + * Bun.serve HTTP on localhost → routes commands to Playwright + * Console/network buffers: in-memory (all entries) + disk flush every 1s + * Chromium crash → server EXITS with clear error (CLI auto-restarts) + * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min) + */ + +import { BrowserManager } from './browser-manager'; +import { handleReadCommand } from './read-commands'; +import { handleWriteCommand } from './write-commands'; +import { handleMetaCommand } from './meta-commands'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as crypto from 'crypto'; + +// ─── Auth (inline) ───────────────────────────────────────────── +const AUTH_TOKEN = crypto.randomUUID(); +const STATE_FILE = process.env.BROWSE_STATE_FILE || '/tmp/browse-server.json'; +const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min + +function validateAuth(req: Request): boolean { + const header = req.headers.get('authorization'); + return header === `Bearer ${AUTH_TOKEN}`; +} + +// ─── Buffer (from buffers.ts) ──────────────────────────────────── +import { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry } from './buffers'; +export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry }; +const CONSOLE_LOG_PATH = '/tmp/browse-console.log'; +const NETWORK_LOG_PATH = '/tmp/browse-network.log'; +let lastConsoleFlushed = 0; +let lastNetworkFlushed = 0; + +function flushBuffers() { + // Flush new console entries to disk + if (consoleBuffer.length > lastConsoleFlushed) { + const newEntries = consoleBuffer.slice(lastConsoleFlushed); + const lines = newEntries.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` + ).join('\n') + '\n'; + fs.appendFileSync(CONSOLE_LOG_PATH, lines); + lastConsoleFlushed = consoleBuffer.length; + } + + // Flush new network entries to disk + if (networkBuffer.length > lastNetworkFlushed) { + const newEntries = networkBuffer.slice(lastNetworkFlushed); + const lines = newEntries.map(e => + `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n') + '\n'; + fs.appendFileSync(NETWORK_LOG_PATH, lines); + lastNetworkFlushed = networkBuffer.length; + } +} + +// Flush every 1 second +const flushInterval = setInterval(flushBuffers, 1000); + +// ─── Idle Timer ──────────────────────────────────────────────── +let lastActivity = Date.now(); + +function resetIdleTimer() { + lastActivity = Date.now(); +} + +const idleCheckInterval = setInterval(() => { + if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) { + console.log(`[browse] Idle for ${IDLE_TIMEOUT_MS / 1000}s, shutting down`); + shutdown(); + } +}, 60_000); + +// ─── Server ──────────────────────────────────────────────────── +const browserManager = new BrowserManager(); +let isShuttingDown = false; + +// Read/write/meta command sets for routing +const READ_COMMANDS = new Set([ + 'text', 'html', 'links', 'forms', 'accessibility', + 'js', 'eval', 'css', 'attrs', + 'console', 'network', 'cookies', 'storage', 'perf', +]); + +const WRITE_COMMANDS = new Set([ + 'goto', 'back', 'forward', 'reload', + 'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait', + 'viewport', 'cookie', 'header', 'useragent', +]); + +const META_COMMANDS = new Set([ + 'tabs', 'tab', 'newtab', 'closetab', + 'status', 'stop', 'restart', + 'screenshot', 'pdf', 'responsive', + 'chain', 'diff', + 'url', +]); + +// Find available port +async function findPort(): Promise { + const start = parseInt(process.env.BROWSE_PORT_START || '9400', 10); + for (let port = start; port < start + 10; port++) { + try { + const testServer = Bun.serve({ port, fetch: () => new Response('ok') }); + testServer.stop(); + return port; + } catch { + continue; + } + } + throw new Error(`[browse] No available port in range ${start}-${start + 9}`); +} + +async function handleCommand(body: any): Promise { + const { command, args = [] } = body; + + if (!command) { + return new Response(JSON.stringify({ error: 'Missing "command" field' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + try { + let result: string; + + if (READ_COMMANDS.has(command)) { + result = await handleReadCommand(command, args, browserManager); + } else if (WRITE_COMMANDS.has(command)) { + result = await handleWriteCommand(command, args, browserManager); + } else if (META_COMMANDS.has(command)) { + result = await handleMetaCommand(command, args, browserManager, shutdown); + } else { + return new Response(JSON.stringify({ + error: `Unknown command: ${command}`, + hint: `Available commands: ${[...READ_COMMANDS, ...WRITE_COMMANDS, ...META_COMMANDS].sort().join(', ')}`, + }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(result, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }); + } +} + +async function shutdown() { + if (isShuttingDown) return; + isShuttingDown = true; + + console.log('[browse] Shutting down...'); + clearInterval(flushInterval); + clearInterval(idleCheckInterval); + flushBuffers(); // Final flush + + await browserManager.close(); + + // Clean up state file + try { fs.unlinkSync(STATE_FILE); } catch {} + + process.exit(0); +} + +// Handle signals +process.on('SIGTERM', shutdown); +process.on('SIGINT', shutdown); + +// ─── Start ───────────────────────────────────────────────────── +async function start() { + // Clear old log files + try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {} + try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {} + + const port = await findPort(); + + // Launch browser + await browserManager.launch(); + + const startTime = Date.now(); + const server = Bun.serve({ + port, + hostname: '127.0.0.1', + fetch: async (req) => { + resetIdleTimer(); + + const url = new URL(req.url); + + // Health check — no auth required + if (url.pathname === '/health') { + const healthy = browserManager.isHealthy(); + return new Response(JSON.stringify({ + status: healthy ? 'healthy' : 'unhealthy', + uptime: Math.floor((Date.now() - startTime) / 1000), + tabs: browserManager.getTabCount(), + currentUrl: browserManager.getCurrentUrl(), + }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + } + + // All other endpoints require auth + if (!validateAuth(req)) { + return new Response(JSON.stringify({ error: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (url.pathname === '/command' && req.method === 'POST') { + const body = await req.json(); + return handleCommand(body); + } + + return new Response('Not found', { status: 404 }); + }, + }); + + // Write state file + const state = { + pid: process.pid, + port, + token: AUTH_TOKEN, + startedAt: new Date().toISOString(), + serverPath: path.resolve(import.meta.dir, 'server.ts'), + }; + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 }); + + console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`); + console.log(`[browse] State file: ${STATE_FILE}`); + console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`); +} + +start().catch((err) => { + console.error(`[browse] Failed to start: ${err.message}`); + process.exit(1); +});