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