mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-08 14:34:49 +02: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:
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
/tmp/
|
||||||
|
*.log
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+211
@@ -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<ServerState> {
|
||||||
|
// 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<ServerState> {
|
||||||
|
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<void> {
|
||||||
|
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 <command> [args...]
|
||||||
|
|
||||||
|
Navigation: goto <url> | back | forward | reload | url
|
||||||
|
Content: text | html [sel] | links | forms | accessibility
|
||||||
|
Interaction: click <sel> | fill <sel> <val> | select <sel> <val>
|
||||||
|
hover <sel> | type <text> | press <key>
|
||||||
|
scroll [sel] | wait <sel> | viewport <WxH>
|
||||||
|
Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
||||||
|
console [--clear] | network [--clear]
|
||||||
|
cookies | storage [set <k> <v>] | perf
|
||||||
|
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
||||||
|
Compare: diff <url1> <url2>
|
||||||
|
Multi-step: chain (reads JSON from stdin)
|
||||||
|
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
||||||
|
Server: status | cookie <n>=<v> | header <n>:<v>
|
||||||
|
useragent <str> | 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);
|
||||||
|
});
|
||||||
+247
@@ -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<number> {
|
||||||
|
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<Response> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user