// rnx serve — foreground bridge host // // hosts the WS bridge + runtime HTTP server on port 7668 (or --port) so // CLI commands and electron windows both work against one bridge. sims // connect over ws to register themselves; cli clients connect to drive // them; electron fetches renderer assets over http from the same port. // // run in the foreground (ctrl-c to stop). the persistent launchd/systemd // daemon installation is handled by `rnx daemon install`. import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants' import { ensureRnxHome, isDaemonLockfileFresh, readDaemonLockfile, } from '../../src/home-paths' import { SootSimBridgeHost } from '../../src/host/bridge-host' import { rnxExit } from '../run-rnx' import { resolveDefaultUploadOrigin } from './upload' interface ServeOptions { port?: number } export async function runServe(args: string[], opts: ServeOptions = {}) { if (args.includes('--help') || args.includes('-h')) { console.log(` rnx serve — run the rnx bridge in the foreground hosts the WS bridge that CLI commands talk to. once running, any rnx renderer (browser, electron, headless playwright) that connects to port 7668 becomes drivable from 'rnx describe', 'rnx do tap', etc. usage: rnx serve [options] options: --port bridge port (defaults to ${DEFAULT_SOOTSIM_BRIDGE_PORT}) --quiet suppress per-connection logging examples: rnx serve rnx serve --port 7668 --quiet `) rnxExit(0) } const portArgIdx = args.indexOf('--port') const port = portArgIdx >= 0 && args[portArgIdx + 1] ? Number(args[portArgIdx + 1]) : (opts.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT) if (Number.isNaN(port)) { console.error(` invalid --port value: ${args[portArgIdx + 1]}`) rnxExit(1) } const quiet = args.includes('--quiet') || args.includes('-q') // only one bridge per rnx home. refuse to start a second one // regardless of requested port, since both would try to write the same // daemon.json and electron/cli clients only read one lockfile. const existingLock = readDaemonLockfile() if (existingLock && isDaemonLockfileFresh(existingLock)) { console.error( ` an rnx bridge is already running (pid ${existingLock.pid}, port ${existingLock.bridgePort})`, ) console.error(` stop it with 'rnx daemon stop' first`) rnxExit(1) } ensureRnxHome() // use the explicit upload/auth origin when configured, otherwise production. // never probe a local Contrast stack during bridge startup. const contrastOrigin = await resolveDefaultUploadOrigin() const host = new SootSimBridgeHost({ port, writeLockfile: true, contrastOrigin, }) const boundPort = await host.startAsync({ silent: quiet }) const started = Date.now() const log = (line: string) => { if (quiet) return process.stdout.write(`${line}\n`) } const lastSimIds = new Set() const tickInterval = setInterval(() => { const sims = host.listSims() const currentIds = new Set(sims.map((b) => b.id)) for (const b of sims) { if (!lastSimIds.has(b.id)) { const label = b.title || b.url || b.origin || '(unknown)' log(` + ${b.id} ${label}`) } } for (const id of lastSimIds) { if (!currentIds.has(id)) log(` - ${id}`) } lastSimIds.clear() for (const id of currentIds) lastSimIds.add(id) }, 500) log(`rnx bridge listening on ws://localhost:${boundPort} (runtime http on same port)`) if (boundPort !== port) { log(` (preferred port ${port} was taken — fell back to ${boundPort})`) } log(` ready for browser, electron, or headless playwright sims to connect`) log(` (ctrl-c to stop)`) const shutdown = async (signal: NodeJS.Signals) => { clearInterval(tickInterval) log( `\n ${signal} received — shutting down after ${Math.round((Date.now() - started) / 1000)}s`, ) try { await host.close() } catch {} rnxExit(0) } process.on('SIGINT', () => shutdown('SIGINT')) process.on('SIGTERM', () => shutdown('SIGTERM')) // closing the terminal that spawned us sends SIGHUP — treat it like a // clean shutdown so the lockfile doesn't linger. process.on('SIGHUP', () => shutdown('SIGHUP')) // last-ditch lockfile cleanup on any synchronous exit path. the // close() call above is async so we can't await it here, but removing // the lockfile is cheap + idempotent. process.on('exit', () => { try { host.removeLockfile() } catch {} }) // keep the event loop alive even if ws/http servers all close — we want // the process to live until an explicit signal. await new Promise(() => {}) }