feat: persistent Chromium daemon with CLI wrapper

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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-11 14:23:00 -07:00
parent 3b79ca5684
commit 564599e58b
6 changed files with 745 additions and 0 deletions

212
src/browser-manager.ts Normal file
View File

@@ -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<number, Page> = new Map();
private activeTabId: number = 0;
private nextTabId: number = 1;
private extraHeaders: Record<string, string> = {};
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<number> {
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<void> {
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<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: 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 <url>" 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 {}
});
}
}