rnxsim 0.0.0 → 0.1.312

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +512 -0
  3. package/detox/colors.ts +54 -0
  4. package/detox/config-loader.ts +135 -0
  5. package/detox/element-types.ts +39 -0
  6. package/detox/expectations.ts +537 -0
  7. package/detox/gestures.ts +480 -0
  8. package/detox/index.ts +2118 -0
  9. package/detox/jest-preset.cjs +51 -0
  10. package/detox/jest-setup-after-env.cjs +18 -0
  11. package/detox/matchers.ts +30 -0
  12. package/detox/motion-change.cjs +109 -0
  13. package/detox/motion-change.d.cts +35 -0
  14. package/detox/navigation.ts +43 -0
  15. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  17. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  18. package/detox/screenshots/theme-blue-toggle.png +0 -0
  19. package/detox/screenshots/theme-blue.png +0 -0
  20. package/detox/screenshots/theme-red-toggle.png +0 -0
  21. package/detox/screenshots/theme-red.png +0 -0
  22. package/dist-cli/bin.js +15 -0
  23. package/dist-cli/chunks/agent-NXVDDCFG.js +48 -0
  24. package/dist-cli/chunks/agent-wrapper-DXJCFKL3.js +17 -0
  25. package/dist-cli/chunks/app-fonts-W5AJZQJF.js +4 -0
  26. package/dist-cli/chunks/assert-3LOJEUDT.js +49 -0
  27. package/dist-cli/chunks/auth-MFTYYPNX.js +12 -0
  28. package/dist-cli/chunks/beta-GT3NN67B.js +4 -0
  29. package/dist-cli/chunks/camera-6EAK7CUL.js +22 -0
  30. package/dist-cli/chunks/chunk-274UD5FD.js +4 -0
  31. package/dist-cli/chunks/chunk-2SA2VTGP.js +5 -0
  32. package/dist-cli/chunks/chunk-33FKQH54.js +5 -0
  33. package/dist-cli/chunks/chunk-37I5AIS7.js +3 -0
  34. package/dist-cli/chunks/chunk-3D57IBPC.js +4 -0
  35. package/dist-cli/chunks/chunk-4BXCZHAU.js +4 -0
  36. package/dist-cli/chunks/chunk-5OX7ANRA.js +5 -0
  37. package/dist-cli/chunks/chunk-66NF2KNG.js +9 -0
  38. package/dist-cli/chunks/chunk-6UKJB2VC.js +125 -0
  39. package/dist-cli/chunks/chunk-7INBNA3U.js +26 -0
  40. package/dist-cli/chunks/chunk-7VWOSTD5.js +5 -0
  41. package/dist-cli/chunks/chunk-AY47WKS5.js +23 -0
  42. package/dist-cli/chunks/chunk-B7KMNHX7.js +4 -0
  43. package/dist-cli/chunks/chunk-BKNOLP3J.js +4 -0
  44. package/dist-cli/chunks/chunk-BRIZOR4K.js +28 -0
  45. package/dist-cli/chunks/chunk-DM6KUAOZ.js +4 -0
  46. package/dist-cli/chunks/chunk-EUAVBTKU.js +4 -0
  47. package/dist-cli/chunks/chunk-FTC4M26Q.js +4 -0
  48. package/dist-cli/chunks/chunk-FWYYCUAZ.js +4 -0
  49. package/dist-cli/chunks/chunk-G74YIEVS.js +7 -0
  50. package/dist-cli/chunks/chunk-GZDHEONW.js +507 -0
  51. package/dist-cli/chunks/chunk-HG7ORJMT.js +5 -0
  52. package/dist-cli/chunks/chunk-HMDOLCKG.js +15 -0
  53. package/dist-cli/chunks/chunk-JG6S4RSH.js +4 -0
  54. package/dist-cli/chunks/chunk-KR5BGMBZ.js +4 -0
  55. package/dist-cli/chunks/chunk-KZW5WBGU.js +983 -0
  56. package/dist-cli/chunks/chunk-L24Z3CRS.js +30 -0
  57. package/dist-cli/chunks/chunk-LDQ7HVCW.js +4 -0
  58. package/dist-cli/chunks/chunk-MENV7VLD.js +4 -0
  59. package/dist-cli/chunks/chunk-MNGQ42QE.js +568 -0
  60. package/dist-cli/chunks/chunk-N6A3SXMR.js +4 -0
  61. package/dist-cli/chunks/chunk-NLEMQ4PY.js +6 -0
  62. package/dist-cli/chunks/chunk-NPVGMH2N.js +101 -0
  63. package/dist-cli/chunks/chunk-OR3XTXEB.js +107 -0
  64. package/dist-cli/chunks/chunk-PEPUGJVI.js +4 -0
  65. package/dist-cli/chunks/chunk-Q3RGLERJ.js +142 -0
  66. package/dist-cli/chunks/chunk-SI3BUTQR.js +4 -0
  67. package/dist-cli/chunks/chunk-T4FT6CM7.js +4 -0
  68. package/dist-cli/chunks/chunk-TBMVBI26.js +7 -0
  69. package/dist-cli/chunks/chunk-TCWQJILS.js +5 -0
  70. package/dist-cli/chunks/chunk-TGQNQ6XT.js +766 -0
  71. package/dist-cli/chunks/chunk-TYE3WBSR.js +5 -0
  72. package/dist-cli/chunks/chunk-U4PN6JOT.js +6 -0
  73. package/dist-cli/chunks/chunk-VUFRB347.js +4 -0
  74. package/dist-cli/chunks/chunk-VZBLFMYC.js +10 -0
  75. package/dist-cli/chunks/chunk-WBT32R6N.js +25 -0
  76. package/dist-cli/chunks/chunk-XRGEEPJ5.js +38 -0
  77. package/dist-cli/chunks/chunk-Y2FSZSO7.js +71 -0
  78. package/dist-cli/chunks/chunk-YRV7HR3P.js +74 -0
  79. package/dist-cli/chunks/chunk-YZRFF4YA.js +14 -0
  80. package/dist-cli/chunks/chunk-ZMZTHU7V.js +4 -0
  81. package/dist-cli/chunks/cleanup-H3GWQB63.js +25 -0
  82. package/dist-cli/chunks/cli-version-3QPAJ3LC.js +4 -0
  83. package/dist-cli/chunks/compat-5WG2XJV7.js +20 -0
  84. package/dist-cli/chunks/config-ANMBVVLL.js +13 -0
  85. package/dist-cli/chunks/control-NSGNQFJG.js +4 -0
  86. package/dist-cli/chunks/daemon-JHF4BJ3U.js +4 -0
  87. package/dist-cli/chunks/debug-CHV2O2KF.js +200 -0
  88. package/dist-cli/chunks/desktop-FIPIK4CH.js +31 -0
  89. package/dist-cli/chunks/detox-CVUJP7K6.js +52 -0
  90. package/dist-cli/chunks/device-6WCUM4W6.js +18 -0
  91. package/dist-cli/chunks/diagnose-I6EMWEII.js +43 -0
  92. package/dist-cli/chunks/disk-cleanup-P27CYFTN.js +4 -0
  93. package/dist-cli/chunks/drivers-HQ6AKZT6.js +4 -0
  94. package/dist-cli/chunks/film-UOQFFYNF.js +74 -0
  95. package/dist-cli/chunks/flow-N27MRRNL.js +4 -0
  96. package/dist-cli/chunks/help-SF6FEGEE.js +4 -0
  97. package/dist-cli/chunks/hidden-runtime-alias-NJYFSJKZ.js +4 -0
  98. package/dist-cli/chunks/home-paths-XOV44JEF.js +4 -0
  99. package/dist-cli/chunks/inspect-CWFRFQTY.js +4 -0
  100. package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +4 -0
  101. package/dist-cli/chunks/login-BPFQENCX.js +28 -0
  102. package/dist-cli/chunks/logout-5A45ZGY7.js +4 -0
  103. package/dist-cli/chunks/maestro-E4CSDMLM.js +104 -0
  104. package/dist-cli/chunks/maestro-generate-CKMOJQX2.js +36 -0
  105. package/dist-cli/chunks/mode-SZYNMIQF.js +26 -0
  106. package/dist-cli/chunks/optional-demo-registry-FRH4SFVG.js +4 -0
  107. package/dist-cli/chunks/perf-VXU2XJAX.js +48 -0
  108. package/dist-cli/chunks/permissions-IRIAOVND.js +13 -0
  109. package/dist-cli/chunks/record-RY7FPLLH.js +72 -0
  110. package/dist-cli/chunks/report-issue-KNWXOE53.js +29 -0
  111. package/dist-cli/chunks/runtime-AR3GJTGK.js +4 -0
  112. package/dist-cli/chunks/screenshot-command-QLALR6GY.js +159 -0
  113. package/dist-cli/chunks/screenshot-layers-OQFMFVRL.js +20 -0
  114. package/dist-cli/chunks/screenshots-capture-6TSRJFGB.js +42 -0
  115. package/dist-cli/chunks/serve-F54J2NQO.js +44 -0
  116. package/dist-cli/chunks/setup-7V6UKX4U.js +32 -0
  117. package/dist-cli/chunks/skills-QQBOSETH.js +50 -0
  118. package/dist-cli/chunks/storage-TAPMKK5O.js +37 -0
  119. package/dist-cli/chunks/store-7NX3S43H.js +4 -0
  120. package/dist-cli/chunks/telemetry-E7BUK6RA.js +4 -0
  121. package/dist-cli/chunks/timeline-IAM564NA.js +24 -0
  122. package/dist-cli/chunks/upgrade-CD55QXIX.js +7 -0
  123. package/dist-cli/chunks/upload-GZ6LIMYM.js +4 -0
  124. package/dist-cli/chunks/version-3DYJ5AS3.js +6 -0
  125. package/dist-cli/chunks/web-6NW6YLPI.js +4 -0
  126. package/dist-cli/chunks/what-happened-CZZDOOVG.js +25 -0
  127. package/dist-lib/agent-daemon-client.cjs +468 -0
  128. package/dist-lib/agent-events.cjs +48 -0
  129. package/dist-lib/agent-identity.cjs +53 -0
  130. package/dist-lib/agent-sessions.cjs +725 -0
  131. package/dist-lib/attached-projects.cjs +526 -0
  132. package/dist-lib/auth/shared-session.cjs +195 -0
  133. package/dist-lib/backend-origin.cjs +70 -0
  134. package/dist-lib/beta.cjs +41 -0
  135. package/dist-lib/beta.mjs +13 -0
  136. package/dist-lib/bridge-constants.cjs +161 -0
  137. package/dist-lib/cli-constants.cjs +32 -0
  138. package/dist-lib/config.cjs +96 -0
  139. package/dist-lib/detox/index.cjs +2863 -0
  140. package/dist-lib/detox/jest-preset.cjs +51 -0
  141. package/dist-lib/dev-bundle-resolution.cjs +223 -0
  142. package/dist-lib/home-paths.cjs +421 -0
  143. package/dist-lib/host/bridge-host.cjs +5600 -0
  144. package/dist-lib/host/fetch-proxy-handler.cjs +373 -0
  145. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  146. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  147. package/dist-lib/host/websocket-proxy.cjs +214 -0
  148. package/dist-lib/index.cjs +513 -0
  149. package/dist-lib/jump-to-source-babel.cjs +93 -0
  150. package/dist-lib/menu.cjs +620 -0
  151. package/dist-lib/menu.mjs +581 -0
  152. package/dist-lib/metro.cjs +297 -0
  153. package/dist-lib/profiles.cjs +246 -0
  154. package/dist-lib/public-brand.cjs +39 -0
  155. package/dist-lib/render-mode.cjs +54 -0
  156. package/dist-lib/scripts/dev-server-scanner.cjs +1034 -0
  157. package/dist-lib/sdk.cjs +2333 -0
  158. package/dist-lib/sdk.mjs +2240 -0
  159. package/dist-lib/skills.cjs +23911 -0
  160. package/dist-lib/vite.cjs +358 -0
  161. package/package.json +189 -18
  162. package/scripts/dev-server-scanner.ts +941 -0
  163. package/scripts/optional-demo-registry.ts +113 -0
  164. package/scripts/postinstall.cjs +3 -0
  165. package/scripts/preuninstall.cjs +118 -0
  166. package/skills/contrast/SKILL.md +171 -0
  167. package/skills/rnx-debug/SKILL.md +425 -0
  168. package/skills/rnx-perf/SKILL.md +295 -0
  169. package/skills/rnx-setup/SKILL.md +160 -0
  170. package/skills/rnx-test/SKILL.md +292 -0
  171. package/skills/rnx-visual/SKILL.md +122 -0
  172. package/src/agent-daemon-client.ts +410 -0
  173. package/src/agent-events.ts +71 -0
  174. package/src/agent-identity.ts +23 -0
  175. package/src/agent-prompt.ts +71 -0
  176. package/src/agent-sessions.ts +573 -0
  177. package/src/attached-projects.ts +539 -0
  178. package/src/auth/shared-session.ts +204 -0
  179. package/src/backend-origin.ts +49 -0
  180. package/src/beta.ts +19 -0
  181. package/src/bridge-constants.ts +171 -0
  182. package/src/browser-cache.ts +16 -0
  183. package/src/cli-constants.ts +1 -0
  184. package/src/cli-version.ts +32 -0
  185. package/src/codex-client.ts +215 -0
  186. package/src/config.ts +134 -0
  187. package/src/dev-bundle-resolution.ts +236 -0
  188. package/src/disk-cleanup.ts +466 -0
  189. package/src/home-paths.ts +552 -0
  190. package/src/host/agent-host.ts +666 -0
  191. package/src/host/bridge-host.ts +2626 -0
  192. package/src/host/fetch-proxy-handler.ts +397 -0
  193. package/src/host/fetch-proxy-overrides.ts +51 -0
  194. package/src/host/open-url.ts +89 -0
  195. package/src/host/websocket-proxy.ts +210 -0
  196. package/src/index.ts +12 -705
  197. package/src/jump-to-source-babel.ts +135 -0
  198. package/src/menu.ts +734 -0
  199. package/src/metro-plugin.ts +139 -0
  200. package/src/native-dev-bundle-url.ts +96 -0
  201. package/src/native-seam-manifest.ts +341 -0
  202. package/src/plist.ts +27 -0
  203. package/src/poll-until.ts +92 -0
  204. package/src/profiles.ts +179 -0
  205. package/src/public-brand.ts +9 -0
  206. package/src/render-mode.ts +27 -0
  207. package/src/runtime-assets.ts +228 -0
  208. package/src/runtime-delivery.ts +54 -0
  209. package/src/screenshots/compose.ts +422 -0
  210. package/src/screenshots/frame-compose.ts +454 -0
  211. package/src/screenshots/orchestrate.ts +256 -0
  212. package/src/screenshots/org-deck.ts +91 -0
  213. package/src/screenshots/registry.ts +58 -0
  214. package/src/screenshots/schema.ts +364 -0
  215. package/src/sdk.ts +7 -0
  216. package/src/settings-bundle.ts +418 -0
  217. package/src/skills/builtin/a11y-review.ts +132 -0
  218. package/src/skills/builtin/compat-check.ts +71 -0
  219. package/src/skills/builtin/maestro-test.ts +121 -0
  220. package/src/skills/builtin/perf-profile.ts +90 -0
  221. package/src/skills/builtin/screenshot-all.ts +52 -0
  222. package/src/skills/builtin/visual-diff.ts +100 -0
  223. package/src/skills/registry.ts +107 -0
  224. package/src/skills/types.ts +41 -0
  225. package/src/team-machine-prompt.ts +73 -0
  226. package/src/vite-plugin-one.ts +190 -0
  227. package/src/vite-plugin.ts +1750 -0
  228. package/src/worklets-babel.ts +134 -0
