mirror of
https://github.com/garrytan/gstack.git
synced 2026-07-07 00:27:56 +02:00
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:
@@ -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
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user