fix: Windows support — Node.js server fallback for Playwright

Setup hangs on Windows 11 because Bun's child_process can't handle
Playwright's --remote-debugging-pipe (fd 3/4 pipe handles). Fall back
to Node.js on Windows for both the setup verification and server
runtime. macOS/Linux completely unaffected — all Windows code behind
IS_WINDOWS / process.platform === 'win32' guards.

Based on community PR #194 by @sozairali. Fixed sed -i portability
(perl -pi -e) in build-node-server.sh for macOS compatibility.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-20 08:37:35 -07:00
parent ae2d841012
commit 73f6e30c15
5 changed files with 237 additions and 10 deletions

109
browse/src/bun-polyfill.cjs Normal file
View File

@@ -0,0 +1,109 @@
/**
* Bun API polyfill for Node.js — Windows compatibility layer.
*
* On Windows, Bun can't launch or connect to Playwright's Chromium
* (oven-sh/bun#4253, #9911). The browse server falls back to running
* under Node.js with this polyfill providing Bun API equivalents.
*
* Loaded via --require before the transpiled server bundle.
*/
'use strict';
const http = require('http');
const { spawnSync, spawn } = require('child_process');
globalThis.Bun = {
serve(options) {
const { port, hostname = '127.0.0.1', fetch } = options;
const server = http.createServer(async (nodeReq, nodeRes) => {
try {
const url = `http://${hostname}:${port}${nodeReq.url}`;
const headers = new Headers();
for (const [key, val] of Object.entries(nodeReq.headers)) {
if (val) headers.set(key, Array.isArray(val) ? val[0] : val);
}
let body = null;
if (nodeReq.method !== 'GET' && nodeReq.method !== 'HEAD') {
body = await new Promise((resolve) => {
const chunks = [];
nodeReq.on('data', (chunk) => chunks.push(chunk));
nodeReq.on('end', () => resolve(Buffer.concat(chunks)));
});
}
const webReq = new Request(url, {
method: nodeReq.method,
headers,
body,
});
const webRes = await fetch(webReq);
nodeRes.statusCode = webRes.status;
webRes.headers.forEach((val, key) => {
nodeRes.setHeader(key, val);
});
const resBody = await webRes.arrayBuffer();
nodeRes.end(Buffer.from(resBody));
} catch (err) {
nodeRes.statusCode = 500;
nodeRes.end(JSON.stringify({ error: err.message }));
}
});
server.listen(port, hostname);
return {
stop() { server.close(); },
port,
hostname,
};
},
spawnSync(cmd, options = {}) {
const [command, ...args] = cmd;
const result = spawnSync(command, args, {
stdio: [
options.stdin || 'pipe',
options.stdout === 'pipe' ? 'pipe' : 'ignore',
options.stderr === 'pipe' ? 'pipe' : 'ignore',
],
timeout: options.timeout,
env: options.env,
cwd: options.cwd,
});
return {
exitCode: result.status,
stdout: result.stdout || Buffer.from(''),
stderr: result.stderr || Buffer.from(''),
};
},
spawn(cmd, options = {}) {
const [command, ...args] = cmd;
const stdio = options.stdio || ['pipe', 'pipe', 'pipe'];
const proc = spawn(command, args, {
stdio,
env: options.env,
cwd: options.cwd,
});
return {
pid: proc.pid,
stdout: proc.stdout,
stderr: proc.stderr,
stdin: proc.stdin,
unref() { proc.unref(); },
kill(signal) { proc.kill(signal); },
};
},
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
};

View File

@@ -14,7 +14,8 @@ import * as path from 'path';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
const config = resolveConfig();
const MAX_START_WAIT = 8000; // 8 seconds to start
const IS_WINDOWS = process.platform === 'win32';
const MAX_START_WAIT = IS_WINDOWS ? 15000 : 8000; // Node+Chromium takes longer on Windows
export function resolveServerScript(
env: Record<string, string | undefined> = process.env,
@@ -26,7 +27,9 @@ export function resolveServerScript(
}
// Dev mode: cli.ts runs directly from browse/src
if (metaDir.startsWith('/') && !metaDir.includes('$bunfs')) {
// On macOS/Linux, import.meta.dir starts with /
// On Windows, it starts with a drive letter (e.g., C:\...)
if (!metaDir.includes('$bunfs')) {
const direct = path.resolve(metaDir, 'server.ts');
if (fs.existsSync(direct)) {
return direct;
@@ -48,6 +51,31 @@ export function resolveServerScript(
const SERVER_SCRIPT = resolveServerScript();
/**
* On Windows, resolve the Node.js-compatible server bundle.
* Falls back to null if not found (server will use Bun instead).
*/
export function resolveNodeServerScript(
metaDir: string = import.meta.dir,
execPath: string = process.execPath
): string | null {
// Dev mode
if (!metaDir.includes('$bunfs')) {
const distScript = path.resolve(metaDir, '..', 'dist', 'server-node.mjs');
if (fs.existsSync(distScript)) return distScript;
}
// Compiled binary: browse/dist/browse → browse/dist/server-node.mjs
if (execPath) {
const adjacent = path.resolve(path.dirname(execPath), 'server-node.mjs');
if (fs.existsSync(adjacent)) return adjacent;
}
return null;
}
const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null;
interface ServerState {
pid: number;
port: number;
@@ -139,8 +167,14 @@ async function startServer(): Promise<ServerState> {
// Clean up stale state file
try { fs.unlinkSync(config.stateFile); } catch {}
// Start server as detached background process
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
// Start server as detached background process.
// On Windows, Bun can't launch/connect to Playwright's Chromium (oven-sh/bun#4253, #9911).
// Fall back to running the server under Node.js with Bun API polyfills.
const useNode = IS_WINDOWS && NODE_SERVER_SCRIPT;
const serverCmd = useNode
? ['node', NODE_SERVER_SCRIPT]
: ['bun', 'run', SERVER_SCRIPT];
const proc = Bun.spawn(serverCmd, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
});