fix: Windows support — Node.js server fallback for Playwright (#255)

* 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>

* fix: cross-platform path handling for Windows compatibility

Replace hardcoded '/tmp' and 'dir + "/"' path checks with
platform-aware constants from new platform.ts module. On macOS/Linux
this evaluates identically ('/tmp', '/'); on Windows it uses
os.tmpdir() and path.sep. Zero behavior change on Unix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* test: add tests for Windows polyfill, platform constants, and Node server resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* docs: Windows support in README + CHANGELOG (v0.9.1.1)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.9.3.0)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-03-20 12:22:11 -07:00
committed by GitHub
parent 6a6b2b0766
commit d7c732b282
20 changed files with 430 additions and 29 deletions
+109
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));
},
};
+38 -4
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 },
});
+6 -5
View File
@@ -10,13 +10,14 @@ import { validateNavigationUrl } from './url-validation';
import * as Diff from 'diff';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
export function validateOutputPath(filePath: string): void {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
@@ -88,7 +89,7 @@ export async function handleMetaCommand(
case 'screenshot': {
// Parse priority: flags (--viewport, --clip) → selector (@ref, CSS) → output path
const page = bm.getPage();
let outputPath = '/tmp/browse-screenshot.png';
let outputPath = `${TEMP_DIR}/browse-screenshot.png`;
let clipRect: { x: number; y: number; width: number; height: number } | undefined;
let targetSelector: string | undefined;
let viewportOnly = false;
@@ -147,7 +148,7 @@ export async function handleMetaCommand(
case 'pdf': {
const page = bm.getPage();
const pdfPath = args[0] || '/tmp/browse-page.pdf';
const pdfPath = args[0] || `${TEMP_DIR}/browse-page.pdf`;
validateOutputPath(pdfPath);
await page.pdf({ path: pdfPath, format: 'A4' });
return `PDF saved: ${pdfPath}`;
@@ -155,7 +156,7 @@ export async function handleMetaCommand(
case 'responsive': {
const page = bm.getPage();
const prefix = args[0] || '/tmp/browse-responsive';
const prefix = args[0] || `${TEMP_DIR}/browse-responsive`;
validateOutputPath(prefix);
const viewports = [
{ name: 'mobile', width: 375, height: 812 },
+17
View File
@@ -0,0 +1,17 @@
/**
* Cross-platform constants for gstack browse.
*
* On macOS/Linux: TEMP_DIR = '/tmp', path.sep = '/' — identical to hardcoded values.
* On Windows: TEMP_DIR = os.tmpdir(), path.sep = '\\' — correct Windows behavior.
*/
import * as os from 'os';
import * as path from 'path';
export const IS_WINDOWS = process.platform === 'win32';
export const TEMP_DIR = IS_WINDOWS ? os.tmpdir() : '/tmp';
/** Check if resolvedPath is within dir, using platform-aware separators. */
export function isPathWithin(resolvedPath: string, dir: string): boolean {
return resolvedPath === dir || resolvedPath.startsWith(dir + path.sep);
}
+3 -2
View File
@@ -10,6 +10,7 @@ import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
import type { Page } from 'playwright';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
function hasAwait(code: string): boolean {
@@ -36,12 +37,12 @@ function wrapForEvaluate(code: string): string {
}
// Security: Path validation to prevent path traversal attacks
const SAFE_DIRECTORIES = ['/tmp', process.cwd()];
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
export function validateReadPath(filePath: string): void {
if (path.isAbsolute(filePath)) {
const resolved = path.resolve(filePath);
const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/'));
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
if (!isSafe) {
throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
}
+5 -4
View File
@@ -20,6 +20,7 @@
import type { Page, Locator } from 'playwright';
import type { BrowserManager, RefEntry } from './browser-manager';
import * as Diff from 'diff';
import { TEMP_DIR, isPathWithin } from './platform';
// Roles considered "interactive" for the -i flag
const INTERACTIVE_ROLES = new Set([
@@ -61,7 +62,7 @@ export const SNAPSHOT_FLAGS: Array<{
{ short: '-s', long: '--selector', description: 'Scope to CSS selector', takesValue: true, valueHint: '<sel>', optionKey: 'selector' },
{ short: '-D', long: '--diff', description: 'Unified diff against previous snapshot (first call stores baseline)', optionKey: 'diff' },
{ short: '-a', long: '--annotate', description: 'Annotated screenshot with red overlay boxes and ref labels', optionKey: 'annotate' },
{ short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: /tmp/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
{ short: '-o', long: '--output', description: 'Output path for annotated screenshot (default: <temp>/browse-annotated.png)', takesValue: true, valueHint: '<path>', optionKey: 'outputPath' },
{ short: '-C', long: '--cursor-interactive', description: 'Cursor-interactive elements (@c refs — divs with pointer, onclick)', optionKey: 'cursorInteractive' },
];
@@ -308,11 +309,11 @@ export async function handleSnapshot(
// ─── Annotated screenshot (-a) ────────────────────────────
if (opts.annotate) {
const screenshotPath = opts.outputPath || '/tmp/browse-annotated.png';
const screenshotPath = opts.outputPath || `${TEMP_DIR}/browse-annotated.png`;
// Validate output path (consistent with screenshot/pdf/responsive)
const resolvedPath = require('path').resolve(screenshotPath);
const safeDirs = ['/tmp', process.cwd()];
if (!safeDirs.some((dir: string) => resolvedPath === dir || resolvedPath.startsWith(dir + '/'))) {
const safeDirs = [TEMP_DIR, process.cwd()];
if (!safeDirs.some((dir: string) => isPathWithin(resolvedPath, dir))) {
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
}
try {
+3 -2
View File
@@ -10,6 +10,7 @@ import { findInstalledBrowsers, importCookies } from './cookie-import-browser';
import { validateNavigationUrl } from './url-validation';
import * as fs from 'fs';
import * as path from 'path';
import { TEMP_DIR, isPathWithin } from './platform';
export async function handleWriteCommand(
command: string,
@@ -277,9 +278,9 @@ export async function handleWriteCommand(
if (!filePath) throw new Error('Usage: browse cookie-import <json-file>');
// Path validation — prevent reading arbitrary files
if (path.isAbsolute(filePath)) {
const safeDirs = ['/tmp', process.cwd()];
const safeDirs = [TEMP_DIR, process.cwd()];
const resolved = path.resolve(filePath);
if (!safeDirs.some(dir => resolved === dir || resolved.startsWith(dir + '/'))) {
if (!safeDirs.some(dir => isPathWithin(resolved, dir))) {
throw new Error(`Path must be within: ${safeDirs.join(', ')}`);
}
}