peachsim 0.1.527

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 (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,1291 @@
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 { realpathSync } from 'node:fs'
21
+ import { resolve } from 'node:path'
22
+ import {
23
+ buildLoopbackProbeBaseUrl,
24
+ isLoopbackHost,
25
+ LOOPBACK_PROBE_HOSTNAMES,
26
+ normalizeHostname,
27
+ } from '../src/backend-origin.ts'
28
+ import { applyRNXConfigToUrl } from '../src/config.ts'
29
+ import {
30
+ alignLoopbackBundleUrlWithProbe,
31
+ normalizeNativeDevBundleUrl,
32
+ } from '../src/native-dev-bundle-url.ts'
33
+ import {
34
+ __resetAppSettingsBundleCacheForTests,
35
+ readAppSettingsBundle,
36
+ } from '../src/settings-bundle.ts'
37
+ import { loadOptionalDemoApps, optionalDemoAppsSync } from './optional-demo-registry.ts'
38
+ import type { AppSettingsSpecifier } from '@rnx/globals'
39
+
40
+ // how long past a command's own budget we wait before deciding its exit event is
41
+ // never coming. generous, because this only fires when the child is already
42
+ // overdue and we are choosing between a wrong answer and a hang.
43
+ const EXEC_EXIT_GRACE_MS = 2_000
44
+
45
+ /** every subprocess in this file goes through here.
46
+ *
47
+ * `exec`'s own `timeout` option is NOT a backstop. it kills the child and then
48
+ * STILL waits for a 'close' event to settle the promise, so when that event is
49
+ * lost the promise never settles — the timeout is already spent and nothing is
50
+ * left to rescue it. (same failure m10843 pinned in `CheckoutFilePlane.refresh`.)
51
+ * `lsof` walks every open fd on the machine, which makes it a realistic victim.
52
+ *
53
+ * a hung scan is worse than a slow one: `inflightScan` never clears, so
54
+ * `/__server-scan` stops answering at all instead of answering "could not
55
+ * look". so we race the child against a real wall-clock deadline and reject
56
+ * with `killed`, which callers already read as could-not-look. */
57
+ async function execGuarded(
58
+ command: string,
59
+ timeout: number,
60
+ extra: { maxBuffer?: number } = {},
61
+ ): Promise<{ stdout: string }> {
62
+ let child: ReturnType<typeof exec> | undefined
63
+ const ran = new Promise<{ stdout: string }>((resolve, reject) => {
64
+ child = exec(command, { encoding: 'utf8', timeout, ...extra }, (err, stdout) => {
65
+ if (err) reject(err)
66
+ else resolve({ stdout })
67
+ })
68
+ })
69
+ let deadlineTimer: ReturnType<typeof setTimeout> | undefined
70
+ const deadline = new Promise<never>((_resolve, reject) => {
71
+ deadlineTimer = setTimeout(() => {
72
+ child?.kill('SIGKILL')
73
+ const err = new Error(`\`${command}\` did not exit within ${timeout}ms`)
74
+ // matches the shape `wasKilledByTimeout` reads off a real exec timeout, so
75
+ // a lost exit event and a genuine timeout reach callers as the same fact.
76
+ Object.assign(err, { killed: true })
77
+ reject(err)
78
+ }, timeout + EXEC_EXIT_GRACE_MS)
79
+ })
80
+ try {
81
+ return await Promise.race([ran, deadline])
82
+ } finally {
83
+ // the race is settled, so drop the deadline rather than leaving it pending.
84
+ clearTimeout(deadlineTimer)
85
+ }
86
+ }
87
+
88
+ export interface DiscoveredServer {
89
+ port: number
90
+ framework: 'metro' | 'expo' | 'vxrn' | 'one' | 'unknown'
91
+ projectName?: string
92
+ bundleUrl: string
93
+ hmrUrl?: string
94
+ lastSeen: number
95
+ iconUrl?: string
96
+ iconPath?: string
97
+ /** the app's own brand colour from its manifest, used as the home tile's
98
+ * background so the tile reads as that app before its icon decodes and
99
+ * when the icon is missing entirely. */
100
+ primaryColor?: string
101
+ bundleId?: string
102
+ settingsBundle?: readonly AppSettingsSpecifier[]
103
+ settingsStorageScopeUrl?: string
104
+ patched?: boolean
105
+ /** true when `bundleUrl` was NOT derived from the server's own manifest —
106
+ * the manifest probe got no answer, so this is the generic `/index.bundle`
107
+ * guess. good enough to LIST the server; never good enough to open it. see
108
+ * `manifestState` below for why the two are different answers. */
109
+ bundleUrlProvisional?: boolean
110
+ /** absolute cwd of the owning node/bun process — resolved from `lsof -d cwd`
111
+ * when the scanner can see the pid. used to auto-attach the project for
112
+ * agent sessions without requiring a manual `sootsim agent attach`. */
113
+ cwd?: string
114
+ pid?: number
115
+ }
116
+
117
+ /** how the manifest probe (`GET /` with expo-platform) actually went. the same
118
+ * three answers `dev-bundle-resolution.ts` uses, deliberately spelled the same
119
+ * way: a second vocabulary for one question is how the two resolvers drift.
120
+ *
121
+ * the rule this encodes, which the whole scanner depends on: A PROBE THAT
122
+ * COULD NOT LOOK MUST NEVER ANSWER AS THOUGH IT LOOKED. silence, timeout and
123
+ * empty are three different answers, and only `no-manifest` means "there is
124
+ * nothing there". reading `no-answer` as `no-manifest` is what made
125
+ * `rnx open` hand back `<base>/index.bundle` for a One app whose manifest
126
+ * names a different entry point entirely. */
127
+ type ManifestState = 'resolved' | 'no-manifest' | 'no-answer'
128
+
129
+ // localhost dev servers respond in well under 100ms when alive. drop dead
130
+ // branches fast so a non-bundler process doesn't drag scan latency.
131
+ const TIMEOUT_MS = 250
132
+ // the expo-style manifest GET / is intentionally slower than the cheap
133
+ // signature probes — for One framework apps it serializes app.config.js
134
+ // (including embedded googleServicesFile blobs), which can push the
135
+ // response to 200–400ms on first hit. when this probe times out we fall
136
+ // back to the One-HEAD legacy URL `/node_modules/one/metro-entry.bundle`,
137
+ // which is the path that originally caused the "failed to fetch bundle
138
+ // (404|502)" regression on monorepo One projects (apps/<name>/...). give
139
+ // the manifest more headroom so the canonical launchAsset.url wins.
140
+ const MANIFEST_TIMEOUT_MS = 1500
141
+ // cheap TCP-connect check gate for HTTP probes — if we can't open a socket
142
+ // within this budget the port isn't actually reachable, and all 5 HTTP probes
143
+ // would just time out for nothing.
144
+ const TCP_GATE_MS = 120
145
+ // once a server has sent response headers we never abort it (see httpGet). this
146
+ // only bounds a response that never ends, so it is deliberately far above any
147
+ // real manifest: aborting a slow-but-answering server is the defect this whole
148
+ // path exists to avoid.
149
+ const BODY_TIMEOUT_MS = 30_000
150
+
151
+ interface HttpResult {
152
+ statusCode: number
153
+ body: string
154
+ contentType?: string
155
+ hostname: string
156
+ }
157
+
158
+ // cheap TCP-connect gate. lsof already says the socket is LISTEN, but the
159
+ // process can be closing, the fd can be a different protocol, or a zombie
160
+ // can linger — none of those cases are worth firing 5 HTTP probes at.
161
+ async function tcpPing(port: number, timeout = TCP_GATE_MS): Promise<string | null> {
162
+ try {
163
+ return await Promise.any(
164
+ LOOPBACK_PROBE_HOSTNAMES.map(
165
+ (hostname) =>
166
+ new Promise<string>((resolve, reject) => {
167
+ const sock = new net.Socket()
168
+ let settled = false
169
+ const done = (reachable: boolean) => {
170
+ if (settled) return
171
+ settled = true
172
+ sock.destroy()
173
+ if (reachable) resolve(hostname)
174
+ else reject(new Error(`unreachable loopback host ${hostname}`))
175
+ }
176
+ sock.setTimeout(timeout)
177
+ sock.once('connect', () => done(true))
178
+ sock.once('timeout', () => done(false))
179
+ sock.once('error', () => done(false))
180
+ sock.connect(port, hostname)
181
+ }),
182
+ ),
183
+ )
184
+ } catch {
185
+ return null
186
+ }
187
+ }
188
+
189
+ function httpGet(
190
+ hostname: string,
191
+ port: number,
192
+ path: string,
193
+ method: 'GET' | 'HEAD' = 'GET',
194
+ timeout = TIMEOUT_MS,
195
+ headers: Record<string, string> = {},
196
+ ): Promise<HttpResult | null> {
197
+ return new Promise((resolve) => {
198
+ // first answer wins: the response, the deadline, and the connection closing
199
+ // all race, and every one of them can arrive after another has decided.
200
+ let settled = false
201
+ const settle = (value: HttpResult | null) => {
202
+ if (settled) return
203
+ settled = true
204
+ resolve(value)
205
+ }
206
+ // NO socket timeout here, deliberately. `options.timeout` / `req.setTimeout`
207
+ // make node DESTROY the connection when the budget expires, and destroying a
208
+ // dev server's connection while it is still assembling its manifest fires
209
+ // "Cannot pipe to a closed or destroyed stream" into One's manifest
210
+ // middleware as an unhandled rejection — the hazard the knownOne comment
211
+ // below already documents, which can exit the user's dev server. measured:
212
+ // six probes with a socket timeout produced six of those errors, six probes
213
+ // that give up in JS produced none, and the request completes later with the
214
+ // server none the wiser.
215
+ //
216
+ // giving up WAITING and KILLING THE REQUEST are different acts, and only the
217
+ // first one is ours to take. the deadline below resolves the scan on time;
218
+ // the request runs itself out in the background and its result is discarded.
219
+ const req = http.request({ hostname, port, path, method, headers }, (res) => {
220
+ let body = ''
221
+ res.on('data', (c: Buffer) => (body += c.toString()))
222
+ const contentType = (() => {
223
+ const raw = res.headers['content-type']
224
+ if (typeof raw === 'string') return raw
225
+ if (Array.isArray(raw)) return raw[0]
226
+ return undefined
227
+ })()
228
+ res.on('end', () =>
229
+ settle({
230
+ statusCode: res.statusCode || 0,
231
+ body,
232
+ contentType,
233
+ hostname,
234
+ }),
235
+ )
236
+ })
237
+ req.on('error', () => settle(null))
238
+ req.end()
239
+ const giveUp = setTimeout(() => {
240
+ settle(null)
241
+ // nothing is waiting on it now, so it must not hold a CLI process open.
242
+ req.socket?.unref()
243
+ }, timeout)
244
+ // the only thing that ever destroys a connection here. by BODY_TIMEOUT_MS a
245
+ // server has long since answered or died, so there is no live write to cut.
246
+ const abandon = setTimeout(() => req.destroy(), BODY_TIMEOUT_MS)
247
+ req.on('close', () => {
248
+ clearTimeout(giveUp)
249
+ clearTimeout(abandon)
250
+ // `close` on the request fires BEFORE the response's own `end` handler,
251
+ // so settling synchronously here turns every successful probe into a
252
+ // null. defer one tick and let a real answer win; this only settles the
253
+ // case where the connection closed without one.
254
+ setImmediate(() => settle(null))
255
+ })
256
+ })
257
+ }
258
+
259
+ // ── discovery ───────────────────────────────────────────────────────────────
260
+
261
+ export interface ListeningProcess {
262
+ port: number
263
+ pid: number
264
+ }
265
+
266
+ // bare-port fallback list used when neither lsof nor ss are usable. pid 0
267
+ // signals "unknown owner" — the cache layer will re-probe these each scan.
268
+ const FALLBACK_PORTS: ListeningProcess[] = [
269
+ 8081, 8082, 8083, 8084, 8085, 8086, 3000, 3001, 19000,
270
+ ].map((port) => ({ port, pid: 0 }))
271
+
272
+ // sootsim's own dev server range at PORT_OFFSET=0. an isolated stack shifts its
273
+ // shell by the same offset as every other port it owns, so the guard has to
274
+ // shift with it: at PORT_OFFSET=90 the shell listens on :5263 and a range fixed
275
+ // at 5170-5200 does not cover it, which lets a scan discover ITSELF and offer
276
+ // the shell as though it were an app's dev server. a guard that is only correct
277
+ // at offset 0 is wrong for everyone running an isolated stack, which is exactly
278
+ // who runs concurrent scans.
279
+ const SELF_PORT_RANGE_START = 5170
280
+ const SELF_PORT_RANGE_END = 5200
281
+
282
+ function isOwnShellPort(port: number): boolean {
283
+ const offset = Number(process.env.PORT_OFFSET) || 0
284
+ // the unshifted range still counts: another checkout's default-offset shell is
285
+ // no more an app dev server than our own is.
286
+ if (port >= SELF_PORT_RANGE_START && port <= SELF_PORT_RANGE_END) return true
287
+ return (
288
+ offset > 0 &&
289
+ port >= SELF_PORT_RANGE_START + offset &&
290
+ port <= SELF_PORT_RANGE_END + offset
291
+ )
292
+ }
293
+
294
+ function acceptPort(port: number, excluded: Set<number>): boolean {
295
+ if (port <= 0 || port >= 20000) return false
296
+ if (excluded.has(port)) return false
297
+ if (isOwnShellPort(port)) return false
298
+ return true
299
+ }
300
+
301
+ /** ran the listing tool and these are the listeners, versus could not run it at
302
+ * all. an empty `processes` here means the tool looked and there was genuinely
303
+ * nothing; `could-not-look` means we have no information. substituting the
304
+ * hardcoded FALLBACK_PORTS for the second case is what made `/__server-scan`
305
+ * answer `[]` — "there are no dev servers" — to a user running three of them. */
306
+ export type ListeningProcessScan =
307
+ | { state: 'looked'; processes: ListeningProcess[] }
308
+ | { state: 'could-not-look' }
309
+
310
+ /** thrown by `scanDevServers` when listener enumeration itself failed. callers
311
+ * must treat it as "no information" — keep whatever list they already had, or
312
+ * answer their own caller with a failure — never as an empty result. */
313
+ export class DevServerScanUnavailableError extends Error {
314
+ constructor() {
315
+ super('could not enumerate listening processes (lsof/ss unavailable or timed out)')
316
+ this.name = 'DevServerScanUnavailableError'
317
+ }
318
+ }
319
+
320
+ // lsof walks every open fd on the machine, so on a busy host it is genuinely
321
+ // slow — measured between 1.2s and 5.0s here with 58 matching listeners. the
322
+ // budget has to fit the real worst case, because the alternative is not a
323
+ // faster answer, it is a WRONG one.
324
+ const LISTING_TIMEOUT_MS = 10_000
325
+
326
+ /** true when a failed exec was killed by our own timeout rather than by the
327
+ * command being absent or erroring — the difference between "could not look"
328
+ * and "this tool is not available here". */
329
+ function wasKilledByTimeout(err: unknown): boolean {
330
+ return !!(err && typeof err === 'object' && (err as { killed?: boolean }).killed)
331
+ }
332
+
333
+ export async function discoverListeningProcesses(
334
+ excludePorts: number[] = [],
335
+ ): Promise<ListeningProcess[]> {
336
+ const scan = await scanListeningProcesses(excludePorts)
337
+ return scan.state === 'looked' ? scan.processes : []
338
+ }
339
+
340
+ export async function scanListeningProcesses(
341
+ excludePorts: number[] = [],
342
+ ): Promise<ListeningProcessScan> {
343
+ const excluded = new Set(excludePorts)
344
+ let timedOut = false
345
+
346
+ // lsof layout: COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
347
+ // NAME looks like "*:8081" or "127.0.0.1:8081" for a LISTEN line.
348
+ // async exec — sync blocked CrBrowserMain for ~600ms/scan on macOS.
349
+ try {
350
+ const { stdout } = await execGuarded(
351
+ `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E '^(node|bun)'`,
352
+ LISTING_TIMEOUT_MS,
353
+ )
354
+ const seen = new Map<number, number>()
355
+ for (const line of stdout.trim() ? stdout.trim().split('\n') : []) {
356
+ const parts = line.trim().split(/\s+/)
357
+ if (parts.length < 9) continue
358
+ const pid = Number(parts[1])
359
+ const addr = parts[8]
360
+ const m = addr.match(/:(\d+)$/)
361
+ if (!m) continue
362
+ const port = Number(m[1])
363
+ if (!acceptPort(port, excluded)) continue
364
+ if (!seen.has(port)) seen.set(port, pid)
365
+ }
366
+ // lsof ran. an empty result is a real answer: nothing is listening.
367
+ return {
368
+ state: 'looked',
369
+ processes: [...seen.entries()].map(([port, pid]) => ({ port, pid })),
370
+ }
371
+ } catch (err) {
372
+ if (wasKilledByTimeout(err)) timedOut = true
373
+ }
374
+
375
+ // ss layout: State Recv-Q Send-Q Local-Address:Port Peer-Addr:Port users:(("node",pid=1234,fd=5))
376
+ try {
377
+ const { stdout } = await execGuarded(
378
+ `ss -tlnp 2>/dev/null | grep -E '"(node|bun)"'`,
379
+ LISTING_TIMEOUT_MS,
380
+ )
381
+ const seen = new Map<number, number>()
382
+ for (const line of stdout.trim() ? stdout.trim().split('\n') : []) {
383
+ const portMatch = line.match(/:(\d+)\s/)
384
+ const pidMatch = line.match(/pid=(\d+)/)
385
+ if (!portMatch) continue
386
+ const port = Number(portMatch[1])
387
+ const pid = pidMatch ? Number(pidMatch[1]) : 0
388
+ if (!acceptPort(port, excluded)) continue
389
+ if (!seen.has(port)) seen.set(port, pid)
390
+ }
391
+ return {
392
+ state: 'looked',
393
+ processes: [...seen.entries()].map(([port, pid]) => ({ port, pid })),
394
+ }
395
+ } catch (err) {
396
+ if (wasKilledByTimeout(err)) timedOut = true
397
+ }
398
+
399
+ // a tool that timed out told us nothing, so say so rather than substituting
400
+ // the guess list below and reporting its emptiness as fact.
401
+ if (timedOut) return { state: 'could-not-look' }
402
+
403
+ // neither tool exists here, so probe the conventional bundler ports directly.
404
+ // that is a real (if narrow) look, not a substitute for one.
405
+ return {
406
+ state: 'looked',
407
+ processes: FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded)),
408
+ }
409
+ }
410
+
411
+ // kept for backwards compatibility with any external callers that only need
412
+ // port numbers. internal paths should prefer discoverListeningProcesses so
413
+ // the (port, pid) cache can detect process restarts.
414
+ export async function discoverListeningPorts(
415
+ excludePorts: number[] = [],
416
+ ): Promise<number[]> {
417
+ const processes = await discoverListeningProcesses(excludePorts)
418
+ return processes.map((p) => p.port)
419
+ }
420
+
421
+ /** parse `netstat -anv -p tcp` output for the pid listening on one port.
422
+ *
423
+ * macOS lays a LISTEN row out as
424
+ * tcp4 0 0 127.0.0.1.8090 *.* LISTEN 0 0 131072 131072 node:97330 ...
425
+ * so the local address is the 4th column with a DOT before the port (which is
426
+ * what keeps port 8137 from matching a row for 18137), and the owner is the
427
+ * only token shaped "<name>:<pid>".
428
+ *
429
+ * exported because this column layout is the fragile part and a child process
430
+ * cannot exercise it under bun's test runner, which hands back empty stdout. */
431
+ export function pidFromNetstat(stdout: string, port: number): number | null {
432
+ for (const line of stdout.split('\n')) {
433
+ if (!line.includes('LISTEN')) continue
434
+ const parts = line.trim().split(/\s+/)
435
+ if (!parts[3]?.endsWith(`.${port}`)) continue
436
+ for (const token of parts) {
437
+ const owner = token.match(/^\S+:(\d+)$/)
438
+ if (owner) {
439
+ const pid = Number(owner[1])
440
+ if (pid > 0) return pid
441
+ }
442
+ }
443
+ }
444
+ return null
445
+ }
446
+
447
+ /** resolve the pid of the process listening on one local port.
448
+ *
449
+ * `lsof` walks every descriptor of every process, which measures 2-4s on a
450
+ * loaded machine — longer than any timeout worth giving it, so
451
+ * `discoverListeningProcesses` above degrades to its pid-less port list
452
+ * exactly when the machine is busy. netstat (macOS/BSD) and ss (linux) read
453
+ * the kernel socket table instead and answer in well under a second, and both
454
+ * carry the owning pid. use this whenever the question is "who owns this one
455
+ * port"; `discoverListeningProcesses` answers the different question of what
456
+ * is listening at all. */
457
+ export async function resolveListeningPid(port: number): Promise<number | null> {
458
+ if (!Number.isInteger(port) || port <= 0) return null
459
+
460
+ // macOS netstat: "tcp4 0 0 127.0.0.1.8090 *.* LISTEN ... node:97330 ..."
461
+ // the local address uses a dot before the port, and the owner column is the
462
+ // only token shaped "<name>:<pid>".
463
+ try {
464
+ const { stdout } = await execGuarded('netstat -anv -p tcp 2>/dev/null', 8000, {
465
+ maxBuffer: 16 * 1024 * 1024,
466
+ })
467
+ const pid = pidFromNetstat(stdout, port)
468
+ if (pid !== null) return pid
469
+ } catch {}
470
+
471
+ // linux ss: 'LISTEN 0 511 0.0.0.0:8081 0.0.0.0:* users:(("node",pid=1234,fd=5))'
472
+ try {
473
+ const { stdout } = await execGuarded(`ss -tlnp 2>/dev/null`, 8000, {
474
+ maxBuffer: 16 * 1024 * 1024,
475
+ })
476
+ for (const line of stdout.split('\n')) {
477
+ if (!new RegExp(`:${port}\\s`).test(line)) continue
478
+ const pid = Number(line.match(/pid=(\d+)/)?.[1])
479
+ if (pid > 0) return pid
480
+ }
481
+ } catch {}
482
+
483
+ return null
484
+ }
485
+
486
+ // per-pid cwd cache. lsof is the only way to read another process's cwd on
487
+ // macOS and it is NOT cheap — measured at 3-8s for a single pid on a machine
488
+ // running a few dozen node processes — so caching the result until the pid
489
+ // goes away is what keeps the 5s tray scan viable.
490
+ const cwdByPid = new Map<number, string>()
491
+
492
+ /** resolve the current working directory of a pid. returns null for pid 0
493
+ * (unknown owner) and whenever neither mechanism below can see the process.
494
+ * async — sync exec blocked CrBrowserMain at ~600ms on macOS.
495
+ *
496
+ * `-b -w` skips the blocking stat calls lsof otherwise makes against every
497
+ * mount, which roughly halves it. the timeout is generous because the answer
498
+ * is cached per pid: too tight a limit silently returns null on exactly the
499
+ * busy machines where auto-attaching a project matters most. */
500
+ export async function resolveProcessCwd(pid: number): Promise<string | null> {
501
+ if (pid <= 0) return null
502
+ const cached = cwdByPid.get(pid)
503
+ if (cached) return cached
504
+
505
+ // linux publishes the cwd as a symlink, which costs one readlink and no
506
+ // subprocess at all. macOS has no /proc and has to pay for lsof below.
507
+ try {
508
+ const cwd = await readlink(`/proc/${pid}/cwd`)
509
+ if (cwd) {
510
+ cwdByPid.set(pid, cwd)
511
+ return cwd
512
+ }
513
+ } catch {}
514
+
515
+ try {
516
+ // -Fn → machine-readable, one field per line. lines look like:
517
+ // p<pid>
518
+ // fcwd
519
+ // n<absolute path>
520
+ const { stdout } = await execGuarded(
521
+ `lsof -b -w -p ${pid} -a -d cwd -Fn 2>/dev/null`,
522
+ 8000,
523
+ )
524
+ for (const line of stdout.split('\n')) {
525
+ if (line.startsWith('n') && line.length > 1) {
526
+ const cwd = line.slice(1).trim()
527
+ if (cwd) {
528
+ cwdByPid.set(pid, cwd)
529
+ return cwd
530
+ }
531
+ }
532
+ }
533
+ } catch {}
534
+ return null
535
+ }
536
+
537
+ // ── per-port probe orchestration ────────────────────────────────────────────
538
+
539
+ function makeResult(
540
+ hostname: string,
541
+ port: number,
542
+ framework: DiscoveredServer['framework'],
543
+ owner: Pick<DiscoveredServer, 'cwd' | 'pid'>,
544
+ ): DiscoveredServer {
545
+ return {
546
+ port,
547
+ framework,
548
+ bundleUrl: withRuntimeConfig(
549
+ port,
550
+ `${buildLoopbackProbeBaseUrl('http:', hostname, port)}/index.bundle?platform=ios&dev=true&hot=true&minify=false`,
551
+ owner.cwd,
552
+ ),
553
+ hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', hostname, port)}/hot`,
554
+ lastSeen: Date.now(),
555
+ ...owner,
556
+ }
557
+ }
558
+
559
+ function withRuntimeConfig(port: number, bundleUrl: string, projectCwd?: string): string {
560
+ let physicalProjectCwd: string | undefined
561
+ if (projectCwd) {
562
+ try {
563
+ physicalProjectCwd = realpathSync(resolve(projectCwd))
564
+ } catch {}
565
+ }
566
+ const knownApps = physicalProjectCwd
567
+ ? optionalDemoAppsSync().filter((app) => {
568
+ try {
569
+ return realpathSync(resolve(app.dir)) === physicalProjectCwd
570
+ } catch {
571
+ return false
572
+ }
573
+ })
574
+ : []
575
+ const exactKnownApps = knownApps.filter((app) => app.preferredPort === port)
576
+ const knownApp =
577
+ exactKnownApps.length === 1
578
+ ? exactKnownApps[0]
579
+ : exactKnownApps.length === 0 && knownApps.length === 1
580
+ ? knownApps[0]
581
+ : undefined
582
+ const configured = knownApp?.runtimeConfig
583
+ ? applyRNXConfigToUrl(bundleUrl, knownApp.runtimeConfig)
584
+ : bundleUrl
585
+ return normalizeNativeDevBundleUrl(configured)
586
+ }
587
+
588
+ function isDirectOneBundleUrl(bundleUrl: string): boolean {
589
+ return bundleUrl.includes('/node_modules/one/metro-entry.bundle')
590
+ }
591
+
592
+ function safeParseManifest(body: string): Record<string, unknown> | null {
593
+ try {
594
+ const parsed = JSON.parse(body) as Record<string, unknown>
595
+ return parsed && typeof parsed === 'object' ? parsed : null
596
+ } catch {
597
+ return null
598
+ }
599
+ }
600
+
601
+ // a manifest icon path ("./assets/app-icons/x.png") is relative to the project
602
+ // root. metro's asset route prefixes the whole relative path with /assets/; a
603
+ // statically-served project answers at the literal path. the manifest says
604
+ // nothing about which, so the two candidates are probed once per port and the
605
+ // winner cached. keyed by port and cleared with the rest of that port's state
606
+ // when the process disappears.
607
+ const localIconPathCache = new Map<number, string>()
608
+ const localIconProbeInFlight = new Set<number>()
609
+
610
+ function metroAssetPath(rawIcon: string): string {
611
+ return `/assets/${rawIcon.replace(/^\.?\//, '')}`
612
+ }
613
+
614
+ function staticAssetPath(rawIcon: string): string {
615
+ return `/${rawIcon.replace(/^\.?\//, '')}`
616
+ }
617
+
618
+ // probe both shapes and cache whichever the server actually serves. resolves
619
+ // nothing when neither answers — the tile falls back to the app's brand colour.
620
+ async function resolveLocalIconPath(
621
+ hostname: string,
622
+ port: number,
623
+ rawIcon: string,
624
+ ): Promise<void> {
625
+ const candidates = [metroAssetPath(rawIcon), staticAssetPath(rawIcon)]
626
+ for (const candidate of candidates) {
627
+ const res = await httpGet(hostname, port, candidate, 'HEAD', ENRICH_TIMEOUT_MS)
628
+ if (res?.statusCode === 200) {
629
+ localIconPathCache.set(port, candidate)
630
+ // the identified result caches as a strong entry and won't re-probe on
631
+ // its own; drop it so the next scan republishes with the working url.
632
+ portCache.delete(port)
633
+ return
634
+ }
635
+ }
636
+ }
637
+
638
+ // fold expo manifest fields (name, icon, bundleId, launchAsset) into a result.
639
+ // the manifest body is always the GET / response we already have from probePort,
640
+ // so this never issues another http request.
641
+ function applyManifest(
642
+ result: DiscoveredServer,
643
+ manifestRes: HttpResult | null,
644
+ buildIconProxyUrl?: (externalUrl: string) => string,
645
+ ): DiscoveredServer {
646
+ if (!manifestRes) return result
647
+ try {
648
+ const manifest = JSON.parse(manifestRes.body)
649
+ const client = manifest?.extra?.expoClient || manifest?.extra || {}
650
+ if (client.name) result.projectName = client.name
651
+ if (typeof client.primaryColor === 'string') result.primaryColor = client.primaryColor
652
+ if (client.ios?.bundleIdentifier) result.bundleId = client.ios.bundleIdentifier
653
+ if (result.framework === 'metro' && client.sdkVersion) result.framework = 'expo'
654
+ // preserve a confirmed direct one/vxrn metro-entry bundle. expo manifests
655
+ // and one/vxrn manifests point at a launchAsset URL with `hot=false`
656
+ // (hardcoded by @expo/cli — see metroOptions.ts). keep its path and query
657
+ // so metro shares the iOS bundle cache. when both hosts are loopback, keep
658
+ // the host that answered discovery so address-family drift stays reachable.
659
+ const rawLaunchUrl = manifest?.launchAsset?.url
660
+ const launchUrl =
661
+ typeof rawLaunchUrl === 'string'
662
+ ? alignLoopbackBundleUrlWithProbe(
663
+ rawLaunchUrl,
664
+ buildLoopbackProbeBaseUrl('http:', manifestRes.hostname, result.port),
665
+ )
666
+ : rawLaunchUrl
667
+ if (launchUrl && !result.patched && !isDirectOneBundleUrl(result.bundleUrl)) {
668
+ result.bundleUrl = withRuntimeConfig(result.port, launchUrl, result.cwd)
669
+ }
670
+ const localIcon = client.icon || client.ios?.icon
671
+ const remoteIcon = client.iconUrl || client.ios?.iconUrl
672
+ const rawIcon = localIcon || remoteIcon
673
+ if (rawIcon) {
674
+ result.iconPath = rawIcon
675
+ let resolvedIconUrl: string | undefined
676
+ if (rawIcon.startsWith('http://') || rawIcon.startsWith('https://')) {
677
+ let isLoopback = false
678
+ try {
679
+ const parsed = new URL(rawIcon)
680
+ isLoopback = isLoopbackHost(parsed.hostname)
681
+ } catch {}
682
+ if (buildIconProxyUrl) {
683
+ if (isLoopback) {
684
+ resolvedIconUrl = buildIconProxyUrl(rawIcon)
685
+ }
686
+ } else {
687
+ resolvedIconUrl = rawIcon
688
+ }
689
+ } else {
690
+ // a manifest icon path is relative to the project root, and the two
691
+ // server shapes we meet serve it at different URLs: metro's asset route
692
+ // is `/assets/` + that whole relative path (so `assets/x.png` lands at
693
+ // `/assets/assets/x.png`), while a statically-served project answers at
694
+ // the literal path. neither shape can be derived from the manifest, so
695
+ // resolveLocalIconPath probes the server once and caches the answer;
696
+ // until it lands we publish the metro form, which is the common case.
697
+ const base = buildLoopbackProbeBaseUrl('http:', manifestRes.hostname, result.port)
698
+ const assetPath = localIconPathCache.get(result.port) ?? metroAssetPath(rawIcon)
699
+ const localUrl = `${base}${assetPath}`
700
+ resolvedIconUrl = buildIconProxyUrl ? buildIconProxyUrl(localUrl) : localUrl
701
+ }
702
+ if (resolvedIconUrl) {
703
+ result.iconUrl = resolvedIconUrl
704
+ }
705
+ }
706
+ } catch {}
707
+ return result
708
+ }
709
+
710
+ export function __applyManifestForTests(
711
+ result: DiscoveredServer,
712
+ manifestBody: string,
713
+ buildIconProxyUrl?: (externalUrl: string) => string,
714
+ ): DiscoveredServer {
715
+ return applyManifest(
716
+ result,
717
+ {
718
+ statusCode: 200,
719
+ body: manifestBody,
720
+ hostname: new URL(result.bundleUrl).hostname,
721
+ },
722
+ buildIconProxyUrl,
723
+ )
724
+ }
725
+
726
+ // ── name/icon enrichment ────────────────────────────────────────────────────
727
+ // a server's display name + icon come ONLY from its expo manifest (GET / with
728
+ // expo-platform), which is far slower than the cheap signature probes: One apps
729
+ // serialize app.config per request, and some (expo-updates / asset resolution)
730
+ // block several seconds. the fast probe race times the manifest out at
731
+ // MANIFEST_TIMEOUT_MS, so a slow-manifest server is identified via /status or
732
+ // the One HEAD with no projectName and shows as ":<port>" in the dock — never
733
+ // re-probed, because that nameless result caches as a strong (expo/one) entry.
734
+ // bumping the race timeout isn't an option: it would stall every probe (and a
735
+ // non-bundler node process) for seconds. instead capture name/icon out-of-band:
736
+ // when a server is identified without them, fetch the manifest ONCE with a
737
+ // generous timeout, cache the fields per port, and drop the strong port-cache
738
+ // entry so the next scan re-probes and publishes the enriched server. discovery
739
+ // stays fast; name/icon fill in a scan or two after the app first appears.
740
+ const ENRICH_TIMEOUT_MS = 20_000
741
+ interface ManifestEnrichment {
742
+ projectName?: string
743
+ iconUrl?: string
744
+ iconPath?: string
745
+ bundleId?: string
746
+ }
747
+ const enrichmentCache = new Map<number, ManifestEnrichment>()
748
+ const enrichmentInFlight = new Set<number>()
749
+
750
+ // a result that carries a relative icon path has not had its serving shape
751
+ // confirmed yet. probe once per port; applyManifest reads the cached answer.
752
+ function ensureLocalIconPathResolved(result: DiscoveredServer): void {
753
+ const rawIcon = result.iconPath
754
+ if (!rawIcon) return
755
+ if (rawIcon.startsWith('http://') || rawIcon.startsWith('https://')) return
756
+ if (localIconPathCache.has(result.port)) return
757
+ if (localIconProbeInFlight.has(result.port)) return
758
+ localIconProbeInFlight.add(result.port)
759
+ const hostname = normalizeHostname(new URL(result.bundleUrl).hostname)
760
+ void resolveLocalIconPath(hostname, result.port, rawIcon).finally(() =>
761
+ localIconProbeInFlight.delete(result.port),
762
+ )
763
+ }
764
+
765
+ // apply already-captured name/icon, or kick off a single background manifest
766
+ // fetch to capture them. a no-op once the in-race manifest already named the
767
+ // server, so it's safe to wrap every identified return.
768
+ function enrichWithManifest(
769
+ result: DiscoveredServer,
770
+ buildIconProxyUrl?: (externalUrl: string) => string,
771
+ ): DiscoveredServer {
772
+ ensureLocalIconPathResolved(result)
773
+ if (result.projectName) return result
774
+
775
+ const cached = enrichmentCache.get(result.port)
776
+ if (cached) {
777
+ if (cached.projectName) result.projectName = cached.projectName
778
+ if (cached.iconUrl && !result.iconUrl) {
779
+ result.iconUrl = cached.iconUrl
780
+ result.iconPath = cached.iconPath
781
+ }
782
+ if (cached.bundleId && !result.bundleId) result.bundleId = cached.bundleId
783
+ return result
784
+ }
785
+
786
+ if (!enrichmentInFlight.has(result.port)) {
787
+ enrichmentInFlight.add(result.port)
788
+ void fetchManifestEnrichment(result, buildIconProxyUrl).finally(() =>
789
+ enrichmentInFlight.delete(result.port),
790
+ )
791
+ }
792
+ return result
793
+ }
794
+
795
+ async function fetchManifestEnrichment(
796
+ result: DiscoveredServer,
797
+ buildIconProxyUrl?: (externalUrl: string) => string,
798
+ ): Promise<void> {
799
+ const hostname = normalizeHostname(new URL(result.bundleUrl).hostname)
800
+ const manifestRes = await httpGet(
801
+ hostname,
802
+ result.port,
803
+ '/',
804
+ 'GET',
805
+ ENRICH_TIMEOUT_MS,
806
+ {
807
+ 'expo-platform': 'ios',
808
+ },
809
+ )
810
+ if (!manifestRes || manifestRes.statusCode !== 200) return
811
+ // reuse applyManifest's exact field extraction against a throwaway result.
812
+ const parsed = applyManifest(
813
+ {
814
+ port: result.port,
815
+ framework: 'one',
816
+ bundleUrl: `${buildLoopbackProbeBaseUrl('http:', hostname, result.port)}/index.bundle`,
817
+ lastSeen: 0,
818
+ },
819
+ manifestRes,
820
+ buildIconProxyUrl,
821
+ )
822
+ if (!parsed.projectName && !parsed.iconUrl) return
823
+ enrichmentCache.set(result.port, {
824
+ projectName: parsed.projectName,
825
+ iconUrl: parsed.iconUrl,
826
+ iconPath: parsed.iconPath,
827
+ bundleId: parsed.bundleId,
828
+ })
829
+ // the nameless result cached as a strong entry and won't re-probe on its own;
830
+ // drop it so the next scan re-runs probePort and publishes the enriched name.
831
+ portCache.delete(result.port)
832
+ }
833
+
834
+ // ports confirmed to be running a non-sootsim dev server — safe to skip the
835
+ // /__soot/ probe on subsequent scans. cleared when the port disappears from the
836
+ // listening set (process restarted, could now be a different server).
837
+ const knownNonPatched = new Set<number>()
838
+
839
+ // ports confirmed to not answer /_expo/status as an expo packager — safe to
840
+ // skip that probe on subsequent scans. same invalidation rule as
841
+ // knownNonPatched: cleared when the port's owning pid changes.
842
+ const knownNonExpo = new Set<number>()
843
+
844
+ // ports confirmed to be a one/vxrn dev server. on these, we skip the manifest
845
+ // probe (`GET /` with expo-platform headers) on subsequent scans: one already
846
+ // served the bundle HEAD so we know what it is, AND one's Expo Go manifest
847
+ // middleware crashes the whole server when a probe aborts mid-stream
848
+ // (Cannot pipe to a closed or destroyed stream → unhandled rejection → exit).
849
+ // the bundle URL is enough; no information is lost by skipping the manifest.
850
+ // same invalidation rules as knownNonPatched/knownNonExpo: cleared on port
851
+ // disappearance, owning-pid change, and __resetScannerCache.
852
+ const knownOne = new Set<number>()
853
+
854
+ // fire signature probes in waves and pick the best match by precedence:
855
+ // 1. expo manifest (JSON manifest at /)
856
+ // 2. metro/expo (packager-status:running on /status)
857
+ // 3. sootsim-patched (/__soot/)
858
+ // 4. one/vxrn (HEAD on /node_modules/one/metro-entry.bundle) — only
859
+ // fires as a true last resort; on expo apps this probe
860
+ // crashes metro by triggering HmrServer.registerEntryPoint
861
+ // for a path metro can't resolve, so we never fire it
862
+ // when one of the higher-precedence probes already
863
+ // identified the server.
864
+ // cheapest check first: a TCP connect gate short-circuits all HTTP probes when
865
+ // the port isn't actually reachable (zombie listener, non-http protocol,
866
+ // process mid-shutdown). after that, racing the safe probes is fast —
867
+ // localhost refuses/closes connections quickly, and Promise.all finishes at
868
+ // the slowest single probe rather than the sum.
869
+ export async function probePort(
870
+ port: number,
871
+ buildIconProxyUrl?: (externalUrl: string) => string,
872
+ listeningProcess?: ListeningProcess,
873
+ ): Promise<DiscoveredServer | null> {
874
+ // callers such as `rnx open <port>` probe one port directly instead of
875
+ // going through scanDevServers. wait for the optional in-repo registry here
876
+ // too, or a cold CLI process can build the known app's bundle url before its
877
+ // runtime config is available.
878
+ await loadOptionalDemoApps()
879
+ const probeHostname = await tcpPing(port)
880
+ if (!probeHostname) return null
881
+
882
+ const suppliedPid = listeningProcess?.pid
883
+ const ownerPid =
884
+ suppliedPid && suppliedPid > 0 ? suppliedPid : await resolveListeningPid(port)
885
+ const ownerCwd = ownerPid ? await resolveProcessCwd(ownerPid) : null
886
+ const owner: Pick<DiscoveredServer, 'cwd' | 'pid'> = {
887
+ ...(ownerPid ? { pid: ownerPid } : {}),
888
+ ...(ownerCwd ? { cwd: ownerCwd } : {}),
889
+ }
890
+
891
+ const onePath = `/node_modules/one/metro-entry.bundle?platform=ios&dev=true`
892
+
893
+ // wave 1 — safe parallel probes. none of these mutate metro state.
894
+ const [sootsimRes, statusRes, manifestRes, expoRes] = await Promise.all([
895
+ knownNonPatched.has(port)
896
+ ? Promise.resolve(null)
897
+ : httpGet(probeHostname, port, '/__soot/'),
898
+ httpGet(probeHostname, port, '/status'),
899
+ knownOne.has(port)
900
+ ? Promise.resolve(null)
901
+ : httpGet(probeHostname, port, '/', 'GET', MANIFEST_TIMEOUT_MS, {
902
+ 'expo-platform': 'ios',
903
+ }),
904
+ knownNonExpo.has(port)
905
+ ? Promise.resolve(null)
906
+ : httpGet(probeHostname, port, '/_expo/status'),
907
+ ])
908
+
909
+ // remember negative /_expo/status responses so we don't keep probing this
910
+ // endpoint on ports that clearly aren't expo packagers (one/vxrn, vite,
911
+ // random node processes). a 200 flips the port out of the non-expo set.
912
+ if (expoRes && expoRes.statusCode === 200) {
913
+ knownNonExpo.delete(port)
914
+ } else if (!knownNonExpo.has(port)) {
915
+ knownNonExpo.add(port)
916
+ }
917
+
918
+ // expo manifest (One framework + expo apps both serve this at `/` with the
919
+ // expo-platform header). when present, `launchAsset.url` is the canonical
920
+ // bundle URL the app actually serves — for One projects it's the
921
+ // monorepo-aware path (e.g. /apps/one/node_modules/one/metro-entry.bundle
922
+ // with full hermes transform params), and for Expo it's /index.bundle with
923
+ // the right transforms. either way it's strictly better than the bare
924
+ // /node_modules/one/metro-entry.bundle constant we used as a discriminator
925
+ // below, so we check the manifest FIRST.
926
+ const manifestParsed = manifestRes
927
+ ? (safeParseManifest(manifestRes.body) as {
928
+ launchAsset?: { url?: unknown }
929
+ extra?: { expoClient?: { name?: unknown }; name?: unknown }
930
+ } | null)
931
+ : null
932
+ const manifestLaunchUrl =
933
+ typeof manifestParsed?.launchAsset?.url === 'string'
934
+ ? manifestParsed.launchAsset.url
935
+ : null
936
+ const manifestClient =
937
+ (manifestParsed?.extra?.expoClient as { name?: unknown } | undefined) ||
938
+ (manifestParsed?.extra as { name?: unknown } | undefined) ||
939
+ {}
940
+ // `manifestRes === null` means the probe got NO answer — it timed out or the
941
+ // connection failed. that is not the same fact as a server that answered and
942
+ // turned out to have no manifest, and only the second one licenses the
943
+ // generic `/index.bundle` entry point below.
944
+ const manifestState: ManifestState = !manifestRes
945
+ ? 'no-answer'
946
+ : manifestParsed && (manifestLaunchUrl || typeof manifestClient.name === 'string')
947
+ ? 'resolved'
948
+ : 'no-manifest'
949
+ if (manifestState === 'resolved') {
950
+ knownNonPatched.add(port)
951
+ const launchUrl =
952
+ manifestLaunchUrl ||
953
+ `${buildLoopbackProbeBaseUrl('http:', probeHostname, port)}/index.bundle?platform=ios&dev=true&hot=true&minify=false`
954
+ // call the framework "one" only when launchAsset clearly points at
955
+ // a One metro entry; otherwise treat it as plain expo so downstream
956
+ // bundle handling uses the right path semantics.
957
+ const framework: DiscoveredServer['framework'] = launchUrl.includes(
958
+ '/one/metro-entry.bundle',
959
+ )
960
+ ? 'one'
961
+ : 'expo'
962
+ return applyManifest(
963
+ {
964
+ port,
965
+ framework,
966
+ bundleUrl: withRuntimeConfig(port, launchUrl, owner.cwd),
967
+ hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', probeHostname, port)}/hot`,
968
+ lastSeen: Date.now(),
969
+ ...owner,
970
+ },
971
+ manifestRes,
972
+ buildIconProxyUrl,
973
+ )
974
+ }
975
+
976
+ // metro/expo — packager-status:running on /status. checked BEFORE the one
977
+ // HEAD because the HEAD has a known side-effect: it triggers metro's
978
+ // HmrServer.registerEntryPoint for `/node_modules/one/metro-entry.bundle`,
979
+ // and on metro instances where that path doesn't resolve (i.e. any plain
980
+ // expo app) metro crashes with "UnableToResolveError" and exits. by
981
+ // identifying expo/metro instances here from their /status response we
982
+ // never fire the HEAD against them.
983
+ if (statusRes && statusRes.body.includes('packager-status:running')) {
984
+ knownNonPatched.add(port)
985
+ // `makeResult` builds the generic `<base>/index.bundle` entry point. that is
986
+ // the RIGHT answer for a vanilla Metro or plain Expo packager, which is a
987
+ // server that ANSWERED `/` and had no manifest to give. it is a guess when
988
+ // the manifest simply never answered: this server may well be a One app
989
+ // whose manifest names `node_modules/one/metro-entry.bundle`, and opening
990
+ // the generic path instead makes metro build the whole app a second time
991
+ // under a module graph the app never named. flag it so `open` refuses to
992
+ // act on it, while the dock still lists the server it can plainly see.
993
+ const result = makeResult(
994
+ probeHostname,
995
+ port,
996
+ expoRes && expoRes.statusCode === 200 ? 'expo' : 'metro',
997
+ owner,
998
+ )
999
+ if (manifestState === 'no-answer') result.bundleUrlProvisional = true
1000
+ return enrichWithManifest(
1001
+ applyManifest(result, manifestRes, buildIconProxyUrl),
1002
+ buildIconProxyUrl,
1003
+ )
1004
+ }
1005
+
1006
+ // patched sootsim — fallback for legacy /__soot/ patched servers. also
1007
+ // checked before the one HEAD for the same reason.
1008
+ if (
1009
+ sootsimRes &&
1010
+ sootsimRes.statusCode === 200 &&
1011
+ sootsimRes.body.includes('sootsim-patched')
1012
+ ) {
1013
+ knownNonPatched.delete(port)
1014
+ return enrichWithManifest(
1015
+ applyManifest(
1016
+ {
1017
+ port,
1018
+ framework: 'one',
1019
+ bundleUrl: withRuntimeConfig(
1020
+ port,
1021
+ `${buildLoopbackProbeBaseUrl('http:', probeHostname, port)}/__soot/bundle.js`,
1022
+ owner.cwd,
1023
+ ),
1024
+ hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', probeHostname, port)}/hot`,
1025
+ lastSeen: Date.now(),
1026
+ patched: true,
1027
+ ...owner,
1028
+ },
1029
+ manifestRes,
1030
+ buildIconProxyUrl,
1031
+ ),
1032
+ buildIconProxyUrl,
1033
+ )
1034
+ }
1035
+
1036
+ // wave 2 — one/vxrn HEAD probe. only fired as a true last resort, since
1037
+ // it has the metro side-effect documented above. older One bare-dev-server
1038
+ // setups that don't serve a manifest still resolve through this path; new
1039
+ // expo/metro/one setups are caught above and never reach here.
1040
+ //
1041
+ // hard safety gate (the recurring "metro on 8081 crashes" regression): a
1042
+ // plain expo/metro packager that is still BOOTING serves `/status`,
1043
+ // `/_expo/status`, and the `/` manifest a few seconds apart, so during the
1044
+ // boot window all three wave-1 catches above can miss (status body not yet
1045
+ // `packager-status:running`, manifest not yet parseable). without this gate
1046
+ // we then fire the lethal HEAD against a metro that IS a metro — and the
1047
+ // engine, handed a bogus `one/metro-entry.bundle` URL, opens an HMR socket
1048
+ // whose `register-entrypoints` throws UnableToResolveError inside
1049
+ // HmrServer._registerEntryPoint → unhandledRejection → metro exits. a real
1050
+ // legacy bare-One dev server answers NONE of these metro/expo endpoints, so
1051
+ // skipping the HEAD here never hides a genuine One server — it just defers
1052
+ // this port to the next scan, by which point wave 1 identifies it cleanly.
1053
+ const looksLikeMetroPackager =
1054
+ (statusRes && statusRes.statusCode === 200) ||
1055
+ (expoRes && expoRes.statusCode === 200) ||
1056
+ (manifestRes && manifestRes.statusCode === 200 && manifestParsed != null)
1057
+ if (looksLikeMetroPackager) {
1058
+ // do NOT cache as knownNonPatched — we want a fresh wave-1 pass next scan
1059
+ // once the packager finishes booting and its manifest/status are ready.
1060
+ return null
1061
+ }
1062
+
1063
+ const oneRes = await httpGet(probeHostname, port, onePath, 'HEAD')
1064
+ if (
1065
+ oneRes &&
1066
+ oneRes.statusCode > 0 &&
1067
+ oneRes.statusCode < 400 &&
1068
+ /application\/javascript/i.test(oneRes.contentType || '')
1069
+ ) {
1070
+ knownNonPatched.add(port)
1071
+ knownOne.add(port)
1072
+ return enrichWithManifest(
1073
+ applyManifest(
1074
+ {
1075
+ port,
1076
+ framework: 'one',
1077
+ bundleUrl: withRuntimeConfig(
1078
+ port,
1079
+ `${buildLoopbackProbeBaseUrl('http:', probeHostname, port)}${onePath}&minify=false`,
1080
+ owner.cwd,
1081
+ ),
1082
+ hmrUrl: `${buildLoopbackProbeBaseUrl('ws:', probeHostname, port)}/hot`,
1083
+ lastSeen: Date.now(),
1084
+ ...owner,
1085
+ },
1086
+ manifestRes,
1087
+ buildIconProxyUrl,
1088
+ ),
1089
+ buildIconProxyUrl,
1090
+ )
1091
+ }
1092
+
1093
+ knownNonPatched.add(port)
1094
+ return null
1095
+ }
1096
+
1097
+ // ── top-level convenience ───────────────────────────────────────────────────
1098
+
1099
+ export interface ScanOptions {
1100
+ excludePorts?: number[]
1101
+ buildIconProxyUrl?: (externalUrl: string) => string
1102
+ }
1103
+
1104
+ function isSootSimSelfServer(server: DiscoveredServer): boolean {
1105
+ const bundleId = server.bundleId?.trim().toLowerCase()
1106
+ if (bundleId?.startsWith('dev.sootsim.')) return true
1107
+
1108
+ return false
1109
+ }
1110
+
1111
+ export const __isSootSimSelfServerForTests = isSootSimSelfServer
1112
+
1113
+ // per-(port, pid) probe cache. as long as the same process still owns the
1114
+ // port, its signature endpoints can't have changed, so we return the cached
1115
+ // result without issuing any HTTP. negative results (null) are cached too,
1116
+ // which prevents repeat-probing node/bun processes that aren't dev servers.
1117
+ // invalidation is cheap: we drop any entry whose port dropped out of lsof,
1118
+ // and any entry whose pid changed (process restart).
1119
+ interface PortCacheEntry {
1120
+ pid: number
1121
+ result: DiscoveredServer | null
1122
+ cachedAt: number
1123
+ }
1124
+ const portCache = new Map<number, PortCacheEntry>()
1125
+
1126
+ // negative-cache holds a node/bun port that isn't a bundler (e.g. Contrast's own
1127
+ // api server, Zero, SQLite sync host, a dev tool). at scan interval 3s and a 1.5s ttl
1128
+ // we used to re-probe every scan, which hits `/` on the port every 3s — that
1129
+ // waking Contrast's SSR router ~20x/min was a real memory-leak driver. 30s gives
1130
+ // the scanner a much lighter touch on unrelated node processes while still
1131
+ // catching pid-change invalidation immediately when a process restarts.
1132
+ const NEGATIVE_CACHE_TTL_MS = 30_000
1133
+ const WEAK_RESULT_CACHE_TTL_MS = 1_500
1134
+
1135
+ function isWeakCachedResult(result: DiscoveredServer | null): boolean {
1136
+ if (!result) return true
1137
+ if (result.framework === 'metro' || result.framework === 'unknown') return true
1138
+ return false
1139
+ }
1140
+
1141
+ function hasCurrentRuntimeConfig(result: DiscoveredServer | null): boolean {
1142
+ if (!result) return true
1143
+ return withRuntimeConfig(result.port, result.bundleUrl, result.cwd) === result.bundleUrl
1144
+ }
1145
+
1146
+ export function __shouldReuseScannerCacheEntry(
1147
+ entry: { pid: number; result: DiscoveredServer | null; cachedAt: number },
1148
+ pid: number,
1149
+ now = Date.now(),
1150
+ ): boolean {
1151
+ if (pid === 0) return false
1152
+ if (entry.pid !== pid) return false
1153
+ if (!hasCurrentRuntimeConfig(entry.result)) return false
1154
+
1155
+ const ageMs = now - entry.cachedAt
1156
+ if (entry.result === null && ageMs >= NEGATIVE_CACHE_TTL_MS) return false
1157
+ if (isWeakCachedResult(entry.result) && ageMs >= WEAK_RESULT_CACHE_TTL_MS) return false
1158
+ return true
1159
+ }
1160
+
1161
+ // exported for tests / `rnx debug` — forces the next scan to re-probe.
1162
+ export function __resetScannerCache() {
1163
+ portCache.clear()
1164
+ knownNonPatched.clear()
1165
+ knownNonExpo.clear()
1166
+ knownOne.clear()
1167
+ enrichmentCache.clear()
1168
+ enrichmentInFlight.clear()
1169
+ __resetAppSettingsBundleCacheForTests()
1170
+ }
1171
+
1172
+ async function attachAppSettingsBundle(
1173
+ result: DiscoveredServer,
1174
+ projectRoot: string,
1175
+ ): Promise<void> {
1176
+ const settingsBundle = await readAppSettingsBundle(projectRoot)
1177
+ if (settingsBundle) {
1178
+ result.settingsBundle = settingsBundle
1179
+ result.settingsStorageScopeUrl = result.bundleUrl
1180
+ } else {
1181
+ delete result.settingsBundle
1182
+ delete result.settingsStorageScopeUrl
1183
+ }
1184
+ }
1185
+
1186
+ export const __attachAppSettingsBundleForTests = attachAppSettingsBundle
1187
+
1188
+ export async function scanDevServers(
1189
+ opts: ScanOptions = {},
1190
+ ): Promise<DiscoveredServer[]> {
1191
+ // optionalDemoAppsSync() returns [] until the dynamic import resolves, and
1192
+ // an empty registry is indistinguishable from a published install. await it
1193
+ // before cache validation so a known app cannot lose its runtimeConfig when
1194
+ // disk contention delays the import. memoized, so this is free after the
1195
+ // first call.
1196
+ await loadOptionalDemoApps()
1197
+ const scan = await scanListeningProcesses(opts.excludePorts)
1198
+ // we could not enumerate listeners, so we know nothing about what is running.
1199
+ // returning [] here would evict every cache entry and report "no dev servers"
1200
+ // to a user who has several — the caller must be able to tell the two apart.
1201
+ if (scan.state === 'could-not-look') throw new DevServerScanUnavailableError()
1202
+ const processes = scan.processes
1203
+ const currentPorts = new Set(processes.map((p) => p.port))
1204
+
1205
+ // evict cache entries for ports that disappeared. a restarted process may
1206
+ // bring up a different server (possibly sootsim-patched) on the same port,
1207
+ // and both caches need to let go so the next probe sees the new state.
1208
+ for (const p of [...portCache.keys()]) {
1209
+ if (!currentPorts.has(p)) portCache.delete(p)
1210
+ }
1211
+ for (const p of [...knownNonPatched]) {
1212
+ if (!currentPorts.has(p)) knownNonPatched.delete(p)
1213
+ }
1214
+ for (const p of [...knownNonExpo]) {
1215
+ if (!currentPorts.has(p)) knownNonExpo.delete(p)
1216
+ }
1217
+ for (const p of [...knownOne]) {
1218
+ if (!currentPorts.has(p)) knownOne.delete(p)
1219
+ }
1220
+ for (const p of [...enrichmentCache.keys()]) {
1221
+ if (!currentPorts.has(p)) enrichmentCache.delete(p)
1222
+ }
1223
+ for (const p of [...localIconPathCache.keys()]) {
1224
+ if (!currentPorts.has(p)) localIconPathCache.delete(p)
1225
+ }
1226
+
1227
+ const results: DiscoveredServer[] = []
1228
+ const toProbe: ListeningProcess[] = []
1229
+
1230
+ for (const { port, pid } of processes) {
1231
+ const cached = portCache.get(port)
1232
+ // pid 0 means the platform couldn't identify the owner (fallback path),
1233
+ // so we can't trust the cache — reprobe.
1234
+ if (cached && __shouldReuseScannerCacheEntry(cached, pid)) {
1235
+ if (cached.result) results.push(cached.result)
1236
+ continue
1237
+ }
1238
+ // pid changed or cache expired — drop any endpoint-specific verdicts so
1239
+ // the re-probe starts from scratch. otherwise a restarted process that
1240
+ // swapped frameworks keeps the old skip-/__soot/ or skip-/_expo/status
1241
+ // decisions, and the scanner never discovers the new server.
1242
+ if (cached && cached.pid !== pid) {
1243
+ knownNonPatched.delete(port)
1244
+ knownNonExpo.delete(port)
1245
+ knownOne.delete(port)
1246
+ // a restarted process may be a different app — drop its captured name/icon.
1247
+ enrichmentCache.delete(port)
1248
+ localIconPathCache.delete(port)
1249
+ }
1250
+ toProbe.push({ port, pid })
1251
+ }
1252
+
1253
+ if (toProbe.length > 0) {
1254
+ const probed = await Promise.all(
1255
+ toProbe.map((p) => probePort(p.port, opts.buildIconProxyUrl, p)),
1256
+ )
1257
+ probed.forEach((result, i) => {
1258
+ const { port, pid } = toProbe[i]
1259
+ // only cache when we have a real pid to key the entry against.
1260
+ // fallback-list ports (pid 0) always reprobe so we catch state changes.
1261
+ if (pid !== 0) portCache.set(port, { pid, result, cachedAt: Date.now() })
1262
+ if (result) results.push(result)
1263
+ })
1264
+ }
1265
+
1266
+ // attach pid + cwd. pid comes from the listening-process scan; cwd is
1267
+ // lsof-resolved and cached per pid. both enable auto-attach of agent
1268
+ // sessions to the right project without a manual `sootsim agent attach`.
1269
+ const pidByPort = new Map<number, number>()
1270
+ for (const { port, pid } of processes) {
1271
+ if (pid > 0) pidByPort.set(port, pid)
1272
+ }
1273
+ await Promise.all(
1274
+ results.map(async (result) => {
1275
+ const pid = pidByPort.get(result.port)
1276
+ if (!pid) return
1277
+ result.pid = pid
1278
+ const cwd = await resolveProcessCwd(pid)
1279
+ if (!cwd) return
1280
+ result.cwd = cwd
1281
+ await attachAppSettingsBundle(result, cwd)
1282
+ }),
1283
+ )
1284
+ // evict stale cwd cache entries for pids no longer listening
1285
+ const livePids = new Set(pidByPort.values())
1286
+ for (const pid of [...cwdByPid.keys()]) {
1287
+ if (!livePids.has(pid)) cwdByPid.delete(pid)
1288
+ }
1289
+
1290
+ return results.filter((r) => !isSootSimSelfServer(r))
1291
+ }