sootsim 0.1.83 → 0.1.85

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 (209) hide show
  1. package/README.md +0 -1
  2. package/detox/colors.ts +54 -0
  3. package/detox/config-loader.ts +135 -0
  4. package/detox/element-types.ts +36 -0
  5. package/detox/expectations.ts +477 -0
  6. package/detox/gestures.ts +442 -0
  7. package/detox/index.ts +1403 -0
  8. package/detox/jest-environment.ts +86 -0
  9. package/detox/jest-preset.cjs +50 -0
  10. package/detox/matchers.ts +29 -0
  11. package/detox/navigation.ts +43 -0
  12. package/detox/run-test.ts +113 -0
  13. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  14. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  15. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  16. package/detox/screenshots/theme-blue-toggle.png +0 -0
  17. package/detox/screenshots/theme-blue.png +0 -0
  18. package/detox/screenshots/theme-red-toggle.png +0 -0
  19. package/detox/screenshots/theme-red.png +0 -0
  20. package/dist-cli/bin.js +3 -3
  21. package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-T3DUH5YJ.js} +2 -2
  22. package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-NSBF4THI.js} +2 -2
  23. package/dist-cli/chunks/{assert-TV46GUNU.js → assert-X3F7TRCZ.js} +2 -2
  24. package/dist-cli/chunks/auto-bootstrap-47RN2V5G.js +2 -0
  25. package/dist-cli/chunks/beta-BRCGAF2N.js +2 -0
  26. package/dist-cli/chunks/chunk-36RPD6JI.js +2 -0
  27. package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-3WGHC7JN.js} +2 -2
  28. package/dist-cli/chunks/chunk-4DBPNLGI.js +1 -0
  29. package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-4EVSIUNB.js} +2 -2
  30. package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-4QZHZ6BC.js} +2 -2
  31. package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-5DIGWOY7.js} +1 -1
  32. package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-5N3V7OCG.js} +2 -2
  33. package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-5S6D7K4L.js} +2 -2
  34. package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-7LKUN46F.js} +2 -2
  35. package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-AC6QGW22.js} +2 -2
  36. package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-AFNDVS4E.js} +2 -2
  37. package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-BESAZ2HA.js} +2 -2
  38. package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-BHZJ6RIH.js} +2 -2
  39. package/dist-cli/chunks/{chunk-277XAALA.js → chunk-BZL6D4TV.js} +3 -3
  40. package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-CF2LPRXD.js} +2 -2
  41. package/dist-cli/chunks/chunk-DWTLRPEN.js +79 -0
  42. package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-E2QE5FFP.js} +1 -1
  43. package/dist-cli/chunks/chunk-EBEL6TTJ.js +4 -0
  44. package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-EFM53PZ5.js} +1 -1
  45. package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-EKXK3SWK.js} +2 -2
  46. package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-G7CIZ5S3.js} +3 -3
  47. package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-GTAD6IUV.js} +1 -1
  48. package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-H44IQHKZ.js} +1 -1
  49. package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-HQDJ5BOF.js} +1 -1
  50. package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-KUSQ4NNJ.js} +1 -1
  51. package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-MAO7F5PH.js} +3 -3
  52. package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-NVTL3JQG.js} +1 -1
  53. package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-O6N2CEET.js} +2 -2
  54. package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-OISHLFON.js} +1 -1
  55. package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-OUNLJM56.js} +2 -2
  56. package/dist-cli/chunks/chunk-OXOARRKR.js +67 -0
  57. package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-PHPXGLME.js} +1 -1
  58. package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PQFFUJR6.js} +2 -2
  59. package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-QLJNSOS7.js} +1 -1
  60. package/dist-cli/chunks/chunk-QQAECG5B.js +2 -0
  61. package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-RZHREO3M.js} +2 -2
  62. package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-SBGOUA6F.js} +2 -2
  63. package/dist-cli/chunks/chunk-SSCA2AEA.js +1 -0
  64. package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-UYRGCJ4N.js} +1 -1
  65. package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-WGDL5V6C.js} +1 -1
  66. package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-Y5PLPEEU.js} +2 -2
  67. package/dist-cli/chunks/chunk-ZFAM4N5B.js +1 -0
  68. package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-ZO3VHP6W.js} +1 -1
  69. package/dist-cli/chunks/cli-version-WPFDM2A6.js +2 -0
  70. package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-PCXGGZBZ.js} +3 -3
  71. package/dist-cli/chunks/{config-2DSLDCXV.js → config-LULEVEYL.js} +2 -2
  72. package/dist-cli/chunks/control-6P6HY7UF.js +2 -0
  73. package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-NOK73ZYW.js} +2 -2
  74. package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-4A3DMUYL.js} +2 -2
  75. package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-74BWB2ZG.js} +3 -3
  76. package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-HEOMINSC.js} +2 -2
  77. package/dist-cli/chunks/{device-AYKXKVIQ.js → device-TTXXBJFZ.js} +2 -2
  78. package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-QZ3GOHSE.js} +2 -2
  79. package/dist-cli/chunks/drivers-QRPWNOIT.js +2 -0
  80. package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-QVOWV44R.js} +3 -3
  81. package/dist-cli/chunks/flow-QMA7GVN6.js +2 -0
  82. package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-YKWRNMJC.js} +2 -2
  83. package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-SFADSTJM.js} +2 -2
  84. package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-LEWGQCIU.js} +3 -3
  85. package/dist-cli/chunks/install-7N2N7Q32.js +2 -0
  86. package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-22HYQZ2G.js} +3 -3
  87. package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-3ZT3MICU.js} +2 -2
  88. package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-ZXW2NFLG.js} +3 -3
  89. package/dist-cli/chunks/{login-EACQXE24.js → login-NJKJ7GZO.js} +4 -4
  90. package/dist-cli/chunks/{logout-IBQLMUML.js → logout-VMMQL7CB.js} +2 -2
  91. package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-OJY4MTI7.js} +2 -2
  92. package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-QU2GXTEV.js} +2 -2
  93. package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-7APWK47T.js} +2 -2
  94. package/dist-cli/chunks/{react-QDHLMVYL.js → react-RSVO5JZZ.js} +2 -2
  95. package/dist-cli/chunks/{record-BUEUWPDI.js → record-UWH4MDEO.js} +2 -2
  96. package/dist-cli/chunks/runtime-3FUENRHM.js +2 -0
  97. package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-QMKGRV7N.js} +2 -2
  98. package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-43M27ALE.js} +2 -2
  99. package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-EBYYN6TY.js} +2 -2
  100. package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-7TQZL6Z6.js} +2 -2
  101. package/dist-cli/chunks/{server-MFFVYUGG.js → server-VCFM25Z6.js} +2 -2
  102. package/dist-cli/chunks/setup-repo-HFH4VKJQ.js +2 -0
  103. package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-RQA6EJQL.js} +2 -2
  104. package/dist-cli/chunks/{start-E3DRYY7W.js → start-ZT6MBYND.js} +4 -4
  105. package/dist-cli/chunks/store-BJBTDSZE.js +2 -0
  106. package/dist-cli/chunks/telemetry-ZZZKTILZ.js +2 -0
  107. package/dist-cli/chunks/{test-ZY3EF62K.js → test-RNRX5SWV.js} +3 -3
  108. package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-TQZH25ZO.js} +2 -2
  109. package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-GGN3AY6P.js} +2 -2
  110. package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-XT22D67C.js} +2 -2
  111. package/dist-cli/chunks/upload-NC2AYLC5.js +2 -0
  112. package/dist-cli/chunks/{web-D2AOZY44.js → web-KEHVF5MB.js} +2 -2
  113. package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PATQRJ5T.js} +2 -2
  114. package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-CXVY26VV.js} +2 -2
  115. package/dist-lib/agent-daemon-client.cjs +1 -1
  116. package/dist-lib/agent-events.cjs +1 -1
  117. package/dist-lib/agent-sessions.cjs +1 -1
  118. package/dist-lib/attached-projects.cjs +1 -1
  119. package/dist-lib/auth/shared-session.cjs +1 -1
  120. package/dist-lib/backend-origin.cjs +1 -1
  121. package/dist-lib/beta.cjs +44 -0
  122. package/dist-lib/bridge-constants.cjs +1 -1
  123. package/dist-lib/cli-constants.cjs +1 -1
  124. package/dist-lib/config.cjs +1 -1
  125. package/dist-lib/detox/index.cjs +1770 -0
  126. package/dist-lib/detox/jest-preset.cjs +50 -0
  127. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  128. package/dist-lib/home-paths.cjs +1 -1
  129. package/dist-lib/host/bridge-host.cjs +1 -1
  130. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  131. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  132. package/dist-lib/index.cjs +136 -138
  133. package/dist-lib/metro.cjs +31 -26
  134. package/dist-lib/profiles.cjs +1 -1
  135. package/dist-lib/render-mode.cjs +1 -1
  136. package/dist-lib/scripts/demo-app-registry.cjs +809 -0
  137. package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
  138. package/dist-lib/skills.cjs +17766 -0
  139. package/dist-lib/vite.cjs +129 -39
  140. package/package.json +39 -14
  141. package/scripts/demo-app-registry.ts +989 -0
  142. package/scripts/dev-server-scanner.ts +674 -0
  143. package/src/agent-daemon-client.ts +390 -0
  144. package/src/agent-events.ts +71 -0
  145. package/src/agent-prompt.ts +71 -0
  146. package/src/agent-sessions.ts +572 -0
  147. package/src/attached-projects.ts +536 -0
  148. package/src/auth/shared-session.ts +199 -0
  149. package/src/backend-origin.ts +49 -0
  150. package/src/beta.ts +21 -0
  151. package/src/bridge-constants.ts +10 -0
  152. package/src/cli-constants.ts +1 -0
  153. package/src/cli-version.ts +30 -0
  154. package/src/codex-client.ts +215 -0
  155. package/src/config.ts +110 -0
  156. package/src/dev-bundle-resolution.ts +180 -0
  157. package/src/home-paths.ts +382 -0
  158. package/src/host/agent-host.ts +576 -0
  159. package/src/host/bridge-host.ts +2293 -0
  160. package/src/host/fetch-proxy-handler.ts +288 -0
  161. package/src/host/fetch-proxy-overrides.ts +39 -0
  162. package/src/host/open-url.ts +234 -0
  163. package/src/index.ts +9 -0
  164. package/src/metro-plugin.ts +139 -0
  165. package/src/native-dev-bundle-url.ts +62 -0
  166. package/src/native-seam-manifest.ts +313 -0
  167. package/src/profiles.ts +179 -0
  168. package/src/render-mode.ts +27 -0
  169. package/src/runtime-assets.ts +84 -0
  170. package/src/runtime-delivery.ts +334 -0
  171. package/src/screenshots/compose.ts +422 -0
  172. package/src/screenshots/frame-compose.ts +438 -0
  173. package/src/screenshots/orchestrate.ts +244 -0
  174. package/src/screenshots/registry.ts +58 -0
  175. package/src/screenshots/schema.ts +364 -0
  176. package/src/skills/builtin/a11y-review.ts +126 -0
  177. package/src/skills/builtin/compat-check.ts +104 -0
  178. package/src/skills/builtin/perf-profile.ts +84 -0
  179. package/src/skills/builtin/screenshot-all.ts +46 -0
  180. package/src/skills/builtin/test-flow.ts +118 -0
  181. package/src/skills/builtin/visual-diff.ts +94 -0
  182. package/src/skills/registry.ts +107 -0
  183. package/src/skills/types.ts +41 -0
  184. package/src/vite-plugin-one.ts +189 -0
  185. package/src/vite-plugin.ts +1381 -0
  186. package/src/worklets-babel.ts +132 -0
  187. package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
  188. package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
  189. package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
  190. package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
  191. package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
  192. package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
  193. package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
  194. package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
  195. package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
  196. package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
  197. package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
  198. package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
  199. package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
  200. package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
  201. package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
  202. package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
  203. package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
  204. package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
  205. package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
  206. package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
  207. package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
  208. package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
  209. package/dist-lib/vite-base.cjs +0 -6937
