mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-06 16:17:56 +02:00
Initial release — gstack v0.0.1
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
/**
|
||||
* 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, type Locator } 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;
|
||||
|
||||
// ─── Ref Map (snapshot → @e1, @e2, ...) ────────────────────
|
||||
private refMap: Map<string, Locator> = new Map();
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Ref Map ──────────────────────────────────────────────
|
||||
setRefMap(refs: Map<string, Locator>) {
|
||||
this.refMap = refs;
|
||||
}
|
||||
|
||||
clearRefs() {
|
||||
this.refMap.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a selector that may be a @ref (e.g., "@e3") or a CSS selector.
|
||||
* Returns { locator } for refs or { selector } for CSS selectors.
|
||||
*/
|
||||
resolveRef(selector: string): { locator: Locator } | { selector: string } {
|
||||
if (selector.startsWith('@e')) {
|
||||
const ref = selector.slice(1); // "e3"
|
||||
const locator = this.refMap.get(ref);
|
||||
if (!locator) {
|
||||
throw new Error(
|
||||
`Ref ${selector} not found. Page may have changed — run 'snapshot' to get fresh refs.`
|
||||
);
|
||||
}
|
||||
return { locator };
|
||||
}
|
||||
return { selector };
|
||||
}
|
||||
|
||||
getRefCount(): number {
|
||||
return this.refMap.size;
|
||||
}
|
||||
|
||||
// ─── 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/Ref Wiring ────────────────────────────
|
||||
private wirePageEvents(page: Page) {
|
||||
// Clear ref map on navigation — refs point to stale elements after page change
|
||||
page.on('framenavigated', (frame) => {
|
||||
if (frame === page.mainFrame()) {
|
||||
this.clearRefs();
|
||||
}
|
||||
});
|
||||
|
||||
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,44 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Total entries ever added — used by server.ts flush logic as a cursor
|
||||
// that keeps advancing even after the ring buffer wraps.
|
||||
export let consoleTotalAdded = 0;
|
||||
export let networkTotalAdded = 0;
|
||||
|
||||
export function addConsoleEntry(entry: LogEntry) {
|
||||
if (consoleBuffer.length >= HIGH_WATER_MARK) {
|
||||
consoleBuffer.shift();
|
||||
}
|
||||
consoleBuffer.push(entry);
|
||||
consoleTotalAdded++;
|
||||
}
|
||||
|
||||
export function addNetworkEntry(entry: NetworkEntry) {
|
||||
if (networkBuffer.length >= HIGH_WATER_MARK) {
|
||||
networkBuffer.shift();
|
||||
}
|
||||
networkBuffer.push(entry);
|
||||
networkTotalAdded++;
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* gstack 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 PORT_OFFSET = 45600;
|
||||
const BROWSE_PORT = process.env.CONDUCTOR_PORT
|
||||
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
|
||||
: parseInt(process.env.BROWSE_PORT || '0', 10);
|
||||
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.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/gstack/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[], retries = 0): 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')) {
|
||||
if (retries >= 1) throw new Error('[browse] Server crashed twice in a row — aborting');
|
||||
console.error('[browse] Server connection lost. Restarting...');
|
||||
const newState = await startServer();
|
||||
return sendCommand(newState, command, args, retries + 1);
|
||||
}
|
||||
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]
|
||||
Snapshot: snapshot [-i] [-c] [-d N] [-s sel]
|
||||
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
|
||||
|
||||
Refs: After 'snapshot', use @e1, @e2... as selectors:
|
||||
click @e3 | fill @e4 "value" | hover @e1`);
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Meta commands — tabs, server control, screenshots, chain, diff, snapshot
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { handleSnapshot } from './snapshot';
|
||||
import * as Diff from 'diff';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export async function handleMetaCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
bm: BrowserManager,
|
||||
shutdown: () => Promise<void> | void
|
||||
): Promise<string> {
|
||||
switch (command) {
|
||||
// ─── Tabs ──────────────────────────────────────────
|
||||
case 'tabs': {
|
||||
const tabs = await bm.getTabListWithTitles();
|
||||
return tabs.map(t =>
|
||||
`${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'tab': {
|
||||
const id = parseInt(args[0], 10);
|
||||
if (isNaN(id)) throw new Error('Usage: browse tab <id>');
|
||||
bm.switchTab(id);
|
||||
return `Switched to tab ${id}`;
|
||||
}
|
||||
|
||||
case 'newtab': {
|
||||
const url = args[0];
|
||||
const id = await bm.newTab(url);
|
||||
return `Opened tab ${id}${url ? ` → ${url}` : ''}`;
|
||||
}
|
||||
|
||||
case 'closetab': {
|
||||
const id = args[0] ? parseInt(args[0], 10) : undefined;
|
||||
await bm.closeTab(id);
|
||||
return `Closed tab${id ? ` ${id}` : ''}`;
|
||||
}
|
||||
|
||||
// ─── Server Control ────────────────────────────────
|
||||
case 'status': {
|
||||
const page = bm.getPage();
|
||||
const tabs = bm.getTabCount();
|
||||
return [
|
||||
`Status: healthy`,
|
||||
`URL: ${page.url()}`,
|
||||
`Tabs: ${tabs}`,
|
||||
`PID: ${process.pid}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
case 'url': {
|
||||
return bm.getCurrentUrl();
|
||||
}
|
||||
|
||||
case 'stop': {
|
||||
await shutdown();
|
||||
return 'Server stopped';
|
||||
}
|
||||
|
||||
case 'restart': {
|
||||
// Signal that we want a restart — the CLI will detect exit and restart
|
||||
console.log('[browse] Restart requested. Exiting for CLI to restart.');
|
||||
await shutdown();
|
||||
return 'Restarting...';
|
||||
}
|
||||
|
||||
// ─── Visual ────────────────────────────────────────
|
||||
case 'screenshot': {
|
||||
const page = bm.getPage();
|
||||
const screenshotPath = args[0] || '/tmp/browse-screenshot.png';
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
return `Screenshot saved: ${screenshotPath}`;
|
||||
}
|
||||
|
||||
case 'pdf': {
|
||||
const page = bm.getPage();
|
||||
const pdfPath = args[0] || '/tmp/browse-page.pdf';
|
||||
await page.pdf({ path: pdfPath, format: 'A4' });
|
||||
return `PDF saved: ${pdfPath}`;
|
||||
}
|
||||
|
||||
case 'responsive': {
|
||||
const page = bm.getPage();
|
||||
const prefix = args[0] || '/tmp/browse-responsive';
|
||||
const viewports = [
|
||||
{ name: 'mobile', width: 375, height: 812 },
|
||||
{ name: 'tablet', width: 768, height: 1024 },
|
||||
{ name: 'desktop', width: 1280, height: 720 },
|
||||
];
|
||||
const originalViewport = page.viewportSize();
|
||||
const results: string[] = [];
|
||||
|
||||
for (const vp of viewports) {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
const path = `${prefix}-${vp.name}.png`;
|
||||
await page.screenshot({ path, fullPage: true });
|
||||
results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`);
|
||||
}
|
||||
|
||||
// Restore original viewport
|
||||
if (originalViewport) {
|
||||
await page.setViewportSize(originalViewport);
|
||||
}
|
||||
|
||||
return results.join('\n');
|
||||
}
|
||||
|
||||
// ─── Chain ─────────────────────────────────────────
|
||||
case 'chain': {
|
||||
// Read JSON array from args[0] (if provided) or expect it was passed as body
|
||||
const jsonStr = args[0];
|
||||
if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain');
|
||||
|
||||
let commands: string[][];
|
||||
try {
|
||||
commands = JSON.parse(jsonStr);
|
||||
} catch {
|
||||
throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]');
|
||||
}
|
||||
|
||||
if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands');
|
||||
|
||||
const results: string[] = [];
|
||||
const { handleReadCommand } = await import('./read-commands');
|
||||
const { handleWriteCommand } = await import('./write-commands');
|
||||
|
||||
const WRITE_SET = new Set(['goto','back','forward','reload','click','fill','select','hover','type','press','scroll','wait','viewport','cookie','header','useragent']);
|
||||
const READ_SET = new Set(['text','html','links','forms','accessibility','js','eval','css','attrs','console','network','cookies','storage','perf']);
|
||||
|
||||
for (const cmd of commands) {
|
||||
const [name, ...cmdArgs] = cmd;
|
||||
try {
|
||||
let result: string;
|
||||
if (WRITE_SET.has(name)) result = await handleWriteCommand(name, cmdArgs, bm);
|
||||
else if (READ_SET.has(name)) result = await handleReadCommand(name, cmdArgs, bm);
|
||||
else result = await handleMetaCommand(name, cmdArgs, bm, shutdown);
|
||||
results.push(`[${name}] ${result}`);
|
||||
} catch (err: any) {
|
||||
results.push(`[${name}] ERROR: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return results.join('\n\n');
|
||||
}
|
||||
|
||||
// ─── Diff ──────────────────────────────────────────
|
||||
case 'diff': {
|
||||
const [url1, url2] = args;
|
||||
if (!url1 || !url2) throw new Error('Usage: browse diff <url1> <url2>');
|
||||
|
||||
// Get text from URL1
|
||||
const page = bm.getPage();
|
||||
await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const text1 = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n');
|
||||
});
|
||||
|
||||
// Get text from URL2
|
||||
await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const text2 = await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText.split('\n').map(l => l.trim()).filter(l => l).join('\n');
|
||||
});
|
||||
|
||||
const changes = Diff.diffLines(text1, text2);
|
||||
const output: string[] = [`--- ${url1}`, `+++ ${url2}`, ''];
|
||||
|
||||
for (const part of changes) {
|
||||
const prefix = part.added ? '+' : part.removed ? '-' : ' ';
|
||||
const lines = part.value.split('\n').filter(l => l.length > 0);
|
||||
for (const line of lines) {
|
||||
output.push(`${prefix} ${line}`);
|
||||
}
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
|
||||
// ─── Snapshot ─────────────────────────────────────
|
||||
case 'snapshot': {
|
||||
return await handleSnapshot(args, bm);
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown meta command: ${command}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* Read commands — extract data from pages without side effects
|
||||
*
|
||||
* text, html, links, forms, accessibility, js, eval, css, attrs,
|
||||
* console, network, cookies, storage, perf
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
import { consoleBuffer, networkBuffer } from './buffers';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export async function handleReadCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
const page = bm.getPage();
|
||||
|
||||
switch (command) {
|
||||
case 'text': {
|
||||
return await page.evaluate(() => {
|
||||
const body = document.body;
|
||||
if (!body) return '';
|
||||
const clone = body.cloneNode(true) as HTMLElement;
|
||||
clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
|
||||
return clone.innerText
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(line => line.length > 0)
|
||||
.join('\n');
|
||||
});
|
||||
}
|
||||
|
||||
case 'html': {
|
||||
const selector = args[0];
|
||||
if (selector) {
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
return await resolved.locator.innerHTML({ timeout: 5000 });
|
||||
}
|
||||
return await page.innerHTML(resolved.selector);
|
||||
}
|
||||
return await page.content();
|
||||
}
|
||||
|
||||
case 'links': {
|
||||
const links = await page.evaluate(() =>
|
||||
[...document.querySelectorAll('a[href]')].map(a => ({
|
||||
text: a.textContent?.trim().slice(0, 120) || '',
|
||||
href: (a as HTMLAnchorElement).href,
|
||||
})).filter(l => l.text && l.href)
|
||||
);
|
||||
return links.map(l => `${l.text} → ${l.href}`).join('\n');
|
||||
}
|
||||
|
||||
case 'forms': {
|
||||
const forms = await page.evaluate(() => {
|
||||
return [...document.querySelectorAll('form')].map((form, i) => {
|
||||
const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
|
||||
const input = el as HTMLInputElement;
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
type: input.type || undefined,
|
||||
name: input.name || undefined,
|
||||
id: input.id || undefined,
|
||||
placeholder: input.placeholder || undefined,
|
||||
required: input.required || undefined,
|
||||
value: input.value || undefined,
|
||||
options: el.tagName === 'SELECT'
|
||||
? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
return {
|
||||
index: i,
|
||||
action: form.action || undefined,
|
||||
method: form.method || 'get',
|
||||
id: form.id || undefined,
|
||||
fields,
|
||||
};
|
||||
});
|
||||
});
|
||||
return JSON.stringify(forms, null, 2);
|
||||
}
|
||||
|
||||
case 'accessibility': {
|
||||
const snapshot = await page.locator("body").ariaSnapshot();
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
case 'js': {
|
||||
const expr = args[0];
|
||||
if (!expr) throw new Error('Usage: browse js <expression>');
|
||||
const result = await page.evaluate(expr);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
}
|
||||
|
||||
case 'eval': {
|
||||
const filePath = args[0];
|
||||
if (!filePath) throw new Error('Usage: browse eval <js-file>');
|
||||
if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
|
||||
const code = fs.readFileSync(filePath, 'utf-8');
|
||||
const result = await page.evaluate(code);
|
||||
return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? '');
|
||||
}
|
||||
|
||||
case 'css': {
|
||||
const [selector, property] = args;
|
||||
if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
const value = await resolved.locator.evaluate(
|
||||
(el, prop) => getComputedStyle(el).getPropertyValue(prop),
|
||||
property
|
||||
);
|
||||
return value;
|
||||
}
|
||||
const value = await page.evaluate(
|
||||
([sel, prop]) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return `Element not found: ${sel}`;
|
||||
return getComputedStyle(el).getPropertyValue(prop);
|
||||
},
|
||||
[resolved.selector, property]
|
||||
);
|
||||
return value;
|
||||
}
|
||||
|
||||
case 'attrs': {
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse attrs <selector>');
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
const attrs = await resolved.locator.evaluate((el) => {
|
||||
const result: Record<string, string> = {};
|
||||
for (const attr of el.attributes) {
|
||||
result[attr.name] = attr.value;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
return JSON.stringify(attrs, null, 2);
|
||||
}
|
||||
const attrs = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
if (!el) return `Element not found: ${sel}`;
|
||||
const result: Record<string, string> = {};
|
||||
for (const attr of el.attributes) {
|
||||
result[attr.name] = attr.value;
|
||||
}
|
||||
return result;
|
||||
}, resolved.selector);
|
||||
return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2);
|
||||
}
|
||||
|
||||
case 'console': {
|
||||
if (args[0] === '--clear') {
|
||||
consoleBuffer.length = 0;
|
||||
return 'Console buffer cleared.';
|
||||
}
|
||||
if (consoleBuffer.length === 0) return '(no console messages)';
|
||||
return consoleBuffer.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'network': {
|
||||
if (args[0] === '--clear') {
|
||||
networkBuffer.length = 0;
|
||||
return 'Network buffer cleared.';
|
||||
}
|
||||
if (networkBuffer.length === 0) return '(no network requests)';
|
||||
return networkBuffer.map(e =>
|
||||
`${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
case 'cookies': {
|
||||
const cookies = await page.context().cookies();
|
||||
return JSON.stringify(cookies, null, 2);
|
||||
}
|
||||
|
||||
case 'storage': {
|
||||
if (args[0] === 'set' && args[1]) {
|
||||
const key = args[1];
|
||||
const value = args[2] || '';
|
||||
await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]);
|
||||
return `Set localStorage["${key}"] = "${value}"`;
|
||||
}
|
||||
const storage = await page.evaluate(() => ({
|
||||
localStorage: { ...localStorage },
|
||||
sessionStorage: { ...sessionStorage },
|
||||
}));
|
||||
return JSON.stringify(storage, null, 2);
|
||||
}
|
||||
|
||||
case 'perf': {
|
||||
const timings = await page.evaluate(() => {
|
||||
const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
|
||||
if (!nav) return 'No navigation timing data available.';
|
||||
return {
|
||||
dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
|
||||
tcp: Math.round(nav.connectEnd - nav.connectStart),
|
||||
ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
|
||||
ttfb: Math.round(nav.responseStart - nav.requestStart),
|
||||
download: Math.round(nav.responseEnd - nav.responseStart),
|
||||
domParse: Math.round(nav.domInteractive - nav.responseEnd),
|
||||
domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
|
||||
load: Math.round(nav.loadEventEnd - nav.startTime),
|
||||
total: Math.round(nav.loadEventEnd - nav.startTime),
|
||||
};
|
||||
});
|
||||
if (typeof timings === 'string') return timings;
|
||||
return Object.entries(timings)
|
||||
.map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown read command: ${command}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* 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 PORT_OFFSET = 45600;
|
||||
const BROWSE_PORT = process.env.CONDUCTOR_PORT
|
||||
? parseInt(process.env.CONDUCTOR_PORT, 10) - PORT_OFFSET
|
||||
: parseInt(process.env.BROWSE_PORT || '0', 10); // 0 = auto-scan
|
||||
const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : '';
|
||||
const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.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, consoleTotalAdded, networkTotalAdded, type LogEntry, type NetworkEntry } from './buffers';
|
||||
export { consoleBuffer, networkBuffer, addConsoleEntry, addNetworkEntry, type LogEntry, type NetworkEntry };
|
||||
const CONSOLE_LOG_PATH = `/tmp/browse-console${INSTANCE_SUFFIX}.log`;
|
||||
const NETWORK_LOG_PATH = `/tmp/browse-network${INSTANCE_SUFFIX}.log`;
|
||||
let lastConsoleFlushed = 0;
|
||||
let lastNetworkFlushed = 0;
|
||||
|
||||
function flushBuffers() {
|
||||
// Use totalAdded cursor (not buffer.length) because the ring buffer
|
||||
// stays pinned at HIGH_WATER_MARK after wrapping.
|
||||
const newConsoleCount = consoleTotalAdded - lastConsoleFlushed;
|
||||
if (newConsoleCount > 0) {
|
||||
const count = Math.min(newConsoleCount, consoleBuffer.length);
|
||||
const newEntries = consoleBuffer.slice(-count);
|
||||
const lines = newEntries.map(e =>
|
||||
`[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
|
||||
).join('\n') + '\n';
|
||||
fs.appendFileSync(CONSOLE_LOG_PATH, lines);
|
||||
lastConsoleFlushed = consoleTotalAdded;
|
||||
}
|
||||
|
||||
const newNetworkCount = networkTotalAdded - lastNetworkFlushed;
|
||||
if (newNetworkCount > 0) {
|
||||
const count = Math.min(newNetworkCount, networkBuffer.length);
|
||||
const newEntries = networkBuffer.slice(-count);
|
||||
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 = networkTotalAdded;
|
||||
}
|
||||
}
|
||||
|
||||
// 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', 'snapshot',
|
||||
]);
|
||||
|
||||
// Find port: deterministic from CONDUCTOR_PORT, or scan range
|
||||
async function findPort(): Promise<number> {
|
||||
// Deterministic port from CONDUCTOR_PORT (e.g., 55040 - 45600 = 9440)
|
||||
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 CONDUCTOR_PORT ${process.env.CONDUCTOR_PORT}) is in use`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: scan range
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Snapshot command — accessibility tree with ref-based element selection
|
||||
*
|
||||
* Architecture (Locator map — no DOM mutation):
|
||||
* 1. page.locator(scope).ariaSnapshot() → YAML-like accessibility tree
|
||||
* 2. Parse tree, assign refs @e1, @e2, ...
|
||||
* 3. Build Playwright Locator for each ref (getByRole + nth)
|
||||
* 4. Store Map<string, Locator> on BrowserManager
|
||||
* 5. Return compact text output with refs prepended
|
||||
*
|
||||
* Later: "click @e3" → look up Locator → locator.click()
|
||||
*/
|
||||
|
||||
import type { Page, Locator } from 'playwright';
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
|
||||
// Roles considered "interactive" for the -i flag
|
||||
const INTERACTIVE_ROLES = new Set([
|
||||
'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox',
|
||||
'listbox', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
|
||||
'option', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab',
|
||||
'treeitem',
|
||||
]);
|
||||
|
||||
interface SnapshotOptions {
|
||||
interactive?: boolean; // -i: only interactive elements
|
||||
compact?: boolean; // -c: remove empty structural elements
|
||||
depth?: number; // -d N: limit tree depth
|
||||
selector?: string; // -s SEL: scope to CSS selector
|
||||
}
|
||||
|
||||
interface ParsedNode {
|
||||
indent: number;
|
||||
role: string;
|
||||
name: string | null;
|
||||
props: string; // e.g., "[level=1]"
|
||||
children: string; // inline text content after ":"
|
||||
rawLine: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse CLI args into SnapshotOptions
|
||||
*/
|
||||
export function parseSnapshotArgs(args: string[]): SnapshotOptions {
|
||||
const opts: SnapshotOptions = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
switch (args[i]) {
|
||||
case '-i':
|
||||
case '--interactive':
|
||||
opts.interactive = true;
|
||||
break;
|
||||
case '-c':
|
||||
case '--compact':
|
||||
opts.compact = true;
|
||||
break;
|
||||
case '-d':
|
||||
case '--depth':
|
||||
opts.depth = parseInt(args[++i], 10);
|
||||
if (isNaN(opts.depth!)) throw new Error('Usage: snapshot -d <number>');
|
||||
break;
|
||||
case '-s':
|
||||
case '--selector':
|
||||
opts.selector = args[++i];
|
||||
if (!opts.selector) throw new Error('Usage: snapshot -s <selector>');
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown snapshot flag: ${args[i]}`);
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse one line of ariaSnapshot output.
|
||||
*
|
||||
* Format examples:
|
||||
* - heading "Test" [level=1]
|
||||
* - link "Link A":
|
||||
* - /url: /a
|
||||
* - textbox "Name"
|
||||
* - paragraph: Some text
|
||||
* - combobox "Role":
|
||||
*/
|
||||
function parseLine(line: string): ParsedNode | null {
|
||||
// Match: (indent)(- )(role)( "name")?( [props])?(: inline)?
|
||||
const match = line.match(/^(\s*)-\s+(\w+)(?:\s+"([^"]*)")?(?:\s+(\[.*?\]))?\s*(?::\s*(.*))?$/);
|
||||
if (!match) {
|
||||
// Skip metadata lines like "- /url: /a"
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
indent: match[1].length,
|
||||
role: match[2],
|
||||
name: match[3] ?? null,
|
||||
props: match[4] || '',
|
||||
children: match[5]?.trim() || '',
|
||||
rawLine: line,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an accessibility snapshot and build the ref map.
|
||||
*/
|
||||
export async function handleSnapshot(
|
||||
args: string[],
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
const opts = parseSnapshotArgs(args);
|
||||
const page = bm.getPage();
|
||||
|
||||
// Get accessibility tree via ariaSnapshot
|
||||
let rootLocator: Locator;
|
||||
if (opts.selector) {
|
||||
rootLocator = page.locator(opts.selector);
|
||||
const count = await rootLocator.count();
|
||||
if (count === 0) throw new Error(`Selector not found: ${opts.selector}`);
|
||||
} else {
|
||||
rootLocator = page.locator('body');
|
||||
}
|
||||
|
||||
const ariaText = await rootLocator.ariaSnapshot();
|
||||
if (!ariaText || ariaText.trim().length === 0) {
|
||||
bm.setRefMap(new Map());
|
||||
return '(no accessible elements found)';
|
||||
}
|
||||
|
||||
// Parse the ariaSnapshot output
|
||||
const lines = ariaText.split('\n');
|
||||
const refMap = new Map<string, Locator>();
|
||||
const output: string[] = [];
|
||||
let refCounter = 1;
|
||||
|
||||
// Track role+name occurrences for nth() disambiguation
|
||||
const roleNameCounts = new Map<string, number>();
|
||||
const roleNameSeen = new Map<string, number>();
|
||||
|
||||
// First pass: count role+name pairs for disambiguation
|
||||
for (const line of lines) {
|
||||
const node = parseLine(line);
|
||||
if (!node) continue;
|
||||
const key = `${node.role}:${node.name || ''}`;
|
||||
roleNameCounts.set(key, (roleNameCounts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
// Second pass: assign refs and build locators
|
||||
for (const line of lines) {
|
||||
const node = parseLine(line);
|
||||
if (!node) continue;
|
||||
|
||||
const depth = Math.floor(node.indent / 2);
|
||||
const isInteractive = INTERACTIVE_ROLES.has(node.role);
|
||||
|
||||
// Depth filter
|
||||
if (opts.depth !== undefined && depth > opts.depth) continue;
|
||||
|
||||
// Interactive filter: skip non-interactive but still count for locator indices
|
||||
if (opts.interactive && !isInteractive) {
|
||||
// Still track for nth() counts
|
||||
const key = `${node.role}:${node.name || ''}`;
|
||||
roleNameSeen.set(key, (roleNameSeen.get(key) || 0) + 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Compact filter: skip elements with no name and no inline content that aren't interactive
|
||||
if (opts.compact && !isInteractive && !node.name && !node.children) continue;
|
||||
|
||||
// Assign ref
|
||||
const ref = `e${refCounter++}`;
|
||||
const indent = ' '.repeat(depth);
|
||||
|
||||
// Build Playwright locator
|
||||
const key = `${node.role}:${node.name || ''}`;
|
||||
const seenIndex = roleNameSeen.get(key) || 0;
|
||||
roleNameSeen.set(key, seenIndex + 1);
|
||||
const totalCount = roleNameCounts.get(key) || 1;
|
||||
|
||||
let locator: Locator;
|
||||
if (opts.selector) {
|
||||
locator = page.locator(opts.selector).getByRole(node.role as any, {
|
||||
name: node.name || undefined,
|
||||
});
|
||||
} else {
|
||||
locator = page.getByRole(node.role as any, {
|
||||
name: node.name || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Disambiguate with nth() if multiple elements share role+name
|
||||
if (totalCount > 1) {
|
||||
locator = locator.nth(seenIndex);
|
||||
}
|
||||
|
||||
refMap.set(ref, locator);
|
||||
|
||||
// Format output line
|
||||
let outputLine = `${indent}@${ref} [${node.role}]`;
|
||||
if (node.name) outputLine += ` "${node.name}"`;
|
||||
if (node.props) outputLine += ` ${node.props}`;
|
||||
if (node.children) outputLine += `: ${node.children}`;
|
||||
|
||||
output.push(outputLine);
|
||||
}
|
||||
|
||||
// Store ref map on BrowserManager
|
||||
bm.setRefMap(refMap);
|
||||
|
||||
if (output.length === 0) {
|
||||
return '(no interactive elements found)';
|
||||
}
|
||||
|
||||
return output.join('\n');
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Write commands — navigate and interact with pages (side effects)
|
||||
*
|
||||
* goto, back, forward, reload, click, fill, select, hover, type,
|
||||
* press, scroll, wait, viewport, cookie, header, useragent
|
||||
*/
|
||||
|
||||
import type { BrowserManager } from './browser-manager';
|
||||
|
||||
export async function handleWriteCommand(
|
||||
command: string,
|
||||
args: string[],
|
||||
bm: BrowserManager
|
||||
): Promise<string> {
|
||||
const page = bm.getPage();
|
||||
|
||||
switch (command) {
|
||||
case 'goto': {
|
||||
const url = args[0];
|
||||
if (!url) throw new Error('Usage: browse goto <url>');
|
||||
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
const status = response?.status() || 'unknown';
|
||||
return `Navigated to ${url} (${status})`;
|
||||
}
|
||||
|
||||
case 'back': {
|
||||
await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
return `Back → ${page.url()}`;
|
||||
}
|
||||
|
||||
case 'forward': {
|
||||
await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
return `Forward → ${page.url()}`;
|
||||
}
|
||||
|
||||
case 'reload': {
|
||||
await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 });
|
||||
return `Reloaded ${page.url()}`;
|
||||
}
|
||||
|
||||
case 'click': {
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse click <selector>');
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.click({ timeout: 5000 });
|
||||
} else {
|
||||
await page.click(resolved.selector, { timeout: 5000 });
|
||||
}
|
||||
// Wait briefly for any navigation/DOM update
|
||||
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
||||
return `Clicked ${selector} → now at ${page.url()}`;
|
||||
}
|
||||
|
||||
case 'fill': {
|
||||
const [selector, ...valueParts] = args;
|
||||
const value = valueParts.join(' ');
|
||||
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.fill(value, { timeout: 5000 });
|
||||
} else {
|
||||
await page.fill(resolved.selector, value, { timeout: 5000 });
|
||||
}
|
||||
return `Filled ${selector}`;
|
||||
}
|
||||
|
||||
case 'select': {
|
||||
const [selector, ...valueParts] = args;
|
||||
const value = valueParts.join(' ');
|
||||
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.selectOption(value, { timeout: 5000 });
|
||||
} else {
|
||||
await page.selectOption(resolved.selector, value, { timeout: 5000 });
|
||||
}
|
||||
return `Selected "${value}" in ${selector}`;
|
||||
}
|
||||
|
||||
case 'hover': {
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse hover <selector>');
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.hover({ timeout: 5000 });
|
||||
} else {
|
||||
await page.hover(resolved.selector, { timeout: 5000 });
|
||||
}
|
||||
return `Hovered ${selector}`;
|
||||
}
|
||||
|
||||
case 'type': {
|
||||
const text = args.join(' ');
|
||||
if (!text) throw new Error('Usage: browse type <text>');
|
||||
await page.keyboard.type(text);
|
||||
return `Typed "${text}"`;
|
||||
}
|
||||
|
||||
case 'press': {
|
||||
const key = args[0];
|
||||
if (!key) throw new Error('Usage: browse press <key> (e.g., Enter, Tab, Escape)');
|
||||
await page.keyboard.press(key);
|
||||
return `Pressed ${key}`;
|
||||
}
|
||||
|
||||
case 'scroll': {
|
||||
const selector = args[0];
|
||||
if (selector) {
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
} else {
|
||||
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: 5000 });
|
||||
}
|
||||
return `Scrolled ${selector} into view`;
|
||||
}
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
return 'Scrolled to bottom';
|
||||
}
|
||||
|
||||
case 'wait': {
|
||||
const selector = args[0];
|
||||
if (!selector) throw new Error('Usage: browse wait <selector>');
|
||||
const timeout = args[1] ? parseInt(args[1], 10) : 15000;
|
||||
const resolved = bm.resolveRef(selector);
|
||||
if ('locator' in resolved) {
|
||||
await resolved.locator.waitFor({ state: 'visible', timeout });
|
||||
} else {
|
||||
await page.waitForSelector(resolved.selector, { timeout });
|
||||
}
|
||||
return `Element ${selector} appeared`;
|
||||
}
|
||||
|
||||
case 'viewport': {
|
||||
const size = args[0];
|
||||
if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
|
||||
const [w, h] = size.split('x').map(Number);
|
||||
await bm.setViewport(w, h);
|
||||
return `Viewport set to ${w}x${h}`;
|
||||
}
|
||||
|
||||
case 'cookie': {
|
||||
const cookieStr = args[0];
|
||||
if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value>');
|
||||
const eq = cookieStr.indexOf('=');
|
||||
const name = cookieStr.slice(0, eq);
|
||||
const value = cookieStr.slice(eq + 1);
|
||||
const url = new URL(page.url());
|
||||
await page.context().addCookies([{
|
||||
name,
|
||||
value,
|
||||
domain: url.hostname,
|
||||
path: '/',
|
||||
}]);
|
||||
return `Cookie set: ${name}=${value}`;
|
||||
}
|
||||
|
||||
case 'header': {
|
||||
const headerStr = args[0];
|
||||
if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header <name>:<value>');
|
||||
const sep = headerStr.indexOf(':');
|
||||
const name = headerStr.slice(0, sep).trim();
|
||||
const value = headerStr.slice(sep + 1).trim();
|
||||
await bm.setExtraHeader(name, value);
|
||||
return `Header set: ${name}: ${value}`;
|
||||
}
|
||||
|
||||
case 'useragent': {
|
||||
const ua = args.join(' ');
|
||||
if (!ua) throw new Error('Usage: browse useragent <string>');
|
||||
bm.setUserAgent(ua);
|
||||
return `User agent set (applies on next restart): ${ua}`;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown write command: ${command}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user