@@ -0,0 +1,941 @@
1
+ // shared dev-server scanner used by both the electron main process
2
+ // (`src-electron/main.ts`) and the vite plugin (`vite.config.ts`). discovers
3
+ // running dev servers on localhost by listing every node/bun process that's
4
+ // listening, then probes each port for known bundler signatures.
5
+ //
6
+ // precedence during probing:
7
+ // 1. one/vxrn (node_modules/one/metro-entry.bundle reachable)
8
+ // 2. metro/expo (packager-status:running + /_expo/status)
9
+ // 3. expo manifest (JSON manifest at /)
10
+ // 4. sootsim-patched (/__soot/ middleware present) → /__soot/bundle.js
11
+ //
12
+ // scans are cached per (port, pid). repeat scans with no process changes issue
13
+ // zero HTTP requests; probes only fire when a port appears, disappears, or its
14
+ // pid changes. before any HTTP, a TCP connect gate skips unreachable ports.
15
+
16
+ import { exec } from 'child_process'
17
+ import { readlink } from 'fs/promises'
18
+ import http from 'http'
19
+ import net from 'net'
20
+ import { resolve } from 'node:path'
21
+ import { promisify } from 'util'
22
+ import type { AppSettingsSpecifier } from '@sootsim/globals'
23
+
24
+ const execP = promisify(exec)
25
+ import { applySootSimConfigToUrl } from '../src/config.ts'
26
+ import { normalizeNativeDevBundleUrl } from '../src/native-dev-bundle-url.ts'
27
+ import {
28
+ __resetAppSettingsBundleCacheForTests,
29
+ readAppSettingsBundle,
30
+ } from '../src/settings-bundle.ts'
31
+ import { loadOptionalDemoApps, optionalDemoAppsSync } from './optional-demo-registry.ts'
32
+
33
+ export interface DiscoveredServer {
34
+ port: number
35
+ framework: 'metro' | 'expo' | 'vxrn' | 'one' | 'unknown'
36
+ projectName?: string
37
+ bundleUrl: string
38
+ hmrUrl?: string
39
+ lastSeen: number
40
+ iconUrl?: string
41
+ iconPath?: string
42
+ bundleId?: string
43
+ settingsBundle?: readonly AppSettingsSpecifier[]
44
+ settingsStorageScopeUrl?: string
45
+ patched?: boolean
46
+ /** absolute cwd of the owning node/bun process — resolved from `lsof -d cwd`
47
+ * when the scanner can see the pid. used to auto-attach the project for
48
+ * agent sessions without requiring a manual `sootsim agent attach`. */
49
+ cwd?: string
50
+ pid?: number
51
+ }
52
+
53
+ // localhost dev servers respond in well under 100ms when alive. drop dead
54
+ // branches fast so a non-bundler process doesn't drag scan latency.
55
+ const TIMEOUT_MS = 250
56
+ // the expo-style manifest GET / is intentionally slower than the cheap
57
+ // signature probes — for One framework apps it serializes app.config.js
58
+ // (including embedded googleServicesFile blobs), which can push the
59
+ // response to 200–400ms on first hit. when this probe times out we fall
60
+ // back to the One-HEAD legacy URL `/node_modules/one/metro-entry.bundle`,
61
+ // which is the path that originally caused the "failed to fetch bundle
62
+ // (404|502)" regression on monorepo One projects (apps/<name>/...). give
63
+ // the manifest more headroom so the canonical launchAsset.url wins.
64
+ const MANIFEST_TIMEOUT_MS = 1500
65
+ // cheap TCP-connect check gate for HTTP probes — if we can't open a socket
66
+ // within this budget the port isn't actually reachable, and all 5 HTTP probes
67
+ // would just time out for nothing.
68
+ const TCP_GATE_MS = 120
69
+
70
+ interface HttpResult {
71
+ statusCode: number
72
+ body: string
73
+ contentType?: string
74
+ }
75
+
76
+ // cheap TCP-connect gate. lsof already says the socket is LISTEN, but the
77
+ // process can be closing, the fd can be a different protocol, or a zombie
78
+ // can linger — none of those cases are worth firing 5 HTTP probes at.
79
+ function tcpPing(port: number, timeout = TCP_GATE_MS): Promise<boolean> {
80
+ return new Promise((resolve) => {
81
+ const sock = new net.Socket()
82
+ let settled = false
83
+ const done = (ok: boolean) => {
84
+ if (settled) return
85
+ settled = true
86
+ sock.destroy()
87
+ resolve(ok)
88
+ }
89
+ sock.setTimeout(timeout)
90
+ sock.once('connect', () => done(true))
91
+ sock.once('timeout', () => done(false))
92
+ sock.once('error', () => done(false))
93
+ sock.connect(port, 'localhost')
94
+ })
95
+ }
96
+
97
+ function httpGet(
98
+ port: number,
99
+ path: string,
100
+ method: 'GET' | 'HEAD' = 'GET',
101
+ timeout = TIMEOUT_MS,
102
+ headers: Record<string, string> = {},
103
+ ): Promise<HttpResult | null> {
104
+ return new Promise((resolve) => {
105
+ const req = http.request(
106
+ { hostname: 'localhost', port, path, method, timeout, headers },
107
+ (res) => {
108
+ let body = ''
109
+ res.on('data', (c: Buffer) => (body += c.toString()))
110
+ const contentType = (() => {
111
+ const raw = res.headers['content-type']
112
+ if (typeof raw === 'string') return raw
113
+ if (Array.isArray(raw)) return raw[0]
114
+ return undefined
115
+ })()
116
+ res.on('end', () =>
117
+ resolve({ statusCode: res.statusCode || 0, body, contentType }),
118
+ )
119
+ },
120
+ )
121
+ req.on('error', () => resolve(null))
122
+ req.setTimeout(timeout, () => {
123
+ req.destroy()
124
+ resolve(null)
125
+ })
126
+ req.end()
127
+ })
128
+ }
129
+
130
+ // ── discovery ───────────────────────────────────────────────────────────────
131
+
132
+ export interface ListeningProcess {
133
+ port: number
134
+ pid: number
135
+ }
136
+
137
+ // bare-port fallback list used when neither lsof nor ss are usable. pid 0
138
+ // signals "unknown owner" — the cache layer will re-probe these each scan.
139
+ const FALLBACK_PORTS: ListeningProcess[] = [
140
+ 8081, 8082, 8083, 8084, 8085, 8086, 3000, 3001, 19000,
141
+ ].map((port) => ({ port, pid: 0 }))
142
+
143
+ function acceptPort(port: number, excluded: Set<number>): boolean {
144
+ if (port <= 0 || port >= 20000) return false
145
+ if (excluded.has(port)) return false
146
+ // sootsim's own dev server range — we never want to discover ourselves.
147
+ if (port >= 5170 && port <= 5200) return false
148
+ return true
149
+ }
150
+
151
+ export async function discoverListeningProcesses(
152
+ excludePorts: number[] = [],
153
+ ): Promise<ListeningProcess[]> {
154
+ const excluded = new Set(excludePorts)
155
+
156
+ // lsof layout: COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
157
+ // NAME looks like "*:8081" or "127.0.0.1:8081" for a LISTEN line.
158
+ // async exec — sync blocked CrBrowserMain for ~600ms/scan on macOS.
159
+ try {
160
+ const { stdout } = await execP(
161
+ `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E '^(node|bun)'`,
162
+ { encoding: 'utf8', timeout: 2000 },
163
+ )
164
+ if (stdout.trim()) {
165
+ const seen = new Map<number, number>()
166
+ for (const line of stdout.trim().split('\n')) {
167
+ const parts = line.trim().split(/\s+/)
168
+ if (parts.length < 9) continue
169
+ const pid = Number(parts[1])
170
+ const addr = parts[8]
171
+ const m = addr.match(/:(\d+)$/)
172
+ if (!m) continue
173
+ const port = Number(m[1])
174
+ if (!acceptPort(port, excluded)) continue
175
+ if (!seen.has(port)) seen.set(port, pid)
176
+ }
177
+ if (seen.size > 0) {
178
+ return [...seen.entries()].map(([port, pid]) => ({ port, pid }))
179
+ }
180
+ }
181
+ } catch {}
182
+
183
+ // ss layout: State Recv-Q Send-Q Local-Address:Port Peer-Addr:Port users:(("node",pid=1234,fd=5))
184
+ try {
185
+ const { stdout } = await execP(`ss -tlnp 2>/dev/null | grep -E '"(node|bun)"'`, {
186
+ encoding: 'utf8',
187
+ timeout: 2000,
188
+ })
189
+ if (stdout.trim()) {
190
+ const seen = new Map<number, number>()
191
+ for (const line of stdout.trim().split('\n')) {
192
+ const portMatch = line.match(/:(\d+)\s/)
193
+ const pidMatch = line.match(/pid=(\d+)/)
194
+ if (!portMatch) continue
195
+ const port = Number(portMatch[1])
196
+ const pid = pidMatch ? Number(pidMatch[1]) : 0
197
+ if (!acceptPort(port, excluded)) continue
198
+ if (!seen.has(port)) seen.set(port, pid)
199
+ }
200
+ if (seen.size > 0) {
201
+ return [...seen.entries()].map(([port, pid]) => ({ port, pid }))
202
+ }
203
+ }
204
+ } catch {}
205
+
206
+ return FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded))
207
+ }
208
+
209
+ // kept for backwards compatibility with any external callers that only need
210
+ // port numbers. internal paths should prefer discoverListeningProcesses so
211
+ // the (port, pid) cache can detect process restarts.
212
+ export async function discoverListeningPorts(
213
+ excludePorts: number[] = [],
214
+ ): Promise<number[]> {
215
+ const processes = await discoverListeningProcesses(excludePorts)
216
+ return processes.map((p) => p.port)
217
+ }
218
+
219
+ /** parse `netstat -anv -p tcp` output for the pid listening on one port.
220
+ *
221
+ * macOS lays a LISTEN row out as
222
+ * tcp4 0 0 127.0.0.1.8090 *.* LISTEN 0 0 131072 131072 node:97330 ...
223
+ * so the local address is the 4th column with a DOT before the port (which is
224
+ * what keeps port 8137 from matching a row for 18137), and the owner is the
225
+ * only token shaped "<name>:<pid>".
226
+ *
227
+ * exported because this column layout is the fragile part and a child process
228
+ * cannot exercise it under bun's test runner, which hands back empty stdout. */
229
+ export function pidFromNetstat(stdout: string, port: number): number | null {
230
+ for (const line of stdout.split('\n')) {
231
+ if (!line.includes('LISTEN')) continue
232
+ const parts = line.trim().split(/\s+/)
233
+ if (!parts[3]?.endsWith(`.${port}`)) continue
234
+ for (const token of parts) {
235
+ const owner = token.match(/^\S+:(\d+)$/)
236
+ if (owner) {
237
+ const pid = Number(owner[1])
238
+ if (pid > 0) return pid
239
+ }
240
+ }
241
+ }
242
+ return null
243
+ }
244
+
245
+ /** resolve the pid of the process listening on one local port.
246
+ *
247
+ * `lsof` walks every descriptor of every process, which measures 2-4s on a
248
+ * loaded machine — longer than any timeout worth giving it, so
249
+ * `discoverListeningProcesses` above degrades to its pid-less port list
250
+ * exactly when the machine is busy. netstat (macOS/BSD) and ss (linux) read
251
+ * the kernel socket table instead and answer in well under a second, and both
252
+ * carry the owning pid. use this whenever the question is "who owns this one
253
+ * port"; `discoverListeningProcesses` answers the different question of what
254
+ * is listening at all. */
255
+ export async function resolveListeningPid(port: number): Promise<number | null> {
256
+ if (!Number.isInteger(port) || port <= 0) return null
257
+
258
+ // macOS netstat: "tcp4 0 0 127.0.0.1.8090 *.* LISTEN ... node:97330 ..."
259
+ // the local address uses a dot before the port, and the owner column is the
260
+ // only token shaped "<name>:<pid>".
261
+ try {
262
+ const { stdout } = await execP('netstat -anv -p tcp 2>/dev/null', {
263
+ encoding: 'utf8',
264
+ timeout: 8000,
265
+ maxBuffer: 16 * 1024 * 1024,
266
+ })
267
+ const pid = pidFromNetstat(stdout, port)
268
+ if (pid !== null) return pid
269
+ } catch {}
270
+
271
+ // linux ss: 'LISTEN 0 511 0.0.0.0:8081 0.0.0.0:* users:(("node",pid=1234,fd=5))'
272
+ try {
273
+ const { stdout } = await execP(`ss -tlnp 2>/dev/null`, {
274
+ encoding: 'utf8',
275
+ timeout: 8000,
276
+ maxBuffer: 16 * 1024 * 1024,
277
+ })
278
+ for (const line of stdout.split('\n')) {
279
+ if (!new RegExp(`:${port}\\s`).test(line)) continue
280
+ const pid = Number(line.match(/pid=(\d+)/)?.[1])
281
+ if (pid > 0) return pid
282
+ }
283
+ } catch {}
284
+
285
+ return null
286
+ }
287
+
288
+ // per-pid cwd cache. lsof is the only way to read another process's cwd on
289
+ // macOS and it is NOT cheap — measured at 3-8s for a single pid on a machine
290
+ // running a few dozen node processes — so caching the result until the pid
291
+ // goes away is what keeps the 5s tray scan viable.
292
+ const cwdByPid = new Map<number, string>()
293
+
294
+ /** resolve the current working directory of a pid. returns null for pid 0
295
+ * (unknown owner) and whenever neither mechanism below can see the process.
296
+ * async — sync exec blocked CrBrowserMain at ~600ms on macOS.
297
+ *
298
+ * `-b -w` skips the blocking stat calls lsof otherwise makes against every
299
+ * mount, which roughly halves it. the timeout is generous because the answer
300
+ * is cached per pid: too tight a limit silently returns null on exactly the
301
+ * busy machines where auto-attaching a project matters most. */
302
+ export async function resolveProcessCwd(pid: number): Promise<string | null> {
303
+ if (pid <= 0) return null
304
+ const cached = cwdByPid.get(pid)
305
+ if (cached) return cached
306
+
307
+ // linux publishes the cwd as a symlink, which costs one readlink and no
308
+ // subprocess at all. macOS has no /proc and has to pay for lsof below.
309
+ try {
310
+ const cwd = await readlink(`/proc/${pid}/cwd`)
311
+ if (cwd) {
312
+ cwdByPid.set(pid, cwd)
313
+ return cwd
314
+ }
315
+ } catch {}
316
+
317
+ try {
318
+ // -Fn → machine-readable, one field per line. lines look like:
319
+ // p<pid>
320
+ // fcwd
321
+ // n<absolute path>
322
+ const { stdout } = await execP(`lsof -b -w -p ${pid} -a -d cwd -Fn 2>/dev/null`, {
323
+ encoding: 'utf8',
324
+ timeout: 8000,
325
+ })
326
+ for (const line of stdout.split('\n')) {
327
+ if (line.startsWith('n') && line.length > 1) {
328
+ const cwd = line.slice(1).trim()
329
+ if (cwd) {
330
+ cwdByPid.set(pid, cwd)
331
+ return cwd
332
+ }
333
+ }
334
+ }
335
+ } catch {}
336
+ return null
337
+ }
338
+
339
+ // ── per-port probe orchestration ────────────────────────────────────────────
340
+
341
+ function makeResult(
342
+ port: number,
343
+ framework: DiscoveredServer['framework'],
344
+ owner: Pick<DiscoveredServer, 'cwd' | 'pid'>,
345
+ ): DiscoveredServer {
346
+ return {
347
+ port,
348
+ framework,
349
+ bundleUrl: withRuntimeConfig(
350
+ port,
351
+ `http://localhost:${port}/index.bundle?platform=ios&dev=true&hot=true&minify=false`,
352
+ owner.cwd,
353
+ ),
354
+ hmrUrl: `ws://localhost:${port}/hot`,
355
+ lastSeen: Date.now(),
356
+ ...owner,
357
+ }
358
+ }
359
+
360
+ function withRuntimeConfig(port: number, bundleUrl: string, projectCwd?: string): string {
361
+ const knownApp = projectCwd
362
+ ? optionalDemoAppsSync().find(
363
+ (app) => app.preferredPort === port && resolve(app.dir) === resolve(projectCwd),
364
+ )
365
+ : undefined
366
+ const configured = knownApp?.runtimeConfig
367
+ ? applySootSimConfigToUrl(bundleUrl, knownApp.runtimeConfig)
368
+ : bundleUrl
369
+ return normalizeNativeDevBundleUrl(configured)
370
+ }
371
+
372
+ function isDirectOneBundleUrl(bundleUrl: string): boolean {
373
+ return bundleUrl.includes('/node_modules/one/metro-entry.bundle')
374
+ }
375
+
376
+ function safeParseManifest(body: string): Record<string, unknown> | null {
377
+ try {
378
+ const parsed = JSON.parse(body) as Record<string, unknown>
379
+ return parsed && typeof parsed === 'object' ? parsed : null
380
+ } catch {
381
+ return null
382
+ }
383
+ }
384
+
385
+ // fold expo manifest fields (name, icon, bundleId, launchAsset) into a result.
386
+ // the manifest body is always the GET / response we already have from probePort,
387
+ // so this never issues another http request.
388
+ function applyManifest(
389
+ result: DiscoveredServer,
390
+ manifestRes: HttpResult | null,
391
+ buildIconProxyUrl?: (externalUrl: string) => string,
392
+ ): DiscoveredServer {
393
+ if (!manifestRes) return result
394
+ try {
395
+ const manifest = JSON.parse(manifestRes.body)
396
+ const client = manifest?.extra?.expoClient || manifest?.extra || {}
397
+ if (client.name) result.projectName = client.name
398
+ if (client.ios?.bundleIdentifier) result.bundleId = client.ios.bundleIdentifier
399
+ if (result.framework === 'metro' && client.sdkVersion) result.framework = 'expo'
400
+ // preserve a confirmed direct one/vxrn metro-entry bundle. expo manifests
401
+ // and one/vxrn manifests point at a launchAsset URL with `hot=false`
402
+ // (hardcoded by @expo/cli — see metroOptions.ts). that's the same URL
403
+ // real iOS sims load, so sootsim uses it verbatim to share metro's
404
+ // bundle cache; HMR still works via sootsim's HmrClient.
405
+ const launchUrl = manifest?.launchAsset?.url
406
+ if (launchUrl && !result.patched && !isDirectOneBundleUrl(result.bundleUrl)) {
407
+ result.bundleUrl = withRuntimeConfig(result.port, launchUrl, result.cwd)
408
+ }
409
+ const rawIconUrl =
410
+ client.iconUrl || client.ios?.iconUrl || client.icon || client.ios?.icon
411
+ if (rawIconUrl) {
412
+ result.iconPath = rawIconUrl
413
+ if (buildIconProxyUrl) {
414
+ if (rawIconUrl.startsWith('http')) {
415
+ result.iconUrl = buildIconProxyUrl(rawIconUrl)
416
+ } else {
417
+ const cleanPath = rawIconUrl.replace(/^\.\//, '')
418
+ result.iconUrl = buildIconProxyUrl(
419
+ `http://localhost:${result.port}/assets/${cleanPath}`,
420
+ )
421
+ }
422
+ } else {
423
+ // no proxy requested — use the raw URL
424
+ result.iconUrl = rawIconUrl.startsWith('http')
425
+ ? rawIconUrl
426
+ : `http://localhost:${result.port}/assets/${rawIconUrl.replace(/^\.\//, '')}`
427
+ }
428
+ }
429
+ } catch {}
430
+ return result
431
+ }
432
+
433
+ export function __applyManifestForTests(
434
+ result: DiscoveredServer,
435
+ manifestBody: string,
436
+ ): DiscoveredServer {
437
+ return applyManifest(result, { statusCode: 200, body: manifestBody })
438
+ }
439
+
440
+ // ── name/icon enrichment ────────────────────────────────────────────────────
441
+ // a server's display name + icon come ONLY from its expo manifest (GET / with
442
+ // expo-platform), which is far slower than the cheap signature probes: One apps
443
+ // serialize app.config per request, and some (expo-updates / asset resolution)
444
+ // block several seconds. the fast probe race times the manifest out at
445
+ // MANIFEST_TIMEOUT_MS, so a slow-manifest server is identified via /status or
446
+ // the One HEAD with no projectName and shows as ":<port>" in the dock — never
447
+ // re-probed, because that nameless result caches as a strong (expo/one) entry.
448
+ // bumping the race timeout isn't an option: it would stall every probe (and a
449
+ // non-bundler node process) for seconds. instead capture name/icon out-of-band:
450
+ // when a server is identified without them, fetch the manifest ONCE with a
451
+ // generous timeout, cache the fields per port, and drop the strong port-cache
452
+ // entry so the next scan re-probes and publishes the enriched server. discovery
453
+ // stays fast; name/icon fill in a scan or two after the app first appears.
454
+ const ENRICH_TIMEOUT_MS = 20_000
455
+ interface ManifestEnrichment {
456
+ projectName?: string
457
+ iconUrl?: string
458
+ iconPath?: string
459
+ bundleId?: string
460
+ }
461
+ const enrichmentCache = new Map<number, ManifestEnrichment>()
462
+ const enrichmentInFlight = new Set<number>()
463
+
464
+ // apply already-captured name/icon, or kick off a single background manifest
465
+ // fetch to capture them. a no-op once the in-race manifest already named the
466
+ // server, so it's safe to wrap every identified return.
467
+ function enrichWithManifest(
468
+ result: DiscoveredServer,
469
+ buildIconProxyUrl?: (externalUrl: string) => string,
470
+ ): DiscoveredServer {
471
+ if (result.projectName) return result
472
+
473
+ const cached = enrichmentCache.get(result.port)
474
+ if (cached) {
475
+ if (cached.projectName) result.projectName = cached.projectName
476
+ if (cached.iconUrl && !result.iconUrl) {
477
+ result.iconUrl = cached.iconUrl
478
+ result.iconPath = cached.iconPath
479
+ }
480
+ if (cached.bundleId && !result.bundleId) result.bundleId = cached.bundleId
481
+ return result
482
+ }
483
+
484
+ if (!enrichmentInFlight.has(result.port)) {
485
+ enrichmentInFlight.add(result.port)
486
+ const port = result.port
487
+ void fetchManifestEnrichment(port, buildIconProxyUrl).finally(() =>
488
+ enrichmentInFlight.delete(port),
489
+ )
490
+ }
491
+ return result
492
+ }
493
+
494
+ async function fetchManifestEnrichment(
495
+ port: number,
496
+ buildIconProxyUrl?: (externalUrl: string) => string,
497
+ ): Promise<void> {
498
+ const manifestRes = await httpGet(port, '/', 'GET', ENRICH_TIMEOUT_MS, {
499
+ 'expo-platform': 'ios',
500
+ })
501
+ if (!manifestRes || manifestRes.statusCode !== 200) return
502
+ // reuse applyManifest's exact field extraction against a throwaway result.
503
+ const parsed = applyManifest(
504
+ { port, framework: 'one', bundleUrl: '', lastSeen: 0 },
505
+ manifestRes,
506
+ buildIconProxyUrl,
507
+ )
508
+ if (!parsed.projectName && !parsed.iconUrl) return
509
+ enrichmentCache.set(port, {
510
+ projectName: parsed.projectName,
511
+ iconUrl: parsed.iconUrl,
512
+ iconPath: parsed.iconPath,
513
+ bundleId: parsed.bundleId,
514
+ })
515
+ // the nameless result cached as a strong entry and won't re-probe on its own;
516
+ // drop it so the next scan re-runs probePort and publishes the enriched name.
517
+ portCache.delete(port)
518
+ }
519
+
520
+ // ports confirmed to be running a non-sootsim dev server — safe to skip the
521
+ // /__soot/ probe on subsequent scans. cleared when the port disappears from the
522
+ // listening set (process restarted, could now be a different server).
523
+ const knownNonPatched = new Set<number>()
524
+
525
+ // ports confirmed to not answer /_expo/status as an expo packager — safe to
526
+ // skip that probe on subsequent scans. same invalidation rule as
527
+ // knownNonPatched: cleared when the port's owning pid changes.
528
+ const knownNonExpo = new Set<number>()
529
+
530
+ // ports confirmed to be a one/vxrn dev server. on these, we skip the manifest
531
+ // probe (`GET /` with expo-platform headers) on subsequent scans: one already
532
+ // served the bundle HEAD so we know what it is, AND one's Expo Go manifest
533
+ // middleware crashes the whole server when a probe aborts mid-stream
534
+ // (Cannot pipe to a closed or destroyed stream → unhandled rejection → exit).
535
+ // the bundle URL is enough; no information is lost by skipping the manifest.
536
+ // same invalidation rules as knownNonPatched/knownNonExpo: cleared on port
537
+ // disappearance, owning-pid change, and __resetScannerCache.
538
+ const knownOne = new Set<number>()
539
+
540
+ // fire signature probes in waves and pick the best match by precedence:
541
+ // 1. expo manifest (JSON manifest at /)
542
+ // 2. metro/expo (packager-status:running on /status)
543
+ // 3. sootsim-patched (/__soot/)
544
+ // 4. one/vxrn (HEAD on /node_modules/one/metro-entry.bundle) — only
545
+ // fires as a true last resort; on expo apps this probe
546
+ // crashes metro by triggering HmrServer.registerEntryPoint
547
+ // for a path metro can't resolve, so we never fire it
548
+ // when one of the higher-precedence probes already
549
+ // identified the server.
550
+ // cheapest check first: a TCP connect gate short-circuits all HTTP probes when
551
+ // the port isn't actually reachable (zombie listener, non-http protocol,
552
+ // process mid-shutdown). after that, racing the safe probes is fast —
553
+ // localhost refuses/closes connections quickly, and Promise.all finishes at
554
+ // the slowest single probe rather than the sum.
555
+ export async function probePort(
556
+ port: number,
557
+ buildIconProxyUrl?: (externalUrl: string) => string,
558
+ listeningProcess?: ListeningProcess,
559
+ ): Promise<DiscoveredServer | null> {
560
+ // callers such as `sootsim open <port>` probe one port directly instead of
561
+ // going through scanDevServers. wait for the optional in-repo registry here
562
+ // too, or a cold CLI process can build the known app's bundle url before its
563
+ // runtime config is available.
564
+ await loadOptionalDemoApps()
565
+ if (!(await tcpPing(port))) return null
566
+
567
+ const suppliedPid = listeningProcess?.pid
568
+ const ownerPid =
569
+ suppliedPid && suppliedPid > 0 ? suppliedPid : await resolveListeningPid(port)
570
+ const ownerCwd = ownerPid ? await resolveProcessCwd(ownerPid) : null
571
+ const owner: Pick<DiscoveredServer, 'cwd' | 'pid'> = {
572
+ ...(ownerPid ? { pid: ownerPid } : {}),
573
+ ...(ownerCwd ? { cwd: ownerCwd } : {}),
574
+ }
575
+
576
+ const onePath = `/node_modules/one/metro-entry.bundle?platform=ios&dev=true`
577
+
578
+ // wave 1 — safe parallel probes. none of these mutate metro state.
579
+ const [sootsimRes, statusRes, manifestRes, expoRes] = await Promise.all([
580
+ knownNonPatched.has(port) ? Promise.resolve(null) : httpGet(port, '/__soot/'),
581
+ httpGet(port, '/status'),
582
+ knownOne.has(port)
583
+ ? Promise.resolve(null)
584
+ : httpGet(port, '/', 'GET', MANIFEST_TIMEOUT_MS, { 'expo-platform': 'ios' }),
585
+ knownNonExpo.has(port) ? Promise.resolve(null) : httpGet(port, '/_expo/status'),
586
+ ])
587
+
588
+ // remember negative /_expo/status responses so we don't keep probing this
589
+ // endpoint on ports that clearly aren't expo packagers (one/vxrn, vite,
590
+ // random node processes). a 200 flips the port out of the non-expo set.
591
+ if (expoRes && expoRes.statusCode === 200) {
592
+ knownNonExpo.delete(port)
593
+ } else if (!knownNonExpo.has(port)) {
594
+ knownNonExpo.add(port)
595
+ }
596
+
597
+ // expo manifest (One framework + expo apps both serve this at `/` with the
598
+ // expo-platform header). when present, `launchAsset.url` is the canonical
599
+ // bundle URL the app actually serves — for One projects it's the
600
+ // monorepo-aware path (e.g. /apps/one/node_modules/one/metro-entry.bundle
601
+ // with full hermes transform params), and for Expo it's /index.bundle with
602
+ // the right transforms. either way it's strictly better than the bare
603
+ // /node_modules/one/metro-entry.bundle constant we used as a discriminator
604
+ // below, so we check the manifest FIRST.
605
+ const manifestParsed = manifestRes
606
+ ? (safeParseManifest(manifestRes.body) as {
607
+ launchAsset?: { url?: unknown }
608
+ extra?: { expoClient?: { name?: unknown }; name?: unknown }
609
+ } | null)
610
+ : null
611
+ const manifestLaunchUrl =
612
+ typeof manifestParsed?.launchAsset?.url === 'string'
613
+ ? manifestParsed.launchAsset.url
614
+ : null
615
+ const manifestClient =
616
+ (manifestParsed?.extra?.expoClient as { name?: unknown } | undefined) ||
617
+ (manifestParsed?.extra as { name?: unknown } | undefined) ||
618
+ {}
619
+ if (manifestParsed && (manifestLaunchUrl || typeof manifestClient.name === 'string')) {
620
+ knownNonPatched.add(port)
621
+ const launchUrl =
622
+ manifestLaunchUrl ||
623
+ `http://localhost:${port}/index.bundle?platform=ios&dev=true&hot=true&minify=false`
624
+ // call the framework "one" only when launchAsset clearly points at
625
+ // a One metro entry; otherwise treat it as plain expo so downstream
626
+ // bundle handling uses the right path semantics.
627
+ const framework: DiscoveredServer['framework'] = launchUrl.includes(
628
+ '/one/metro-entry.bundle',
629
+ )
630
+ ? 'one'
631
+ : 'expo'
632
+ return applyManifest(
633
+ {
634
+ port,
635
+ framework,
636
+ bundleUrl: withRuntimeConfig(port, launchUrl, owner.cwd),
637
+ hmrUrl: `ws://localhost:${port}/hot`,
638
+ lastSeen: Date.now(),
639
+ ...owner,
640
+ },
641
+ manifestRes,
642
+ buildIconProxyUrl,
643
+ )
644
+ }
645
+
646
+ // metro/expo — packager-status:running on /status. checked BEFORE the one
647
+ // HEAD because the HEAD has a known side-effect: it triggers metro's
648
+ // HmrServer.registerEntryPoint for `/node_modules/one/metro-entry.bundle`,
649
+ // and on metro instances where that path doesn't resolve (i.e. any plain
650
+ // expo app) metro crashes with "UnableToResolveError" and exits. by
651
+ // identifying expo/metro instances here from their /status response we
652
+ // never fire the HEAD against them.
653
+ if (statusRes && statusRes.body.includes('packager-status:running')) {
654
+ knownNonPatched.add(port)
655
+ return enrichWithManifest(
656
+ applyManifest(
657
+ makeResult(port, expoRes && expoRes.statusCode === 200 ? 'expo' : 'metro', owner),
658
+ manifestRes,
659
+ buildIconProxyUrl,
660
+ ),
661
+ buildIconProxyUrl,
662
+ )
663
+ }
664
+
665
+ // patched sootsim — fallback for legacy /__soot/ patched servers. also
666
+ // checked before the one HEAD for the same reason.
667
+ if (
668
+ sootsimRes &&
669
+ sootsimRes.statusCode === 200 &&
670
+ sootsimRes.body.includes('sootsim-patched')
671
+ ) {
672
+ knownNonPatched.delete(port)
673
+ return enrichWithManifest(
674
+ applyManifest(
675
+ {
676
+ port,
677
+ framework: 'one',
678
+ bundleUrl: withRuntimeConfig(
679
+ port,
680
+ `http://localhost:${port}/__soot/bundle.js`,
681
+ owner.cwd,
682
+ ),
683
+ hmrUrl: `ws://localhost:${port}/hot`,
684
+ lastSeen: Date.now(),
685
+ patched: true,
686
+ ...owner,
687
+ },
688
+ manifestRes,
689
+ buildIconProxyUrl,
690
+ ),
691
+ buildIconProxyUrl,
692
+ )
693
+ }
694
+
695
+ // wave 2 — one/vxrn HEAD probe. only fired as a true last resort, since
696
+ // it has the metro side-effect documented above. older One bare-dev-server
697
+ // setups that don't serve a manifest still resolve through this path; new
698
+ // expo/metro/one setups are caught above and never reach here.
699
+ //
700
+ // hard safety gate (the recurring "metro on 8081 crashes" regression): a
701
+ // plain expo/metro packager that is still BOOTING serves `/status`,
702
+ // `/_expo/status`, and the `/` manifest a few seconds apart, so during the
703
+ // boot window all three wave-1 catches above can miss (status body not yet
704
+ // `packager-status:running`, manifest not yet parseable). without this gate
705
+ // we then fire the lethal HEAD against a metro that IS a metro — and the
706
+ // engine, handed a bogus `one/metro-entry.bundle` URL, opens an HMR socket
707
+ // whose `register-entrypoints` throws UnableToResolveError inside
708
+ // HmrServer._registerEntryPoint → unhandledRejection → metro exits. a real
709
+ // legacy bare-One dev server answers NONE of these metro/expo endpoints, so
710
+ // skipping the HEAD here never hides a genuine One server — it just defers
711
+ // this port to the next scan, by which point wave 1 identifies it cleanly.
712
+ const looksLikeMetroPackager =
713
+ (statusRes && statusRes.statusCode === 200) ||
714
+ (expoRes && expoRes.statusCode === 200) ||
715
+ (manifestRes && manifestRes.statusCode === 200 && manifestParsed != null)
716
+ if (looksLikeMetroPackager) {
717
+ // do NOT cache as knownNonPatched — we want a fresh wave-1 pass next scan
718
+ // once the packager finishes booting and its manifest/status are ready.
719
+ return null
720
+ }
721
+
722
+ const oneRes = await httpGet(port, onePath, 'HEAD')
723
+ if (
724
+ oneRes &&
725
+ oneRes.statusCode > 0 &&
726
+ oneRes.statusCode < 400 &&
727
+ /application\/javascript/i.test(oneRes.contentType || '')
728
+ ) {
729
+ knownNonPatched.add(port)
730
+ knownOne.add(port)
731
+ return enrichWithManifest(
732
+ applyManifest(
733
+ {
734
+ port,
735
+ framework: 'one',
736
+ bundleUrl: withRuntimeConfig(
737
+ port,
738
+ `http://localhost:${port}${onePath}&minify=false`,
739
+ owner.cwd,
740
+ ),
741
+ hmrUrl: `ws://localhost:${port}/hot`,
742
+ lastSeen: Date.now(),
743
+ ...owner,
744
+ },
745
+ manifestRes,
746
+ buildIconProxyUrl,
747
+ ),
748
+ buildIconProxyUrl,
749
+ )
750
+ }
751
+
752
+ knownNonPatched.add(port)
753
+ return null
754
+ }
755
+
756
+ // ── top-level convenience ───────────────────────────────────────────────────
757
+
758
+ export interface ScanOptions {
759
+ excludePorts?: number[]
760
+ buildIconProxyUrl?: (externalUrl: string) => string
761
+ }
762
+
763
+ function isSootSimSelfServer(server: DiscoveredServer): boolean {
764
+ const bundleId = server.bundleId?.trim().toLowerCase()
765
+ if (bundleId?.startsWith('dev.sootsim.')) return true
766
+
767
+ return false
768
+ }
769
+
770
+ export const __isSootSimSelfServerForTests = isSootSimSelfServer
771
+
772
+ // per-(port, pid) probe cache. as long as the same process still owns the
773
+ // port, its signature endpoints can't have changed, so we return the cached
774
+ // result without issuing any HTTP. negative results (null) are cached too,
775
+ // which prevents repeat-probing node/bun processes that aren't dev servers.
776
+ // invalidation is cheap: we drop any entry whose port dropped out of lsof,
777
+ // and any entry whose pid changed (process restart).
778
+ interface PortCacheEntry {
779
+ pid: number
780
+ result: DiscoveredServer | null
781
+ cachedAt: number
782
+ }
783
+ const portCache = new Map<number, PortCacheEntry>()
784
+
785
+ // negative-cache holds a node/bun port that isn't a bundler (e.g. Contrast's own
786
+ // api server, Zero, SQLite sync host, a dev tool). at scan interval 3s and a 1.5s ttl
787
+ // we used to re-probe every scan, which hits `/` on the port every 3s — that
788
+ // waking Contrast's SSR router ~20x/min was a real memory-leak driver. 30s gives
789
+ // the scanner a much lighter touch on unrelated node processes while still
790
+ // catching pid-change invalidation immediately when a process restarts.
791
+ const NEGATIVE_CACHE_TTL_MS = 30_000
792
+ const WEAK_RESULT_CACHE_TTL_MS = 1_500
793
+
794
+ function isWeakCachedResult(result: DiscoveredServer | null): boolean {
795
+ if (!result) return true
796
+ if (result.framework === 'metro' || result.framework === 'unknown') return true
797
+ return false
798
+ }
799
+
800
+ function hasCurrentRuntimeConfig(result: DiscoveredServer | null): boolean {
801
+ if (!result) return true
802
+ return withRuntimeConfig(result.port, result.bundleUrl, result.cwd) === result.bundleUrl
803
+ }
804
+
805
+ export function __shouldReuseScannerCacheEntry(
806
+ entry: { pid: number; result: DiscoveredServer | null; cachedAt: number },
807
+ pid: number,
808
+ now = Date.now(),
809
+ ): boolean {
810
+ if (pid === 0) return false
811
+ if (entry.pid !== pid) return false
812
+ if (!hasCurrentRuntimeConfig(entry.result)) return false
813
+
814
+ const ageMs = now - entry.cachedAt
815
+ if (entry.result === null && ageMs >= NEGATIVE_CACHE_TTL_MS) return false
816
+ if (isWeakCachedResult(entry.result) && ageMs >= WEAK_RESULT_CACHE_TTL_MS) return false
817
+ return true
818
+ }
819
+
820
+ // exported for tests / `sootsim debug` — forces the next scan to re-probe.
821
+ export function __resetScannerCache() {
822
+ portCache.clear()
823
+ knownNonPatched.clear()
824
+ knownNonExpo.clear()
825
+ knownOne.clear()
826
+ enrichmentCache.clear()
827
+ enrichmentInFlight.clear()
828
+ __resetAppSettingsBundleCacheForTests()
829
+ }
830
+
831
+ async function attachAppSettingsBundle(
832
+ result: DiscoveredServer,
833
+ projectRoot: string,
834
+ ): Promise<void> {
835
+ const settingsBundle = await readAppSettingsBundle(projectRoot)
836
+ if (settingsBundle) {
837
+ result.settingsBundle = settingsBundle
838
+ result.settingsStorageScopeUrl = result.bundleUrl
839
+ } else {
840
+ delete result.settingsBundle
841
+ delete result.settingsStorageScopeUrl
842
+ }
843
+ }
844
+
845
+ export const __attachAppSettingsBundleForTests = attachAppSettingsBundle
846
+
847
+ export async function scanDevServers(
848
+ opts: ScanOptions = {},
849
+ ): Promise<DiscoveredServer[]> {
850
+ // optionalDemoAppsSync() returns [] until the dynamic import resolves, and
851
+ // an empty registry is indistinguishable from a published install. await it
852
+ // before cache validation so a known app cannot lose its runtimeConfig when
853
+ // disk contention delays the import. memoized, so this is free after the
854
+ // first call.
855
+ await loadOptionalDemoApps()
856
+ const processes = await discoverListeningProcesses(opts.excludePorts)
857
+ const currentPorts = new Set(processes.map((p) => p.port))
858
+
859
+ // evict cache entries for ports that disappeared. a restarted process may
860
+ // bring up a different server (possibly sootsim-patched) on the same port,
861
+ // and both caches need to let go so the next probe sees the new state.
862
+ for (const p of [...portCache.keys()]) {
863
+ if (!currentPorts.has(p)) portCache.delete(p)
864
+ }
865
+ for (const p of [...knownNonPatched]) {
866
+ if (!currentPorts.has(p)) knownNonPatched.delete(p)
867
+ }
868
+ for (const p of [...knownNonExpo]) {
869
+ if (!currentPorts.has(p)) knownNonExpo.delete(p)
870
+ }
871
+ for (const p of [...knownOne]) {
872
+ if (!currentPorts.has(p)) knownOne.delete(p)
873
+ }
874
+ for (const p of [...enrichmentCache.keys()]) {
875
+ if (!currentPorts.has(p)) enrichmentCache.delete(p)
876
+ }
877
+
878
+ const results: DiscoveredServer[] = []
879
+ const toProbe: ListeningProcess[] = []
880
+
881
+ for (const { port, pid } of processes) {
882
+ const cached = portCache.get(port)
883
+ // pid 0 means the platform couldn't identify the owner (fallback path),
884
+ // so we can't trust the cache — reprobe.
885
+ if (cached && __shouldReuseScannerCacheEntry(cached, pid)) {
886
+ if (cached.result) results.push(cached.result)
887
+ continue
888
+ }
889
+ // pid changed or cache expired — drop any endpoint-specific verdicts so
890
+ // the re-probe starts from scratch. otherwise a restarted process that
891
+ // swapped frameworks keeps the old skip-/__soot/ or skip-/_expo/status
892
+ // decisions, and the scanner never discovers the new server.
893
+ if (cached && cached.pid !== pid) {
894
+ knownNonPatched.delete(port)
895
+ knownNonExpo.delete(port)
896
+ knownOne.delete(port)
897
+ // a restarted process may be a different app — drop its captured name/icon.
898
+ enrichmentCache.delete(port)
899
+ }
900
+ toProbe.push({ port, pid })
901
+ }
902
+
903
+ if (toProbe.length > 0) {
904
+ const probed = await Promise.all(
905
+ toProbe.map((p) => probePort(p.port, opts.buildIconProxyUrl, p)),
906
+ )
907
+ probed.forEach((result, i) => {
908
+ const { port, pid } = toProbe[i]
909
+ // only cache when we have a real pid to key the entry against.
910
+ // fallback-list ports (pid 0) always reprobe so we catch state changes.
911
+ if (pid !== 0) portCache.set(port, { pid, result, cachedAt: Date.now() })
912
+ if (result) results.push(result)
913
+ })
914
+ }
915
+
916
+ // attach pid + cwd. pid comes from the listening-process scan; cwd is
917
+ // lsof-resolved and cached per pid. both enable auto-attach of agent
918
+ // sessions to the right project without a manual `sootsim agent attach`.
919
+ const pidByPort = new Map<number, number>()
920
+ for (const { port, pid } of processes) {
921
+ if (pid > 0) pidByPort.set(port, pid)
922
+ }
923
+ await Promise.all(
924
+ results.map(async (result) => {
925
+ const pid = pidByPort.get(result.port)
926
+ if (!pid) return
927
+ result.pid = pid
928
+ const cwd = await resolveProcessCwd(pid)
929
+ if (!cwd) return
930
+ result.cwd = cwd
931
+ await attachAppSettingsBundle(result, cwd)
932
+ }),
933
+ )
934
+ // evict stale cwd cache entries for pids no longer listening
935
+ const livePids = new Set(pidByPort.values())
936
+ for (const pid of [...cwdByPid.keys()]) {
937
+ if (!livePids.has(pid)) cwdByPid.delete(pid)
938
+ }
939
+
940
+ return results.filter((r) => !isSootSimSelfServer(r))
941
+ }