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