fix: add Windows compatibility for browse server

Bun's subprocess management hangs when Playwright calls chromium.launch()
on Windows. This replaces Bun-specific APIs with cross-runtime equivalents
so the server works under both Bun (Unix) and Node (Windows):

- Replace Bun.serve() with node:http createServer (works in both runtimes)
- Replace Bun.serve() port scanning with node:net createServer
- On Windows, spawn server via node:child_process with detached:true
  (Bun.spawn + unref doesn't properly orphan on Windows)
- Build step now also bundles server.js for Node via --target node
- Fix resolveServerScript() dev-mode check for Windows paths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DanMcInerney 2026-03-12 16:41:20 -07:00
parent 1b317aae9a
commit d66bb41e25
3 changed files with 127 additions and 70 deletions

View File

@ -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<string, string | undefined> = 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<string, string | undefined> = 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<ServerState> {
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<ServerState> {
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`);
}

View File

@ -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<boolean> {
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<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();
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<string> {
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<string, string> = {};
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<void>((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 });

View File

@ -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",