// shared dev-server scanner used by both the electron main process // (`src-electron/main.ts`) and the vite plugin (`vite.config.ts`). discovers // running dev servers on localhost by listing every node/bun process that's // listening, then probes each port for known bundler signatures. // // precedence during probing: // 1. one/vxrn (node_modules/one/metro-entry.bundle reachable) // 2. metro/expo (packager-status:running + /_expo/status) // 3. expo manifest (JSON manifest at /) // 4. sootsim-patched (/__soot/ middleware present) → /__soot/bundle.js // // scans are cached per (port, pid). repeat scans with no process changes issue // zero HTTP requests; probes only fire when a port appears, disappears, or its // pid changes. before any HTTP, a TCP connect gate skips unreachable ports. import { exec } from 'child_process' import { readlink } from 'fs/promises' import http from 'http' import net from 'net' import { realpathSync } from 'node:fs' import { resolve } from 'node:path' import { buildLoopbackProbeBaseUrl, isLoopbackHost, LOOPBACK_PROBE_HOSTNAMES, normalizeHostname, } from '../src/backend-origin.ts' import { applyRNXConfigToUrl } from '../src/config.ts' import { alignLoopbackBundleUrlWithProbe, normalizeNativeDevBundleUrl, } from '../src/native-dev-bundle-url.ts' import { __resetAppSettingsBundleCacheForTests, readAppSettingsBundle, } from '../src/settings-bundle.ts' import { loadOptionalDemoApps, optionalDemoAppsSync } from './optional-demo-registry.ts' import type { AppSettingsSpecifier } from '@rnx/globals' // how long past a command's own budget we wait before deciding its exit event is // never coming. generous, because this only fires when the child is already // overdue and we are choosing between a wrong answer and a hang. const EXEC_EXIT_GRACE_MS = 2_000 /** every subprocess in this file goes through here. * * `exec`'s own `timeout` option is NOT a backstop. it kills the child and then * STILL waits for a 'close' event to settle the promise, so when that event is * lost the promise never settles — the timeout is already spent and nothing is * left to rescue it. (same failure m10843 pinned in `CheckoutFilePlane.refresh`.) * `lsof` walks every open fd on the machine, which makes it a realistic victim. * * a hung scan is worse than a slow one: `inflightScan` never clears, so * `/__server-scan` stops answering at all instead of answering "could not * look". so we race the child against a real wall-clock deadline and reject * with `killed`, which callers already read as could-not-look. */ async function execGuarded( command: string, timeout: number, extra: { maxBuffer?: number } = {}, ): Promise<{ stdout: string }> { let child: ReturnType | undefined const ran = new Promise<{ stdout: string }>((resolve, reject) => { child = exec(command, { encoding: 'utf8', timeout, ...extra }, (err, stdout) => { if (err) reject(err) else resolve({ stdout }) }) }) let deadlineTimer: ReturnType | undefined const deadline = new Promise((_resolve, reject) => { deadlineTimer = setTimeout(() => { child?.kill('SIGKILL') const err = new Error(`\`${command}\` did not exit within ${timeout}ms`) // matches the shape `wasKilledByTimeout` reads off a real exec timeout, so // a lost exit event and a genuine timeout reach callers as the same fact. Object.assign(err, { killed: true }) reject(err) }, timeout + EXEC_EXIT_GRACE_MS) }) try { return await Promise.race([ran, deadline]) } finally { // the race is settled, so drop the deadline rather than leaving it pending. clearTimeout(deadlineTimer) } } export interface DiscoveredServer { port: number framework: 'metro' | 'expo' | 'vxrn' | 'one' | 'unknown' projectName?: string bundleUrl: string hmrUrl?: string lastSeen: number iconUrl?: string iconPath?: string /** the app's own brand colour from its manifest, used as the home tile's * background so the tile reads as that app before its icon decodes and * when the icon is missing entirely. */ primaryColor?: string bundleId?: string settingsBundle?: readonly AppSettingsSpecifier[] settingsStorageScopeUrl?: string patched?: boolean /** true when `bundleUrl` was NOT derived from the server's own manifest — * the manifest probe got no answer, so this is the generic `/index.bundle` * guess. good enough to LIST the server; never good enough to open it. see * `manifestState` below for why the two are different answers. */ bundleUrlProvisional?: boolean /** absolute cwd of the owning node/bun process — resolved from `lsof -d cwd` * when the scanner can see the pid. used to auto-attach the project for * agent sessions without requiring a manual `sootsim agent attach`. */ cwd?: string pid?: number } /** how the manifest probe (`GET /` with expo-platform) actually went. the same * three answers `dev-bundle-resolution.ts` uses, deliberately spelled the same * way: a second vocabulary for one question is how the two resolvers drift. * * the rule this encodes, which the whole scanner depends on: A PROBE THAT * COULD NOT LOOK MUST NEVER ANSWER AS THOUGH IT LOOKED. silence, timeout and * empty are three different answers, and only `no-manifest` means "there is * nothing there". reading `no-answer` as `no-manifest` is what made * `rnx open` hand back `/index.bundle` for a One app whose manifest * names a different entry point entirely. */ type ManifestState = 'resolved' | 'no-manifest' | 'no-answer' // localhost dev servers respond in well under 100ms when alive. drop dead // branches fast so a non-bundler process doesn't drag scan latency. const TIMEOUT_MS = 250 // the expo-style manifest GET / is intentionally slower than the cheap // signature probes — for One framework apps it serializes app.config.js // (including embedded googleServicesFile blobs), which can push the // response to 200–400ms on first hit. when this probe times out we fall // back to the One-HEAD legacy URL `/node_modules/one/metro-entry.bundle`, // which is the path that originally caused the "failed to fetch bundle // (404|502)" regression on monorepo One projects (apps//...). give // the manifest more headroom so the canonical launchAsset.url wins. const MANIFEST_TIMEOUT_MS = 1500 // cheap TCP-connect check gate for HTTP probes — if we can't open a socket // within this budget the port isn't actually reachable, and all 5 HTTP probes // would just time out for nothing. const TCP_GATE_MS = 120 // once a server has sent response headers we never abort it (see httpGet). this // only bounds a response that never ends, so it is deliberately far above any // real manifest: aborting a slow-but-answering server is the defect this whole // path exists to avoid. const BODY_TIMEOUT_MS = 30_000 interface HttpResult { statusCode: number body: string contentType?: string hostname: string } // cheap TCP-connect gate. lsof already says the socket is LISTEN, but the // process can be closing, the fd can be a different protocol, or a zombie // can linger — none of those cases are worth firing 5 HTTP probes at. async function tcpPing(port: number, timeout = TCP_GATE_MS): Promise { try { return await Promise.any( LOOPBACK_PROBE_HOSTNAMES.map( (hostname) => new Promise((resolve, reject) => { const sock = new net.Socket() let settled = false const done = (reachable: boolean) => { if (settled) return settled = true sock.destroy() if (reachable) resolve(hostname) else reject(new Error(`unreachable loopback host ${hostname}`)) } sock.setTimeout(timeout) sock.once('connect', () => done(true)) sock.once('timeout', () => done(false)) sock.once('error', () => done(false)) sock.connect(port, hostname) }), ), ) } catch { return null } } function httpGet( hostname: string, port: number, path: string, method: 'GET' | 'HEAD' = 'GET', timeout = TIMEOUT_MS, headers: Record = {}, ): Promise { return new Promise((resolve) => { // first answer wins: the response, the deadline, and the connection closing // all race, and every one of them can arrive after another has decided. let settled = false const settle = (value: HttpResult | null) => { if (settled) return settled = true resolve(value) } // NO socket timeout here, deliberately. `options.timeout` / `req.setTimeout` // make node DESTROY the connection when the budget expires, and destroying a // dev server's connection while it is still assembling its manifest fires // "Cannot pipe to a closed or destroyed stream" into One's manifest // middleware as an unhandled rejection — the hazard the knownOne comment // below already documents, which can exit the user's dev server. measured: // six probes with a socket timeout produced six of those errors, six probes // that give up in JS produced none, and the request completes later with the // server none the wiser. // // giving up WAITING and KILLING THE REQUEST are different acts, and only the // first one is ours to take. the deadline below resolves the scan on time; // the request runs itself out in the background and its result is discarded. const req = http.request({ hostname, port, path, method, headers }, (res) => { let body = '' res.on('data', (c: Buffer) => (body += c.toString())) const contentType = (() => { const raw = res.headers['content-type'] if (typeof raw === 'string') return raw if (Array.isArray(raw)) return raw[0] return undefined })() res.on('end', () => settle({ statusCode: res.statusCode || 0, body, contentType, hostname, }), ) }) req.on('error', () => settle(null)) req.end() const giveUp = setTimeout(() => { settle(null) // nothing is waiting on it now, so it must not hold a CLI process open. req.socket?.unref() }, timeout) // the only thing that ever destroys a connection here. by BODY_TIMEOUT_MS a // server has long since answered or died, so there is no live write to cut. const abandon = setTimeout(() => req.destroy(), BODY_TIMEOUT_MS) req.on('close', () => { clearTimeout(giveUp) clearTimeout(abandon) // `close` on the request fires BEFORE the response's own `end` handler, // so settling synchronously here turns every successful probe into a // null. defer one tick and let a real answer win; this only settles the // case where the connection closed without one. setImmediate(() => settle(null)) }) }) } // ── discovery ─────────────────────────────────────────────────────────────── export interface ListeningProcess { port: number pid: number } // bare-port fallback list used when neither lsof nor ss are usable. pid 0 // signals "unknown owner" — the cache layer will re-probe these each scan. const FALLBACK_PORTS: ListeningProcess[] = [ 8081, 8082, 8083, 8084, 8085, 8086, 3000, 3001, 19000, ].map((port) => ({ port, pid: 0 })) // sootsim's own dev server range at PORT_OFFSET=0. an isolated stack shifts its // shell by the same offset as every other port it owns, so the guard has to // shift with it: at PORT_OFFSET=90 the shell listens on :5263 and a range fixed // at 5170-5200 does not cover it, which lets a scan discover ITSELF and offer // the shell as though it were an app's dev server. a guard that is only correct // at offset 0 is wrong for everyone running an isolated stack, which is exactly // who runs concurrent scans. const SELF_PORT_RANGE_START = 5170 const SELF_PORT_RANGE_END = 5200 function isOwnShellPort(port: number): boolean { const offset = Number(process.env.PORT_OFFSET) || 0 // the unshifted range still counts: another checkout's default-offset shell is // no more an app dev server than our own is. if (port >= SELF_PORT_RANGE_START && port <= SELF_PORT_RANGE_END) return true return ( offset > 0 && port >= SELF_PORT_RANGE_START + offset && port <= SELF_PORT_RANGE_END + offset ) } function acceptPort(port: number, excluded: Set): boolean { if (port <= 0 || port >= 20000) return false if (excluded.has(port)) return false if (isOwnShellPort(port)) return false return true } /** ran the listing tool and these are the listeners, versus could not run it at * all. an empty `processes` here means the tool looked and there was genuinely * nothing; `could-not-look` means we have no information. substituting the * hardcoded FALLBACK_PORTS for the second case is what made `/__server-scan` * answer `[]` — "there are no dev servers" — to a user running three of them. */ export type ListeningProcessScan = | { state: 'looked'; processes: ListeningProcess[] } | { state: 'could-not-look' } /** thrown by `scanDevServers` when listener enumeration itself failed. callers * must treat it as "no information" — keep whatever list they already had, or * answer their own caller with a failure — never as an empty result. */ export class DevServerScanUnavailableError extends Error { constructor() { super('could not enumerate listening processes (lsof/ss unavailable or timed out)') this.name = 'DevServerScanUnavailableError' } } // lsof walks every open fd on the machine, so on a busy host it is genuinely // slow — measured between 1.2s and 5.0s here with 58 matching listeners. the // budget has to fit the real worst case, because the alternative is not a // faster answer, it is a WRONG one. const LISTING_TIMEOUT_MS = 10_000 /** true when a failed exec was killed by our own timeout rather than by the * command being absent or erroring — the difference between "could not look" * and "this tool is not available here". */ function wasKilledByTimeout(err: unknown): boolean { return !!(err && typeof err === 'object' && (err as { killed?: boolean }).killed) } export async function discoverListeningProcesses( excludePorts: number[] = [], ): Promise { const scan = await scanListeningProcesses(excludePorts) return scan.state === 'looked' ? scan.processes : [] } export async function scanListeningProcesses( excludePorts: number[] = [], ): Promise { const excluded = new Set(excludePorts) let timedOut = false // lsof layout: COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME // NAME looks like "*:8081" or "127.0.0.1:8081" for a LISTEN line. // async exec — sync blocked CrBrowserMain for ~600ms/scan on macOS. try { const { stdout } = await execGuarded( `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E '^(node|bun)'`, LISTING_TIMEOUT_MS, ) const seen = new Map() for (const line of stdout.trim() ? stdout.trim().split('\n') : []) { const parts = line.trim().split(/\s+/) if (parts.length < 9) continue const pid = Number(parts[1]) const addr = parts[8] const m = addr.match(/:(\d+)$/) if (!m) continue const port = Number(m[1]) if (!acceptPort(port, excluded)) continue if (!seen.has(port)) seen.set(port, pid) } // lsof ran. an empty result is a real answer: nothing is listening. return { state: 'looked', processes: [...seen.entries()].map(([port, pid]) => ({ port, pid })), } } catch (err) { if (wasKilledByTimeout(err)) timedOut = true } // ss layout: State Recv-Q Send-Q Local-Address:Port Peer-Addr:Port users:(("node",pid=1234,fd=5)) try { const { stdout } = await execGuarded( `ss -tlnp 2>/dev/null | grep -E '"(node|bun)"'`, LISTING_TIMEOUT_MS, ) const seen = new Map() for (const line of stdout.trim() ? stdout.trim().split('\n') : []) { const portMatch = line.match(/:(\d+)\s/) const pidMatch = line.match(/pid=(\d+)/) if (!portMatch) continue const port = Number(portMatch[1]) const pid = pidMatch ? Number(pidMatch[1]) : 0 if (!acceptPort(port, excluded)) continue if (!seen.has(port)) seen.set(port, pid) } return { state: 'looked', processes: [...seen.entries()].map(([port, pid]) => ({ port, pid })), } } catch (err) { if (wasKilledByTimeout(err)) timedOut = true } // a tool that timed out told us nothing, so say so rather than substituting // the guess list below and reporting its emptiness as fact. if (timedOut) return { state: 'could-not-look' } // neither tool exists here, so probe the conventional bundler ports directly. // that is a real (if narrow) look, not a substitute for one. return { state: 'looked', processes: FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded)), } } // kept for backwards compatibility with any external callers that only need // port numbers. internal paths should prefer discoverListeningProcesses so // the (port, pid) cache can detect process restarts. export async function discoverListeningPorts( excludePorts: number[] = [], ): Promise { const processes = await discoverListeningProcesses(excludePorts) return processes.map((p) => p.port) } /** parse `netstat -anv -p tcp` output for the pid listening on one port. * * macOS lays a LISTEN row out as * tcp4 0 0 127.0.0.1.8090 *.* LISTEN 0 0 131072 131072 node:97330 ... * so the local address is the 4th column with a DOT before the port (which is * what keeps port 8137 from matching a row for 18137), and the owner is the * only token shaped ":". * * exported because this column layout is the fragile part and a child process * cannot exercise it under bun's test runner, which hands back empty stdout. */ export function pidFromNetstat(stdout: string, port: number): number | null { for (const line of stdout.split('\n')) { if (!line.includes('LISTEN')) continue const parts = line.trim().split(/\s+/) if (!parts[3]?.endsWith(`.${port}`)) continue for (const token of parts) { const owner = token.match(/^\S+:(\d+)$/) if (owner) { const pid = Number(owner[1]) if (pid > 0) return pid } } } return null } /** resolve the pid of the process listening on one local port. * * `lsof` walks every descriptor of every process, which measures 2-4s on a * loaded machine — longer than any timeout worth giving it, so * `discoverListeningProcesses` above degrades to its pid-less port list * exactly when the machine is busy. netstat (macOS/BSD) and ss (linux) read * the kernel socket table instead and answer in well under a second, and both * carry the owning pid. use this whenever the question is "who owns this one * port"; `discoverListeningProcesses` answers the different question of what * is listening at all. */ export async function resolveListeningPid(port: number): Promise { if (!Number.isInteger(port) || port <= 0) return null // macOS netstat: "tcp4 0 0 127.0.0.1.8090 *.* LISTEN ... node:97330 ..." // the local address uses a dot before the port, and the owner column is the // only token shaped ":". try { const { stdout } = await execGuarded('netstat -anv -p tcp 2>/dev/null', 8000, { maxBuffer: 16 * 1024 * 1024, }) const pid = pidFromNetstat(stdout, port) if (pid !== null) return pid } catch {} // linux ss: 'LISTEN 0 511 0.0.0.0:8081 0.0.0.0:* users:(("node",pid=1234,fd=5))' try { const { stdout } = await execGuarded(`ss -tlnp 2>/dev/null`, 8000, { maxBuffer: 16 * 1024 * 1024, }) for (const line of stdout.split('\n')) { if (!new RegExp(`:${port}\\s`).test(line)) continue const pid = Number(line.match(/pid=(\d+)/)?.[1]) if (pid > 0) return pid } } catch {} return null } // per-pid cwd cache. lsof is the only way to read another process's cwd on // macOS and it is NOT cheap — measured at 3-8s for a single pid on a machine // running a few dozen node processes — so caching the result until the pid // goes away is what keeps the 5s tray scan viable. const cwdByPid = new Map() /** resolve the current working directory of a pid. returns null for pid 0 * (unknown owner) and whenever neither mechanism below can see the process. * async — sync exec blocked CrBrowserMain at ~600ms on macOS. * * `-b -w` skips the blocking stat calls lsof otherwise makes against every * mount, which roughly halves it. the timeout is generous because the answer * is cached per pid: too tight a limit silently returns null on exactly the * busy machines where auto-attaching a project matters most. */ export async function resolveProcessCwd(pid: number): Promise { if (pid <= 0) return null const cached = cwdByPid.get(pid) if (cached) return cached // linux publishes the cwd as a symlink, which costs one readlink and no // subprocess at all. macOS has no /proc and has to pay for lsof below. try { const cwd = await readlink(`/proc/${pid}/cwd`) if (cwd) { cwdByPid.set(pid, cwd) return cwd } } catch {} try { // -Fn → machine-readable, one field per line. lines look like: // p // fcwd // n const { stdout } = await execGuarded( `lsof -b -w -p ${pid} -a -d cwd -Fn 2>/dev/null`, 8000, ) for (const line of stdout.split('\n')) { if (line.startsWith('n') && line.length > 1) { const cwd = line.slice(1).trim() if (cwd) { cwdByPid.set(pid, cwd) return cwd } } } } catch {} return null } // ── per-port probe orchestration ──────────────────────────────────────────── function makeResult( hostname: string, port: number, framework: DiscoveredServer['framework'], owner: Pick, ): DiscoveredServer { return { port, framework, bundleUrl: withRuntimeConfig( port, `${buildLoopbackProbeBaseUrl('http:', hostname, port)}/index.bundle?platform=ios&dev=true&hot=true&minify=false`, owner.cwd, ), hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', hostname, port)}/hot`, lastSeen: Date.now(), ...owner, } } function withRuntimeConfig(port: number, bundleUrl: string, projectCwd?: string): string { let physicalProjectCwd: string | undefined if (projectCwd) { try { physicalProjectCwd = realpathSync(resolve(projectCwd)) } catch {} } const knownApps = physicalProjectCwd ? optionalDemoAppsSync().filter((app) => { try { return realpathSync(resolve(app.dir)) === physicalProjectCwd } catch { return false } }) : [] const exactKnownApps = knownApps.filter((app) => app.preferredPort === port) const knownApp = exactKnownApps.length === 1 ? exactKnownApps[0] : exactKnownApps.length === 0 && knownApps.length === 1 ? knownApps[0] : undefined const configured = knownApp?.runtimeConfig ? applyRNXConfigToUrl(bundleUrl, knownApp.runtimeConfig) : bundleUrl return normalizeNativeDevBundleUrl(configured) } function isDirectOneBundleUrl(bundleUrl: string): boolean { return bundleUrl.includes('/node_modules/one/metro-entry.bundle') } function safeParseManifest(body: string): Record | null { try { const parsed = JSON.parse(body) as Record return parsed && typeof parsed === 'object' ? parsed : null } catch { return null } } // a manifest icon path ("./assets/app-icons/x.png") is relative to the project // root. metro's asset route prefixes the whole relative path with /assets/; a // statically-served project answers at the literal path. the manifest says // nothing about which, so the two candidates are probed once per port and the // winner cached. keyed by port and cleared with the rest of that port's state // when the process disappears. const localIconPathCache = new Map() const localIconProbeInFlight = new Set() function metroAssetPath(rawIcon: string): string { return `/assets/${rawIcon.replace(/^\.?\//, '')}` } function staticAssetPath(rawIcon: string): string { return `/${rawIcon.replace(/^\.?\//, '')}` } // probe both shapes and cache whichever the server actually serves. resolves // nothing when neither answers — the tile falls back to the app's brand colour. async function resolveLocalIconPath( hostname: string, port: number, rawIcon: string, ): Promise { const candidates = [metroAssetPath(rawIcon), staticAssetPath(rawIcon)] for (const candidate of candidates) { const res = await httpGet(hostname, port, candidate, 'HEAD', ENRICH_TIMEOUT_MS) if (res?.statusCode === 200) { localIconPathCache.set(port, candidate) // the identified result caches as a strong entry and won't re-probe on // its own; drop it so the next scan republishes with the working url. invalidatePortCache(port) return } } } // fold expo manifest fields (name, icon, bundleId, launchAsset) into a result. // the manifest body is always the GET / response we already have from probePort, // so this never issues another http request. function applyManifest( result: DiscoveredServer, manifestRes: HttpResult | null, buildIconProxyUrl?: (externalUrl: string) => string, ): DiscoveredServer { if (!manifestRes) return result try { const manifest = JSON.parse(manifestRes.body) const client = manifest?.extra?.expoClient || manifest?.extra || {} if (client.name) result.projectName = client.name if (typeof client.primaryColor === 'string') result.primaryColor = client.primaryColor if (client.ios?.bundleIdentifier) result.bundleId = client.ios.bundleIdentifier if (result.framework === 'metro' && client.sdkVersion) result.framework = 'expo' // preserve a confirmed direct one/vxrn metro-entry bundle. expo manifests // and one/vxrn manifests point at a launchAsset URL with `hot=false` // (hardcoded by @expo/cli — see metroOptions.ts). keep its path and query // so metro shares the iOS bundle cache. when both hosts are loopback, keep // the host that answered discovery so address-family drift stays reachable. const rawLaunchUrl = manifest?.launchAsset?.url const launchUrl = typeof rawLaunchUrl === 'string' ? alignLoopbackBundleUrlWithProbe( rawLaunchUrl, buildLoopbackProbeBaseUrl('http:', manifestRes.hostname, result.port), ) : rawLaunchUrl if (launchUrl && !result.patched && !isDirectOneBundleUrl(result.bundleUrl)) { result.bundleUrl = withRuntimeConfig(result.port, launchUrl, result.cwd) } const localIcon = client.icon || client.ios?.icon const remoteIcon = client.iconUrl || client.ios?.iconUrl const rawIcon = localIcon || remoteIcon if (rawIcon) { result.iconPath = rawIcon let resolvedIconUrl: string | undefined if (rawIcon.startsWith('http://') || rawIcon.startsWith('https://')) { let isLoopback = false try { const parsed = new URL(rawIcon) isLoopback = isLoopbackHost(parsed.hostname) } catch {} if (buildIconProxyUrl) { if (isLoopback) { resolvedIconUrl = buildIconProxyUrl(rawIcon) } } else { resolvedIconUrl = rawIcon } } else { // a manifest icon path is relative to the project root, and the two // server shapes we meet serve it at different URLs: metro's asset route // is `/assets/` + that whole relative path (so `assets/x.png` lands at // `/assets/assets/x.png`), while a statically-served project answers at // the literal path. neither shape can be derived from the manifest, so // resolveLocalIconPath probes the server once and caches the answer; // until it lands we publish the metro form, which is the common case. const base = buildLoopbackProbeBaseUrl('http:', manifestRes.hostname, result.port) const assetPath = localIconPathCache.get(result.port) ?? metroAssetPath(rawIcon) const localUrl = `${base}${assetPath}` resolvedIconUrl = buildIconProxyUrl ? buildIconProxyUrl(localUrl) : localUrl } if (resolvedIconUrl) { result.iconUrl = resolvedIconUrl } } } catch {} return result } export function __applyManifestForTests( result: DiscoveredServer, manifestBody: string, buildIconProxyUrl?: (externalUrl: string) => string, ): DiscoveredServer { return applyManifest( result, { statusCode: 200, body: manifestBody, hostname: new URL(result.bundleUrl).hostname, }, buildIconProxyUrl, ) } // ── name/icon enrichment ──────────────────────────────────────────────────── // a server's display name + icon come ONLY from its expo manifest (GET / with // expo-platform), which is far slower than the cheap signature probes: One apps // serialize app.config per request, and some (expo-updates / asset resolution) // block several seconds. the fast probe race times the manifest out at // MANIFEST_TIMEOUT_MS, so a slow-manifest server is identified via /status or // the One HEAD with no projectName and shows as ":" in the dock — never // re-probed, because that nameless result caches as a strong (expo/one) entry. // bumping the race timeout isn't an option: it would stall every probe (and a // non-bundler node process) for seconds. instead capture name/icon out-of-band: // when a server is identified without them, fetch the manifest ONCE with a // generous timeout, cache the fields per port, and drop the strong port-cache // entry so the next scan re-probes and publishes the enriched server. discovery // stays fast; name/icon fill in a scan or two after the app first appears. const ENRICH_TIMEOUT_MS = 20_000 interface ManifestEnrichment { projectName?: string iconUrl?: string iconPath?: string bundleId?: string } const enrichmentCache = new Map() const enrichmentInFlight = new Set() // a result that carries a relative icon path has not had its serving shape // confirmed yet. probe once per port; applyManifest reads the cached answer. function ensureLocalIconPathResolved(result: DiscoveredServer): void { const rawIcon = result.iconPath if (!rawIcon) return if (rawIcon.startsWith('http://') || rawIcon.startsWith('https://')) return if (localIconPathCache.has(result.port)) return if (localIconProbeInFlight.has(result.port)) return localIconProbeInFlight.add(result.port) const hostname = normalizeHostname(new URL(result.bundleUrl).hostname) void resolveLocalIconPath(hostname, result.port, rawIcon).finally(() => localIconProbeInFlight.delete(result.port), ) } // apply already-captured name/icon, or kick off a single background manifest // fetch to capture them. a no-op once the in-race manifest already named the // server, so it's safe to wrap every identified return. function enrichWithManifest( result: DiscoveredServer, buildIconProxyUrl?: (externalUrl: string) => string, ): DiscoveredServer { ensureLocalIconPathResolved(result) if (result.projectName) return result const cached = enrichmentCache.get(result.port) if (cached) { if (cached.projectName) result.projectName = cached.projectName if (cached.iconUrl && !result.iconUrl) { result.iconUrl = cached.iconUrl result.iconPath = cached.iconPath } if (cached.bundleId && !result.bundleId) result.bundleId = cached.bundleId return result } if (!enrichmentInFlight.has(result.port)) { enrichmentInFlight.add(result.port) void fetchManifestEnrichment(result, buildIconProxyUrl).finally(() => enrichmentInFlight.delete(result.port), ) } return result } async function fetchManifestEnrichment( result: DiscoveredServer, buildIconProxyUrl?: (externalUrl: string) => string, ): Promise { const hostname = normalizeHostname(new URL(result.bundleUrl).hostname) const manifestRes = await httpGet( hostname, result.port, '/', 'GET', ENRICH_TIMEOUT_MS, { 'expo-platform': 'ios', }, ) if (!manifestRes || manifestRes.statusCode !== 200) return // reuse applyManifest's exact field extraction against a throwaway result. const parsed = applyManifest( { port: result.port, framework: 'one', bundleUrl: `${buildLoopbackProbeBaseUrl('http:', hostname, result.port)}/index.bundle`, lastSeen: 0, }, manifestRes, buildIconProxyUrl, ) if (!parsed.projectName && !parsed.iconUrl) return enrichmentCache.set(result.port, { projectName: parsed.projectName, iconUrl: parsed.iconUrl, iconPath: parsed.iconPath, bundleId: parsed.bundleId, }) // the nameless result cached as a strong entry and won't re-probe on its own; // drop it so the next scan re-runs probePort and publishes the enriched name. invalidatePortCache(result.port) } // ports confirmed to be running a non-sootsim dev server — safe to skip the // /__soot/ probe on subsequent scans. cleared when the port disappears from the // listening set (process restarted, could now be a different server). const knownNonPatched = new Set() // ports confirmed to not answer /_expo/status as an expo packager — safe to // skip that probe on subsequent scans. same invalidation rule as // knownNonPatched: cleared when the port's owning pid changes. const knownNonExpo = new Set() // ports confirmed to be a one/vxrn dev server. on these, we skip the manifest // probe (`GET /` with expo-platform headers) on subsequent scans: one already // served the bundle HEAD so we know what it is, AND one's Expo Go manifest // middleware crashes the whole server when a probe aborts mid-stream // (Cannot pipe to a closed or destroyed stream → unhandled rejection → exit). // the bundle URL is enough; no information is lost by skipping the manifest. // same invalidation rules as knownNonPatched/knownNonExpo: cleared on port // disappearance, owning-pid change, and __resetScannerCache. const knownOne = new Set() // fire signature probes in waves and pick the best match by precedence: // 1. expo manifest (JSON manifest at /) // 2. metro/expo (packager-status:running on /status) // 3. sootsim-patched (/__soot/) // 4. one/vxrn (HEAD on /node_modules/one/metro-entry.bundle) — only // fires as a true last resort; on expo apps this probe // crashes metro by triggering HmrServer.registerEntryPoint // for a path metro can't resolve, so we never fire it // when one of the higher-precedence probes already // identified the server. // cheapest check first: a TCP connect gate short-circuits all HTTP probes when // the port isn't actually reachable (zombie listener, non-http protocol, // process mid-shutdown). after that, racing the safe probes is fast — // localhost refuses/closes connections quickly, and Promise.all finishes at // the slowest single probe rather than the sum. export async function probePort( port: number, buildIconProxyUrl?: (externalUrl: string) => string, listeningProcess?: ListeningProcess, ): Promise { // callers such as `rnx open ` probe one port directly instead of // going through scanDevServers. wait for the optional in-repo registry here // too, or a cold CLI process can build the known app's bundle url before its // runtime config is available. await loadOptionalDemoApps() const probeHostname = await tcpPing(port) if (!probeHostname) return null const suppliedPid = listeningProcess?.pid const ownerPid = suppliedPid && suppliedPid > 0 ? suppliedPid : await resolveListeningPid(port) const ownerCwd = ownerPid ? await resolveProcessCwd(ownerPid) : null const owner: Pick = { ...(ownerPid ? { pid: ownerPid } : {}), ...(ownerCwd ? { cwd: ownerCwd } : {}), } const onePath = `/node_modules/one/metro-entry.bundle?platform=ios&dev=true` // wave 1 — safe parallel probes. none of these mutate metro state. const [sootsimRes, statusRes, manifestRes, expoRes] = await Promise.all([ knownNonPatched.has(port) ? Promise.resolve(null) : httpGet(probeHostname, port, '/__soot/'), httpGet(probeHostname, port, '/status'), knownOne.has(port) ? Promise.resolve(null) : httpGet(probeHostname, port, '/', 'GET', MANIFEST_TIMEOUT_MS, { 'expo-platform': 'ios', }), knownNonExpo.has(port) ? Promise.resolve(null) : httpGet(probeHostname, port, '/_expo/status'), ]) // remember negative /_expo/status responses so we don't keep probing this // endpoint on ports that clearly aren't expo packagers (one/vxrn, vite, // random node processes). a 200 flips the port out of the non-expo set. if (expoRes && expoRes.statusCode === 200) { knownNonExpo.delete(port) } else if (!knownNonExpo.has(port)) { knownNonExpo.add(port) } // expo manifest (One framework + expo apps both serve this at `/` with the // expo-platform header). when present, `launchAsset.url` is the canonical // bundle URL the app actually serves — for One projects it's the // monorepo-aware path (e.g. /apps/one/node_modules/one/metro-entry.bundle // with full hermes transform params), and for Expo it's /index.bundle with // the right transforms. either way it's strictly better than the bare // /node_modules/one/metro-entry.bundle constant we used as a discriminator // below, so we check the manifest FIRST. const manifestParsed = manifestRes ? (safeParseManifest(manifestRes.body) as { launchAsset?: { url?: unknown } extra?: { expoClient?: { name?: unknown }; name?: unknown } } | null) : null const manifestLaunchUrl = typeof manifestParsed?.launchAsset?.url === 'string' ? manifestParsed.launchAsset.url : null const manifestClient = (manifestParsed?.extra?.expoClient as { name?: unknown } | undefined) || (manifestParsed?.extra as { name?: unknown } | undefined) || {} // `manifestRes === null` means the probe got NO answer — it timed out or the // connection failed. that is not the same fact as a server that answered and // turned out to have no manifest, and only the second one licenses the // generic `/index.bundle` entry point below. const manifestState: ManifestState = !manifestRes ? 'no-answer' : manifestParsed && (manifestLaunchUrl || typeof manifestClient.name === 'string') ? 'resolved' : 'no-manifest' if (manifestState === 'resolved') { knownNonPatched.add(port) const launchUrl = manifestLaunchUrl || `${buildLoopbackProbeBaseUrl('http:', probeHostname, port)}/index.bundle?platform=ios&dev=true&hot=true&minify=false` // call the framework "one" only when launchAsset clearly points at // a One metro entry; otherwise treat it as plain expo so downstream // bundle handling uses the right path semantics. const framework: DiscoveredServer['framework'] = launchUrl.includes( '/one/metro-entry.bundle', ) ? 'one' : 'expo' // enrichWithManifest kicks the local-icon shape probe; without it the // optimistic metro icon URL publishes forever and the confirmed shape // never lands. every other identified return wraps it too. return enrichWithManifest( applyManifest( { port, framework, bundleUrl: withRuntimeConfig(port, launchUrl, owner.cwd), hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', probeHostname, port)}/hot`, lastSeen: Date.now(), ...owner, }, manifestRes, buildIconProxyUrl, ), buildIconProxyUrl, ) } // metro/expo — packager-status:running on /status. checked BEFORE the one // HEAD because the HEAD has a known side-effect: it triggers metro's // HmrServer.registerEntryPoint for `/node_modules/one/metro-entry.bundle`, // and on metro instances where that path doesn't resolve (i.e. any plain // expo app) metro crashes with "UnableToResolveError" and exits. by // identifying expo/metro instances here from their /status response we // never fire the HEAD against them. if (statusRes && statusRes.body.includes('packager-status:running')) { knownNonPatched.add(port) // `makeResult` builds the generic `/index.bundle` entry point. that is // the RIGHT answer for a vanilla Metro or plain Expo packager, which is a // server that ANSWERED `/` and had no manifest to give. it is a guess when // the manifest simply never answered: this server may well be a One app // whose manifest names `node_modules/one/metro-entry.bundle`, and opening // the generic path instead makes metro build the whole app a second time // under a module graph the app never named. flag it so `open` refuses to // act on it, while the dock still lists the server it can plainly see. const result = makeResult( probeHostname, port, expoRes && expoRes.statusCode === 200 ? 'expo' : 'metro', owner, ) if (manifestState === 'no-answer') result.bundleUrlProvisional = true return enrichWithManifest( applyManifest(result, manifestRes, buildIconProxyUrl), buildIconProxyUrl, ) } // patched sootsim — fallback for legacy /__soot/ patched servers. also // checked before the one HEAD for the same reason. if ( sootsimRes && sootsimRes.statusCode === 200 && sootsimRes.body.includes('sootsim-patched') ) { knownNonPatched.delete(port) return enrichWithManifest( applyManifest( { port, framework: 'one', bundleUrl: withRuntimeConfig( port, `${buildLoopbackProbeBaseUrl('http:', probeHostname, port)}/__soot/bundle.js`, owner.cwd, ), hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', probeHostname, port)}/hot`, lastSeen: Date.now(), patched: true, ...owner, }, manifestRes, buildIconProxyUrl, ), buildIconProxyUrl, ) } // wave 2 — one/vxrn HEAD probe. only fired as a true last resort, since // it has the metro side-effect documented above. older One bare-dev-server // setups that don't serve a manifest still resolve through this path; new // expo/metro/one setups are caught above and never reach here. // // hard safety gate (the recurring "metro on 8081 crashes" regression): a // plain expo/metro packager that is still BOOTING serves `/status`, // `/_expo/status`, and the `/` manifest a few seconds apart, so during the // boot window all three wave-1 catches above can miss (status body not yet // `packager-status:running`, manifest not yet parseable). without this gate // we then fire the lethal HEAD against a metro that IS a metro — and the // engine, handed a bogus `one/metro-entry.bundle` URL, opens an HMR socket // whose `register-entrypoints` throws UnableToResolveError inside // HmrServer._registerEntryPoint → unhandledRejection → metro exits. a real // legacy bare-One dev server answers NONE of these metro/expo endpoints, so // skipping the HEAD here never hides a genuine One server — it just defers // this port to the next scan, by which point wave 1 identifies it cleanly. const looksLikeMetroPackager = (statusRes && statusRes.statusCode === 200) || (expoRes && expoRes.statusCode === 200) || (manifestRes && manifestRes.statusCode === 200 && manifestParsed != null) if (looksLikeMetroPackager) { // do NOT cache as knownNonPatched — we want a fresh wave-1 pass next scan // once the packager finishes booting and its manifest/status are ready. return null } const oneRes = await httpGet(probeHostname, port, onePath, 'HEAD') if ( oneRes && oneRes.statusCode > 0 && oneRes.statusCode < 400 && /application\/javascript/i.test(oneRes.contentType || '') ) { knownNonPatched.add(port) knownOne.add(port) return enrichWithManifest( applyManifest( { port, framework: 'one', bundleUrl: withRuntimeConfig( port, `${buildLoopbackProbeBaseUrl('http:', probeHostname, port)}${onePath}&minify=false`, owner.cwd, ), hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', probeHostname, port)}/hot`, lastSeen: Date.now(), ...owner, }, manifestRes, buildIconProxyUrl, ), buildIconProxyUrl, ) } knownNonPatched.add(port) return null } // ── top-level convenience ─────────────────────────────────────────────────── export interface ScanOptions { excludePorts?: number[] buildIconProxyUrl?: (externalUrl: string) => string } function isSootSimSelfServer(server: DiscoveredServer): boolean { const bundleId = server.bundleId?.trim().toLowerCase() if (bundleId?.startsWith('dev.sootsim.')) return true return false } export const __isSootSimSelfServerForTests = isSootSimSelfServer // per-(port, pid) probe cache. as long as the same process still owns the // port, its signature endpoints can't have changed, so we return the cached // result without issuing any HTTP. negative results (null) are cached too, // which prevents repeat-probing node/bun processes that aren't dev servers. // invalidation is cheap: we drop any entry whose port dropped out of lsof, // and any entry whose pid changed (process restart). interface PortCacheEntry { pid: number result: DiscoveredServer | null cachedAt: number } const portCache = new Map() // out-of-band completions (the icon-shape probe, manifest enrichment) land // while a scan's own probeAll is still in flight, and the scan's cache write // would resurrect the entry they just dropped. stamp every such invalidation // so a scan only caches ports whose verdicts are newer than its own start. const portCacheInvalidatedAt = new Map() function invalidatePortCache(port: number): void { portCache.delete(port) portCacheInvalidatedAt.set(port, Date.now()) } // negative-cache holds a node/bun port that isn't a bundler (e.g. Contrast's own // api server, Zero, SQLite sync host, a dev tool). at scan interval 3s and a 1.5s ttl // we used to re-probe every scan, which hits `/` on the port every 3s — that // waking Contrast's SSR router ~20x/min was a real memory-leak driver. 30s gives // the scanner a much lighter touch on unrelated node processes while still // catching pid-change invalidation immediately when a process restarts. const NEGATIVE_CACHE_TTL_MS = 30_000 const WEAK_RESULT_CACHE_TTL_MS = 1_500 function isWeakCachedResult(result: DiscoveredServer | null): boolean { if (!result) return true if (result.framework === 'metro' || result.framework === 'unknown') return true return false } function hasCurrentRuntimeConfig(result: DiscoveredServer | null): boolean { if (!result) return true return withRuntimeConfig(result.port, result.bundleUrl, result.cwd) === result.bundleUrl } export function __shouldReuseScannerCacheEntry( entry: { pid: number; result: DiscoveredServer | null; cachedAt: number }, pid: number, now = Date.now(), ): boolean { if (pid === 0) return false if (entry.pid !== pid) return false if (!hasCurrentRuntimeConfig(entry.result)) return false const ageMs = now - entry.cachedAt if (entry.result === null && ageMs >= NEGATIVE_CACHE_TTL_MS) return false if (isWeakCachedResult(entry.result) && ageMs >= WEAK_RESULT_CACHE_TTL_MS) return false return true } // exported for tests / `rnx debug` — forces the next scan to re-probe. export function __resetScannerCache() { portCache.clear() knownNonPatched.clear() knownNonExpo.clear() knownOne.clear() enrichmentCache.clear() enrichmentInFlight.clear() portCacheInvalidatedAt.clear() __resetAppSettingsBundleCacheForTests() } async function attachAppSettingsBundle( result: DiscoveredServer, projectRoot: string, ): Promise { const settingsBundle = await readAppSettingsBundle(projectRoot) if (settingsBundle) { result.settingsBundle = settingsBundle result.settingsStorageScopeUrl = result.bundleUrl } else { delete result.settingsBundle delete result.settingsStorageScopeUrl } } export const __attachAppSettingsBundleForTests = attachAppSettingsBundle export async function scanDevServers( opts: ScanOptions = {}, ): Promise { // optionalDemoAppsSync() returns [] until the dynamic import resolves, and // an empty registry is indistinguishable from a published install. await it // before cache validation so a known app cannot lose its runtimeConfig when // disk contention delays the import. memoized, so this is free after the // first call. await loadOptionalDemoApps() const scan = await scanListeningProcesses(opts.excludePorts) // we could not enumerate listeners, so we know nothing about what is running. // returning [] here would evict every cache entry and report "no dev servers" // to a user who has several — the caller must be able to tell the two apart. if (scan.state === 'could-not-look') throw new DevServerScanUnavailableError() const processes = scan.processes const currentPorts = new Set(processes.map((p) => p.port)) // evict cache entries for ports that disappeared. a restarted process may // bring up a different server (possibly sootsim-patched) on the same port, // and both caches need to let go so the next probe sees the new state. for (const p of [...portCache.keys()]) { if (!currentPorts.has(p)) portCache.delete(p) } for (const p of [...knownNonPatched]) { if (!currentPorts.has(p)) knownNonPatched.delete(p) } for (const p of [...knownNonExpo]) { if (!currentPorts.has(p)) knownNonExpo.delete(p) } for (const p of [...knownOne]) { if (!currentPorts.has(p)) knownOne.delete(p) } for (const p of [...enrichmentCache.keys()]) { if (!currentPorts.has(p)) enrichmentCache.delete(p) } for (const p of [...localIconPathCache.keys()]) { if (!currentPorts.has(p)) localIconPathCache.delete(p) } for (const p of [...portCacheInvalidatedAt.keys()]) { if (!currentPorts.has(p)) portCacheInvalidatedAt.delete(p) } const results: DiscoveredServer[] = [] const toProbe: ListeningProcess[] = [] for (const { port, pid } of processes) { const cached = portCache.get(port) // pid 0 means the platform couldn't identify the owner (fallback path), // so we can't trust the cache — reprobe. if (cached && __shouldReuseScannerCacheEntry(cached, pid)) { if (cached.result) results.push(cached.result) continue } // pid changed or cache expired — drop any endpoint-specific verdicts so // the re-probe starts from scratch. otherwise a restarted process that // swapped frameworks keeps the old skip-/__soot/ or skip-/_expo/status // decisions, and the scanner never discovers the new server. if (cached && cached.pid !== pid) { knownNonPatched.delete(port) knownNonExpo.delete(port) knownOne.delete(port) // a restarted process may be a different app — drop its captured name/icon. enrichmentCache.delete(port) localIconPathCache.delete(port) } toProbe.push({ port, pid }) } if (toProbe.length > 0) { const scanStartedAt = Date.now() const probed = await Promise.all( toProbe.map((p) => probePort(p.port, opts.buildIconProxyUrl, p)), ) probed.forEach((result, i) => { const { port, pid } = toProbe[i] // only cache when we have a real pid to key the entry against. // fallback-list ports (pid 0) always reprobe so we catch state changes. // skip ports an out-of-band completion invalidated mid-scan, so the // next scan re-probes instead of re-caching this scan's stale verdict. const invalidatedAt = portCacheInvalidatedAt.get(port) ?? 0 if (pid !== 0 && invalidatedAt < scanStartedAt) { portCache.set(port, { pid, result, cachedAt: Date.now() }) } if (result) results.push(result) }) } // attach pid + cwd. pid comes from the listening-process scan; cwd is // lsof-resolved and cached per pid. both enable auto-attach of agent // sessions to the right project without a manual `sootsim agent attach`. const pidByPort = new Map() for (const { port, pid } of processes) { if (pid > 0) pidByPort.set(port, pid) } await Promise.all( results.map(async (result) => { const pid = pidByPort.get(result.port) if (!pid) return result.pid = pid const cwd = await resolveProcessCwd(pid) if (!cwd) return result.cwd = cwd await attachAppSettingsBundle(result, cwd) }), ) // evict stale cwd cache entries for pids no longer listening const livePids = new Set(pidByPort.values()) for (const pid of [...cwdByPid.keys()]) { if (!livePids.has(pid)) cwdByPid.delete(pid) } return results.filter((r) => !isSootSimSelfServer(r)) }