mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-10 06:28:23 +08:00
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:
212
src/browser-manager.ts
Normal file
212
src/browser-manager.ts
Normal 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 {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user