@@ -0,0 +1,674 @@
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 http from 'http'
18
+ import net from 'net'
19
+ import { promisify } from 'util'
20
+
21
+ const execP = promisify(exec)
22
+ import { applySootSimConfigToUrl } from '../src/config.ts'
23
+ import { normalizeNativeDevBundleUrl } from '../src/native-dev-bundle-url.ts'
24
+ import { APPS } from './demo-app-registry.ts'
25
+
26
+ export interface DiscoveredServer {
27
+ port: number
28
+ framework: 'metro' | 'expo' | 'vxrn' | 'one' | 'unknown'
29
+ projectName?: string
30
+ bundleUrl: string
31
+ hmrUrl?: string
32
+ lastSeen: number
33
+ iconUrl?: string
34
+ iconPath?: string
35
+ bundleId?: string
36
+ patched?: boolean
37
+ /** absolute cwd of the owning node/bun process — resolved from `lsof -d cwd`
38
+ * when the scanner can see the pid. used to auto-attach the project for
39
+ * agent sessions without requiring a manual `sootsim agent attach`. */
40
+ cwd?: string
41
+ pid?: number
42
+ }
43
+
44
+ // localhost dev servers respond in well under 100ms when alive. drop dead
45
+ // branches fast so a non-bundler process doesn't drag scan latency.
46
+ const TIMEOUT_MS = 250
47
+ // the expo-style manifest GET / is intentionally slower than the cheap
48
+ // signature probes — for One framework apps it serializes app.config.js
49
+ // (including embedded googleServicesFile blobs), which can push the
50
+ // response to 200–400ms on first hit. when this probe times out we fall
51
+ // back to the One-HEAD legacy URL `/node_modules/one/metro-entry.bundle`,
52
+ // which is the path that originally caused the "failed to fetch bundle
53
+ // (404|502)" regression on monorepo One projects (apps/<name>/...). give
54
+ // the manifest more headroom so the canonical launchAsset.url wins.
55
+ const MANIFEST_TIMEOUT_MS = 1500
56
+ // cheap TCP-connect check gate for HTTP probes — if we can't open a socket
57
+ // within this budget the port isn't actually reachable, and all 5 HTTP probes
58
+ // would just time out for nothing.
59
+ const TCP_GATE_MS = 120
60
+
61
+ interface HttpResult {
62
+ statusCode: number
63
+ body: string
64
+ contentType?: string
65
+ }
66
+
67
+ // cheap TCP-connect gate. lsof already says the socket is LISTEN, but the
68
+ // process can be closing, the fd can be a different protocol, or a zombie
69
+ // can linger — none of those cases are worth firing 5 HTTP probes at.
70
+ function tcpPing(port: number, timeout = TCP_GATE_MS): Promise<boolean> {
71
+ return new Promise((resolve) => {
72
+ const sock = new net.Socket()
73
+ let settled = false
74
+ const done = (ok: boolean) => {
75
+ if (settled) return
76
+ settled = true
77
+ sock.destroy()
78
+ resolve(ok)
79
+ }
80
+ sock.setTimeout(timeout)
81
+ sock.once('connect', () => done(true))
82
+ sock.once('timeout', () => done(false))
83
+ sock.once('error', () => done(false))
84
+ sock.connect(port, 'localhost')
85
+ })
86
+ }
87
+
88
+ function httpGet(
89
+ port: number,
90
+ path: string,
91
+ method: 'GET' | 'HEAD' = 'GET',
92
+ timeout = TIMEOUT_MS,
93
+ headers: Record<string, string> = {},
94
+ ): Promise<HttpResult | null> {
95
+ return new Promise((resolve) => {
96
+ const req = http.request(
97
+ { hostname: 'localhost', port, path, method, timeout, headers },
98
+ (res) => {
99
+ let body = ''
100
+ res.on('data', (c: Buffer) => (body += c.toString()))
101
+ const contentType = (() => {
102
+ const raw = res.headers['content-type']
103
+ if (typeof raw === 'string') return raw
104
+ if (Array.isArray(raw)) return raw[0]
105
+ return undefined
106
+ })()
107
+ res.on('end', () =>
108
+ resolve({ statusCode: res.statusCode || 0, body, contentType }),
109
+ )
110
+ },
111
+ )
112
+ req.on('error', () => resolve(null))
113
+ req.setTimeout(timeout, () => {
114
+ req.destroy()
115
+ resolve(null)
116
+ })
117
+ req.end()
118
+ })
119
+ }
120
+
121
+ // ── discovery ───────────────────────────────────────────────────────────────
122
+
123
+ export interface ListeningProcess {
124
+ port: number
125
+ pid: number
126
+ }
127
+
128
+ // bare-port fallback list used when neither lsof nor ss are usable. pid 0
129
+ // signals "unknown owner" — the cache layer will re-probe these each scan.
130
+ const FALLBACK_PORTS: ListeningProcess[] = [
131
+ 8081, 8082, 8083, 8084, 8085, 8086, 3000, 3001, 19000,
132
+ ].map((port) => ({ port, pid: 0 }))
133
+
134
+ function acceptPort(port: number, excluded: Set<number>): boolean {
135
+ if (port <= 0 || port >= 20000) return false
136
+ if (excluded.has(port)) return false
137
+ // sootsim's own dev server range — we never want to discover ourselves.
138
+ if (port >= 5170 && port <= 5200) return false
139
+ return true
140
+ }
141
+
142
+ export async function discoverListeningProcesses(
143
+ excludePorts: number[] = [],
144
+ ): Promise<ListeningProcess[]> {
145
+ const excluded = new Set(excludePorts)
146
+
147
+ // lsof layout: COMMAND PID USER FD TYPE DEVICE SIZE NODE NAME
148
+ // NAME looks like "*:8081" or "127.0.0.1:8081" for a LISTEN line.
149
+ // async exec — sync blocked CrBrowserMain for ~600ms/scan on macOS.
150
+ try {
151
+ const { stdout } = await execP(
152
+ `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E '^(node|bun)'`,
153
+ { encoding: 'utf8', timeout: 2000 },
154
+ )
155
+ if (stdout.trim()) {
156
+ const seen = new Map<number, number>()
157
+ for (const line of stdout.trim().split('\n')) {
158
+ const parts = line.trim().split(/\s+/)
159
+ if (parts.length < 9) continue
160
+ const pid = Number(parts[1])
161
+ const addr = parts[8]
162
+ const m = addr.match(/:(\d+)$/)
163
+ if (!m) continue
164
+ const port = Number(m[1])
165
+ if (!acceptPort(port, excluded)) continue
166
+ if (!seen.has(port)) seen.set(port, pid)
167
+ }
168
+ if (seen.size > 0) {
169
+ return [...seen.entries()].map(([port, pid]) => ({ port, pid }))
170
+ }
171
+ }
172
+ } catch {}
173
+
174
+ // ss layout: State Recv-Q Send-Q Local-Address:Port Peer-Addr:Port users:(("node",pid=1234,fd=5))
175
+ try {
176
+ const { stdout } = await execP(`ss -tlnp 2>/dev/null | grep -E '"(node|bun)"'`, {
177
+ encoding: 'utf8',
178
+ timeout: 2000,
179
+ })
180
+ if (stdout.trim()) {
181
+ const seen = new Map<number, number>()
182
+ for (const line of stdout.trim().split('\n')) {
183
+ const portMatch = line.match(/:(\d+)\s/)
184
+ const pidMatch = line.match(/pid=(\d+)/)
185
+ if (!portMatch) continue
186
+ const port = Number(portMatch[1])
187
+ const pid = pidMatch ? Number(pidMatch[1]) : 0
188
+ if (!acceptPort(port, excluded)) continue
189
+ if (!seen.has(port)) seen.set(port, pid)
190
+ }
191
+ if (seen.size > 0) {
192
+ return [...seen.entries()].map(([port, pid]) => ({ port, pid }))
193
+ }
194
+ }
195
+ } catch {}
196
+
197
+ return FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded))
198
+ }
199
+
200
+ // kept for backwards compatibility with any external callers that only need
201
+ // port numbers. internal paths should prefer discoverListeningProcesses so
202
+ // the (port, pid) cache can detect process restarts.
203
+ export async function discoverListeningPorts(
204
+ excludePorts: number[] = [],
205
+ ): Promise<number[]> {
206
+ const processes = await discoverListeningProcesses(excludePorts)
207
+ return processes.map((p) => p.port)
208
+ }
209
+
210
+ // per-pid cwd cache. lsof is fairly cheap but we call it per discovered
211
+ // server per scan (every 5s in the tray), so caching the result until the
212
+ // pid goes away avoids redundant forks.
213
+ const cwdByPid = new Map<number, string>()
214
+
215
+ /** resolve the current working directory of a pid using `lsof -d cwd`.
216
+ * macOS + linux both accept this. returns null for pid 0 (unknown owner)
217
+ * and whenever lsof is unavailable or the fd isn't present.
218
+ * async — sync exec blocked CrBrowserMain at ~600ms on macOS. */
219
+ export async function resolveProcessCwd(pid: number): Promise<string | null> {
220
+ if (pid <= 0) return null
221
+ const cached = cwdByPid.get(pid)
222
+ if (cached) return cached
223
+ try {
224
+ // -Fn → machine-readable, one field per line. lines look like:
225
+ // p<pid>
226
+ // fcwd
227
+ // n<absolute path>
228
+ const { stdout } = await execP(`lsof -p ${pid} -a -d cwd -Fn 2>/dev/null`, {
229
+ encoding: 'utf8',
230
+ timeout: 1500,
231
+ })
232
+ for (const line of stdout.split('\n')) {
233
+ if (line.startsWith('n') && line.length > 1) {
234
+ const cwd = line.slice(1).trim()
235
+ if (cwd) {
236
+ cwdByPid.set(pid, cwd)
237
+ return cwd
238
+ }
239
+ }
240
+ }
241
+ } catch {}
242
+ return null
243
+ }
244
+
245
+ // ── per-port probe orchestration ────────────────────────────────────────────
246
+
247
+ function makeResult(
248
+ port: number,
249
+ framework: DiscoveredServer['framework'],
250
+ ): DiscoveredServer {
251
+ return {
252
+ port,
253
+ framework,
254
+ bundleUrl: withRuntimeConfig(
255
+ port,
256
+ `http://localhost:${port}/index.bundle?platform=ios&dev=true&hot=true&minify=false`,
257
+ ),
258
+ hmrUrl: `ws://localhost:${port}/hot`,
259
+ lastSeen: Date.now(),
260
+ }
261
+ }
262
+
263
+ function withRuntimeConfig(port: number, bundleUrl: string): string {
264
+ const knownApp = APPS.find((app) => app.preferredPort === port)
265
+ const configured = knownApp?.runtimeConfig
266
+ ? applySootSimConfigToUrl(bundleUrl, knownApp.runtimeConfig)
267
+ : bundleUrl
268
+ return normalizeNativeDevBundleUrl(configured)
269
+ }
270
+
271
+ function isDirectOneBundleUrl(bundleUrl: string): boolean {
272
+ return bundleUrl.includes('/node_modules/one/metro-entry.bundle')
273
+ }
274
+
275
+ function safeParseManifest(body: string): Record<string, unknown> | null {
276
+ try {
277
+ const parsed = JSON.parse(body) as Record<string, unknown>
278
+ return parsed && typeof parsed === 'object' ? parsed : null
279
+ } catch {
280
+ return null
281
+ }
282
+ }
283
+
284
+ // fold expo manifest fields (name, icon, bundleId, launchAsset) into a result.
285
+ // the manifest body is always the GET / response we already have from probePort,
286
+ // so this never issues another http request.
287
+ function applyManifest(
288
+ result: DiscoveredServer,
289
+ manifestRes: HttpResult | null,
290
+ buildIconProxyUrl?: (externalUrl: string) => string,
291
+ ): DiscoveredServer {
292
+ if (!manifestRes) return result
293
+ try {
294
+ const manifest = JSON.parse(manifestRes.body)
295
+ const client = manifest?.extra?.expoClient || manifest?.extra || {}
296
+ if (client.name) result.projectName = client.name
297
+ if (client.ios?.bundleIdentifier) result.bundleId = client.ios.bundleIdentifier
298
+ if (result.framework === 'metro' && client.sdkVersion) result.framework = 'expo'
299
+ // preserve a confirmed direct one/vxrn metro-entry bundle. expo manifests
300
+ // and one/vxrn manifests point at a launchAsset URL with `hot=false`
301
+ // (hardcoded by @expo/cli — see metroOptions.ts). that's the same URL
302
+ // real iOS sims load, so sootsim uses it verbatim to share metro's
303
+ // bundle cache; HMR still works via sootsim's HmrClient.
304
+ const launchUrl = manifest?.launchAsset?.url
305
+ if (launchUrl && !result.patched && !isDirectOneBundleUrl(result.bundleUrl)) {
306
+ result.bundleUrl = withRuntimeConfig(result.port, launchUrl)
307
+ }
308
+ const rawIconUrl =
309
+ client.iconUrl || client.ios?.iconUrl || client.icon || client.ios?.icon
310
+ if (rawIconUrl) {
311
+ result.iconPath = rawIconUrl
312
+ if (buildIconProxyUrl) {
313
+ if (rawIconUrl.startsWith('http')) {
314
+ result.iconUrl = buildIconProxyUrl(rawIconUrl)
315
+ } else {
316
+ const cleanPath = rawIconUrl.replace(/^\.\//, '')
317
+ result.iconUrl = buildIconProxyUrl(
318
+ `http://localhost:${result.port}/assets/${cleanPath}`,
319
+ )
320
+ }
321
+ } else {
322
+ // no proxy requested — use the raw URL
323
+ result.iconUrl = rawIconUrl.startsWith('http')
324
+ ? rawIconUrl
325
+ : `http://localhost:${result.port}/assets/${rawIconUrl.replace(/^\.\//, '')}`
326
+ }
327
+ }
328
+ } catch {}
329
+ return result
330
+ }
331
+
332
+ export function __applyManifestForTests(
333
+ result: DiscoveredServer,
334
+ manifestBody: string,
335
+ ): DiscoveredServer {
336
+ return applyManifest(result, { statusCode: 200, body: manifestBody })
337
+ }
338
+
339
+ // ports confirmed to be running a non-sootsim dev server — safe to skip the
340
+ // /__soot/ probe on subsequent scans. cleared when the port disappears from the
341
+ // listening set (process restarted, could now be a different server).
342
+ const knownNonPatched = new Set<number>()
343
+
344
+ // ports confirmed to not answer /_expo/status as an expo packager — safe to
345
+ // skip that probe on subsequent scans. same invalidation rule as
346
+ // knownNonPatched: cleared when the port's owning pid changes.
347
+ const knownNonExpo = new Set<number>()
348
+
349
+ // ports confirmed to be a one/vxrn dev server. on these, we skip the manifest
350
+ // probe (`GET /` with expo-platform headers) on subsequent scans: one already
351
+ // served the bundle HEAD so we know what it is, AND one's Expo Go manifest
352
+ // middleware crashes the whole server when a probe aborts mid-stream
353
+ // (Cannot pipe to a closed or destroyed stream → unhandled rejection → exit).
354
+ // the bundle URL is enough; no information is lost by skipping the manifest.
355
+ // same invalidation rules as knownNonPatched/knownNonExpo: cleared on port
356
+ // disappearance, owning-pid change, and __resetScannerCache.
357
+ const knownOne = new Set<number>()
358
+
359
+ // fire signature probes in waves and pick the best match by precedence:
360
+ // 1. expo manifest (JSON manifest at /)
361
+ // 2. metro/expo (packager-status:running on /status)
362
+ // 3. sootsim-patched (/__soot/)
363
+ // 4. one/vxrn (HEAD on /node_modules/one/metro-entry.bundle) — only
364
+ // fires as a true last resort; on expo apps this probe
365
+ // crashes metro by triggering HmrServer.registerEntryPoint
366
+ // for a path metro can't resolve, so we never fire it
367
+ // when one of the higher-precedence probes already
368
+ // identified the server.
369
+ // cheapest check first: a TCP connect gate short-circuits all HTTP probes when
370
+ // the port isn't actually reachable (zombie listener, non-http protocol,
371
+ // process mid-shutdown). after that, racing the safe probes is fast —
372
+ // localhost refuses/closes connections quickly, and Promise.all finishes at
373
+ // the slowest single probe rather than the sum.
374
+ export async function probePort(
375
+ port: number,
376
+ buildIconProxyUrl?: (externalUrl: string) => string,
377
+ ): Promise<DiscoveredServer | null> {
378
+ if (!(await tcpPing(port))) return null
379
+
380
+ const onePath = `/node_modules/one/metro-entry.bundle?platform=ios&dev=true`
381
+
382
+ // wave 1 — safe parallel probes. none of these mutate metro state.
383
+ const [sootsimRes, statusRes, manifestRes, expoRes] = await Promise.all([
384
+ knownNonPatched.has(port) ? Promise.resolve(null) : httpGet(port, '/__soot/'),
385
+ httpGet(port, '/status'),
386
+ knownOne.has(port)
387
+ ? Promise.resolve(null)
388
+ : httpGet(port, '/', 'GET', MANIFEST_TIMEOUT_MS, { 'expo-platform': 'ios' }),
389
+ knownNonExpo.has(port) ? Promise.resolve(null) : httpGet(port, '/_expo/status'),
390
+ ])
391
+
392
+ // remember negative /_expo/status responses so we don't keep probing this
393
+ // endpoint on ports that clearly aren't expo packagers (one/vxrn, vite,
394
+ // random node processes). a 200 flips the port out of the non-expo set.
395
+ if (expoRes && expoRes.statusCode === 200) {
396
+ knownNonExpo.delete(port)
397
+ } else if (!knownNonExpo.has(port)) {
398
+ knownNonExpo.add(port)
399
+ }
400
+
401
+ // expo manifest (One framework + expo apps both serve this at `/` with the
402
+ // expo-platform header). when present, `launchAsset.url` is the canonical
403
+ // bundle URL the app actually serves — for One projects it's the
404
+ // monorepo-aware path (e.g. /apps/one/node_modules/one/metro-entry.bundle
405
+ // with full hermes transform params), and for Expo it's /index.bundle with
406
+ // the right transforms. either way it's strictly better than the bare
407
+ // /node_modules/one/metro-entry.bundle constant we used as a discriminator
408
+ // below, so we check the manifest FIRST.
409
+ const manifestParsed = manifestRes
410
+ ? (safeParseManifest(manifestRes.body) as {
411
+ launchAsset?: { url?: unknown }
412
+ extra?: { expoClient?: { name?: unknown }; name?: unknown }
413
+ } | null)
414
+ : null
415
+ const manifestLaunchUrl =
416
+ typeof manifestParsed?.launchAsset?.url === 'string'
417
+ ? manifestParsed.launchAsset.url
418
+ : null
419
+ const manifestClient =
420
+ (manifestParsed?.extra?.expoClient as { name?: unknown } | undefined) ||
421
+ (manifestParsed?.extra as { name?: unknown } | undefined) ||
422
+ {}
423
+ if (manifestParsed && (manifestLaunchUrl || typeof manifestClient.name === 'string')) {
424
+ knownNonPatched.add(port)
425
+ const launchUrl =
426
+ manifestLaunchUrl ||
427
+ `http://localhost:${port}/index.bundle?platform=ios&dev=true&hot=true&minify=false`
428
+ // call the framework "one" only when launchAsset clearly points at
429
+ // a One metro entry; otherwise treat it as plain expo so downstream
430
+ // bundle handling uses the right path semantics.
431
+ const framework: DiscoveredServer['framework'] = launchUrl.includes(
432
+ '/one/metro-entry.bundle',
433
+ )
434
+ ? 'one'
435
+ : 'expo'
436
+ return applyManifest(
437
+ {
438
+ port,
439
+ framework,
440
+ bundleUrl: withRuntimeConfig(port, launchUrl),
441
+ hmrUrl: `ws://localhost:${port}/hot`,
442
+ lastSeen: Date.now(),
443
+ },
444
+ manifestRes,
445
+ buildIconProxyUrl,
446
+ )
447
+ }
448
+
449
+ // metro/expo — packager-status:running on /status. checked BEFORE the one
450
+ // HEAD because the HEAD has a known side-effect: it triggers metro's
451
+ // HmrServer.registerEntryPoint for `/node_modules/one/metro-entry.bundle`,
452
+ // and on metro instances where that path doesn't resolve (i.e. any plain
453
+ // expo app) metro crashes with "UnableToResolveError" and exits. by
454
+ // identifying expo/metro instances here from their /status response we
455
+ // never fire the HEAD against them.
456
+ if (statusRes && statusRes.body.includes('packager-status:running')) {
457
+ knownNonPatched.add(port)
458
+ return applyManifest(
459
+ makeResult(port, expoRes && expoRes.statusCode === 200 ? 'expo' : 'metro'),
460
+ manifestRes,
461
+ buildIconProxyUrl,
462
+ )
463
+ }
464
+
465
+ // patched sootsim — fallback for legacy /__soot/ patched servers. also
466
+ // checked before the one HEAD for the same reason.
467
+ if (
468
+ sootsimRes &&
469
+ sootsimRes.statusCode === 200 &&
470
+ sootsimRes.body.includes('sootsim-patched')
471
+ ) {
472
+ knownNonPatched.delete(port)
473
+ return applyManifest(
474
+ {
475
+ port,
476
+ framework: 'one',
477
+ bundleUrl: withRuntimeConfig(port, `http://localhost:${port}/__soot/bundle.js`),
478
+ hmrUrl: `ws://localhost:${port}/hot`,
479
+ lastSeen: Date.now(),
480
+ patched: true,
481
+ },
482
+ manifestRes,
483
+ buildIconProxyUrl,
484
+ )
485
+ }
486
+
487
+ // wave 2 — one/vxrn HEAD probe. only fired as a true last resort, since
488
+ // it has the metro side-effect documented above. older One bare-dev-server
489
+ // setups that don't serve a manifest still resolve through this path; new
490
+ // expo/metro/one setups are caught above and never reach here.
491
+ const oneRes = await httpGet(port, onePath, 'HEAD')
492
+ if (
493
+ oneRes &&
494
+ oneRes.statusCode > 0 &&
495
+ oneRes.statusCode < 400 &&
496
+ /application\/javascript/i.test(oneRes.contentType || '')
497
+ ) {
498
+ knownNonPatched.add(port)
499
+ knownOne.add(port)
500
+ return applyManifest(
501
+ {
502
+ port,
503
+ framework: 'one',
504
+ bundleUrl: withRuntimeConfig(
505
+ port,
506
+ `http://localhost:${port}${onePath}&minify=false`,
507
+ ),
508
+ hmrUrl: `ws://localhost:${port}/hot`,
509
+ lastSeen: Date.now(),
510
+ },
511
+ manifestRes,
512
+ buildIconProxyUrl,
513
+ )
514
+ }
515
+
516
+ knownNonPatched.add(port)
517
+ return null
518
+ }
519
+
520
+ // ── top-level convenience ───────────────────────────────────────────────────
521
+
522
+ export interface ScanOptions {
523
+ excludePorts?: number[]
524
+ buildIconProxyUrl?: (externalUrl: string) => string
525
+ }
526
+
527
+ function isSootSelfServer(server: DiscoveredServer): boolean {
528
+ const projectName = server.projectName?.trim().toLowerCase()
529
+ if (projectName === 'soot' || projectName === 'sootsim') return true
530
+
531
+ const bundleId = server.bundleId?.trim().toLowerCase()
532
+ if (bundleId?.startsWith('dev.soot')) return true
533
+
534
+ return false
535
+ }
536
+
537
+ // per-(port, pid) probe cache. as long as the same process still owns the
538
+ // port, its signature endpoints can't have changed, so we return the cached
539
+ // result without issuing any HTTP. negative results (null) are cached too,
540
+ // which prevents repeat-probing node/bun processes that aren't dev servers.
541
+ // invalidation is cheap: we drop any entry whose port dropped out of lsof,
542
+ // and any entry whose pid changed (process restart).
543
+ interface PortCacheEntry {
544
+ pid: number
545
+ result: DiscoveredServer | null
546
+ cachedAt: number
547
+ }
548
+ const portCache = new Map<number, PortCacheEntry>()
549
+
550
+ // negative-cache holds a node/bun port that isn't a bundler (e.g. soot's own
551
+ // api server, zero, postgres, a dev tool). at scan interval 3s and a 1.5s ttl
552
+ // we used to re-probe every scan, which hits `/` on the port every 3s — that
553
+ // waking soot's SSR router ~20x/min was a real memory-leak driver. 30s gives
554
+ // the scanner a much lighter touch on unrelated node processes while still
555
+ // catching pid-change invalidation immediately when a process restarts.
556
+ const NEGATIVE_CACHE_TTL_MS = 30_000
557
+ const WEAK_RESULT_CACHE_TTL_MS = 1_500
558
+
559
+ function isWeakCachedResult(result: DiscoveredServer | null): boolean {
560
+ if (!result) return true
561
+ if (result.framework === 'metro' || result.framework === 'unknown') return true
562
+ return false
563
+ }
564
+
565
+ function hasCurrentRuntimeConfig(result: DiscoveredServer | null): boolean {
566
+ if (!result) return true
567
+ return withRuntimeConfig(result.port, result.bundleUrl) === result.bundleUrl
568
+ }
569
+
570
+ export function __shouldReuseScannerCacheEntry(
571
+ entry: { pid: number; result: DiscoveredServer | null; cachedAt: number },
572
+ pid: number,
573
+ now = Date.now(),
574
+ ): boolean {
575
+ if (pid === 0) return false
576
+ if (entry.pid !== pid) return false
577
+ if (!hasCurrentRuntimeConfig(entry.result)) return false
578
+
579
+ const ageMs = now - entry.cachedAt
580
+ if (entry.result === null && ageMs >= NEGATIVE_CACHE_TTL_MS) return false
581
+ if (isWeakCachedResult(entry.result) && ageMs >= WEAK_RESULT_CACHE_TTL_MS) return false
582
+ return true
583
+ }
584
+
585
+ // exported for tests / `sootsim debug` — forces the next scan to re-probe.
586
+ export function __resetScannerCache() {
587
+ portCache.clear()
588
+ knownNonPatched.clear()
589
+ knownNonExpo.clear()
590
+ knownOne.clear()
591
+ }
592
+
593
+ export async function scanDevServers(
594
+ opts: ScanOptions = {},
595
+ ): Promise<DiscoveredServer[]> {
596
+ const processes = await discoverListeningProcesses(opts.excludePorts)
597
+ const currentPorts = new Set(processes.map((p) => p.port))
598
+
599
+ // evict cache entries for ports that disappeared. a restarted process may
600
+ // bring up a different server (possibly sootsim-patched) on the same port,
601
+ // and both caches need to let go so the next probe sees the new state.
602
+ for (const p of [...portCache.keys()]) {
603
+ if (!currentPorts.has(p)) portCache.delete(p)
604
+ }
605
+ for (const p of [...knownNonPatched]) {
606
+ if (!currentPorts.has(p)) knownNonPatched.delete(p)
607
+ }
608
+ for (const p of [...knownNonExpo]) {
609
+ if (!currentPorts.has(p)) knownNonExpo.delete(p)
610
+ }
611
+ for (const p of [...knownOne]) {
612
+ if (!currentPorts.has(p)) knownOne.delete(p)
613
+ }
614
+
615
+ const results: DiscoveredServer[] = []
616
+ const toProbe: ListeningProcess[] = []
617
+
618
+ for (const { port, pid } of processes) {
619
+ const cached = portCache.get(port)
620
+ // pid 0 means the platform couldn't identify the owner (fallback path),
621
+ // so we can't trust the cache — reprobe.
622
+ if (cached && __shouldReuseScannerCacheEntry(cached, pid)) {
623
+ if (cached.result) results.push(cached.result)
624
+ continue
625
+ }
626
+ // pid changed or cache expired — drop any endpoint-specific verdicts so
627
+ // the re-probe starts from scratch. otherwise a restarted process that
628
+ // swapped frameworks keeps the old skip-/__soot/ or skip-/_expo/status
629
+ // decisions, and the scanner never discovers the new server.
630
+ if (cached && cached.pid !== pid) {
631
+ knownNonPatched.delete(port)
632
+ knownNonExpo.delete(port)
633
+ knownOne.delete(port)
634
+ }
635
+ toProbe.push({ port, pid })
636
+ }
637
+
638
+ if (toProbe.length > 0) {
639
+ const probed = await Promise.all(
640
+ toProbe.map((p) => probePort(p.port, opts.buildIconProxyUrl)),
641
+ )
642
+ probed.forEach((result, i) => {
643
+ const { port, pid } = toProbe[i]
644
+ // only cache when we have a real pid to key the entry against.
645
+ // fallback-list ports (pid 0) always reprobe so we catch state changes.
646
+ if (pid !== 0) portCache.set(port, { pid, result, cachedAt: Date.now() })
647
+ if (result) results.push(result)
648
+ })
649
+ }
650
+
651
+ // attach pid + cwd. pid comes from the listening-process scan; cwd is
652
+ // lsof-resolved and cached per pid. both enable auto-attach of agent
653
+ // sessions to the right project without a manual `sootsim agent attach`.
654
+ const pidByPort = new Map<number, number>()
655
+ for (const { port, pid } of processes) {
656
+ if (pid > 0) pidByPort.set(port, pid)
657
+ }
658
+ await Promise.all(
659
+ results.map(async (result) => {
660
+ const pid = pidByPort.get(result.port)
661
+ if (!pid) return
662
+ result.pid = pid
663
+ const cwd = await resolveProcessCwd(pid)
664
+ if (cwd) result.cwd = cwd
665
+ }),
666
+ )
667
+ // evict stale cwd cache entries for pids no longer listening
668
+ const livePids = new Set(pidByPort.values())
669
+ for (const pid of [...cwdByPid.keys()]) {
670
+ if (!livePids.has(pid)) cwdByPid.delete(pid)
671
+ }
672
+
673
+ return results.filter((r) => !isSootSelfServer(r))
674
+ }