diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 931b85c10..af5b9f2e8 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -11,6 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { spawn as nodeSpawn } from 'node:child_process'; const PORT_OFFSET = 45600; const BROWSE_PORT = process.env.CONDUCTOR_PORT @@ -20,6 +21,8 @@ const INSTANCE_SUFFIX = BROWSE_PORT ? `-${BROWSE_PORT}` : ''; const STATE_FILE = process.env.BROWSE_STATE_FILE || `/tmp/browse-server${INSTANCE_SUFFIX}.json`; const MAX_START_WAIT = 8000; // 8 seconds to start +const IS_WINDOWS = process.platform === 'win32'; + export function resolveServerScript( env: Record = process.env, metaDir: string = import.meta.dir, @@ -29,8 +32,8 @@ export function resolveServerScript( return env.BROWSE_SERVER_SCRIPT; } - // Dev mode: cli.ts runs directly from browse/src - if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) { + // Dev mode: cli.ts runs directly from browse/src (Unix paths start with /, Windows with drive letter) + if (!metaDir.includes('$bunfs')) { const direct = path.resolve(metaDir, 'server.ts'); if (fs.existsSync(direct)) { return direct; @@ -46,10 +49,34 @@ export function resolveServerScript( } // Legacy fallback for user-level installs - return path.resolve(env.HOME || '/tmp', '.claude/skills/gstack/browse/src/server.ts'); + return path.resolve(env.HOME || env.USERPROFILE || '/tmp', '.claude/skills/gstack/browse/src/server.ts'); +} + +/** + * On Windows, Bun's subprocess management hangs when Playwright spawns Chromium. + * Use a pre-bundled server.js with Node instead. On Unix, Bun works fine. + */ +export function resolveServerBundle( + env: Record = process.env, + execPath: string = process.execPath +): string | null { + if (!IS_WINDOWS) return null; + + // Look for compiled server.js next to the browse binary + if (execPath) { + const bundled = path.resolve(path.dirname(execPath), 'server.js'); + if (fs.existsSync(bundled)) return bundled; + } + + // Fallback: check user-level install + const fallback = path.resolve(env.HOME || env.USERPROFILE || '/tmp', '.claude/skills/gstack/browse/dist/server.js'); + if (fs.existsSync(fallback)) return fallback; + + return null; } const SERVER_SCRIPT = resolveServerScript(); +const SERVER_BUNDLE = resolveServerBundle(); interface ServerState { pid: number; @@ -84,13 +111,29 @@ async function startServer(): Promise { 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 }, - }); + // On Windows, Bun + Playwright hangs on chromium.launch(), so use Node with + // a pre-bundled server.js. On Unix, Bun runs the .ts source directly. + // We also use node:child_process on Windows for proper detaching (Bun.spawn + // + unref doesn't fully detach on Windows, causing the server to die with the CLI). + const useNode = IS_WINDOWS && SERVER_BUNDLE; + const spawnCmd = useNode ? 'node' : 'bun'; + const spawnArgs = useNode ? [SERVER_BUNDLE] : ['run', SERVER_SCRIPT]; - // Don't hold the CLI open - proc.unref(); + if (IS_WINDOWS) { + // node:child_process with detached:true properly orphans on Windows + const child = nodeSpawn(spawnCmd, spawnArgs, { + stdio: 'ignore', + detached: true, + env: { ...process.env }, + }); + child.unref(); + } else { + const proc = Bun.spawn([spawnCmd, ...spawnArgs], { + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env }, + }); + proc.unref(); + } // Wait for state file to appear const start = Date.now(); @@ -102,17 +145,6 @@ async function startServer(): Promise { 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`); } diff --git a/browse/src/server.ts b/browse/src/server.ts index 71e3b5c04..ba6d8c376 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -2,10 +2,12 @@ * gstack browse server — persistent Chromium daemon * * Architecture: - * Bun.serve HTTP on localhost → routes commands to Playwright + * HTTP server 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) + * + * Runtime: works under both Bun and Node (uses node:http/node:net) */ import { BrowserManager } from './browser-manager'; @@ -15,6 +17,8 @@ import { handleMetaCommand } from './meta-commands'; import * as fs from 'fs'; import * as path from 'path'; import * as crypto from 'crypto'; +import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { createServer as createNetServer } from 'node:net'; // ─── Auth (inline) ───────────────────────────────────────────── const AUTH_TOKEN = crypto.randomUUID(); @@ -26,11 +30,6 @@ 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 }; @@ -107,28 +106,32 @@ const META_COMMANDS = new Set([ 'url', 'snapshot', ]); +// Check if a port is available using node:net (works in both Bun and Node) +function isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = createNetServer(); + server.once('error', () => resolve(false)); + server.listen(port, '127.0.0.1', () => { + server.close(() => resolve(true)); + }); + }); +} + // Find port: deterministic from CONDUCTOR_PORT, or scan range async function findPort(): Promise { // 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(); + if (await isPortAvailable(BROWSE_PORT)) { return BROWSE_PORT; - } catch { - throw new Error(`[browse] Port ${BROWSE_PORT} (from CONDUCTOR_PORT ${process.env.CONDUCTOR_PORT}) is in use`); } + 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(); + if (await isPortAvailable(port)) { return port; - } catch { - continue; } } throw new Error(`[browse] No available port in range ${start}-${start + 9}`); @@ -208,43 +211,65 @@ async function start() { 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); + // Collect full request body from node:http IncomingMessage + function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + let data = ''; + req.on('data', (chunk: Buffer) => { data += chunk; }); + req.on('end', () => resolve(data)); + req.on('error', reject); + }); + } - // 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' }, - }); - } + // Send a Response object through node:http ServerResponse + async function sendResponse(res: ServerResponse, response: Response) { + const body = await response.text(); + const headers: Record = {}; + response.headers.forEach((v, k) => { headers[k] = v; }); + res.writeHead(response.status, headers); + res.end(body); + } - // All other endpoints require auth - if (!validateAuth(req)) { - return new Response(JSON.stringify({ error: 'Unauthorized' }), { - status: 401, - headers: { 'Content-Type': 'application/json' }, - }); - } + const server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => { + resetIdleTimer(); - if (url.pathname === '/command' && req.method === 'POST') { - const body = await req.json(); - return handleCommand(body); - } + const url = new URL(req.url!, `http://127.0.0.1:${port}`); - return new Response('Not found', { status: 404 }); - }, + // Health check — no auth required + if (url.pathname === '/health') { + const healthy = browserManager.isHealthy(); + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: healthy ? 'healthy' : 'unhealthy', + uptime: Math.floor((Date.now() - startTime) / 1000), + tabs: browserManager.getTabCount(), + currentUrl: browserManager.getCurrentUrl(), + })); + return; + } + + // All other endpoints require auth + if (req.headers['authorization'] !== `Bearer ${AUTH_TOKEN}`) { + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Unauthorized' })); + return; + } + + if (url.pathname === '/command' && req.method === 'POST') { + const bodyStr = await readBody(req); + const body = JSON.parse(bodyStr); + const response = await handleCommand(body); + await sendResponse(res, response); + return; + } + + res.writeHead(404); + res.end('Not found'); + }); + + await new Promise((resolve) => { + server.listen(port, '127.0.0.1', () => resolve()); }); // Write state file @@ -253,7 +278,7 @@ async function start() { port, token: AUTH_TOKEN, startedAt: new Date().toISOString(), - serverPath: path.resolve(import.meta.dir, 'server.ts'), + serverPath: path.resolve(import.meta.dir ?? __dirname, 'server.ts'), }; fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2), { mode: 0o600 }); diff --git a/package.json b/package.json index d486f91f6..4730fa207 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "browse": "./browse/dist/browse" }, "scripts": { - "build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse", + "build": "bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build browse/src/server.ts --outfile browse/dist/server.js --target node --packages external --format esm", "dev": "bun run browse/src/cli.ts", "server": "bun run browse/src/server.ts", "test": "bun test",