mirror of https://github.com/garrytan/gstack.git
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:
parent
ae2d841012
commit
73f6e30c15
|
|
@ -0,0 +1,48 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a Node.js-compatible server bundle for Windows.
|
||||||
|
#
|
||||||
|
# On Windows, Bun can't launch or connect to Playwright's Chromium
|
||||||
|
# (oven-sh/bun#4253, #9911). This script produces a server bundle
|
||||||
|
# that runs under Node.js with Bun API polyfills.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
GSTACK_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
SRC_DIR="$GSTACK_DIR/browse/src"
|
||||||
|
DIST_DIR="$GSTACK_DIR/browse/dist"
|
||||||
|
|
||||||
|
echo "Building Node-compatible server bundle..."
|
||||||
|
|
||||||
|
# Step 1: Transpile server.ts to a single .mjs bundle (externalize runtime deps)
|
||||||
|
bun build "$SRC_DIR/server.ts" \
|
||||||
|
--target=node \
|
||||||
|
--outfile "$DIST_DIR/server-node.mjs" \
|
||||||
|
--external playwright \
|
||||||
|
--external playwright-core \
|
||||||
|
--external diff \
|
||||||
|
--external "bun:sqlite"
|
||||||
|
|
||||||
|
# Step 2: Post-process
|
||||||
|
# Replace import.meta.dir with a resolvable reference
|
||||||
|
perl -pi -e 's/import\.meta\.dir/__browseNodeSrcDir/g' "$DIST_DIR/server-node.mjs"
|
||||||
|
# Stub out bun:sqlite (macOS-only cookie import, not needed on Windows)
|
||||||
|
perl -pi -e 's|import { Database } from "bun:sqlite";|const Database = null; // bun:sqlite stubbed on Node|g' "$DIST_DIR/server-node.mjs"
|
||||||
|
|
||||||
|
# Step 3: Create the final file with polyfill header injected after the first line
|
||||||
|
{
|
||||||
|
head -1 "$DIST_DIR/server-node.mjs"
|
||||||
|
echo '// ── Windows Node.js compatibility (auto-generated) ──'
|
||||||
|
echo 'import { fileURLToPath as _ftp } from "node:url";'
|
||||||
|
echo 'import { dirname as _dn } from "node:path";'
|
||||||
|
echo 'const __browseNodeSrcDir = _dn(_dn(_ftp(import.meta.url))) + "/src";'
|
||||||
|
echo '{ const _r = createRequire(import.meta.url); _r("./bun-polyfill.cjs"); }'
|
||||||
|
echo '// ── end compatibility ──'
|
||||||
|
tail -n +2 "$DIST_DIR/server-node.mjs"
|
||||||
|
} > "$DIST_DIR/server-node.tmp.mjs"
|
||||||
|
|
||||||
|
mv "$DIST_DIR/server-node.tmp.mjs" "$DIST_DIR/server-node.mjs"
|
||||||
|
|
||||||
|
# Step 4: Copy polyfill to dist/
|
||||||
|
cp "$SRC_DIR/bun-polyfill.cjs" "$DIST_DIR/bun-polyfill.cjs"
|
||||||
|
|
||||||
|
echo "Node server bundle ready: $DIST_DIR/server-node.mjs"
|
||||||
|
|
@ -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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -14,7 +14,8 @@ import * as path from 'path';
|
||||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||||
|
|
||||||
const config = resolveConfig();
|
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(
|
export function resolveServerScript(
|
||||||
env: Record<string, string | undefined> = process.env,
|
env: Record<string, string | undefined> = process.env,
|
||||||
|
|
@ -26,7 +27,9 @@ export function resolveServerScript(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dev mode: cli.ts runs directly from browse/src
|
// 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');
|
const direct = path.resolve(metaDir, 'server.ts');
|
||||||
if (fs.existsSync(direct)) {
|
if (fs.existsSync(direct)) {
|
||||||
return direct;
|
return direct;
|
||||||
|
|
@ -48,6 +51,31 @@ export function resolveServerScript(
|
||||||
|
|
||||||
const SERVER_SCRIPT = 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 {
|
interface ServerState {
|
||||||
pid: number;
|
pid: number;
|
||||||
port: number;
|
port: number;
|
||||||
|
|
@ -139,8 +167,14 @@ async function startServer(): Promise<ServerState> {
|
||||||
// Clean up stale state file
|
// Clean up stale state file
|
||||||
try { fs.unlinkSync(config.stateFile); } catch {}
|
try { fs.unlinkSync(config.stateFile); } catch {}
|
||||||
|
|
||||||
// Start server as detached background process
|
// Start server as detached background process.
|
||||||
const proc = Bun.spawn(['bun', 'run', SERVER_SCRIPT], {
|
// 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'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
env: { ...process.env, BROWSE_STATE_FILE: config.stateFile },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@
|
||||||
"browse": "./browse/dist/browse"
|
"browse": "./browse/dist/browse"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun run gen:skill-docs && bun run gen:skill-docs --host codex && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && git rev-parse HEAD > browse/dist/.version && rm -f .*.bun-build || true",
|
"build": "bun run gen:skill-docs && bun run gen:skill-docs --host codex && bun build --compile browse/src/cli.ts --outfile browse/dist/browse && bun build --compile browse/src/find-browse.ts --outfile browse/dist/find-browse && bash browse/scripts/build-node-server.sh && git rev-parse HEAD > browse/dist/.version && rm -f .*.bun-build || true",
|
||||||
"gen:skill-docs": "bun run scripts/gen-skill-docs.ts",
|
"gen:skill-docs": "bun run scripts/gen-skill-docs.ts",
|
||||||
"dev": "bun run browse/src/cli.ts",
|
"dev": "bun run browse/src/cli.ts",
|
||||||
"server": "bun run browse/src/server.ts",
|
"server": "bun run browse/src/server.ts",
|
||||||
|
|
|
||||||
36
setup
36
setup
|
|
@ -12,6 +12,11 @@ GSTACK_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
SKILLS_DIR="$(dirname "$GSTACK_DIR")"
|
SKILLS_DIR="$(dirname "$GSTACK_DIR")"
|
||||||
BROWSE_BIN="$GSTACK_DIR/browse/dist/browse"
|
BROWSE_BIN="$GSTACK_DIR/browse/dist/browse"
|
||||||
|
|
||||||
|
IS_WINDOWS=0
|
||||||
|
case "$(uname -s)" in
|
||||||
|
MINGW*|MSYS*|CYGWIN*|Windows_NT) IS_WINDOWS=1 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
# ─── Parse --host flag ─────────────────────────────────────────
|
# ─── Parse --host flag ─────────────────────────────────────────
|
||||||
HOST="claude"
|
HOST="claude"
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
|
|
@ -44,10 +49,19 @@ elif [ "$HOST" = "codex" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
ensure_playwright_browser() {
|
ensure_playwright_browser() {
|
||||||
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||||
|
# On Windows, Bun can't launch Chromium due to broken pipe handling
|
||||||
|
# (oven-sh/bun#4253). Use Node.js to verify Chromium works instead.
|
||||||
|
(
|
||||||
|
cd "$GSTACK_DIR"
|
||||||
|
node -e "const { chromium } = require('playwright'); (async () => { const b = await chromium.launch(); await b.close(); })()" 2>/dev/null
|
||||||
|
)
|
||||||
|
else
|
||||||
(
|
(
|
||||||
cd "$GSTACK_DIR"
|
cd "$GSTACK_DIR"
|
||||||
bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();'
|
bun --eval 'import { chromium } from "playwright"; const browser = await chromium.launch(); await browser.close();'
|
||||||
) >/dev/null 2>&1
|
) >/dev/null 2>&1
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock)
|
# 1. Build browse binary if needed (smart rebuild: stale sources, package.json, lock)
|
||||||
|
|
@ -87,10 +101,32 @@ if ! ensure_playwright_browser; then
|
||||||
cd "$GSTACK_DIR"
|
cd "$GSTACK_DIR"
|
||||||
bunx playwright install chromium
|
bunx playwright install chromium
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||||
|
# On Windows, Node.js launches Chromium (not Bun — see oven-sh/bun#4253).
|
||||||
|
# Ensure playwright is importable by Node from the gstack directory.
|
||||||
|
if ! command -v node >/dev/null 2>&1; then
|
||||||
|
echo "gstack setup failed: Node.js is required on Windows (Bun cannot launch Chromium due to a pipe bug)" >&2
|
||||||
|
echo " Install Node.js: https://nodejs.org/" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Windows detected — verifying Node.js can load Playwright..."
|
||||||
|
(
|
||||||
|
cd "$GSTACK_DIR"
|
||||||
|
# Bun's node_modules already has playwright; verify Node can require it
|
||||||
|
node -e "require('playwright')" 2>/dev/null || npm install --no-save playwright
|
||||||
|
)
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! ensure_playwright_browser; then
|
if ! ensure_playwright_browser; then
|
||||||
|
if [ "$IS_WINDOWS" -eq 1 ]; then
|
||||||
|
echo "gstack setup failed: Playwright Chromium could not be launched via Node.js" >&2
|
||||||
|
echo " This is a known issue with Bun on Windows (oven-sh/bun#4253)." >&2
|
||||||
|
echo " Ensure Node.js is installed and 'node -e \"require('playwright')\"' works." >&2
|
||||||
|
else
|
||||||
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
|
echo "gstack setup failed: Playwright Chromium could not be launched" >&2
|
||||||
|
fi
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue