import { spawn } from 'child_process' import fs from 'fs' import { createServer, type IncomingMessage, type ServerResponse } from 'http' import path from 'path' import { WebSocket, WebSocketServer } from 'ws' import { scanDevServers, type DiscoveredServer, } from '../../scripts/dev-server-scanner.ts' import { buildBrowserAssetProxyUrl } from '../backend-origin.ts' import { DEFAULT_SOOTSIM_BRIDGE_PORT, SOOTSIM_BRIDGE_SIM_CLOSE_CODE, SOOTSIM_BRIDGE_SIM_CLOSE_REASON, } from '../bridge-constants.ts' import { getCliVersion } from '../cli-version.ts' import { activeRuntimeDir as getActiveRuntimeDir, claimDaemonLockfile, ensureRnxHome, isSootsimDevCheckout, readActiveRuntime, readSharedConfig, removeDaemonLockfile, removeDevBridgeLockfile, writeActiveRuntime, writeDaemonLockfile, writeDevBridgeLockfile, writeRuntimeUpgradeNotice, writeSharedConfig, cameraFixturesDir, rnxHomeDir, type DaemonLockfile, type DevBridgeLockfile, type SharedConfig, } from '../home-paths.ts' import { rnxPublicBrand } from '../public-brand.ts' import { isRuntimeVersionHostname, resolveDevCheckoutRuntimeRoot, resolveRuntimeRootForHostname, runtimeVersionFromHostname, } from '../runtime-assets.ts' import { rnxRuntime } from '../runtime-delivery.ts' import { AgentHost, type AgentHostOptions } from './agent-host.ts' import { handleAppApiRequest, handleBundleProxyRequest, handleFetchProxyRequest, isAppApiRequestUrl, isFetchProxyRequestUrl, } from './fetch-proxy-handler.ts' import { openUrl as openUrlInBrowser, type OpenUrlOptions } from './open-url.ts' import { PlaneHost } from './plane-host.ts' import { handleReplacementModuleRequest, isReplacementModuleRequestUrl, } from './replacement-module-handler.ts' import { handleWebSocketProxyUpgrade } from './websocket-proxy.ts' import type { WsCommand } from '../bridge-contract.ts' const SOOTSIM_CROSS_ORIGIN_EMBEDDER_POLICY = 'require-corp' export interface BridgeSimInfo { id: string origin?: string url?: string title?: string userAgent?: string connectedAt: number lastSeenAt: number lastActiveAt?: number isPrimary: boolean readyState: 'open' | 'closing' | 'closed' attachedCliCount?: number lockedBy?: string lockedByKind?: 'cli' | 'user-active' lockExpiresAt?: number // true when the lease belongs to the caller asking for this list. the // server computes it because `lockedBy` is a display string (a cliLabel // like "open --describe", or "active user") and cannot be compared // against an identity key by the client. lockedByMe?: boolean userFocused?: boolean userVisible?: boolean visibilityState?: string documentFocused?: boolean /** registration "kind" — lets a single daemon host multiple surface * types. omitted / unknown defaults to 'sootsim'. the contrast web * IDE registers with kind='contrast'; CLI consumers filter by this * rather than running a parallel bridge. */ kind?: string /** opaque metadata supplied at register time (e.g. projectId, route, * attached iOS sim id for a contrast tab). free-form by design — the * daemon does not interpret it, only stores + reports it back. */ meta?: Record } export interface BridgeLockInfo { by: string expiresInMs: number } export type BridgeSimCommand = Omit & { id: number } interface BridgeSimRegistrationMessage { type: 'bridge:register' simId?: string url?: string title?: string userAgent?: string /** see BridgeSimInfo.kind — defaults to 'sootsim' when omitted. */ kind?: string /** see BridgeSimInfo.meta — free-form metadata for filtering/routing. */ meta?: Record } interface BridgeSimUserFocusStateMessage { type: 'bridge:user-focus-state' focused?: boolean visible?: boolean visibilityState?: string documentFocused?: boolean } interface BridgeSimClientStateMessage { type: 'bridge:client-state' attachedCliCount: number activeAgentCommandCount: number recentActions: BridgeRecentAction[] lockedBy?: string lockedByKind?: 'cli' | 'user-active' lockExpiresAt?: number userFocused?: boolean userVisible?: boolean visibilityState?: string documentFocused?: boolean } interface BridgeRecentAction { label: string at: number } interface BridgeSimConnection { id: string ws: WebSocket origin?: string url?: string title?: string userAgent?: string connectedAt: number lastSeenAt: number lastActiveAt: number recentActions: BridgeRecentAction[] cliLease?: BridgeCliLease userFocused?: boolean userVisible?: boolean visibilityState?: string documentFocused?: boolean /** see BridgeSimInfo.kind. */ kind?: string /** see BridgeSimInfo.meta. */ meta?: Record } interface BridgeCliLease { kind: 'cli' | 'user-active' cliIdentityKey: string cliLabel?: string expiresAt: number } // interactive commands change app state and must respect the lease. // observational/lifecycle commands (evaluate/tree/query/resolve/screenshot/capture/focus/call/close) // pass through without taking or checking a lease so cleanup never gets stuck // behind a stale cli owner. // `call` used to acquire a lease too, but most call paths are queries // (`__sootsimTest.findByTestId`, state reads, etc.). the CLI opts write // calls into lease acquisition via msg.acquireLock=true. const WRITE_COMMAND_TYPES = new Set([ 'tap', 'keyboard', 'longPress', 'perform', 'reset', 'camera', ]) // how long a `close` waits for the sim page to tear itself down before the // host disconnects the sim socket. the close code is part of the protocol: // browser clients treat it as terminal and skip their normal reconnect loop. const FORCE_CLOSE_GRACE_MS = 2000 const FORCE_CLOSE_TERMINATE_MS = 1000 // note: `unrefTimer` is declared once, below (with the `UnrefableTimer` // type). a duplicate copy used to live here from a concurrent edit and // broke the standalone CLI esbuild bundle ("symbol already declared"). function shouldAcquireLease(msg: any): boolean { if (!msg || typeof msg.type !== 'string') return false if (msg.acquireLock === true) return true if (msg.readOnly === true) return false return WRITE_COMMAND_TYPES.has(msg.type) } interface BridgeRestorableSimState { recentActions: BridgeRecentAction[] lastActiveAt: number cliLease?: BridgeCliLease expiresAt: number } interface BridgePendingCommand { simId: string resolve: (value: any) => void reject: (error: Error) => void } interface BridgeForwardedCommand { simId: string ws: WebSocket originalId: number | string } export interface BridgeHostOptions { /** use 0 for an OS-assigned port; startAsync returns the bound address. */ port?: number openUrl?: (url: string, options?: OpenUrlOptions) => Promise | void /** Contrast-specific excludes for the agent host's dev-server scan. passed * through to AgentHost; see packages/sootsim-engine/src/dev-scan-excludes.ts * for the canonical list. when omitted, AgentHost reads env vars directly. */ agentScanExcludes?: AgentHostOptions['getExcludePorts'] /** try preferred port, then port+1, port+2, …, up to this many attempts * before giving up. defaults to 10. set 1 to disable fallback. */ portFallbackCount?: number /** when true, write ~/.rnx/daemon.json on successful bind + update * it on a heartbeat interval + remove it on close. only the standalone * `rnx serve` process should set this — tests, vite plugin, and * anything embedded should leave it off to avoid clobbering a real * daemon's lockfile. defaults to false. */ writeLockfile?: boolean /** when true, write an owner-scoped record under ~/.rnx/dev-bridges on * successful bind, update it on a heartbeat interval, and remove only that * record on close. the Vite dev shell sets this so local CLI commands can * address every live source bridge without concurrent worktrees replacing * one shared pointer. */ writeDevLockfile?: boolean /** resolve the http port of the vite shell dev server that hosts this * bridge (e.g. 5173). called on every dev-lockfile write so the port * fills in once the vite server is actually listening. only meaningful * with writeDevLockfile. */ getShellPort?: () => number | null /** the contrast origin this daemon's runtimes should talk to for auth, * billing, and preview uploads — inlined into served runtime html as * `window.__sootsimContrastOrigin`. without it the engine's origin.ts * resolves the daemon's own loopback host (localhost:) to * the dev `contrast.localhost:3000` stack, so a *prod* CLI user's preview * recording would upload to a localhost stack that doesn't exist. the * `rnx serve` command resolves this with the same probe the * upload itself uses (`resolveDefaultUploadOrigin`). */ contrastOrigin?: string /** override the abandoned-sim GC TTL (ms). defaults to * SIM_IDLE_REAP_TTL_MS (30 min). exists so the reaper integration test * can drive the real timer path deterministically instead of mocking * time. production callers should never set this. */ simIdleReapTtlMs?: number /** override the idle GC TTL (ms) for automation sims (playwright-driver, * capture/factory). defaults to AUTOMATION_SIM_IDLE_REAP_TTL_MS (10 min). * test-only, like simIdleReapTtlMs. */ automationSimIdleReapTtlMs?: number /** cap on concurrent non-primary automation sims before the reaper drops * the most-idle overflow. defaults to MAX_CONCURRENT_AUTOMATION_SIMS (6). * test-only. */ maxConcurrentAutomationSims?: number /** grace (ms) below which an automation sim is never reaped by the cap, * even when over it. defaults to AUTOMATION_SIM_ACTIVE_GRACE_MS (60 s). * test-only. */ automationSimActiveGraceMs?: number } const DAEMON_HEARTBEAT_INTERVAL_MS = 5_000 const DEFAULT_RUNTIME_UPDATE_INTERVAL_MS = 60 * 60 * 1000 const RUNTIME_UPDATE_INTERVAL_ENV = 'SOOTSIM_RUNTIME_UPDATE_INTERVAL_MS' const HTTP_MIME_TYPES: Record = { '.html': 'text/html; charset=utf-8', '.js': 'application/javascript', '.cjs': 'application/javascript', '.mjs': 'application/javascript', '.css': 'text/css; charset=utf-8', '.json': 'application/json; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.avif': 'image/avif', '.ico': 'image/x-icon', '.wasm': 'application/wasm', '.ttf': 'font/ttf', '.otf': 'font/otf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.map': 'application/json', '.txt': 'text/plain; charset=utf-8', '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', } // `rnx camera play` stages a video under ~/.rnx/camera-fixtures and the // simulator plays it as the camera feed. the route below serves that one // directory, addressed by basename only. const CAMERA_FIXTURE_ROUTE = '/__camera-fixtures/' // no slashes, no dots leading a segment, no traversal to express. a name that // does not match is rejected rather than normalized, so there is nothing to // get the normalization wrong about. const CAMERA_FIXTURE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/ /** inject host-provided globals into the served runtime html: * - `window.__sootsimSharedConfig` — parsed `~/.rnx/config.json`, * read during engine settingsStore module init (see * settings/persistence.ts). mirrors the inject-into-html plugin in the * shell vite dev server so dev and prod surfaces both produce it. * - `window.__sootsimCliVersion` — the CLI version serving this runtime, * so the shell can surface CLI vs runtime version (MacMenuBar footer, * ReportBody) and users can tell what they're actually running. * - `window.__sootsimBridgePort` — the ws bridge port this daemon is * actually listening on. the runtime http server and the ws bridge are * the same server on the same port, but a daemon whose preferred 7668 * was taken falls back to 7669+. without this the runtime page would * hardcode the 7668 default in `resolveBridgePort` and register on a * *different* daemon (or none), so `rnx open` times out waiting for * a sim that connected to the wrong bridge. * - `window.__sootsimContrastOrigin` — the contrast origin for auth / * billing / preview uploads. the daemon serves runtimes from its own * loopback host, and engine `origin.ts` otherwise resolves any * loopback host to the dev `contrast.localhost:3000` stack — so without this a * prod CLI user's preview recording would upload to a localhost stack * that does not exist. */ export function injectSharedConfigIntoHtml( data: Buffer, bridgePort: number, contrastOrigin: string | null, ): string { let payload: string try { const cfg: SharedConfig = readSharedConfig() payload = JSON.stringify(cfg) } catch { payload = '{}' } const bridgePortTag = bridgePort > 0 ? `window.__sootsimBridgePort=${bridgePort};` : '' const contrastOriginTag = contrastOrigin ? `window.__sootsimContrastOrigin=${JSON.stringify(contrastOrigin)};` : '' const tag = `` const html = data.toString('utf8') if (html.includes('')) return html.replace('', `${tag}`) if (html.includes('')) return html.replace('', tag + '') if (html.includes('')) return html.replace('', tag + '') // no recognized injection point — prepend so the global is defined // before any inline scripts further down in the document. return tag + html } type UnrefableTimer = { unref: () => void } function unrefTimer(timer: ReturnType) { if (typeof timer === 'object' && timer !== null && 'unref' in timer) { ;(timer as UnrefableTimer).unref() } } export class SootSimBridgeHost { private port: number private openUrlHandler?: (url: string, options?: OpenUrlOptions) => Promise | void private httpServer: ReturnType | null = null private wss: WebSocketServer | null = null private nextCommandId = 1 private nextSimNumber = 0xa1 private sims = new Map() private primarySimId: string | null = null private pendingCommands = new Map() private cliBySentId = new Map() private cliSimBySocket = new Map() private cliLastCommandAt = new Map() private cliIdentityKeyBySocket = new Map() private cliLabelBySocket = new Map() private restorableSims = new Map() private nextCliFallbackId = 1 private cliIdleTimer: NodeJS.Timeout | null = null private agentHost: AgentHost private planeHost = new PlaneHost() private static CLI_IDLE_TIMEOUT_MS = 60_000 private static CLI_LEASE_TTL_MS = 600_000 private static USER_ACTIVE_LEASE_TTL_MS = 8_000 // explicit user actions (clicking Boot, focusing the sim to take it over) // hold the sim longer than passive canvas interaction so reconnecting clis // can't immediately reclaim while the user gets oriented. private static USER_BOOT_LEASE_TTL_MS = 60_000 private static SIM_RECONNECT_TTL_MS = 30_000 // abandoned-tab GC. the ws heartbeat only reaps sims whose socket went // dead — but a background tab from a prior `rnx open` / QA / test run // keeps its socket alive (the page still pongs) forever, so `rnx list` // accretes dozens of zombie sims that all time out on every command (QA // F21-3, carried F19-2). reap a sim only when it is provably nobody's: // not primary, not user-focused, no CLI attached, no active lease, and no // CLI-driven activity for this long. closeSimSocketFromHost sends the // terminal 4001 code, so the client closes the abandoned window instead // of reconnecting the zombie straight back in. private static SIM_IDLE_REAP_TTL_MS = 30 * 60_000 // automation sims (spawned by the playwright driver: `rnx open --driver // playwright`, the factory/screenshot capture path — `meta.sootsimHostDriver // === 'playwright'`) are unattended one-shot browsers, never a human's tab. a // capture run opens one per slide/app/retry and the 30-min human/dev TTL lets // hundreds pile up inside the window — orphaned ms-playwright chromiums that // drove the box to swap-thrash (load hit 157 in one incident). give them a far // shorter idle TTL, and cap how many can sit concurrently: reap the most-idle // overflow, but never a sim active within ACTIVE_GRACE (an agent composing // between captures), nor primary / cli-attached / leased sims. private static AUTOMATION_SIM_IDLE_REAP_TTL_MS = 10 * 60_000 private static MAX_CONCURRENT_AUTOMATION_SIMS = 6 private static AUTOMATION_SIM_ACTIVE_GRACE_MS = 60_000 private preferredPort: number private portFallbackCount: number private simIdleReapTtlMs: number private automationSimIdleReapTtlMs: number private maxConcurrentAutomationSims: number private automationSimActiveGraceMs: number private shouldWriteLockfile: boolean private shouldWriteDevLockfile: boolean private getShellPort: (() => number | null) | null private contrastOrigin: string | null = null private effectivePort = 0 private startedAt = 0 private heartbeatTimer: NodeJS.Timeout | null = null private devHeartbeatTimer: NodeJS.Timeout | null = null // ws-level heartbeat: sims that hang up uncleanly (page navigated, network // dropped, sim crashed) leave their server-side WebSocket sitting "open" // forever. ping every WS_HEARTBEAT_INTERVAL_MS; if the previous round's // ping was never answered, terminate(). that fires 'close' which runs the // sim-cleanup path and stops `rnx list` from showing 8 zombie // sims that all time out on every command. private wsHeartbeatTimer: NodeJS.Timeout | null = null private wsIsAlive = new WeakMap() private static WS_HEARTBEAT_INTERVAL_MS = 30_000 private runtimeUpdateTimer: NodeJS.Timeout | null = null private runtimeUpdateInFlight: Promise | null = null private activeRuntimeVersion: string | null = null private activeRuntimeDirPath: string | null = null // /__server-scan cache. mirrors the shell vite dev-middleware so engine // ConnectRN / DemoConnectApp see the same JSON shape whether they boot // from vite dev or from this daemon. without this, the SPA fallback below // would serve index.html for /__server-scan and tenant-worker .json() // crashes with "Unexpected token '<', " | null = null private static SCAN_FRESH_MS = 2000 constructor(opts: BridgeHostOptions = {}) { this.preferredPort = opts.port ?? DEFAULT_SOOTSIM_BRIDGE_PORT this.port = this.preferredPort // default off — callers that want the lockfile (rnx serve) opt in. this.shouldWriteLockfile = opts.writeLockfile === true this.shouldWriteDevLockfile = opts.writeDevLockfile === true this.getShellPort = opts.getShellPort ?? null // fallback freely on port collision regardless of who owns the lockfile. // serve.ts already refuses to start if a fresh daemon.json exists, so // two daemons can't race the lockfile. without this, an unrelated process // on 7668 (another worktree's vite dev fallback bridge, etc.) was enough // to keep the daemon from binding at all — and launchd's KeepAlive then // respawned it forever. this.portFallbackCount = Math.max(1, opts.portFallbackCount ?? 10) this.openUrlHandler = opts.openUrl this.agentHost = new AgentHost({ getExcludePorts: opts.agentScanExcludes, resolveCliLease: (simId) => { const sim = this.sims.get(simId) const lease = sim ? this.getActiveLease(sim) : null return lease ? { kind: lease.kind, cliIdentityKey: lease.cliIdentityKey, expiresAt: lease.expiresAt, } : null }, }) this.contrastOrigin = opts.contrastOrigin?.replace(/\/$/, '') || null this.simIdleReapTtlMs = opts.simIdleReapTtlMs ?? SootSimBridgeHost.SIM_IDLE_REAP_TTL_MS this.automationSimIdleReapTtlMs = opts.automationSimIdleReapTtlMs ?? SootSimBridgeHost.AUTOMATION_SIM_IDLE_REAP_TTL_MS this.maxConcurrentAutomationSims = opts.maxConcurrentAutomationSims ?? SootSimBridgeHost.MAX_CONCURRENT_AUTOMATION_SIMS this.automationSimActiveGraceMs = opts.automationSimActiveGraceMs ?? SootSimBridgeHost.AUTOMATION_SIM_ACTIVE_GRACE_MS } /** expose the agent host so tests and embedders can inspect state or * inject behavior. not part of the public WS protocol. */ getAgentHost(): AgentHost { return this.agentHost } /** run the abandoned-sim GC pass on demand. the reaper normally fires off * the 30s idle-sweep timer; this lets the F21-3 integration test drive * the real path without waiting for the timer. not part of the public * WS protocol. */ reapIdleSimsForTest(now = Date.now()): void { this.reapIdleSims(now) } /** synchronous wrapper around startAsync for callers that don't care * about port fallback outcomes. returns immediately; actual binding * happens on the event loop. callers that need to know the bound port * should await startAsync() instead. */ start(options?: { silent?: boolean }): void { void this.startAsync(options) } async startAsync(options?: { silent?: boolean }): Promise { if (this.httpServer || this.wss) return this.effectivePort // seed active runtime state from disk so the http routes + lockfile // reflect reality at boot. we reread this on runtime:use messages. this.refreshActiveRuntime() for (let attempt = 0; attempt < this.portFallbackCount; attempt++) { const candidate = this.preferredPort + attempt try { const boundPort = await this.bindOnce(candidate, options?.silent === true) this.effectivePort = boundPort this.port = boundPort this.startedAt = Date.now() if (attempt > 0 && !options?.silent) { process.stderr.write( `ws bridge bound to port ${candidate} (preferred ${this.preferredPort} was taken)\n`, ) } this.afterBind() return boundPort } catch (err: unknown) { const e = err as NodeJS.ErrnoException if (e?.code !== 'EADDRINUSE') { throw err } if (!options?.silent) { process.stderr.write( `ws bridge port ${candidate} already in use, trying ${candidate + 1}\n`, ) } // bindOnce already cleaned up httpServer/wss on failure } } throw new Error( `could not bind ws bridge after ${this.portFallbackCount} attempts starting at ${this.preferredPort}`, ) } private bindOnce(port: number, _silent: boolean): Promise { return new Promise((resolve, reject) => { const server = createServer((req, res) => this.handleHttpRequest(req, res)) let settled = false const onError = (err: NodeJS.ErrnoException) => { if (settled) return settled = true try { server.close() } catch {} this.httpServer = null this.wss = null reject(err) } server.once('error', onError) // loopback-only bind. we listen on 127.0.0.1 explicitly because the // daemon serves the active runtime's dist as plain static files — // never expose that to LAN peers. tests + the electron renderer use // 127.0.0.1 explicitly to avoid the localhost-vs-::1 DNS coinflip. server.listen(port, '127.0.0.1', () => { if (settled) return const address = server.address() if (!address || typeof address === 'string') { onError(new Error('ws bridge has no bound TCP address')) return } settled = true server.removeListener('error', onError) server.on('error', (err) => { process.stderr.write(`ws bridge http error: ${String(err)}\n`) }) this.httpServer = server this.wss = new WebSocketServer({ noServer: true }) this.wireWebSocketServer() server.on('upgrade', (req, socket, head) => { if (handleWebSocketProxyUpgrade(req, socket, head)) return this.wss?.handleUpgrade(req, socket, head, (ws) => { this.wss?.emit('connection', ws, req) }) }) resolve(address.port) }) }) } /** attach the WS connection handler to the current wss. called from * bindOnce() after WebSocketServer is freshly created. */ private wireWebSocketServer() { if (!this.wss) return this.wss.on('connection', (ws, req) => { const origin = req.headers.origin let role: 'sim' | 'cli' = origin ? 'sim' : 'cli' let sim: BridgeSimConnection | null = null // ws emits 'error' for late peer-side socket faults (write EIO after // sleep/wake, abrupt sim close, etc). without a listener, node's // EventEmitter rethrows as uncaughtException and crashes the host. // 'close' fires right after and runs the cleanup below. ws.on('error', () => {}) // ws-level heartbeat. pong responses come for free from the ws // protocol — we just need to track them so the heartbeat sweep // (started in afterBind) can terminate connections that stop // answering. fresh connections start alive. this.wsIsAlive.set(ws, true) ws.on('pong', () => { this.wsIsAlive.set(ws, true) }) // register the socket with the agent host so session-status pushes // reach it even before it subscribes to any specific session, and so // subscriptions get cleaned up automatically on close. this.agentHost.registerSocket(ws) if (role === 'sim') { sim = { id: this.allocateSimId(), ws, origin, connectedAt: Date.now(), lastSeenAt: Date.now(), lastActiveAt: 0, recentActions: [], } this.sims.set(sim.id, sim) this.writeConnectedRuntimeSnapshot() if (this.shouldPromoteSim(sim)) { this.primarySimId = sim.id } this.broadcastSimAssignments() this.broadcastSimClientStates() } else { const fallbackKey = `ws-${this.nextCliFallbackId++}` this.cliIdentityKeyBySocket.set(ws, fallbackKey) } ws.on('message', (data) => { let msg: any try { msg = JSON.parse(data.toString()) } catch { return } if (!msg || typeof msg !== 'object') return // agent:* messages are routed uniformly regardless of socket role. // CLI (`sootsim agent …`), electron main (daemon client), and // sim shells all use the same envelope, and agent event // subscriptions work from any of them — this is the whole point of // moving session ownership into the daemon. if (typeof msg.type === 'string' && msg.type.startsWith('agent:')) { void this.agentHost.handleMessage(ws, msg) return } // plane:* is the project file plane, routed the same way and for the // same reason: the CLI, a box, and a browser tab all speak one // envelope. see plans/rnx-cloud/transport-decision.md. if (typeof msg.type === 'string' && msg.type.startsWith('plane:')) { void this.planeHost.handleMessage(ws, msg) return } // runtime:* messages manage installed engine runtimes. list / use // are handled in-daemon; install runs entirely inside the CLI so // we don't need a daemon-side handler for it. if (msg.type === 'runtime:list') { const versions = rnxRuntime.listInstalled() const active = this.getActiveRuntime() const reply = { type: 'runtime:list:ok', id: msg.id, installed: versions, active: active.version, activeRuntimeDir: active.runtimeDir, } try { ws.send(JSON.stringify(reply)) } catch {} return } if (msg.type === 'runtime:use') { const version = typeof msg.version === 'string' ? msg.version : '' const installed = rnxRuntime.listInstalled() if (!installed.includes(version)) { try { ws.send( JSON.stringify({ type: 'runtime:use:error', id: msg.id, error: `runtime ${version || '(missing)'} is not installed`, }), ) } catch {} return } const result = this.setActiveRuntime(version) try { ws.send( JSON.stringify({ type: 'runtime:use:ok', id: msg.id, version: result.version, runtimeDir: result.runtimeDir, }), ) } catch {} return } if (msg.type === 'runtime:get') { const active = this.getActiveRuntime() try { ws.send( JSON.stringify({ type: 'runtime:get:ok', id: msg.id, active: active.version, activeRuntimeDir: active.runtimeDir, }), ) } catch {} return } // Browsers always send an Origin header, but the IDE needs a second, // short-lived CLI-role socket to route typed app_* calls to an // Contrast-dev-owned external runtime driver. `bridge:hello` is // already the CLI handshake, so let it correct the provisional // Origin-based role before this socket has registered a page. This reuses the existing // daemon transport and call envelope; it does not add a second routing // surface or let a registered sim switch roles mid-connection. if (role === 'sim' && msg.type === 'bridge:hello' && sim && !sim.url) { const provisionalId = sim.id this.sims.delete(provisionalId) if (this.primarySimId === provisionalId) { this.primarySimId = this.getOpenSim()?.id ?? null } sim = null role = 'cli' this.cliIdentityKeyBySocket.set(ws, `ws-${this.nextCliFallbackId++}`) this.writeConnectedRuntimeSnapshot() this.broadcastSimAssignments() this.broadcastSimClientStates() } if (role === 'sim') { if (sim) { sim.lastSeenAt = Date.now() } if (msg.type === 'bridge:register' && sim) { const registration = msg as BridgeSimRegistrationMessage const restored = this.tryRestoreSimId(sim, registration.simId) sim.url = registration.url sim.title = registration.title sim.userAgent = registration.userAgent this.writeConnectedRuntimeSnapshot() // kind/meta are optional and free-form. only update if the // sender supplied a value so we don't accidentally clear // state on a heartbeat-style re-register. if (typeof registration.kind === 'string' && registration.kind.trim()) { sim.kind = registration.kind.trim() } if (registration.meta && typeof registration.meta === 'object') { sim.meta = registration.meta as Record } // re-evaluate primary now that the sim has a real page. promotion // is otherwise decided once at bare-connect, before `url` is // known — so a page-less sim that grabbed primary as the // last-resort fallback (or a stale zombie still holding it) // would never be superseded by this freshly-registered, actually // driveable sim (QA F19-2). only adopt a *different* sim so a // routine heartbeat re-register doesn't churn the assignment. const reElected = this.primarySimId !== sim.id && this.shouldPromoteSim(sim) if (reElected) this.primarySimId = sim.id if (restored || reElected) { this.broadcastSimAssignments() this.broadcastSimClientStates() } return } if (msg.type === 'bridge:user-focus-state' && sim) { const focusState = msg as BridgeSimUserFocusStateMessage this.updateUserFocusLease(sim, focusState) return } if (msg.type === 'bridge:user-interact' && sim) { this.updateUserActivity(sim) return } // write a partial patch into ~/.rnx/config.json. fired by the // engine's settingsStore when a persisted key flips in a browser // tab (which has no fs access of its own). after the merge, we // broadcast the new full snapshot to every connected sim so any // tab that has the engine config global picks up the change live // — same shape as electron's `config:changed` IPC. if (msg.type === 'bridge:write-shared-config') { if (!msg.patch || typeof msg.patch !== 'object' || Array.isArray(msg.patch)) { return } const patch = Object.fromEntries( Object.entries(msg.patch), ) satisfies Partial try { this.writeAndBroadcastSharedConfig(patch) } catch (err) { process.stderr.write( `sootsim: bridge:write-shared-config failed: ${err instanceof Error ? err.message : String(err)}\n`, ) return } return } // open a source file in the user's editor. triggered by tappable // stack frames in sootsim's RedBox overlay. if (msg.type === 'bridge:open-path') { const filePath = typeof msg.path === 'string' ? msg.path : '' const line = typeof msg.line === 'number' && Number.isFinite(msg.line) ? msg.line : undefined const column = typeof msg.column === 'number' && Number.isFinite(msg.column) ? msg.column : undefined if (filePath) { void this.openPathInEditor(filePath, line, column) } return } // boot-clients: disconnect all CLI clients attached to this sim // and hand the sim to the user. an explicit Boot is a strong claim, // so we install a user-active lease instead of clearing — otherwise // a reconnecting agent (most spawn a fresh socket within ms of close) // claims the empty slot before the user can interact and the boot // becomes a no-op the user has to repeat indefinitely. if (msg.type === 'bridge:boot-clients' && sim) { const booted: WebSocket[] = [] for (const [cliWs, attachedSimId] of this.cliSimBySocket) { if (attachedSimId === sim.id) { booted.push(cliWs) } } for (const cliWs of booted) { this.cliSimBySocket.delete(cliWs) try { cliWs.close(1000, 'booted by sim') } catch {} } const hadLease = !!sim.cliLease sim.cliLease = { kind: 'user-active', cliIdentityKey: '__user-active__', cliLabel: 'active user', expiresAt: Date.now() + SootSimBridgeHost.USER_BOOT_LEASE_TTL_MS, } process.stderr.write( `rnx booted ${booted.length} cli client(s)${hadLease ? ' (overrode prior lease)' : ''}; held sim for user [${sim.id}]\n`, ) this.recordSimAction(sim.id, 'sim booted cli clients') this.broadcastSimClientStates() return } const internalPending = this.pendingCommands.get(msg.id) if (internalPending) { this.pendingCommands.delete(msg.id) if (msg.error) internalPending.reject(new Error(msg.error)) else internalPending.resolve(msg.result) return } const entry = this.cliBySentId.get(msg.id) if (entry) { this.cliBySentId.delete(msg.id) if (entry.ws.readyState === WebSocket.OPEN) { // include other CLI count so the client can warn about contention const otherCliCount = this.getOtherCliIdentityCount(entry.ws, entry.simId) const response = otherCliCount > 0 ? { ...msg, id: entry.originalId, _otherCliCount: otherCliCount } : { ...msg, id: entry.originalId } entry.ws.send(JSON.stringify(response)) } } return } void (async () => { this.cliLastCommandAt.set(ws, Date.now()) try { if (msg.type === 'bridge:bye') { // explicit goodbye from a cli about to exit. drop its socket // state immediately so the next invocation from the same agent // doesn't see this one as a phantom peer. the subsequent tcp // close event becomes a no-op. const hadSim = this.cliSimBySocket.delete(ws) this.cliLastCommandAt.delete(ws) this.cliIdentityKeyBySocket.delete(ws) this.cliLabelBySocket.delete(ws) for (const [sentId, entry] of this.cliBySentId) { if (entry.ws === ws) this.cliBySentId.delete(sentId) } if (hadSim) this.broadcastSimClientStates() return } if (msg.type === 'bridge:hello') { const key = typeof msg.cliIdentityKey === 'string' && msg.cliIdentityKey.trim() ? msg.cliIdentityKey.trim() : this.cliIdentityKeyBySocket.get(ws) || `ws-${this.nextCliFallbackId++}` this.cliIdentityKeyBySocket.set(ws, key) if (typeof msg.cliLabel === 'string' && msg.cliLabel.trim()) { this.cliLabelBySocket.set(ws, msg.cliLabel.trim()) } // same-identity cli sockets are allowed to coexist. this avoids // self-disconnects when agent tooling issues multiple commands // in parallel from the same logical identity key. if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ id: msg.id, result: { cliIdentityKey: key, leaseTtlMs: SootSimBridgeHost.CLI_LEASE_TTL_MS, leasing: true, }, }), ) } return } if (msg.type === 'bridge:list-sims') { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ id: msg.id, result: this.listSims(this.cliIdentityKeyBySocket.get(ws)), }), ) } return } if (msg.type === 'bridge:claim') { const targetSim = await this.waitForSim(msg.simId) const outcome = this.tryAcquireLease(ws, targetSim, { force: msg.force === true, }) if (!outcome.granted) { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ id: msg.id, error: `sim ${targetSim.id} is locked by another cli`, _locked: outcome.lock, }), ) } return } this.setCliSimTarget(ws, targetSim.id) this.recordSimAction( targetSim.id, outcome.bootedCount > 0 ? `cli force-claimed sim (booted ${outcome.bootedCount})` : 'cli claimed sim', ) if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ id: msg.id, result: { simId: targetSim.id, lockedBy: outcome.lease.cliIdentityKey, lockExpiresAt: outcome.lease.expiresAt, bootedCount: outcome.bootedCount, }, }), ) } return } const targetSim = await this.waitForSim(msg.simId) if (shouldAcquireLease(msg)) { const outcome = this.tryAcquireLease(ws, targetSim) if (!outcome.granted) { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ id: msg.id, error: `sim ${targetSim.id} is locked by another cli — use \`rnx claim ${targetSim.id} --force\` or \`rnx open --new\``, _locked: outcome.lock, }), ) } return } } else { // read-only / observational pass-through: still register this // cli as attached so list/describe shows it, but never block. this.ensureCliIdentityKey(ws) } this.setCliSimTarget(ws, targetSim.id) this.recordSimAction(targetSim.id, this.describeForwardedCommand(msg)) const sentId = this.nextCommandId++ this.cliBySentId.set(sentId, { simId: targetSim.id, ws, originalId: msg.id, }) const { simId: _simId, ...forwarded } = msg targetSim.ws.send(JSON.stringify({ ...forwarded, id: sentId })) // `close` must never hang on the sim page. a frozen sim never // processes the forwarded close and never replies, so the CLI // command would otherwise time out after the full command // window (M3). treat close as fire-and-forget: ack the CLI now, // drop the pending relay entry so a late sim reply is harmless, // and disconnect the sim socket with the terminal close code if // the page has not closed itself within the grace window. if (forwarded.type === 'close') { this.cliBySentId.delete(sentId) if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ id: msg.id, result: { requested: true, simId: targetSim.id }, }), ) } const simWs = targetSim.ws const closeTimer = setTimeout(() => { this.closeSimSocketFromHost(simWs) }, FORCE_CLOSE_GRACE_MS) unrefTimer(closeTimer) } } catch (err) { if (ws.readyState === WebSocket.OPEN) { ws.send( JSON.stringify({ id: msg.id, error: err instanceof Error ? err.message : String(err), }), ) } } })() }) ws.on('close', () => { // always drop agent subscriptions first — the FIFO refcount needs // to settle before any later broadcast fan-out fires. this.agentHost.unregisterSocket(ws) this.planeHost.unregisterSocket(ws) if (role === 'sim' && sim) { this.rememberDisconnectedSim(sim) if (this.primarySimId === sim.id) { this.primarySimId = this.getOpenSim()?.id ?? null } for (const [id, pending] of this.pendingCommands) { if (pending.simId !== sim.id) continue pending.reject(new Error('sim disconnected')) this.pendingCommands.delete(id) } for (const [sentId, entry] of this.cliBySentId) { if (entry.simId !== sim.id) continue if (entry.ws.readyState === WebSocket.OPEN) { entry.ws.send( JSON.stringify({ id: entry.originalId, error: 'sim disconnected before responding', }), ) } this.cliBySentId.delete(sentId) } this.broadcastSimAssignments() this.broadcastSimClientStates() } else if (role === 'cli') { const detached = this.cliSimBySocket.delete(ws) this.cliLastCommandAt.delete(ws) this.cliIdentityKeyBySocket.delete(ws) this.cliLabelBySocket.delete(ws) for (const [sentId, entry] of this.cliBySentId) { if (entry.ws === ws) this.cliBySentId.delete(sentId) } if (detached) { this.broadcastSimClientStates() } } }) }) } /** after a successful bind: start the cli idle sweep, write the daemon/dev * lockfile (if this host owns it), seed the agent host, kick off the * heartbeat loop. idempotent across rebinds because close() tears down * every timer and the lockfile. */ private afterBind() { process.stderr.write(`ws bridge listening on port ${this.port}\n`) this.cliIdleTimer = setInterval( () => this.sweepIdleCliClients(), 30_000, ) as unknown as NodeJS.Timeout this.cliIdleTimer.unref() this.wsHeartbeatTimer = setInterval( () => this.sweepDeadWebSockets(), SootSimBridgeHost.WS_HEARTBEAT_INTERVAL_MS, ) as unknown as NodeJS.Timeout this.wsHeartbeatTimer.unref() if (this.shouldWriteLockfile) { try { ensureRnxHome() // atomic claim: bail if another fresh daemon's lockfile already // exists (stale ones are overwritten). last line of defense // against two daemons clobbering the same file between the // freshness check in serve.ts and here. const claimed = claimDaemonLockfile(this.buildLockfileSnapshot()) if (!claimed) { throw new Error( 'another rnx daemon wrote the lockfile during startup — aborting', ) } } catch (err) { process.stderr.write( `ws bridge failed to claim daemon lockfile: ${String(err)}\n`, ) throw err } this.heartbeatTimer = setInterval(() => { try { this.writeLockfileSnapshot() } catch {} }, DAEMON_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout this.heartbeatTimer.unref() this.startRuntimeUpdater() } if (this.shouldWriteDevLockfile) { try { this.writeDevLockfileSnapshot() } catch (err) { process.stderr.write( `ws bridge failed to write dev bridge lockfile: ${String(err)}\n`, ) } this.devHeartbeatTimer = setInterval(() => { try { this.writeDevLockfileSnapshot() } catch {} }, DAEMON_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout this.devHeartbeatTimer.unref() } // seed the attached-projects store from the demo registry on first // daemon boot. idempotent; no-ops once the store has anything in it. void this.agentHost.seedOnBoot() } private bootstrapping = true private connectedRuntimeVersions(): string[] { const versions = new Set() for (const sim of this.sims.values()) { try { const url = new URL(sim.url || sim.origin || 'http://localhost') const version = runtimeVersionFromHostname(url.hostname) if (version) versions.add(version) } catch {} } return [...versions].sort() } private buildLockfileSnapshot(): DaemonLockfile { return { schema: 1, pid: process.pid, platform: process.platform, bridgePort: this.effectivePort, runtimePort: this.effectivePort, activeRuntime: this.activeRuntimeVersion, activeRuntimeDir: this.activeRuntimeDirPath, servedRuntimes: this.connectedRuntimeVersions(), startedAt: this.startedAt, heartbeatAt: Date.now(), bootstrapping: this.bootstrapping, } } private buildDevLockfileSnapshot(): DevBridgeLockfile { const shellPort = this.getShellPort?.() ?? null return { schema: 1, pid: process.pid, platform: process.platform, bridgePort: this.effectivePort, runtimePort: this.effectivePort, ...(shellPort && shellPort > 0 ? { shellPort } : {}), cwd: process.cwd(), startedAt: this.startedAt, heartbeatAt: Date.now(), source: 'vite-dev', servedRuntimes: this.connectedRuntimeVersions(), } } private writeLockfileSnapshot() { writeDaemonLockfile(this.buildLockfileSnapshot()) } private writeDevLockfileSnapshot() { writeDevBridgeLockfile(this.buildDevLockfileSnapshot()) } private writeConnectedRuntimeSnapshot() { if (this.shouldWriteLockfile && this.httpServer) { try { this.writeLockfileSnapshot() } catch {} } if (this.shouldWriteDevLockfile && this.httpServer) { try { this.writeDevLockfileSnapshot() } catch {} } } private refreshActiveRuntime() { this.activeRuntimeVersion = readActiveRuntime() // mode boundary (see runtime-assets resolveDevCheckoutRuntimeRoot): inside // the Contrast monorepo dev checkout serve the fresh dev-stack build so local // engine edits reach previews without a publish; otherwise the installed // runtime. the version above stays the installed version for display. this.activeRuntimeDirPath = resolveDevCheckoutRuntimeRoot() ?? getActiveRuntimeDir() } private runServerScan(): Promise { if (this.inflightScan) return this.inflightScan const excludePorts = this.effectivePort > 0 ? [this.effectivePort] : [] this.inflightScan = scanDevServers({ excludePorts, buildIconProxyUrl: buildBrowserAssetProxyUrl, }) .then((results) => { this.scanCache = results this.scanCacheAt = Date.now() return results }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) console.error('[rnx] /__server-scan failed:', message) return this.scanCache ?? [] }) .finally(() => { this.inflightScan = null }) return this.inflightScan } // serves one staged camera fixture. addressed by basename inside // ~/.rnx/camera-fixtures, never by a caller-supplied path, so there is // no traversal surface to defend: a name that is not a plain basename is // rejected outright. range support is not optional here — chrome asks for // byte ranges on media and cannot seek or loop a fixture without it. private handleCameraFixture(name: string, req: IncomingMessage, res: ServerResponse) { const method = (req.method || 'GET').toUpperCase() const cors = { 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'no-store', } if (method === 'OPTIONS') { res.writeHead(204, { ...cors, 'Access-Control-Allow-Methods': 'GET, HEAD, OPTIONS', 'Access-Control-Allow-Headers': 'range', }) res.end() return } if (method !== 'GET' && method !== 'HEAD') { res.writeHead(405, cors) res.end() return } if (!CAMERA_FIXTURE_NAME_RE.test(name)) { res.writeHead(400, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) res.end('bad fixture name') return } const root = cameraFixturesDir() const filePath = path.join(root, name) let stats: fs.Stats try { // a symlink dropped into the fixtures dir is the one way out of it. // lstat rather than stat so a link is refused instead of followed. stats = fs.lstatSync(filePath) } catch { res.writeHead(404, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) res.end('not found') return } if (!stats.isFile()) { res.writeHead(403, { ...cors, 'Content-Type': 'text/plain; charset=utf-8' }) res.end('forbidden') return } const contentType = HTTP_MIME_TYPES[path.extname(filePath).toLowerCase()] || 'application/octet-stream' const total = stats.size const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range || '') if (range) { const [, startRaw, endRaw] = range const start = startRaw ? Number(startRaw) : 0 const end = endRaw ? Math.min(Number(endRaw), total - 1) : total - 1 if (!Number.isFinite(start) || start > end || start >= total) { res.writeHead(416, { ...cors, 'Content-Range': `bytes */${total}` }) res.end() return } res.writeHead(206, { ...cors, 'Content-Type': contentType, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Accept-Ranges': 'bytes', 'Content-Length': String(end - start + 1), }) if (method === 'HEAD') { res.end() return } fs.createReadStream(filePath, { start, end }).pipe(res) return } res.writeHead(200, { ...cors, 'Content-Type': contentType, 'Accept-Ranges': 'bytes', 'Content-Length': String(total), }) if (method === 'HEAD') { res.end() return } fs.createReadStream(filePath).pipe(res) } private handleServerScan(res: ServerResponse) { const sendJson = (body: DiscoveredServer[]) => { res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', }) res.end(JSON.stringify(body)) } const age = Date.now() - this.scanCacheAt if (this.scanCache && age < SootSimBridgeHost.SCAN_FRESH_MS) { sendJson(this.scanCache) return } if (this.scanCache) { // stale-while-revalidate: return last known good immediately, kick a // fresh scan in the background. sendJson(this.scanCache) void this.runServerScan().catch(() => {}) return } void this.runServerScan().then((results) => sendJson(results)) } private resolveRuntimeUpdateIntervalMs() { const raw = Number(process.env[RUNTIME_UPDATE_INTERVAL_ENV]) if (Number.isFinite(raw) && raw > 0) return Math.max(100, Math.round(raw)) return DEFAULT_RUNTIME_UPDATE_INTERVAL_MS } private startRuntimeUpdater() { if (!this.shouldWriteLockfile || this.runtimeUpdateTimer) return if (fs.existsSync(path.join(rnxHomeDir(), 'runtime-update-disabled'))) { this.bootstrapping = false if (this.httpServer) this.writeLockfileSnapshot() return } // an isolated dev daemon (explicit RNX_HOME, dev checkout, nothing // staged) serves the repo's public/sootsim build. auto-installing the // published runtime here would stage it active and shadow that dev build // (runtime-assets prefers a staged runtime under an explicit home), so the // daemon would silently validate published code instead of the checkout. // local-publish writes the marker above when it stages a source build. if (process.env.RNX_HOME && isSootsimDevCheckout() && !readActiveRuntime()) { return } void this.runRuntimeUpdate('startup') const intervalMs = this.resolveRuntimeUpdateIntervalMs() this.runtimeUpdateTimer = setInterval(() => { void this.runRuntimeUpdate('periodic') }, intervalMs) as unknown as NodeJS.Timeout this.runtimeUpdateTimer.unref() } private runRuntimeUpdate(reason: 'startup' | 'periodic'): Promise { if (this.runtimeUpdateInFlight) return this.runtimeUpdateInFlight this.runtimeUpdateInFlight = (async () => { try { if (reason === 'startup') { process.stderr.write('rnx: checking for runtime updates…\n') } const result = await rnxRuntime.updateToLatest({ protectVersions: this.connectedRuntimeVersions(), }) if (!result.updated || !result.latestVersion) { if (reason === 'startup') { process.stderr.write( `rnx: runtime ${this.activeRuntimeVersion ?? '(none)'} is current\n`, ) } return } const previousVersion = this.activeRuntimeVersion const active = this.setActiveRuntime(result.latestVersion) process.stderr.write(`rnx runtime updated to ${active.version} (${reason})\n`) // the daemon's stderr goes to a log nobody watches — drop a one-shot // notice so the next interactive CLI run tells the user their engine // moved (consumed + deleted by bin.ts). try { writeRuntimeUpgradeNotice({ from: previousVersion, to: active.version, at: Date.now(), }) } catch {} } catch (err) { process.stderr.write( `rnx runtime update failed (${reason}): ${ err instanceof Error ? err.message : String(err) }\n`, ) } finally { this.runtimeUpdateInFlight = null // first-boot gate flips off once the startup pass finishes (success // or fail) — clients can navigate even if the network update failed // as long as some runtime is on disk. without this the splash would // wait forever on a temporary network blip. if (reason === 'startup' && this.bootstrapping) { this.bootstrapping = false if (this.shouldWriteLockfile && this.httpServer) { try { this.writeLockfileSnapshot() } catch {} } process.stderr.write('rnx: ready\n') } } })() return this.runtimeUpdateInFlight } /** update the active runtime on disk + in memory. the caller guarantees * the version directory exists. pushes a runtime:changed message to all * connected sims so electron (or any renderer) can reload. */ setActiveRuntime(version: string): { version: string; runtimeDir: string | null } { writeActiveRuntime(version) this.refreshActiveRuntime() if (this.shouldWriteLockfile && this.httpServer) { try { this.writeLockfileSnapshot() } catch {} } // broadcast to sims so electron can reload its webContents without // a manual restart. CLI clients ignore this message. const payload = JSON.stringify({ type: 'runtime:changed', version, runtimeDir: this.activeRuntimeDirPath, }) for (const sim of this.sims.values()) { try { const simUrl = new URL(sim.url || sim.origin || 'http://localhost') if (runtimeVersionFromHostname(simUrl.hostname)) continue } catch {} if (sim.ws.readyState === WebSocket.OPEN) { try { sim.ws.send(payload) } catch {} } } return { version, runtimeDir: this.activeRuntimeDirPath } } getActiveRuntime(): { version: string | null; runtimeDir: string | null } { return { version: this.activeRuntimeVersion, runtimeDir: this.activeRuntimeDirPath, } } /** last-ditch lockfile cleanup. safe to call from a synchronous * `process.on('exit', ...)` handler since it only does a fs.unlinkSync. */ removeLockfile() { if (!this.shouldWriteLockfile) return try { removeDaemonLockfile({ pid: process.pid, startedAt: this.startedAt }) } catch {} } /** minimal HTTP request handler attached to the same node http server * that hosts the WS upgrade. handles: * GET /healthz json status for supervisors / curl * GET / + everything serves from the active runtime dist, SPA fallback * non-upgrade routes that don't match serve index.html (SPA behavior) so * electron's webContents can navigate freely inside the runtime. */ private handleHttpRequest(req: IncomingMessage, res: ServerResponse) { // cross-origin isolation — without this, Electron / Chromium refuses // `SharedArrayBuffer`, and the engine's render-worker crashes on boot // ("render worker crashed during boot" / "app:one surface registration // failed"). SAB powers the shell-scene fast channel and the worklet // runtime's SharedValues. mirrors sootsim-shell/vite.config.ts (dev) and // contrast.dev app/_middleware.ts (prod) so the cli daemon serves the // same surface those two already enforce. set via setHeader so subsequent // writeHead() calls preserve them (writeHead only overrides keys it // explicitly passes). Safari/iOS WebKit does not isolate documents for // COEP=credentialless, so use require-corp and keep bridge-served/proxied // resources same-origin with CORP where needed. mirrors the SootSim shell // dev server and the /sootsim production override. res.setHeader('Cross-Origin-Opener-Policy', 'same-origin') res.setHeader('Cross-Origin-Embedder-Policy', SOOTSIM_CROSS_ORIGIN_EMBEDDER_POLICY) res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin') res.setHeader('Document-Policy', 'js-profiling') // proxy + app-api routes need to accept POST/PUT/DELETE/PATCH/OPTIONS // because they forward the request to a real upstream. handle them // BEFORE the read-only method check below — otherwise tenant bundles // fetching cross-origin APIs through the daemon (e.g. when the shell // dev server isn't running) get a 405 instead of the proxied response, // and downstream NetInfo-style reachability probes flip to "offline". if (isFetchProxyRequestUrl(req.url)) { void handleFetchProxyRequest(req, res) return } if (isAppApiRequestUrl(req.url) && handleAppApiRequest(req, res)) { return } if (isReplacementModuleRequestUrl(req.url)) { void handleReplacementModuleRequest(req, res) return } const method = (req.method || 'GET').toUpperCase() let url: URL try { url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`) } catch { res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }) res.end('invalid request URL or host') return } // /__sootsim/shared-config is the one awaited HTTP write. ordinary // settings fan out over WS, but destructive reloads need confirmation // that the disk source of truth accepted their final reset first. if (url.pathname === '/__sootsim/shared-config') { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') res.setHeader('Cache-Control', 'no-store') if (method === 'OPTIONS') { res.writeHead(204, { Allow: 'GET, HEAD, POST, OPTIONS' }) res.end() return } if (method === 'GET' || method === 'HEAD') { let body = '{}' try { body = JSON.stringify(readSharedConfig()) } catch {} res.writeHead(200, { 'Content-Type': 'application/json' }) if (method === 'HEAD') res.end() else res.end(body) return } if (method === 'POST') { void (async () => { try { const chunks: Buffer[] = [] let size = 0 for await (const chunk of req) { const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) size += buffer.byteLength if (size > 8 * 1024 * 1024) { res.writeHead(413, { 'Content-Type': 'text/plain; charset=utf-8' }) res.end('shared config patch is too large') return } chunks.push(buffer) } const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8')) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('shared config patch must be an object') } const patch = Object.fromEntries( Object.entries(parsed), ) satisfies Partial const next = this.writeAndBroadcastSharedConfig(patch) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(next)) } catch (error) { res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' }) res.end( `invalid shared config patch: ${error instanceof Error ? error.message : String(error)}`, ) } })() return } res.writeHead(405, { Allow: 'GET, HEAD, POST, OPTIONS' }) res.end('method not allowed') return } // /__send-to-box — the rail button. the shell cannot create a cloud box // itself: the account credential lives on this machine, not in the page. // it sits above the read-only guard below because it is a POST. if (url.pathname === '/__send-to-box') { res.setHeader('Access-Control-Allow-Origin', '*') res.setHeader('Access-Control-Allow-Headers', 'Content-Type') res.setHeader('Cache-Control', 'no-store') if (method === 'OPTIONS') { res.writeHead(204, { Allow: 'POST, OPTIONS' }) res.end() return } if (method !== 'POST') { res.writeHead(405, { Allow: 'POST, OPTIONS' }) res.end('method not allowed') return } void (async () => { try { const chunks: Buffer[] = [] for await (const chunk of req) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) } const text = Buffer.concat(chunks).toString('utf8').trim() const parsed: unknown = text ? JSON.parse(text) : {} if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new Error('send to box needs a json object body') } const body: Record = { ...parsed } const { CHOOSE_ACCOUNT_HINT, resolveCliAuth, authHeaderValue } = await import('../../cli/auth.ts') const auth = resolveCliAuth() if (!auth) { res.writeHead(401, { 'Content-Type': 'application/json' }) res.end( JSON.stringify({ error: `send to box needs an account: run \`${rnxPublicBrand.commandName} login\` in this checkout`, }), ) return } // the CLI would exit the process here; a daemon serving the rail // answers the button instead. const accountId = auth.kind === 'session' ? auth.accountId : null if (auth.kind === 'session' && accountId === null) { res.writeHead(400, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: CHOOSE_ACCOUNT_HINT })) return } const { sendToBox } = await import('../../cli/send-to-box.ts') const result = await sendToBox({ send: (command) => this.sendCommand({ ...command, simId: typeof command.simId === 'string' ? command.simId : undefined, }), authorization: authHeaderValue(auth), accountId, carryAuth: body.carryAuth !== false, boxName: typeof body.boxName === 'string' ? body.boxName : null, simId: typeof body.simId === 'string' ? body.simId : undefined, }) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify(result)) } catch (error) { res.writeHead(500, { 'Content-Type': 'application/json' }) res.end( JSON.stringify({ error: error instanceof Error ? error.message : String(error), }), ) } })() return } // /__camera-fixtures/ — the staged video behind `rnx camera play`. // the shell page is a different origin from this daemon (vite dev on 5173, // daemon on 7668+), and the camera pipeline draws the