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.
- package/README.md +0 -1
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +36 -0
- package/detox/expectations.ts +477 -0
- package/detox/gestures.ts +442 -0
- package/detox/index.ts +1403 -0
- package/detox/jest-environment.ts +86 -0
- package/detox/jest-preset.cjs +50 -0
- package/detox/matchers.ts +29 -0
- package/detox/navigation.ts +43 -0
- package/detox/run-test.ts +113 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-cli/bin.js +3 -3
- package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-T3DUH5YJ.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-NSBF4THI.js} +2 -2
- package/dist-cli/chunks/{assert-TV46GUNU.js → assert-X3F7TRCZ.js} +2 -2
- package/dist-cli/chunks/auto-bootstrap-47RN2V5G.js +2 -0
- package/dist-cli/chunks/beta-BRCGAF2N.js +2 -0
- package/dist-cli/chunks/chunk-36RPD6JI.js +2 -0
- package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-3WGHC7JN.js} +2 -2
- package/dist-cli/chunks/chunk-4DBPNLGI.js +1 -0
- package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-4EVSIUNB.js} +2 -2
- package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-4QZHZ6BC.js} +2 -2
- package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-5DIGWOY7.js} +1 -1
- package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-5N3V7OCG.js} +2 -2
- package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-5S6D7K4L.js} +2 -2
- package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-7LKUN46F.js} +2 -2
- package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-AC6QGW22.js} +2 -2
- package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-AFNDVS4E.js} +2 -2
- package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-BESAZ2HA.js} +2 -2
- package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-BHZJ6RIH.js} +2 -2
- package/dist-cli/chunks/{chunk-277XAALA.js → chunk-BZL6D4TV.js} +3 -3
- package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-CF2LPRXD.js} +2 -2
- package/dist-cli/chunks/chunk-DWTLRPEN.js +79 -0
- package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-E2QE5FFP.js} +1 -1
- package/dist-cli/chunks/chunk-EBEL6TTJ.js +4 -0
- package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-EFM53PZ5.js} +1 -1
- package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-EKXK3SWK.js} +2 -2
- package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-G7CIZ5S3.js} +3 -3
- package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-GTAD6IUV.js} +1 -1
- package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-H44IQHKZ.js} +1 -1
- package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-HQDJ5BOF.js} +1 -1
- package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-KUSQ4NNJ.js} +1 -1
- package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-MAO7F5PH.js} +3 -3
- package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-NVTL3JQG.js} +1 -1
- package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-O6N2CEET.js} +2 -2
- package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-OISHLFON.js} +1 -1
- package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-OUNLJM56.js} +2 -2
- package/dist-cli/chunks/chunk-OXOARRKR.js +67 -0
- package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-PHPXGLME.js} +1 -1
- package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PQFFUJR6.js} +2 -2
- package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-QLJNSOS7.js} +1 -1
- package/dist-cli/chunks/chunk-QQAECG5B.js +2 -0
- package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-RZHREO3M.js} +2 -2
- package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-SBGOUA6F.js} +2 -2
- package/dist-cli/chunks/chunk-SSCA2AEA.js +1 -0
- package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-UYRGCJ4N.js} +1 -1
- package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-WGDL5V6C.js} +1 -1
- package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-Y5PLPEEU.js} +2 -2
- package/dist-cli/chunks/chunk-ZFAM4N5B.js +1 -0
- package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-ZO3VHP6W.js} +1 -1
- package/dist-cli/chunks/cli-version-WPFDM2A6.js +2 -0
- package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-PCXGGZBZ.js} +3 -3
- package/dist-cli/chunks/{config-2DSLDCXV.js → config-LULEVEYL.js} +2 -2
- package/dist-cli/chunks/control-6P6HY7UF.js +2 -0
- package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-NOK73ZYW.js} +2 -2
- package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-4A3DMUYL.js} +2 -2
- package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-74BWB2ZG.js} +3 -3
- package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-HEOMINSC.js} +2 -2
- package/dist-cli/chunks/{device-AYKXKVIQ.js → device-TTXXBJFZ.js} +2 -2
- package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-QZ3GOHSE.js} +2 -2
- package/dist-cli/chunks/drivers-QRPWNOIT.js +2 -0
- package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-QVOWV44R.js} +3 -3
- package/dist-cli/chunks/flow-QMA7GVN6.js +2 -0
- package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-YKWRNMJC.js} +2 -2
- package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-SFADSTJM.js} +2 -2
- package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-LEWGQCIU.js} +3 -3
- package/dist-cli/chunks/install-7N2N7Q32.js +2 -0
- package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-22HYQZ2G.js} +3 -3
- package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-3ZT3MICU.js} +2 -2
- package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-ZXW2NFLG.js} +3 -3
- package/dist-cli/chunks/{login-EACQXE24.js → login-NJKJ7GZO.js} +4 -4
- package/dist-cli/chunks/{logout-IBQLMUML.js → logout-VMMQL7CB.js} +2 -2
- package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-OJY4MTI7.js} +2 -2
- package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-QU2GXTEV.js} +2 -2
- package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-7APWK47T.js} +2 -2
- package/dist-cli/chunks/{react-QDHLMVYL.js → react-RSVO5JZZ.js} +2 -2
- package/dist-cli/chunks/{record-BUEUWPDI.js → record-UWH4MDEO.js} +2 -2
- package/dist-cli/chunks/runtime-3FUENRHM.js +2 -0
- package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-QMKGRV7N.js} +2 -2
- package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-43M27ALE.js} +2 -2
- package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-EBYYN6TY.js} +2 -2
- package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-7TQZL6Z6.js} +2 -2
- package/dist-cli/chunks/{server-MFFVYUGG.js → server-VCFM25Z6.js} +2 -2
- package/dist-cli/chunks/setup-repo-HFH4VKJQ.js +2 -0
- package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-RQA6EJQL.js} +2 -2
- package/dist-cli/chunks/{start-E3DRYY7W.js → start-ZT6MBYND.js} +4 -4
- package/dist-cli/chunks/store-BJBTDSZE.js +2 -0
- package/dist-cli/chunks/telemetry-ZZZKTILZ.js +2 -0
- package/dist-cli/chunks/{test-ZY3EF62K.js → test-RNRX5SWV.js} +3 -3
- package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-TQZH25ZO.js} +2 -2
- package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-GGN3AY6P.js} +2 -2
- package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-XT22D67C.js} +2 -2
- package/dist-cli/chunks/upload-NC2AYLC5.js +2 -0
- package/dist-cli/chunks/{web-D2AOZY44.js → web-KEHVF5MB.js} +2 -2
- package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PATQRJ5T.js} +2 -2
- package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-CXVY26VV.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +44 -0
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1770 -0
- package/dist-lib/detox/jest-preset.cjs +50 -0
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/index.cjs +136 -138
- package/dist-lib/metro.cjs +31 -26
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/demo-app-registry.cjs +809 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
- package/dist-lib/skills.cjs +17766 -0
- package/dist-lib/vite.cjs +129 -39
- package/package.json +39 -14
- package/scripts/demo-app-registry.ts +989 -0
- package/scripts/dev-server-scanner.ts +674 -0
- package/src/agent-daemon-client.ts +390 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +572 -0
- package/src/attached-projects.ts +536 -0
- package/src/auth/shared-session.ts +199 -0
- package/src/backend-origin.ts +49 -0
- package/src/beta.ts +21 -0
- package/src/bridge-constants.ts +10 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +30 -0
- package/src/codex-client.ts +215 -0
- package/src/config.ts +110 -0
- package/src/dev-bundle-resolution.ts +180 -0
- package/src/home-paths.ts +382 -0
- package/src/host/agent-host.ts +576 -0
- package/src/host/bridge-host.ts +2293 -0
- package/src/host/fetch-proxy-handler.ts +288 -0
- package/src/host/fetch-proxy-overrides.ts +39 -0
- package/src/host/open-url.ts +234 -0
- package/src/index.ts +9 -0
- package/src/metro-plugin.ts +139 -0
- package/src/native-dev-bundle-url.ts +62 -0
- package/src/native-seam-manifest.ts +313 -0
- package/src/profiles.ts +179 -0
- package/src/render-mode.ts +27 -0
- package/src/runtime-assets.ts +84 -0
- package/src/runtime-delivery.ts +334 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +438 -0
- package/src/screenshots/orchestrate.ts +244 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/skills/builtin/a11y-review.ts +126 -0
- package/src/skills/builtin/compat-check.ts +104 -0
- package/src/skills/builtin/perf-profile.ts +84 -0
- package/src/skills/builtin/screenshot-all.ts +46 -0
- package/src/skills/builtin/test-flow.ts +118 -0
- package/src/skills/builtin/visual-diff.ts +94 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/vite-plugin-one.ts +189 -0
- package/src/vite-plugin.ts +1381 -0
- package/src/worklets-babel.ts +132 -0
- package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
- package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
- package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
- package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
- package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
- package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
- package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
- package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
- package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
- package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
- package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
- package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
- package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
- package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
- package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
- package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
- package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
- package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
- package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
- package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
- package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
- package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
- 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
|
+
}
|