/** * gstack browse server — persistent Chromium daemon * * Architecture: * Bun.serve HTTP on localhost → routes commands to Playwright * Console/network/dialog buffers: CircularBuffer in-memory + async disk flush * Chromium crash → server EXITS with clear error (CLI auto-restarts) * Auto-shutdown after BROWSE_IDLE_TIMEOUT (default 30 min) * * State: * State file: /.gstack/browse.json (set via BROWSE_STATE_FILE env) * Log files: /.gstack/browse-{console,network,dialog}.log * Port: random 10000-60000 (or BROWSE_PORT env for debug override) */ import { BrowserManager } from './browser-manager'; import { handleReadCommand } from './read-commands'; import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; import { handleCookiePickerRoute } from './cookie-picker-routes'; import { COMMAND_DESCRIPTIONS } from './commands'; import { SNAPSHOT_FLAGS } from './snapshot'; import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; // ─── Config ───────────────────────────────────────────────────── const config = resolveConfig(); ensureStateDir(config); // ─── Auth ─────────────────────────────────────────────────────── const AUTH_TOKEN = crypto.randomUUID(); const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10); 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}`; } // ─── Help text (auto-generated from COMMAND_DESCRIPTIONS) ──────── function generateHelpText(): string { // Group commands by category const groups = new Map(); for (const [cmd, meta] of Object.entries(COMMAND_DESCRIPTIONS)) { const display = meta.usage || cmd; const list = groups.get(meta.category) || []; list.push(display); groups.set(meta.category, list); } const categoryOrder = [ 'Navigation', 'Reading', 'Interaction', 'Inspection', 'Visual', 'Snapshot', 'Meta', 'Tabs', 'Server', ]; const lines = ['gstack browse — headless browser for AI agents', '', 'Commands:']; for (const cat of categoryOrder) { const cmds = groups.get(cat); if (!cmds) continue; lines.push(` ${(cat + ':').padEnd(15)}${cmds.join(', ')}`); } // Snapshot flags from source of truth lines.push(''); lines.push('Snapshot flags:'); const flagPairs: string[] = []; for (const flag of SNAPSHOT_FLAGS) { const label = flag.valueHint ? `${flag.short} ${flag.valueHint}` : flag.short; flagPairs.push(`${label} ${flag.long}`); } // Print two flags per line for compact display for (let i = 0; i < flagPairs.length; i += 2) { const left = flagPairs[i].padEnd(28); const right = flagPairs[i + 1] || ''; lines.push(` ${left}${right}`); } return lines.join('\n'); } // ─── Buffer (from buffers.ts) ──────────────────────────────────── import { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; export { consoleBuffer, networkBuffer, dialogBuffer, addConsoleEntry, addNetworkEntry, addDialogEntry, type LogEntry, type NetworkEntry, type DialogEntry }; const CONSOLE_LOG_PATH = config.consoleLog; const NETWORK_LOG_PATH = config.networkLog; const DIALOG_LOG_PATH = config.dialogLog; let lastConsoleFlushed = 0; let lastNetworkFlushed = 0; let lastDialogFlushed = 0; let flushInProgress = false; async function flushBuffers() { if (flushInProgress) return; // Guard against concurrent flush flushInProgress = true; try { // Console buffer const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed; if (newConsoleCount > 0) { const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length)); const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` ).join('\n') + '\n'; fs.appendFileSync(CONSOLE_LOG_PATH, lines); lastConsoleFlushed = consoleBuffer.totalAdded; } // Network buffer const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed; if (newNetworkCount > 0) { const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length)); const lines = entries.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.totalAdded; } // Dialog buffer const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; if (newDialogCount > 0) { const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); const lines = entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` ).join('\n') + '\n'; fs.appendFileSync(DIALOG_LOG_PATH, lines); lastDialogFlushed = dialogBuffer.totalAdded; } } catch { // Flush failures are non-fatal — buffers are in memory } finally { flushInProgress = false; } } // 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); // ─── Command Sets (from commands.ts — single source of truth) ─── import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands'; export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS }; // ─── Server ──────────────────────────────────────────────────── const browserManager = new BrowserManager(); let isShuttingDown = false; // Find port: explicit BROWSE_PORT, or random in 10000-60000 async function findPort(): Promise { // Explicit port override (for debugging) if (BROWSE_PORT) { try { const testServer = Bun.serve({ port: BROWSE_PORT, fetch: () => new Response('ok') }); testServer.stop(); return BROWSE_PORT; } catch { throw new Error(`[browse] Port ${BROWSE_PORT} (from BROWSE_PORT env) is in use`); } } // Random port with retry const MIN_PORT = 10000; const MAX_PORT = 60000; const MAX_RETRIES = 5; for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { const port = MIN_PORT + Math.floor(Math.random() * (MAX_PORT - MIN_PORT)); try { const testServer = Bun.serve({ port, fetch: () => new Response('ok') }); testServer.stop(); return port; } catch { continue; } } throw new Error(`[browse] No available port after ${MAX_RETRIES} attempts in range ${MIN_PORT}-${MAX_PORT}`); } /** * Translate Playwright errors into actionable messages for AI agents. */ function wrapError(err: any): string { const msg = err.message || String(err); // Timeout errors if (err.name === 'TimeoutError' || msg.includes('Timeout') || msg.includes('timeout')) { if (msg.includes('locator.click') || msg.includes('locator.fill') || msg.includes('locator.hover')) { return `Element not found or not interactable within timeout. Check your selector or run 'snapshot' for fresh refs.`; } if (msg.includes('page.goto') || msg.includes('Navigation')) { return `Page navigation timed out. The URL may be unreachable or the page may be loading slowly.`; } return `Operation timed out: ${msg.split('\n')[0]}`; } // Multiple elements matched if (msg.includes('resolved to') && msg.includes('elements')) { return `Selector matched multiple elements. Be more specific or use @refs from 'snapshot'.`; } // Pass through other errors return msg; } 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 if (command === 'help') { const helpText = generateHelpText(); return new Response(helpText, { status: 200, headers: { 'Content-Type': 'text/plain' }, }); } 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' }, }); } browserManager.resetFailures(); return new Response(result, { status: 200, headers: { 'Content-Type': 'text/plain' }, }); } catch (err: any) { browserManager.incrementFailures(); let errorMsg = wrapError(err); const hint = browserManager.getFailureHint(); if (hint) errorMsg += '\n' + hint; return new Response(JSON.stringify({ error: errorMsg }), { status: 500, headers: { 'Content-Type': 'application/json' }, }); } } async function shutdown() { if (isShuttingDown) return; isShuttingDown = true; console.log('[browse] Shutting down...'); clearInterval(flushInterval); clearInterval(idleCheckInterval); await flushBuffers(); // Final flush (async now) await browserManager.close(); // Clean up state file try { fs.unlinkSync(config.stateFile); } catch {} process.exit(0); } // Handle signals process.on('SIGTERM', shutdown); process.on('SIGINT', shutdown); // Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths. // Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check. if (process.platform === 'win32') { process.on('exit', () => { try { fs.unlinkSync(config.stateFile); } catch {} }); } // ─── Start ───────────────────────────────────────────────────── async function start() { // Clear old log files try { fs.unlinkSync(CONSOLE_LOG_PATH); } catch {} try { fs.unlinkSync(NETWORK_LOG_PATH); } catch {} try { fs.unlinkSync(DIALOG_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); // Cookie picker routes — no auth required (localhost-only) if (url.pathname.startsWith('/cookie-picker')) { return handleCookiePickerRoute(url, req, browserManager); } // Health check — no auth required (now async) if (url.pathname === '/health') { const healthy = await 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 (atomic: write .tmp then rename) const state = { pid: process.pid, port, token: AUTH_TOKEN, startedAt: new Date().toISOString(), serverPath: path.resolve(import.meta.dir, 'server.ts'), binaryVersion: readVersionHash() || undefined, }; const tmpFile = config.stateFile + '.tmp'; fs.writeFileSync(tmpFile, JSON.stringify(state, null, 2), { mode: 0o600 }); fs.renameSync(tmpFile, config.stateFile); browserManager.serverPort = port; console.log(`[browse] Server running on http://127.0.0.1:${port} (PID: ${process.pid})`); console.log(`[browse] State file: ${config.stateFile}`); console.log(`[browse] Idle timeout: ${IDLE_TIMEOUT_MS / 1000}s`); } start().catch((err) => { console.error(`[browse] Failed to start: ${err.message}`); // Write error to disk for the CLI to read — on Windows, the CLI can't capture // stderr because the server is launched with detached: true, stdio: 'ignore'. try { const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log'); fs.mkdirSync(config.stateDir, { recursive: true }); fs.writeFileSync(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`); } catch { // stateDir may not exist — nothing more we can do } process.exit(1); });