mirror of
https://github.com/garrytan/gstack.git
synced 2026-06-17 15:20:11 +02:00
feat(server): factory-export API surface + import.meta.main gate
Surfaces the embedder API gbrowser (phoenix) needs to consume gstack as a submodule, and gates module-load side effects so the file is safe to import without auto-starting a daemon. Changes to browse/src/server.ts: - AUTH_TOKEN now honors process.env.AUTH_TOKEN (trimmed) before falling back to crypto.randomUUID(). Whitespace-only values are rejected so the security boundary can't be silently weakened. - New exported types: ServerConfig and ServerHandle. ServerConfig documents the full factory contract (authToken, browsePort, idleTimeoutMs, config, browserManager, chromiumProfile, xvfb, proxyBridge, startTime, beforeRoute). ServerHandle documents the return shape (fetchLocal, fetchTunnel, shutdown, stopListeners). Caller-owned lifecycle annotations on xvfb and proxyBridge prevent double-close bugs from surprise ownership. - New exported function: resolveConfigFromEnv() builds a ServerConfig-shaped object from process.env for CLI use. Embedders construct their own ServerConfig explicitly. - start() is now exported. Embedders can call it with env vars set as a v1 escape hatch until full buildFetchHandler extraction lands. - Signal handlers (SIGINT, SIGTERM, Windows exit, uncaughtException, unhandledRejection) and the auto-kickoff at module bottom are now wrapped in `if (import.meta.main)`. CLI path is unchanged. Embedders register their own handlers. - shutdown() and emergencyCleanup() now call cleanSingletonLocks( resolveChromiumProfile()) instead of inline path+loop. Single implementation, defensive guard, honors per-workspace CHROMIUM_PROFILE. New tests: - browse/test/server-no-import-side-effects.test.ts: spawns a fresh Bun subprocess that imports server.ts, asserts no signal handlers registered, no state-dir populated. Guards the core refactor invariant from regression. - browse/test/server-factory.test.ts: 12 tests covering AUTH_TOKEN env behavior (honored, whitespace-rejected, trimmed), preserved exports (TUNNEL_COMMANDS, canDispatchOverTunnel), and ServerConfig/ServerHandle type compatibility. Deferred to follow-up PR: full buildFetchHandler extraction that hoists the 13 module-level mutables + helpers into a factory closure. Phoenix can ship v0.6.0.0 against the start()+env surface today; the cleaner factory comes next. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+172
-65
@@ -35,7 +35,7 @@ import {
|
||||
isRootToken, checkConnectRateLimit, type TokenInfo,
|
||||
} from './token-registry';
|
||||
import { validateTempPath } from './path-security';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash, resolveChromiumProfile, cleanSingletonLocks } from './config';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
import { initAuditLog, writeAuditEntry } from './audit';
|
||||
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
|
||||
@@ -65,7 +65,11 @@ ensureStateDir(config);
|
||||
initAuditLog(config.auditLog);
|
||||
|
||||
// ─── Auth ───────────────────────────────────────────────────────
|
||||
const AUTH_TOKEN = crypto.randomUUID();
|
||||
// AUTH_TOKEN is injectable via process.env.AUTH_TOKEN so embedders
|
||||
// (gbrowser's gbd daemon spawn) can pre-allocate the token and hand it to
|
||||
// the Bun child via env. Whitespace-only values fall back to randomUUID so
|
||||
// the security boundary is never silently weakened by misconfiguration.
|
||||
const AUTH_TOKEN = (process.env.AUTH_TOKEN?.trim()) || crypto.randomUUID();
|
||||
initRegistry(AUTH_TOKEN);
|
||||
const BROWSE_PORT = parseInt(process.env.BROWSE_PORT || '0', 10);
|
||||
const IDLE_TIMEOUT_MS = parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10); // 30 min
|
||||
@@ -97,6 +101,90 @@ let tunnelServer: ReturnType<typeof Bun.serve> | null = null; // tunnel HTTP lis
|
||||
/** Which HTTP listener accepted this request. */
|
||||
export type Surface = 'local' | 'tunnel';
|
||||
|
||||
/**
|
||||
* Factory contract for embedders (gbrowser phoenix overlay).
|
||||
*
|
||||
* Today the CLI calls `start()` which reads env vars and binds Bun.serve
|
||||
* itself. Embedders building on this server as a submodule (gbrowser's
|
||||
* fd-passing gbd architecture) need to inject auth + ports + a
|
||||
* BrowserManager they pre-launched, and own the listener themselves.
|
||||
*
|
||||
* Status: v1 surfaces this type as documentation. AUTH_TOKEN env-injection
|
||||
* is already live (see ~L70). `start()` is exported and the kickoff /
|
||||
* signal-handler registration is gated on `import.meta.main`, so phoenix
|
||||
* can `import { start } from '.../server'` without auto-starting. Full
|
||||
* `buildFetchHandler` extraction lands in a follow-up; see plan
|
||||
* `/Users/garrytan/.claude/plans/system-instruction-you-are-working-swirling-fountain.md`
|
||||
* Part 1.
|
||||
*/
|
||||
export interface ServerConfig {
|
||||
/** Bearer token clients must present. Today injected via AUTH_TOKEN env. */
|
||||
authToken: string;
|
||||
/** Local listener port. Used in /welcome URL + state-file. */
|
||||
browsePort: number;
|
||||
/** Idle shutdown timeout. Default 30 min. */
|
||||
idleTimeoutMs: number;
|
||||
/** Result of resolveConfig() — stateDir, auditLog, stateFile. */
|
||||
config: ReturnType<typeof resolveConfig>;
|
||||
/** Pre-launched BrowserManager. Caller owns lifecycle. */
|
||||
browserManager: BrowserManager;
|
||||
/** Optional Chromium profile path override. Resolved by resolveChromiumProfile(). */
|
||||
chromiumProfile?: string;
|
||||
/** Caller-owned. shutdown() does NOT call xvfb.stop(); caller is responsible. */
|
||||
xvfb?: XvfbHandle | null;
|
||||
/** Caller-owned. shutdown() does NOT call proxyBridge.close(); caller is responsible. */
|
||||
proxyBridge?: BridgeHandle | null;
|
||||
startTime: number;
|
||||
/**
|
||||
* Overlay hook. Runs AFTER gstack resolves auth and BEFORE route dispatch.
|
||||
* Invalid tokens are auto-rejected at the gstack layer (401 returned
|
||||
* before hook fires), so the hook only ever sees valid TokenInfo or null
|
||||
* (no token presented). Returning a Response short-circuits gstack
|
||||
* dispatch; returning null falls through.
|
||||
*/
|
||||
beforeRoute?: (req: Request, surface: Surface, auth: TokenInfo | null) => Promise<Response | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return shape of buildFetchHandler() — fetch handlers + lifecycle helpers
|
||||
* embedders need to drive their own Bun.serve binding. See ServerConfig.
|
||||
*/
|
||||
export interface ServerHandle {
|
||||
fetchLocal: (req: Request, server: any) => Promise<Response>;
|
||||
fetchTunnel: (req: Request, server: any) => Promise<Response>;
|
||||
/**
|
||||
* Drains buffers, kills terminal-agent, closes browser, clears intervals,
|
||||
* removes state files. Does NOT stop bound Bun.Server listeners — call
|
||||
* stopListeners() for that. CLI relies on process.exit() to drop sockets.
|
||||
*/
|
||||
shutdown: (exitCode?: number) => Promise<void>;
|
||||
/**
|
||||
* Graceful listener stop for embedders. Calls server.stop(true) on each
|
||||
* passed Bun.Server. CLI doesn't need this (process.exit handles it).
|
||||
*/
|
||||
stopListeners: (local: any, tunnel?: any) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ServerConfig-shaped object from process.env. Used by gstack's
|
||||
* own CLI when running `bun run dev` or the compiled binary directly.
|
||||
* Embedders construct their own ServerConfig explicitly.
|
||||
*
|
||||
* Reads env, calls resolveConfig(). Does NOT bind a listener or call
|
||||
* initAuditLog/initRegistry — those happen inside the buildFetchHandler
|
||||
* lifecycle.
|
||||
*/
|
||||
export function resolveConfigFromEnv(): Omit<ServerConfig, 'browserManager' | 'startTime'> & {
|
||||
config: ReturnType<typeof resolveConfig>;
|
||||
} {
|
||||
return {
|
||||
authToken: (process.env.AUTH_TOKEN?.trim()) || crypto.randomUUID(),
|
||||
browsePort: parseInt(process.env.BROWSE_PORT || '0', 10),
|
||||
idleTimeoutMs: parseInt(process.env.BROWSE_IDLE_TIMEOUT || '1800000', 10),
|
||||
config: resolveConfig(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths reachable over the tunnel surface. Everything else returns 404.
|
||||
*
|
||||
@@ -964,11 +1052,9 @@ async function shutdown(exitCode: number = 0) {
|
||||
|
||||
await browserManager.close();
|
||||
|
||||
// Clean up Chromium profile locks (prevent SingletonLock on next launch)
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
||||
}
|
||||
// Clean up Chromium profile locks (prevent SingletonLock on next launch).
|
||||
// Defensive guard inside the helper refuses to clean unrecognized dirs.
|
||||
cleanSingletonLocks(resolveChromiumProfile());
|
||||
|
||||
// Clean up state file
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
@@ -983,36 +1069,41 @@ async function shutdown(exitCode: number = 0) {
|
||||
// passed as exitCode and process.exit() coerces it to NaN, exiting with code 1
|
||||
// instead of 0. (Caught in v0.18.1.0 #1025.)
|
||||
//
|
||||
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
|
||||
process.on('SIGINT', () => shutdown());
|
||||
// SIGTERM behavior depends on mode:
|
||||
// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the
|
||||
// parent shell exits between tool invocations. Ignoring it keeps the server
|
||||
// alive across $B calls. Idle timeout (30 min) handles eventual cleanup.
|
||||
// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect
|
||||
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
|
||||
// without waiting forever. Ctrl+C and /stop still work either way.
|
||||
// - Active cookie picker: never tear down mid-import regardless of mode —
|
||||
// would strand the picker UI with "Failed to fetch."
|
||||
process.on('SIGTERM', () => {
|
||||
if (hasActivePicker()) {
|
||||
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
|
||||
return;
|
||||
}
|
||||
const headed = browserManager.getConnectionMode() === 'headed';
|
||||
if (headed || tunnelActive) {
|
||||
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||
shutdown();
|
||||
} else {
|
||||
console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)');
|
||||
}
|
||||
});
|
||||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||
if (process.platform === 'win32') {
|
||||
process.on('exit', () => {
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
// Gated on `import.meta.main` so embedders (gbrowser phoenix) that import
|
||||
// server.ts as a submodule can register their own signal handlers without
|
||||
// fighting with gstack's. CLI path is unchanged.
|
||||
if (import.meta.main) {
|
||||
// SIGINT (Ctrl+C): user intentionally stopping → shutdown.
|
||||
process.on('SIGINT', () => shutdown());
|
||||
// SIGTERM behavior depends on mode:
|
||||
// - Normal (headless) mode: Claude Code's Bash sandbox fires SIGTERM when the
|
||||
// parent shell exits between tool invocations. Ignoring it keeps the server
|
||||
// alive across $B calls. Idle timeout (30 min) handles eventual cleanup.
|
||||
// - Headed / tunnel mode: idle timeout doesn't apply in these modes. Respect
|
||||
// SIGTERM so external tooling (systemd, supervisord, CI) can shut cleanly
|
||||
// without waiting forever. Ctrl+C and /stop still work either way.
|
||||
// - Active cookie picker: never tear down mid-import regardless of mode —
|
||||
// would strand the picker UI with "Failed to fetch."
|
||||
process.on('SIGTERM', () => {
|
||||
if (hasActivePicker()) {
|
||||
console.log('[browse] Received SIGTERM but cookie picker is active, ignoring to avoid stranding the picker UI');
|
||||
return;
|
||||
}
|
||||
const headed = browserManager.getConnectionMode() === 'headed';
|
||||
if (headed || tunnelActive) {
|
||||
console.log(`[browse] Received SIGTERM in ${headed ? 'headed' : 'tunnel'} mode, shutting down`);
|
||||
shutdown();
|
||||
} else {
|
||||
console.log('[browse] Received SIGTERM (ignoring — use /stop or Ctrl+C for intentional shutdown)');
|
||||
}
|
||||
});
|
||||
// Windows: taskkill /F bypasses SIGTERM, but 'exit' fires for some shutdown paths.
|
||||
// Defense-in-depth — primary cleanup is the CLI's stale-state detection via health check.
|
||||
if (process.platform === 'win32') {
|
||||
process.on('exit', () => {
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Emergency cleanup for crashes (OOM, uncaught exceptions, browser disconnect)
|
||||
@@ -1044,26 +1135,37 @@ function emergencyCleanup() {
|
||||
}
|
||||
} catch { /* state file unparseable — fall through to lock + state cleanup */ }
|
||||
|
||||
// Clean Chromium profile locks
|
||||
const profileDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
|
||||
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
|
||||
safeUnlinkQuiet(path.join(profileDir, lockFile));
|
||||
}
|
||||
// Clean Chromium profile locks via the shared helper (defensive guard
|
||||
// refuses to operate on unrecognized profile dirs).
|
||||
cleanSingletonLocks(resolveChromiumProfile());
|
||||
safeUnlinkQuiet(config.stateFile);
|
||||
}
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[browse] FATAL uncaught exception:', err.message);
|
||||
emergencyCleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (err: any) => {
|
||||
console.error('[browse] FATAL unhandled rejection:', err?.message || err);
|
||||
emergencyCleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
// Same import.meta.main gate as SIGINT/SIGTERM — embedders register their
|
||||
// own crash handlers.
|
||||
if (import.meta.main) {
|
||||
process.on('uncaughtException', (err) => {
|
||||
console.error('[browse] FATAL uncaught exception:', err.message);
|
||||
emergencyCleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
process.on('unhandledRejection', (err: any) => {
|
||||
console.error('[browse] FATAL unhandled rejection:', err?.message || err);
|
||||
emergencyCleanup();
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Start ─────────────────────────────────────────────────────
|
||||
async function start() {
|
||||
/**
|
||||
* Entry point for `bun run dev` and the compiled binary.
|
||||
*
|
||||
* Exported so embedders (gbrowser phoenix overlay) can call it
|
||||
* directly with env vars set, bypassing the module-level `import.meta.main`
|
||||
* gate. Phoenix's eventual fd-passing path will use `buildFetchHandler`
|
||||
* directly; until that lands, calling `start()` from a non-main entry is
|
||||
* supported via env (AUTH_TOKEN, BROWSE_PORT, BROWSE_OWN_SIGNALS).
|
||||
*/
|
||||
export async function start() {
|
||||
// Clear old log files
|
||||
safeUnlink(CONSOLE_LOG_PATH);
|
||||
safeUnlink(NETWORK_LOG_PATH);
|
||||
@@ -2269,16 +2371,21 @@ async function start() {
|
||||
}
|
||||
}
|
||||
|
||||
start().catch((err) => {
|
||||
console.error(`[browse] Failed to start: ${err.message}`);
|
||||
// Write error to disk for the CLI to read — on Windows, the CLI can't capture
|
||||
// stderr because the server is launched with detached: true, stdio: 'ignore'.
|
||||
try {
|
||||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||||
mkdirSecure(config.stateDir);
|
||||
writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
|
||||
} catch {
|
||||
// stateDir may not exist — nothing more we can do
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
// Auto-kickoff only when this module is the entry point. Embedders
|
||||
// (gbrowser phoenix overlay) import { start, buildFetchHandler, ... }
|
||||
// without triggering the listener-binding side effects.
|
||||
if (import.meta.main) {
|
||||
start().catch((err) => {
|
||||
console.error(`[browse] Failed to start: ${err.message}`);
|
||||
// Write error to disk for the CLI to read — on Windows, the CLI can't capture
|
||||
// stderr because the server is launched with detached: true, stdio: 'ignore'.
|
||||
try {
|
||||
const errorLogPath = path.join(config.stateDir, 'browse-startup-error.log');
|
||||
mkdirSecure(config.stateDir);
|
||||
writeSecureFile(errorLogPath, `${new Date().toISOString()} ${err.message}\n${err.stack || ''}\n`);
|
||||
} catch {
|
||||
// stateDir may not exist — nothing more we can do
|
||||
}
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import {
|
||||
resolveConfigFromEnv,
|
||||
type ServerConfig,
|
||||
type ServerHandle,
|
||||
type Surface,
|
||||
} from '../src/server';
|
||||
import { TUNNEL_COMMANDS, canDispatchOverTunnel } from '../src/server';
|
||||
|
||||
/**
|
||||
* Tests for the factory-export API surface added so gbrowser (phoenix) can
|
||||
* consume gstack as a submodule. The full buildFetchHandler hybrid hoist is
|
||||
* deferred to a follow-up PR; this test file proves the type contract,
|
||||
* resolveConfigFromEnv behavior, and preserved exports.
|
||||
*/
|
||||
describe('server.ts factory API surface', () => {
|
||||
describe('resolveConfigFromEnv', () => {
|
||||
test('honors AUTH_TOKEN env var', () => {
|
||||
const orig = process.env.AUTH_TOKEN;
|
||||
process.env.AUTH_TOKEN = 'fixed-test-token-abc123';
|
||||
try {
|
||||
const cfg = resolveConfigFromEnv();
|
||||
expect(cfg.authToken).toBe('fixed-test-token-abc123');
|
||||
} finally {
|
||||
if (orig === undefined) delete process.env.AUTH_TOKEN;
|
||||
else process.env.AUTH_TOKEN = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to randomUUID when AUTH_TOKEN env is empty', () => {
|
||||
const orig = process.env.AUTH_TOKEN;
|
||||
process.env.AUTH_TOKEN = '';
|
||||
try {
|
||||
const cfg = resolveConfigFromEnv();
|
||||
// randomUUID returns a 36-char hex+dash string.
|
||||
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
} finally {
|
||||
if (orig === undefined) delete process.env.AUTH_TOKEN;
|
||||
else process.env.AUTH_TOKEN = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('falls back to randomUUID when AUTH_TOKEN is whitespace-only', () => {
|
||||
const orig = process.env.AUTH_TOKEN;
|
||||
process.env.AUTH_TOKEN = ' \t \n ';
|
||||
try {
|
||||
const cfg = resolveConfigFromEnv();
|
||||
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
||||
expect(cfg.authToken.length).toBe(36);
|
||||
} finally {
|
||||
if (orig === undefined) delete process.env.AUTH_TOKEN;
|
||||
else process.env.AUTH_TOKEN = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('AUTH_TOKEN value is trimmed', () => {
|
||||
const orig = process.env.AUTH_TOKEN;
|
||||
process.env.AUTH_TOKEN = ' padded-token ';
|
||||
try {
|
||||
const cfg = resolveConfigFromEnv();
|
||||
expect(cfg.authToken).toBe('padded-token');
|
||||
} finally {
|
||||
if (orig === undefined) delete process.env.AUTH_TOKEN;
|
||||
else process.env.AUTH_TOKEN = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('reads BROWSE_PORT from env, defaults to 0', () => {
|
||||
const orig = process.env.BROWSE_PORT;
|
||||
process.env.BROWSE_PORT = '34567';
|
||||
try {
|
||||
expect(resolveConfigFromEnv().browsePort).toBe(34567);
|
||||
} finally {
|
||||
if (orig === undefined) delete process.env.BROWSE_PORT;
|
||||
else process.env.BROWSE_PORT = orig;
|
||||
}
|
||||
const origUnset = process.env.BROWSE_PORT;
|
||||
delete process.env.BROWSE_PORT;
|
||||
try {
|
||||
expect(resolveConfigFromEnv().browsePort).toBe(0);
|
||||
} finally {
|
||||
if (origUnset !== undefined) process.env.BROWSE_PORT = origUnset;
|
||||
}
|
||||
});
|
||||
|
||||
test('reads BROWSE_IDLE_TIMEOUT from env, defaults to 30 min (1800000ms)', () => {
|
||||
const orig = process.env.BROWSE_IDLE_TIMEOUT;
|
||||
delete process.env.BROWSE_IDLE_TIMEOUT;
|
||||
try {
|
||||
expect(resolveConfigFromEnv().idleTimeoutMs).toBe(1800000);
|
||||
} finally {
|
||||
if (orig !== undefined) process.env.BROWSE_IDLE_TIMEOUT = orig;
|
||||
}
|
||||
});
|
||||
|
||||
test('returns a populated config object with the expected shape', () => {
|
||||
const cfg = resolveConfigFromEnv();
|
||||
expect(cfg).toMatchObject({
|
||||
authToken: expect.any(String),
|
||||
browsePort: expect.any(Number),
|
||||
idleTimeoutMs: expect.any(Number),
|
||||
config: expect.objectContaining({
|
||||
stateDir: expect.any(String),
|
||||
stateFile: expect.any(String),
|
||||
auditLog: expect.any(String),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('preserved exports', () => {
|
||||
test('TUNNEL_COMMANDS still exported and populated', () => {
|
||||
expect(TUNNEL_COMMANDS).toBeInstanceOf(Set);
|
||||
expect(TUNNEL_COMMANDS.size).toBeGreaterThan(0);
|
||||
expect(TUNNEL_COMMANDS.has('goto')).toBe(true);
|
||||
expect(TUNNEL_COMMANDS.has('click')).toBe(true);
|
||||
});
|
||||
|
||||
test('canDispatchOverTunnel still exported and functional', () => {
|
||||
expect(canDispatchOverTunnel('goto')).toBe(true);
|
||||
expect(canDispatchOverTunnel('shutdown')).toBe(false);
|
||||
expect(canDispatchOverTunnel(null)).toBe(false);
|
||||
expect(canDispatchOverTunnel(undefined)).toBe(false);
|
||||
expect(canDispatchOverTunnel('')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('type surface compiles', () => {
|
||||
// Compile-time shape checks. If these break, TypeScript fails to build
|
||||
// the test file — which is exactly the API-compat guarantee we want for
|
||||
// embedders depending on these types.
|
||||
test('Surface type accepts the two known values', () => {
|
||||
const local: Surface = 'local';
|
||||
const tunnel: Surface = 'tunnel';
|
||||
expect(local).toBe('local');
|
||||
expect(tunnel).toBe('tunnel');
|
||||
});
|
||||
|
||||
test('ServerConfig type accepts the documented minimum-required fields', () => {
|
||||
// This compiles only if ServerConfig accepts these field names + types.
|
||||
const minimalConfigShape = {
|
||||
authToken: 'tok',
|
||||
browsePort: 0,
|
||||
idleTimeoutMs: 1800000,
|
||||
config: { stateDir: '', stateFile: '', consoleLog: '', networkLog: '', dialogLog: '', auditLog: '', projectDir: '' },
|
||||
browserManager: {} as any,
|
||||
startTime: Date.now(),
|
||||
} satisfies Partial<ServerConfig>;
|
||||
expect(minimalConfigShape.authToken).toBe('tok');
|
||||
});
|
||||
|
||||
test('ServerHandle type exposes the documented surface', () => {
|
||||
// Compiles only if these property names exist on ServerHandle.
|
||||
type AssertHandleFields = ServerHandle extends {
|
||||
fetchLocal: any;
|
||||
fetchTunnel: any;
|
||||
shutdown: any;
|
||||
stopListeners: any;
|
||||
} ? true : false;
|
||||
const assertion: AssertHandleFields = true;
|
||||
expect(assertion).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, test, expect } from 'bun:test';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
|
||||
/**
|
||||
* Guard the core refactor invariant: importing browse/src/server.ts must NOT
|
||||
* auto-start. Before this PR, the module called `start().catch(...)` at module
|
||||
* load time, which made the file impossible for embedders (gbrowser phoenix
|
||||
* overlay) to import without spawning a daemon. The fix wraps that kickoff in
|
||||
* `if (import.meta.main)` so the side effects only run when the module is the
|
||||
* process entry point.
|
||||
*
|
||||
* Approach: spawn a fresh Bun subprocess that imports the module and emits a
|
||||
* structured snapshot (initial vs post-import process state). Parent asserts
|
||||
* that no listeners were bound, no Bun.serve started, and no SIGINT handlers
|
||||
* were registered. The subprocess uses HOME=tmp + GSTACK_HOME=tmp so any
|
||||
* accidental state-dir write lands in a place we can verify is empty.
|
||||
*/
|
||||
describe('server.ts module import has no auto-start side effects', () => {
|
||||
test('importing server.ts does not bind Bun.serve, register signal handlers, or write state', async () => {
|
||||
const tmpHome = path.join(os.tmpdir(), `browse-no-sfx-${Date.now()}-${process.pid}`);
|
||||
fs.mkdirSync(tmpHome, { recursive: true });
|
||||
const tmpGstack = path.join(tmpHome, '.gstack');
|
||||
|
||||
const childScript = `
|
||||
const sigintBefore = process.listenerCount('SIGINT');
|
||||
const sigtermBefore = process.listenerCount('SIGTERM');
|
||||
const uncaughtBefore = process.listenerCount('uncaughtException');
|
||||
|
||||
// Snapshot any keys that look like our state path.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
await import(${JSON.stringify(path.resolve(import.meta.dir, '../src/server.ts'))});
|
||||
|
||||
// After import, sleep a tick so any setTimeout(0)-style init can run.
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
|
||||
const sigintAfter = process.listenerCount('SIGINT');
|
||||
const sigtermAfter = process.listenerCount('SIGTERM');
|
||||
const uncaughtAfter = process.listenerCount('uncaughtException');
|
||||
|
||||
// Check that the gstack home directory wasn't populated as a side effect.
|
||||
let gstackPopulated = false;
|
||||
try {
|
||||
const entries = fs.readdirSync(${JSON.stringify(tmpGstack)});
|
||||
gstackPopulated = entries.length > 0;
|
||||
} catch {
|
||||
// Doesn't exist — that's the win we want.
|
||||
}
|
||||
|
||||
console.log(JSON.stringify({
|
||||
sigintBefore, sigintAfter,
|
||||
sigtermBefore, sigtermAfter,
|
||||
uncaughtBefore, uncaughtAfter,
|
||||
gstackPopulated,
|
||||
}));
|
||||
// Force exit so any background intervals don't keep this child alive
|
||||
// (the test framework would see a hang otherwise — which itself is a
|
||||
// signal that side effects DID run).
|
||||
process.exit(0);
|
||||
`;
|
||||
|
||||
const proc = Bun.spawn(['bun', '-e', childScript], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: tmpHome,
|
||||
GSTACK_HOME: tmpGstack,
|
||||
// Empty so the AUTH_TOKEN env path doesn't deterministically set a token.
|
||||
AUTH_TOKEN: '',
|
||||
// Force a stub state file so resolveConfig() at module load (if it
|
||||
// happens) won't crawl the host's real .gstack/.
|
||||
BROWSE_STATE_FILE: path.join(tmpGstack, 'browse.json'),
|
||||
},
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
await proc.exited;
|
||||
|
||||
// The last JSON line in stdout is our snapshot.
|
||||
const jsonLine = stdout.trim().split('\n').filter(l => l.startsWith('{')).pop();
|
||||
expect(jsonLine, `child stderr: ${stderr}`).toBeDefined();
|
||||
|
||||
const snapshot = JSON.parse(jsonLine!);
|
||||
|
||||
// No new signal handlers registered (gated on import.meta.main, which
|
||||
// is false in the subprocess because `bun -e` is the entry point).
|
||||
expect(snapshot.sigintAfter).toBe(snapshot.sigintBefore);
|
||||
expect(snapshot.sigtermAfter).toBe(snapshot.sigtermBefore);
|
||||
expect(snapshot.uncaughtAfter).toBe(snapshot.uncaughtBefore);
|
||||
|
||||
// gstack home should remain empty — initRegistry/initAuditLog/etc. side
|
||||
// effects from module load are acceptable (they happen at module level),
|
||||
// but only insofar as they don't bind listeners or write project state.
|
||||
// The presence/absence test here proves we didn't bind Bun.serve (which
|
||||
// would also try to write the state file).
|
||||
expect(snapshot.gstackPopulated).toBe(false);
|
||||
|
||||
// Cleanup
|
||||
try { fs.rmSync(tmpHome, { recursive: true, force: true }); } catch { /* best effort */ }
|
||||
}, 30_000);
|
||||
});
|
||||
Reference in New Issue
Block a user