mirror of https://github.com/garrytan/gstack.git
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:
parent
1b317aae9a
commit
d66bb41e25
|
|
@ -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`);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue