diff --git a/browse/scripts/build-node-server.sh b/browse/scripts/build-node-server.sh index 0694782a7..7603c9fad 100755 --- a/browse/scripts/build-node-server.sh +++ b/browse/scripts/build-node-server.sh @@ -21,7 +21,9 @@ echo "Building Node-compatible server bundle..." # Step 1: Transpile server.ts to a single .mjs bundle (externalize runtime deps) # -# Externalize packages with native addons, dynamic imports, or runtime resolution. +# Externalize packages with native addons, dynamic imports, or runtime resolution +# (sharp loads a platform .node addon; socks is resolved at runtime, and Bun on +# Windows refuses to inline either, #2260). # If you add a new dependency that uses `await import()` or has a .node addon, # add it here. Otherwise `bun build --outfile` will fail with # "cannot write multiple output files without an output directory". @@ -32,7 +34,9 @@ bun build "$SRC_DIR/server.ts" \ --external playwright-core \ --external diff \ --external "bun:sqlite" \ - --external "@ngrok/ngrok" + --external "@ngrok/ngrok" \ + --external socks \ + --external sharp # Step 2: Post-process # Replace import.meta.dir with a resolvable reference diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 02369ab84..cba6c1ab1 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -84,8 +84,6 @@ 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). @@ -109,13 +107,23 @@ export function resolveNodeServerScript( return null; } -const NODE_SERVER_SCRIPT = IS_WINDOWS ? resolveNodeServerScript() : null; - -// On Windows, hard-fail if server-node.mjs is missing — the Bun path is known broken. -if (IS_WINDOWS && !NODE_SERVER_SCRIPT) { - throw new Error( - 'server-node.mjs not found. Run `bun run build` to generate the Windows server bundle.' - ); +/** + * Which server to start, resolved only when a server is actually started + * (#2439). Windows runs the Node bundle and never needs server.ts, which a + * minimal runtime root does not ship; resolving server.ts at module load made + * every command, even --help, fail there. + */ +export function resolveServerLaunch( + platform: NodeJS.Platform = process.platform, + env: Record = process.env, + metaDir: string = import.meta.dir, + execPath: string = process.execPath, +): { runtime: 'node' | 'bun'; script: string } { + if (platform !== 'win32') return { runtime: 'bun', script: resolveServerScript(env, metaDir, execPath) }; + // On Windows, hard-fail if server-node.mjs is missing — the Bun path is known broken. + const script = resolveNodeServerScript(metaDir, execPath); + if (!script) throw new Error('server-node.mjs not found. Run `bun run build` to generate the Windows server bundle.'); + return { runtime: 'node', script }; } interface ServerState { @@ -591,6 +599,7 @@ function openDaemonLogSink(): number | 'ignore' { } async function startServer(extraEnv?: Record): Promise { + const server = resolveServerLaunch(); ensureStateDir(config); // Bound the append-mode daemon log before the new daemon starts writing. @@ -625,7 +634,7 @@ async function startServer(extraEnv?: Record): Promise): Promise): Promise): Promise readPidStartTime(pid) === startTime && readPidCmdline(pid).split(/\s+/).includes(SERVER_SCRIPT); + const stillOurs = () => readPidStartTime(pid) === startTime && readPidCmdline(pid).split(/\s+/).includes(server.script); if (stillOurs()) { safeKill(pid, 'SIGTERM'); const deadline = Date.now() + 500; diff --git a/browse/test/build.test.ts b/browse/test/build.test.ts index 38e3b1891..f194d3387 100644 --- a/browse/test/build.test.ts +++ b/browse/test/build.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test'; import { execSync } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; const DIST_DIR = path.resolve(__dirname, '..', 'dist'); const SERVER_NODE = path.join(DIST_DIR, 'server-node.mjs'); @@ -26,3 +27,22 @@ describe('build: server-node.mjs', () => { expect(bundle).not.toMatch(/ngrok_napi|ngrokNapi|@ngrok\/ngrok-darwin|@ngrok\/ngrok-linux|@ngrok\/ngrok-win32/); }); }); + +describe('build: node server bundle externals (#2260)', () => { + test('sharp and socks stay external, so their native and runtime-resolved code is loaded from node_modules', () => { + const script = fs.readFileSync(path.resolve(__dirname, '..', 'scripts', 'build-node-server.sh'), 'utf-8'); + const externals = [...script.matchAll(/--external\s+"?([^"\s\\]+)"?/g)].map((m) => m[1]); + expect(externals).toEqual(expect.arrayContaining(['sharp', 'socks'])); + const out = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-node-bundle-')); + try { + const outfile = path.join(out, 'server-node.mjs'); + const r = Bun.spawnSync([process.execPath, 'build', path.resolve(__dirname, '..', 'src', 'server.ts'), '--target=node', '--outfile', outfile, ...externals.flatMap((e) => ['--external', e])], { stdout: 'pipe', stderr: 'pipe', timeout: 120_000 }); + expect(r.exitCode, r.stderr.toString()).toBe(0); + const bundle = fs.readFileSync(outfile, 'utf-8'); + expect(bundle).not.toContain('node_modules/sharp/'); + expect(bundle).not.toContain('node_modules/socks/'); + } finally { + fs.rmSync(out, { recursive: true, force: true }); + } + }, 150_000); +}); diff --git a/browse/test/server-launch-resolution.test.ts b/browse/test/server-launch-resolution.test.ts new file mode 100644 index 000000000..3f62ae17b --- /dev/null +++ b/browse/test/server-launch-resolution.test.ts @@ -0,0 +1,49 @@ +/** + * E4 (#2439): the CLI resolves its server script only when it starts a + * server. A Windows minimal runtime ships browse.exe + server-node.mjs and no + * browse/src/server.ts, which used to make every command fail at import. + */ +import { describe, expect, test } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { resolveServerLaunch } from '../src/cli'; + +function withRuntime(files: string[], fn: (dir: string) => T): T { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-runtime-')); + try { + for (const f of files) fs.writeFileSync(path.join(dir, f), '// stub\n'); + return fn(dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +} + +const COMPILED_META = path.join(os.tmpdir(), '$bunfs', 'root'); + +describe('resolveServerLaunch', () => { + test('Windows minimal runtime starts the adjacent Node bundle without server.ts', () => { + withRuntime(['server-node.mjs'], (dir) => { + expect(resolveServerLaunch('win32', {}, COMPILED_META, path.join(dir, 'browse.exe'))) + .toEqual({ runtime: 'node', script: path.join(dir, 'server-node.mjs') }); + }); + }); + + test('Windows without the Node bundle names the build fix', () => { + withRuntime([], (dir) => { + expect(() => resolveServerLaunch('win32', {}, COMPILED_META, path.join(dir, 'browse.exe'))) + .toThrow('server-node.mjs not found. Run `bun run build`'); + }); + }); + + test('macOS/Linux start server.ts with Bun, honoring BROWSE_SERVER_SCRIPT', () => { + expect(resolveServerLaunch('linux', { BROWSE_SERVER_SCRIPT: 'custom-server.ts' }, COMPILED_META, '')) + .toEqual({ runtime: 'bun', script: 'custom-server.ts' }); + }); + + test('importing the CLI resolves nothing: a missing server is reported only when one is started', () => { + const src = fs.readFileSync(path.join(import.meta.dir, '..', 'src', 'cli.ts'), 'utf-8'); + expect(src).not.toMatch(/^const \w+ = resolve(Server|NodeServer)\w*\(/m); + expect(src).toMatch(/async function startServer\([^)]*\)[^{]*\{\n\s*const server = resolveServerLaunch\(\);/); + }); +});