rnxsim 0.0.0 → 0.1.312
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-NXVDDCFG.js +48 -0
- package/dist-cli/chunks/agent-wrapper-DXJCFKL3.js +17 -0
- package/dist-cli/chunks/app-fonts-W5AJZQJF.js +4 -0
- package/dist-cli/chunks/assert-3LOJEUDT.js +49 -0
- package/dist-cli/chunks/auth-MFTYYPNX.js +12 -0
- package/dist-cli/chunks/beta-GT3NN67B.js +4 -0
- package/dist-cli/chunks/camera-6EAK7CUL.js +22 -0
- package/dist-cli/chunks/chunk-274UD5FD.js +4 -0
- package/dist-cli/chunks/chunk-2SA2VTGP.js +5 -0
- package/dist-cli/chunks/chunk-33FKQH54.js +5 -0
- package/dist-cli/chunks/chunk-37I5AIS7.js +3 -0
- package/dist-cli/chunks/chunk-3D57IBPC.js +4 -0
- package/dist-cli/chunks/chunk-4BXCZHAU.js +4 -0
- package/dist-cli/chunks/chunk-5OX7ANRA.js +5 -0
- package/dist-cli/chunks/chunk-66NF2KNG.js +9 -0
- package/dist-cli/chunks/chunk-6UKJB2VC.js +125 -0
- package/dist-cli/chunks/chunk-7INBNA3U.js +26 -0
- package/dist-cli/chunks/chunk-7VWOSTD5.js +5 -0
- package/dist-cli/chunks/chunk-AY47WKS5.js +23 -0
- package/dist-cli/chunks/chunk-B7KMNHX7.js +4 -0
- package/dist-cli/chunks/chunk-BKNOLP3J.js +4 -0
- package/dist-cli/chunks/chunk-BRIZOR4K.js +28 -0
- package/dist-cli/chunks/chunk-DM6KUAOZ.js +4 -0
- package/dist-cli/chunks/chunk-EUAVBTKU.js +4 -0
- package/dist-cli/chunks/chunk-FTC4M26Q.js +4 -0
- package/dist-cli/chunks/chunk-FWYYCUAZ.js +4 -0
- package/dist-cli/chunks/chunk-G74YIEVS.js +7 -0
- package/dist-cli/chunks/chunk-GZDHEONW.js +507 -0
- package/dist-cli/chunks/chunk-HG7ORJMT.js +5 -0
- package/dist-cli/chunks/chunk-HMDOLCKG.js +15 -0
- package/dist-cli/chunks/chunk-JG6S4RSH.js +4 -0
- package/dist-cli/chunks/chunk-KR5BGMBZ.js +4 -0
- package/dist-cli/chunks/chunk-KZW5WBGU.js +983 -0
- package/dist-cli/chunks/chunk-L24Z3CRS.js +30 -0
- package/dist-cli/chunks/chunk-LDQ7HVCW.js +4 -0
- package/dist-cli/chunks/chunk-MENV7VLD.js +4 -0
- package/dist-cli/chunks/chunk-MNGQ42QE.js +568 -0
- package/dist-cli/chunks/chunk-N6A3SXMR.js +4 -0
- package/dist-cli/chunks/chunk-NLEMQ4PY.js +6 -0
- package/dist-cli/chunks/chunk-NPVGMH2N.js +101 -0
- package/dist-cli/chunks/chunk-OR3XTXEB.js +107 -0
- package/dist-cli/chunks/chunk-PEPUGJVI.js +4 -0
- package/dist-cli/chunks/chunk-Q3RGLERJ.js +142 -0
- package/dist-cli/chunks/chunk-SI3BUTQR.js +4 -0
- package/dist-cli/chunks/chunk-T4FT6CM7.js +4 -0
- package/dist-cli/chunks/chunk-TBMVBI26.js +7 -0
- package/dist-cli/chunks/chunk-TCWQJILS.js +5 -0
- package/dist-cli/chunks/chunk-TGQNQ6XT.js +766 -0
- package/dist-cli/chunks/chunk-TYE3WBSR.js +5 -0
- package/dist-cli/chunks/chunk-U4PN6JOT.js +6 -0
- package/dist-cli/chunks/chunk-VUFRB347.js +4 -0
- package/dist-cli/chunks/chunk-VZBLFMYC.js +10 -0
- package/dist-cli/chunks/chunk-WBT32R6N.js +25 -0
- package/dist-cli/chunks/chunk-XRGEEPJ5.js +38 -0
- package/dist-cli/chunks/chunk-Y2FSZSO7.js +71 -0
- package/dist-cli/chunks/chunk-YRV7HR3P.js +74 -0
- package/dist-cli/chunks/chunk-YZRFF4YA.js +14 -0
- package/dist-cli/chunks/chunk-ZMZTHU7V.js +4 -0
- package/dist-cli/chunks/cleanup-H3GWQB63.js +25 -0
- package/dist-cli/chunks/cli-version-3QPAJ3LC.js +4 -0
- package/dist-cli/chunks/compat-5WG2XJV7.js +20 -0
- package/dist-cli/chunks/config-ANMBVVLL.js +13 -0
- package/dist-cli/chunks/control-NSGNQFJG.js +4 -0
- package/dist-cli/chunks/daemon-JHF4BJ3U.js +4 -0
- package/dist-cli/chunks/debug-CHV2O2KF.js +200 -0
- package/dist-cli/chunks/desktop-FIPIK4CH.js +31 -0
- package/dist-cli/chunks/detox-CVUJP7K6.js +52 -0
- package/dist-cli/chunks/device-6WCUM4W6.js +18 -0
- package/dist-cli/chunks/diagnose-I6EMWEII.js +43 -0
- package/dist-cli/chunks/disk-cleanup-P27CYFTN.js +4 -0
- package/dist-cli/chunks/drivers-HQ6AKZT6.js +4 -0
- package/dist-cli/chunks/film-UOQFFYNF.js +74 -0
- package/dist-cli/chunks/flow-N27MRRNL.js +4 -0
- package/dist-cli/chunks/help-SF6FEGEE.js +4 -0
- package/dist-cli/chunks/hidden-runtime-alias-NJYFSJKZ.js +4 -0
- package/dist-cli/chunks/home-paths-XOV44JEF.js +4 -0
- package/dist-cli/chunks/inspect-CWFRFQTY.js +4 -0
- package/dist-cli/chunks/install-desktop-KLQDNXZQ.js +4 -0
- package/dist-cli/chunks/login-BPFQENCX.js +28 -0
- package/dist-cli/chunks/logout-5A45ZGY7.js +4 -0
- package/dist-cli/chunks/maestro-E4CSDMLM.js +104 -0
- package/dist-cli/chunks/maestro-generate-CKMOJQX2.js +36 -0
- package/dist-cli/chunks/mode-SZYNMIQF.js +26 -0
- package/dist-cli/chunks/optional-demo-registry-FRH4SFVG.js +4 -0
- package/dist-cli/chunks/perf-VXU2XJAX.js +48 -0
- package/dist-cli/chunks/permissions-IRIAOVND.js +13 -0
- package/dist-cli/chunks/record-RY7FPLLH.js +72 -0
- package/dist-cli/chunks/report-issue-KNWXOE53.js +29 -0
- package/dist-cli/chunks/runtime-AR3GJTGK.js +4 -0
- package/dist-cli/chunks/screenshot-command-QLALR6GY.js +159 -0
- package/dist-cli/chunks/screenshot-layers-OQFMFVRL.js +20 -0
- package/dist-cli/chunks/screenshots-capture-6TSRJFGB.js +42 -0
- package/dist-cli/chunks/serve-F54J2NQO.js +44 -0
- package/dist-cli/chunks/setup-7V6UKX4U.js +32 -0
- package/dist-cli/chunks/skills-QQBOSETH.js +50 -0
- package/dist-cli/chunks/storage-TAPMKK5O.js +37 -0
- package/dist-cli/chunks/store-7NX3S43H.js +4 -0
- package/dist-cli/chunks/telemetry-E7BUK6RA.js +4 -0
- package/dist-cli/chunks/timeline-IAM564NA.js +24 -0
- package/dist-cli/chunks/upgrade-CD55QXIX.js +7 -0
- package/dist-cli/chunks/upload-GZ6LIMYM.js +4 -0
- package/dist-cli/chunks/version-3DYJ5AS3.js +6 -0
- package/dist-cli/chunks/web-6NW6YLPI.js +4 -0
- package/dist-cli/chunks/what-happened-CZZDOOVG.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 +23911 -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,2626 @@
|
|
|
1
|
+
import { spawn } from 'child_process'
|
|
2
|
+
import fs from 'fs'
|
|
3
|
+
import { createServer, type IncomingMessage, type ServerResponse } from 'http'
|
|
4
|
+
import path from 'path'
|
|
5
|
+
import { WebSocket, WebSocketServer } from 'ws'
|
|
6
|
+
import {
|
|
7
|
+
scanDevServers,
|
|
8
|
+
type DiscoveredServer,
|
|
9
|
+
} from '../../scripts/dev-server-scanner.ts'
|
|
10
|
+
import {
|
|
11
|
+
DEFAULT_SOOTSIM_BRIDGE_PORT,
|
|
12
|
+
SOOTSIM_BRIDGE_SIM_CLOSE_CODE,
|
|
13
|
+
SOOTSIM_BRIDGE_SIM_CLOSE_REASON,
|
|
14
|
+
} from '../bridge-constants.ts'
|
|
15
|
+
import { getCliVersion } from '../cli-version.ts'
|
|
16
|
+
import {
|
|
17
|
+
activeRuntimeDir as getActiveRuntimeDir,
|
|
18
|
+
claimDaemonLockfile,
|
|
19
|
+
ensureSootsimHome,
|
|
20
|
+
isSootsimDevCheckout,
|
|
21
|
+
readActiveRuntime,
|
|
22
|
+
readSharedConfig,
|
|
23
|
+
removeDaemonLockfile,
|
|
24
|
+
removeDevBridgeLockfile,
|
|
25
|
+
writeActiveRuntime,
|
|
26
|
+
writeDaemonLockfile,
|
|
27
|
+
writeDevBridgeLockfile,
|
|
28
|
+
writeRuntimeUpgradeNotice,
|
|
29
|
+
writeSharedConfig,
|
|
30
|
+
sootsimHomeDir,
|
|
31
|
+
type DaemonLockfile,
|
|
32
|
+
type DevBridgeLockfile,
|
|
33
|
+
type SharedConfig,
|
|
34
|
+
} from '../home-paths.ts'
|
|
35
|
+
import {
|
|
36
|
+
isRuntimeVersionHostname,
|
|
37
|
+
resolveDevCheckoutRuntimeRoot,
|
|
38
|
+
resolveRuntimeRootForHostname,
|
|
39
|
+
runtimeVersionFromHostname,
|
|
40
|
+
} from '../runtime-assets.ts'
|
|
41
|
+
import { sootsimRuntime } from '../runtime-delivery.ts'
|
|
42
|
+
import { AgentHost, type AgentHostOptions } from './agent-host.ts'
|
|
43
|
+
import {
|
|
44
|
+
handleAppApiRequest,
|
|
45
|
+
handleFetchProxyRequest,
|
|
46
|
+
isAppApiRequestUrl,
|
|
47
|
+
isFetchProxyRequestUrl,
|
|
48
|
+
} from './fetch-proxy-handler.ts'
|
|
49
|
+
import { openUrl as openUrlInBrowser, type OpenUrlOptions } from './open-url.ts'
|
|
50
|
+
import { handleWebSocketProxyUpgrade } from './websocket-proxy.ts'
|
|
51
|
+
|
|
52
|
+
const SOOTSIM_CROSS_ORIGIN_EMBEDDER_POLICY = 'require-corp'
|
|
53
|
+
|
|
54
|
+
export interface BridgeSimInfo {
|
|
55
|
+
id: string
|
|
56
|
+
origin?: string
|
|
57
|
+
url?: string
|
|
58
|
+
title?: string
|
|
59
|
+
userAgent?: string
|
|
60
|
+
connectedAt: number
|
|
61
|
+
lastSeenAt: number
|
|
62
|
+
lastActiveAt?: number
|
|
63
|
+
isPrimary: boolean
|
|
64
|
+
readyState: 'open' | 'closing' | 'closed'
|
|
65
|
+
attachedCliCount?: number
|
|
66
|
+
lockedBy?: string
|
|
67
|
+
lockedByKind?: 'cli' | 'user-active'
|
|
68
|
+
lockExpiresAt?: number
|
|
69
|
+
userFocused?: boolean
|
|
70
|
+
userVisible?: boolean
|
|
71
|
+
visibilityState?: string
|
|
72
|
+
documentFocused?: boolean
|
|
73
|
+
/** registration "kind" — lets a single daemon host multiple surface
|
|
74
|
+
* types. omitted / unknown defaults to 'sootsim'. the contrast web
|
|
75
|
+
* IDE registers with kind='contrast'; CLI consumers filter by this
|
|
76
|
+
* rather than running a parallel bridge. */
|
|
77
|
+
kind?: string
|
|
78
|
+
/** opaque metadata supplied at register time (e.g. projectId, route,
|
|
79
|
+
* attached iOS sim id for a contrast tab). free-form by design — the
|
|
80
|
+
* daemon does not interpret it, only stores + reports it back. */
|
|
81
|
+
meta?: Record<string, unknown>
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface BridgeLockInfo {
|
|
85
|
+
by: string
|
|
86
|
+
expiresInMs: number
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface BridgeSimCommand {
|
|
90
|
+
id: number
|
|
91
|
+
type: 'evaluate' | 'screenshot' | 'tap' | 'keyboard' | 'tree' | 'focus' | 'close'
|
|
92
|
+
code?: string
|
|
93
|
+
x?: number
|
|
94
|
+
y?: number
|
|
95
|
+
action?: string
|
|
96
|
+
text?: string
|
|
97
|
+
depth?: number
|
|
98
|
+
simId?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface BridgeSimRegistrationMessage {
|
|
102
|
+
type: 'bridge:register'
|
|
103
|
+
simId?: string
|
|
104
|
+
url?: string
|
|
105
|
+
title?: string
|
|
106
|
+
userAgent?: string
|
|
107
|
+
/** see BridgeSimInfo.kind — defaults to 'sootsim' when omitted. */
|
|
108
|
+
kind?: string
|
|
109
|
+
/** see BridgeSimInfo.meta — free-form metadata for filtering/routing. */
|
|
110
|
+
meta?: Record<string, unknown>
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface BridgeSimUserFocusStateMessage {
|
|
114
|
+
type: 'bridge:user-focus-state'
|
|
115
|
+
focused?: boolean
|
|
116
|
+
visible?: boolean
|
|
117
|
+
visibilityState?: string
|
|
118
|
+
documentFocused?: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface BridgeSimClientStateMessage {
|
|
122
|
+
type: 'bridge:client-state'
|
|
123
|
+
attachedCliCount: number
|
|
124
|
+
activeAgentCommandCount: number
|
|
125
|
+
recentActions: BridgeRecentAction[]
|
|
126
|
+
lockedBy?: string
|
|
127
|
+
lockedByKind?: 'cli' | 'user-active'
|
|
128
|
+
lockExpiresAt?: number
|
|
129
|
+
userFocused?: boolean
|
|
130
|
+
userVisible?: boolean
|
|
131
|
+
visibilityState?: string
|
|
132
|
+
documentFocused?: boolean
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface BridgeRecentAction {
|
|
136
|
+
label: string
|
|
137
|
+
at: number
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
interface BridgeSimConnection {
|
|
141
|
+
id: string
|
|
142
|
+
ws: WebSocket
|
|
143
|
+
origin?: string
|
|
144
|
+
url?: string
|
|
145
|
+
title?: string
|
|
146
|
+
userAgent?: string
|
|
147
|
+
connectedAt: number
|
|
148
|
+
lastSeenAt: number
|
|
149
|
+
lastActiveAt: number
|
|
150
|
+
recentActions: BridgeRecentAction[]
|
|
151
|
+
cliLease?: BridgeCliLease
|
|
152
|
+
userFocused?: boolean
|
|
153
|
+
userVisible?: boolean
|
|
154
|
+
visibilityState?: string
|
|
155
|
+
documentFocused?: boolean
|
|
156
|
+
/** see BridgeSimInfo.kind. */
|
|
157
|
+
kind?: string
|
|
158
|
+
/** see BridgeSimInfo.meta. */
|
|
159
|
+
meta?: Record<string, unknown>
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
interface BridgeCliLease {
|
|
163
|
+
kind: 'cli' | 'user-active'
|
|
164
|
+
cliIdentityKey: string
|
|
165
|
+
cliLabel?: string
|
|
166
|
+
expiresAt: number
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// interactive commands change app state and must respect the lease.
|
|
170
|
+
// observational/lifecycle commands (evaluate/tree/screenshot/focus/call/close)
|
|
171
|
+
// pass through without taking or checking a lease so cleanup never gets stuck
|
|
172
|
+
// behind a stale cli owner.
|
|
173
|
+
// `call` used to acquire a lease too, but most call paths are queries
|
|
174
|
+
// (`__sootsimTest.findByTestId`, state reads, etc.). the CLI opts write
|
|
175
|
+
// calls into lease acquisition via msg.acquireLock=true.
|
|
176
|
+
const WRITE_COMMAND_TYPES = new Set(['tap', 'keyboard'])
|
|
177
|
+
|
|
178
|
+
// how long a `close` waits for the sim page to tear itself down before the
|
|
179
|
+
// host disconnects the sim socket. the close code is part of the protocol:
|
|
180
|
+
// browser clients treat it as terminal and skip their normal reconnect loop.
|
|
181
|
+
const FORCE_CLOSE_GRACE_MS = 2000
|
|
182
|
+
const FORCE_CLOSE_TERMINATE_MS = 1000
|
|
183
|
+
|
|
184
|
+
// note: `unrefTimer` is declared once, below (with the `UnrefableTimer`
|
|
185
|
+
// type). a duplicate copy used to live here from a concurrent edit and
|
|
186
|
+
// broke the standalone CLI esbuild bundle ("symbol already declared").
|
|
187
|
+
|
|
188
|
+
function shouldAcquireLease(msg: any): boolean {
|
|
189
|
+
if (!msg || typeof msg.type !== 'string') return false
|
|
190
|
+
if (msg.acquireLock === true) return true
|
|
191
|
+
if (msg.readOnly === true) return false
|
|
192
|
+
return WRITE_COMMAND_TYPES.has(msg.type)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
interface BridgeRestorableSimState {
|
|
196
|
+
recentActions: BridgeRecentAction[]
|
|
197
|
+
lastActiveAt: number
|
|
198
|
+
cliLease?: BridgeCliLease
|
|
199
|
+
expiresAt: number
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
interface BridgePendingCommand {
|
|
203
|
+
simId: string
|
|
204
|
+
resolve: (value: any) => void
|
|
205
|
+
reject: (error: Error) => void
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
interface BridgeForwardedCommand {
|
|
209
|
+
simId: string
|
|
210
|
+
ws: WebSocket
|
|
211
|
+
originalId: number | string
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export interface BridgeHostOptions {
|
|
215
|
+
port?: number
|
|
216
|
+
openUrl?: (url: string, options?: OpenUrlOptions) => Promise<void> | void
|
|
217
|
+
/** Contrast-specific excludes for the agent host's dev-server scan. passed
|
|
218
|
+
* through to AgentHost; see packages/sootsim-engine/src/dev-scan-excludes.ts
|
|
219
|
+
* for the canonical list. when omitted, AgentHost reads env vars directly. */
|
|
220
|
+
agentScanExcludes?: AgentHostOptions['getExcludePorts']
|
|
221
|
+
/** try preferred port, then port+1, port+2, …, up to this many attempts
|
|
222
|
+
* before giving up. defaults to 10. set 1 to disable fallback. */
|
|
223
|
+
portFallbackCount?: number
|
|
224
|
+
/** when true, write ~/.sootsim/daemon.json on successful bind + update
|
|
225
|
+
* it on a heartbeat interval + remove it on close. only the standalone
|
|
226
|
+
* `sootsim serve` process should set this — tests, vite plugin, and
|
|
227
|
+
* anything embedded should leave it off to avoid clobbering a real
|
|
228
|
+
* daemon's lockfile. defaults to false. */
|
|
229
|
+
writeLockfile?: boolean
|
|
230
|
+
/** when true, write ~/.sootsim/dev-bridge.json on successful bind + update
|
|
231
|
+
* it on a heartbeat interval + remove it on close. the Vite dev shell sets
|
|
232
|
+
* this so local CLI commands prefer the live source bridge over a standalone
|
|
233
|
+
* daemon or stale default-port listener. */
|
|
234
|
+
writeDevLockfile?: boolean
|
|
235
|
+
/** resolve the http port of the vite shell dev server that hosts this
|
|
236
|
+
* bridge (e.g. 5173). called on every dev-lockfile write so the port
|
|
237
|
+
* fills in once the vite server is actually listening. only meaningful
|
|
238
|
+
* with writeDevLockfile. */
|
|
239
|
+
getShellPort?: () => number | null
|
|
240
|
+
/** the contrast origin this daemon's runtimes should talk to for auth,
|
|
241
|
+
* billing, and preview uploads — inlined into served runtime html as
|
|
242
|
+
* `window.__sootsimContrastOrigin`. without it the engine's origin.ts
|
|
243
|
+
* resolves the daemon's own loopback host (localhost:<runtimePort>) to
|
|
244
|
+
* the dev `contrast.localhost:3000` stack, so a *prod* CLI user's preview
|
|
245
|
+
* recording would upload to a localhost stack that doesn't exist. the
|
|
246
|
+
* `sootsim serve` command resolves this with the same probe the
|
|
247
|
+
* upload itself uses (`resolveDefaultUploadOrigin`). */
|
|
248
|
+
contrastOrigin?: string
|
|
249
|
+
/** override the abandoned-sim GC TTL (ms). defaults to
|
|
250
|
+
* SIM_IDLE_REAP_TTL_MS (30 min). exists so the reaper integration test
|
|
251
|
+
* can drive the real timer path deterministically instead of mocking
|
|
252
|
+
* time. production callers should never set this. */
|
|
253
|
+
simIdleReapTtlMs?: number
|
|
254
|
+
/** override the idle GC TTL (ms) for automation sims (playwright-driver,
|
|
255
|
+
* capture/factory). defaults to AUTOMATION_SIM_IDLE_REAP_TTL_MS (10 min).
|
|
256
|
+
* test-only, like simIdleReapTtlMs. */
|
|
257
|
+
automationSimIdleReapTtlMs?: number
|
|
258
|
+
/** cap on concurrent non-primary automation sims before the reaper drops
|
|
259
|
+
* the most-idle overflow. defaults to MAX_CONCURRENT_AUTOMATION_SIMS (6).
|
|
260
|
+
* test-only. */
|
|
261
|
+
maxConcurrentAutomationSims?: number
|
|
262
|
+
/** grace (ms) below which an automation sim is never reaped by the cap,
|
|
263
|
+
* even when over it. defaults to AUTOMATION_SIM_ACTIVE_GRACE_MS (60 s).
|
|
264
|
+
* test-only. */
|
|
265
|
+
automationSimActiveGraceMs?: number
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const DAEMON_HEARTBEAT_INTERVAL_MS = 5_000
|
|
269
|
+
const DEFAULT_RUNTIME_UPDATE_INTERVAL_MS = 60 * 60 * 1000
|
|
270
|
+
const RUNTIME_UPDATE_INTERVAL_ENV = 'SOOTSIM_RUNTIME_UPDATE_INTERVAL_MS'
|
|
271
|
+
|
|
272
|
+
const HTTP_MIME_TYPES: Record<string, string> = {
|
|
273
|
+
'.html': 'text/html; charset=utf-8',
|
|
274
|
+
'.js': 'application/javascript',
|
|
275
|
+
'.cjs': 'application/javascript',
|
|
276
|
+
'.mjs': 'application/javascript',
|
|
277
|
+
'.css': 'text/css; charset=utf-8',
|
|
278
|
+
'.json': 'application/json; charset=utf-8',
|
|
279
|
+
'.png': 'image/png',
|
|
280
|
+
'.jpg': 'image/jpeg',
|
|
281
|
+
'.jpeg': 'image/jpeg',
|
|
282
|
+
'.gif': 'image/gif',
|
|
283
|
+
'.svg': 'image/svg+xml',
|
|
284
|
+
'.webp': 'image/webp',
|
|
285
|
+
'.avif': 'image/avif',
|
|
286
|
+
'.ico': 'image/x-icon',
|
|
287
|
+
'.wasm': 'application/wasm',
|
|
288
|
+
'.ttf': 'font/ttf',
|
|
289
|
+
'.otf': 'font/otf',
|
|
290
|
+
'.woff': 'font/woff',
|
|
291
|
+
'.woff2': 'font/woff2',
|
|
292
|
+
'.map': 'application/json',
|
|
293
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** inject host-provided globals into the served runtime html:
|
|
297
|
+
* - `window.__sootsimSharedConfig` — parsed `~/.sootsim/config.json`,
|
|
298
|
+
* read during engine settingsStore module init (see
|
|
299
|
+
* settings/persistence.ts). mirrors the inject-into-html plugin in the
|
|
300
|
+
* shell vite dev server so dev and prod surfaces both produce it.
|
|
301
|
+
* - `window.__sootsimCliVersion` — the CLI version serving this runtime,
|
|
302
|
+
* so the shell can surface CLI vs runtime version (MacMenuBar footer,
|
|
303
|
+
* ReportBody) and users can tell what they're actually running.
|
|
304
|
+
* - `window.__sootsimBridgePort` — the ws bridge port this daemon is
|
|
305
|
+
* actually listening on. the runtime http server and the ws bridge are
|
|
306
|
+
* the same server on the same port, but a daemon whose preferred 7668
|
|
307
|
+
* was taken falls back to 7669+. without this the runtime page would
|
|
308
|
+
* hardcode the 7668 default in `resolveBridgePort` and register on a
|
|
309
|
+
* *different* daemon (or none), so `sootsim open` times out waiting for
|
|
310
|
+
* a sim that connected to the wrong bridge.
|
|
311
|
+
* - `window.__sootsimContrastOrigin` — the contrast origin for auth /
|
|
312
|
+
* billing / preview uploads. the daemon serves runtimes from its own
|
|
313
|
+
* loopback host, and engine `origin.ts` otherwise resolves any
|
|
314
|
+
* loopback host to the dev `contrast.localhost:3000` stack — so without this a
|
|
315
|
+
* prod CLI user's preview recording would upload to a localhost stack
|
|
316
|
+
* that does not exist. */
|
|
317
|
+
export function injectSharedConfigIntoHtml(
|
|
318
|
+
data: Buffer,
|
|
319
|
+
bridgePort: number,
|
|
320
|
+
contrastOrigin: string | null,
|
|
321
|
+
): string {
|
|
322
|
+
let payload: string
|
|
323
|
+
try {
|
|
324
|
+
const cfg: SharedConfig = readSharedConfig()
|
|
325
|
+
payload = JSON.stringify(cfg)
|
|
326
|
+
} catch {
|
|
327
|
+
payload = '{}'
|
|
328
|
+
}
|
|
329
|
+
const bridgePortTag = bridgePort > 0 ? `window.__sootsimBridgePort=${bridgePort};` : ''
|
|
330
|
+
const contrastOriginTag = contrastOrigin
|
|
331
|
+
? `window.__sootsimContrastOrigin=${JSON.stringify(contrastOrigin)};`
|
|
332
|
+
: ''
|
|
333
|
+
const tag =
|
|
334
|
+
`<script>window.__sootsimSharedConfig=${payload};` +
|
|
335
|
+
bridgePortTag +
|
|
336
|
+
contrastOriginTag +
|
|
337
|
+
`window.__sootsimCliVersion=${JSON.stringify(getCliVersion())};</script>`
|
|
338
|
+
const html = data.toString('utf8')
|
|
339
|
+
if (html.includes('<head>')) return html.replace('<head>', `<head>${tag}`)
|
|
340
|
+
if (html.includes('</head>')) return html.replace('</head>', tag + '</head>')
|
|
341
|
+
if (html.includes('</body>')) return html.replace('</body>', tag + '</body>')
|
|
342
|
+
// no recognized injection point — prepend so the global is defined
|
|
343
|
+
// before any inline scripts further down in the document.
|
|
344
|
+
return tag + html
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
type UnrefableTimer = { unref: () => void }
|
|
348
|
+
|
|
349
|
+
function unrefTimer(timer: ReturnType<typeof setTimeout>) {
|
|
350
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
|
351
|
+
;(timer as UnrefableTimer).unref()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export class SootSimBridgeHost {
|
|
356
|
+
private port: number
|
|
357
|
+
private openUrlHandler?: (url: string, options?: OpenUrlOptions) => Promise<void> | void
|
|
358
|
+
private httpServer: ReturnType<typeof createServer> | null = null
|
|
359
|
+
private wss: WebSocketServer | null = null
|
|
360
|
+
private nextCommandId = 1
|
|
361
|
+
private nextSimNumber = 0xa1
|
|
362
|
+
private sims = new Map<string, BridgeSimConnection>()
|
|
363
|
+
private primarySimId: string | null = null
|
|
364
|
+
private pendingCommands = new Map<number, BridgePendingCommand>()
|
|
365
|
+
private cliBySentId = new Map<number, BridgeForwardedCommand>()
|
|
366
|
+
private cliSimBySocket = new Map<WebSocket, string>()
|
|
367
|
+
private cliLastCommandAt = new Map<WebSocket, number>()
|
|
368
|
+
private cliIdentityKeyBySocket = new Map<WebSocket, string>()
|
|
369
|
+
private cliLabelBySocket = new Map<WebSocket, string>()
|
|
370
|
+
private restorableSims = new Map<string, BridgeRestorableSimState>()
|
|
371
|
+
private nextCliFallbackId = 1
|
|
372
|
+
private cliIdleTimer: NodeJS.Timeout | null = null
|
|
373
|
+
private agentHost: AgentHost
|
|
374
|
+
private static CLI_IDLE_TIMEOUT_MS = 60_000
|
|
375
|
+
private static CLI_LEASE_TTL_MS = 600_000
|
|
376
|
+
private static USER_ACTIVE_LEASE_TTL_MS = 8_000
|
|
377
|
+
// explicit user actions (clicking Boot, focusing the sim to take it over)
|
|
378
|
+
// hold the sim longer than passive canvas interaction so reconnecting clis
|
|
379
|
+
// can't immediately reclaim while the user gets oriented.
|
|
380
|
+
private static USER_BOOT_LEASE_TTL_MS = 60_000
|
|
381
|
+
private static SIM_RECONNECT_TTL_MS = 30_000
|
|
382
|
+
// abandoned-tab GC. the ws heartbeat only reaps sims whose socket went
|
|
383
|
+
// dead — but a background tab from a prior `sootsim open` / QA / test run
|
|
384
|
+
// keeps its socket alive (the page still pongs) forever, so `sootsim list`
|
|
385
|
+
// accretes dozens of zombie sims that all time out on every command (QA
|
|
386
|
+
// F21-3, carried F19-2). reap a sim only when it is provably nobody's:
|
|
387
|
+
// not primary, not user-focused, no CLI attached, no active lease, and no
|
|
388
|
+
// CLI-driven activity for this long. closeSimSocketFromHost sends the
|
|
389
|
+
// terminal 4001 code, so the client closes the abandoned window instead
|
|
390
|
+
// of reconnecting the zombie straight back in.
|
|
391
|
+
private static SIM_IDLE_REAP_TTL_MS = 30 * 60_000
|
|
392
|
+
// automation sims (spawned by the playwright driver: `sootsim open --driver
|
|
393
|
+
// playwright`, the factory/screenshot capture path — `meta.sootsimHostDriver
|
|
394
|
+
// === 'playwright'`) are unattended one-shot browsers, never a human's tab. a
|
|
395
|
+
// capture run opens one per slide/app/retry and the 30-min human/dev TTL lets
|
|
396
|
+
// hundreds pile up inside the window — orphaned ms-playwright chromiums that
|
|
397
|
+
// drove the box to swap-thrash (load hit 157 in one incident). give them a far
|
|
398
|
+
// shorter idle TTL, and cap how many can sit concurrently: reap the most-idle
|
|
399
|
+
// overflow, but never a sim active within ACTIVE_GRACE (an agent composing
|
|
400
|
+
// between captures), nor primary / cli-attached / leased sims.
|
|
401
|
+
private static AUTOMATION_SIM_IDLE_REAP_TTL_MS = 10 * 60_000
|
|
402
|
+
private static MAX_CONCURRENT_AUTOMATION_SIMS = 6
|
|
403
|
+
private static AUTOMATION_SIM_ACTIVE_GRACE_MS = 60_000
|
|
404
|
+
|
|
405
|
+
private preferredPort: number
|
|
406
|
+
private portFallbackCount: number
|
|
407
|
+
private simIdleReapTtlMs: number
|
|
408
|
+
private automationSimIdleReapTtlMs: number
|
|
409
|
+
private maxConcurrentAutomationSims: number
|
|
410
|
+
private automationSimActiveGraceMs: number
|
|
411
|
+
private shouldWriteLockfile: boolean
|
|
412
|
+
private shouldWriteDevLockfile: boolean
|
|
413
|
+
private getShellPort: (() => number | null) | null
|
|
414
|
+
private contrastOrigin: string | null = null
|
|
415
|
+
private effectivePort = 0
|
|
416
|
+
private startedAt = 0
|
|
417
|
+
private heartbeatTimer: NodeJS.Timeout | null = null
|
|
418
|
+
private devHeartbeatTimer: NodeJS.Timeout | null = null
|
|
419
|
+
// ws-level heartbeat: sims that hang up uncleanly (page navigated, network
|
|
420
|
+
// dropped, sim crashed) leave their server-side WebSocket sitting "open"
|
|
421
|
+
// forever. ping every WS_HEARTBEAT_INTERVAL_MS; if the previous round's
|
|
422
|
+
// ping was never answered, terminate(). that fires 'close' which runs the
|
|
423
|
+
// sim-cleanup path and stops `sootsim list` from showing 8 zombie
|
|
424
|
+
// sims that all time out on every command.
|
|
425
|
+
private wsHeartbeatTimer: NodeJS.Timeout | null = null
|
|
426
|
+
private wsIsAlive = new WeakMap<WebSocket, boolean>()
|
|
427
|
+
private static WS_HEARTBEAT_INTERVAL_MS = 30_000
|
|
428
|
+
private runtimeUpdateTimer: NodeJS.Timeout | null = null
|
|
429
|
+
private runtimeUpdateInFlight: Promise<void> | null = null
|
|
430
|
+
private activeRuntimeVersion: string | null = null
|
|
431
|
+
private activeRuntimeDirPath: string | null = null
|
|
432
|
+
// /__server-scan cache. mirrors the shell vite dev-middleware so engine
|
|
433
|
+
// ConnectRN / DemoConnectApp see the same JSON shape whether they boot
|
|
434
|
+
// from vite dev or from this daemon. without this, the SPA fallback below
|
|
435
|
+
// would serve index.html for /__server-scan and tenant-worker .json()
|
|
436
|
+
// crashes with "Unexpected token '<', "<!doctype "... is not valid JSON".
|
|
437
|
+
private scanCache: DiscoveredServer[] | null = null
|
|
438
|
+
private scanCacheAt = 0
|
|
439
|
+
private inflightScan: Promise<DiscoveredServer[]> | null = null
|
|
440
|
+
private static SCAN_FRESH_MS = 2000
|
|
441
|
+
|
|
442
|
+
constructor(opts: BridgeHostOptions = {}) {
|
|
443
|
+
this.preferredPort = opts.port || DEFAULT_SOOTSIM_BRIDGE_PORT
|
|
444
|
+
this.port = this.preferredPort
|
|
445
|
+
// default off — callers that want the lockfile (sootsim serve) opt in.
|
|
446
|
+
this.shouldWriteLockfile = opts.writeLockfile === true
|
|
447
|
+
this.shouldWriteDevLockfile = opts.writeDevLockfile === true
|
|
448
|
+
this.getShellPort = opts.getShellPort ?? null
|
|
449
|
+
// fallback freely on port collision regardless of who owns the lockfile.
|
|
450
|
+
// serve.ts already refuses to start if a fresh daemon.json exists, so
|
|
451
|
+
// two daemons can't race the lockfile. without this, an unrelated process
|
|
452
|
+
// on 7668 (another worktree's vite dev fallback bridge, etc.) was enough
|
|
453
|
+
// to keep the daemon from binding at all — and launchd's KeepAlive then
|
|
454
|
+
// respawned it forever.
|
|
455
|
+
this.portFallbackCount = Math.max(1, opts.portFallbackCount ?? 10)
|
|
456
|
+
this.openUrlHandler = opts.openUrl
|
|
457
|
+
this.agentHost = new AgentHost({
|
|
458
|
+
getExcludePorts: opts.agentScanExcludes,
|
|
459
|
+
resolveCliLease: (simId) => {
|
|
460
|
+
const sim = this.sims.get(simId)
|
|
461
|
+
const lease = sim ? this.getActiveLease(sim) : null
|
|
462
|
+
return lease
|
|
463
|
+
? {
|
|
464
|
+
kind: lease.kind,
|
|
465
|
+
cliIdentityKey: lease.cliIdentityKey,
|
|
466
|
+
expiresAt: lease.expiresAt,
|
|
467
|
+
}
|
|
468
|
+
: null
|
|
469
|
+
},
|
|
470
|
+
})
|
|
471
|
+
this.contrastOrigin = opts.contrastOrigin?.replace(/\/$/, '') || null
|
|
472
|
+
this.simIdleReapTtlMs =
|
|
473
|
+
opts.simIdleReapTtlMs ?? SootSimBridgeHost.SIM_IDLE_REAP_TTL_MS
|
|
474
|
+
this.automationSimIdleReapTtlMs =
|
|
475
|
+
opts.automationSimIdleReapTtlMs ?? SootSimBridgeHost.AUTOMATION_SIM_IDLE_REAP_TTL_MS
|
|
476
|
+
this.maxConcurrentAutomationSims =
|
|
477
|
+
opts.maxConcurrentAutomationSims ?? SootSimBridgeHost.MAX_CONCURRENT_AUTOMATION_SIMS
|
|
478
|
+
this.automationSimActiveGraceMs =
|
|
479
|
+
opts.automationSimActiveGraceMs ?? SootSimBridgeHost.AUTOMATION_SIM_ACTIVE_GRACE_MS
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** expose the agent host so tests and embedders can inspect state or
|
|
483
|
+
* inject behavior. not part of the public WS protocol. */
|
|
484
|
+
getAgentHost(): AgentHost {
|
|
485
|
+
return this.agentHost
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** run the abandoned-sim GC pass on demand. the reaper normally fires off
|
|
489
|
+
* the 30s idle-sweep timer; this lets the F21-3 integration test drive
|
|
490
|
+
* the real path without waiting for the timer. not part of the public
|
|
491
|
+
* WS protocol. */
|
|
492
|
+
reapIdleSimsForTest(now = Date.now()): void {
|
|
493
|
+
this.reapIdleSims(now)
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** synchronous wrapper around startAsync for callers that don't care
|
|
497
|
+
* about port fallback outcomes. returns immediately; actual binding
|
|
498
|
+
* happens on the event loop. callers that need to know the bound port
|
|
499
|
+
* should await startAsync() instead. */
|
|
500
|
+
start(options?: { silent?: boolean }): void {
|
|
501
|
+
void this.startAsync(options)
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async startAsync(options?: { silent?: boolean }): Promise<number> {
|
|
505
|
+
if (this.httpServer || this.wss) return this.effectivePort
|
|
506
|
+
|
|
507
|
+
// seed active runtime state from disk so the http routes + lockfile
|
|
508
|
+
// reflect reality at boot. we reread this on runtime:use messages.
|
|
509
|
+
this.refreshActiveRuntime()
|
|
510
|
+
|
|
511
|
+
for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
|
|
512
|
+
const candidate = this.preferredPort + attempt
|
|
513
|
+
try {
|
|
514
|
+
await this.bindOnce(candidate, options?.silent === true)
|
|
515
|
+
this.effectivePort = candidate
|
|
516
|
+
this.port = candidate
|
|
517
|
+
this.startedAt = Date.now()
|
|
518
|
+
if (attempt > 0 && !options?.silent) {
|
|
519
|
+
process.stderr.write(
|
|
520
|
+
`ws bridge bound to port ${candidate} (preferred ${this.preferredPort} was taken)\n`,
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
this.afterBind()
|
|
524
|
+
return candidate
|
|
525
|
+
} catch (err: unknown) {
|
|
526
|
+
const e = err as NodeJS.ErrnoException
|
|
527
|
+
if (e?.code !== 'EADDRINUSE') {
|
|
528
|
+
throw err
|
|
529
|
+
}
|
|
530
|
+
if (!options?.silent) {
|
|
531
|
+
process.stderr.write(
|
|
532
|
+
`ws bridge port ${candidate} already in use, trying ${candidate + 1}\n`,
|
|
533
|
+
)
|
|
534
|
+
}
|
|
535
|
+
// bindOnce already cleaned up httpServer/wss on failure
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
throw new Error(
|
|
539
|
+
`could not bind ws bridge after ${this.portFallbackCount} attempts starting at ${this.preferredPort}`,
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
private bindOnce(port: number, _silent: boolean): Promise<void> {
|
|
544
|
+
return new Promise<void>((resolve, reject) => {
|
|
545
|
+
const server = createServer((req, res) => this.handleHttpRequest(req, res))
|
|
546
|
+
let settled = false
|
|
547
|
+
|
|
548
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
549
|
+
if (settled) return
|
|
550
|
+
settled = true
|
|
551
|
+
try {
|
|
552
|
+
server.close()
|
|
553
|
+
} catch {}
|
|
554
|
+
this.httpServer = null
|
|
555
|
+
this.wss = null
|
|
556
|
+
reject(err)
|
|
557
|
+
}
|
|
558
|
+
server.once('error', onError)
|
|
559
|
+
|
|
560
|
+
// loopback-only bind. we listen on 127.0.0.1 explicitly because the
|
|
561
|
+
// daemon serves the active runtime's dist as plain static files —
|
|
562
|
+
// never expose that to LAN peers. tests + the electron renderer use
|
|
563
|
+
// 127.0.0.1 explicitly to avoid the localhost-vs-::1 DNS coinflip.
|
|
564
|
+
server.listen(port, '127.0.0.1', () => {
|
|
565
|
+
if (settled) return
|
|
566
|
+
settled = true
|
|
567
|
+
server.removeListener('error', onError)
|
|
568
|
+
server.on('error', (err) => {
|
|
569
|
+
process.stderr.write(`ws bridge http error: ${String(err)}\n`)
|
|
570
|
+
})
|
|
571
|
+
this.httpServer = server
|
|
572
|
+
this.wss = new WebSocketServer({ noServer: true })
|
|
573
|
+
this.wireWebSocketServer()
|
|
574
|
+
server.on('upgrade', (req, socket, head) => {
|
|
575
|
+
if (handleWebSocketProxyUpgrade(req, socket, head)) return
|
|
576
|
+
this.wss?.handleUpgrade(req, socket, head, (ws) => {
|
|
577
|
+
this.wss?.emit('connection', ws, req)
|
|
578
|
+
})
|
|
579
|
+
})
|
|
580
|
+
resolve()
|
|
581
|
+
})
|
|
582
|
+
})
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** attach the WS connection handler to the current wss. called from
|
|
586
|
+
* bindOnce() after WebSocketServer is freshly created. */
|
|
587
|
+
private wireWebSocketServer() {
|
|
588
|
+
if (!this.wss) return
|
|
589
|
+
this.wss.on('connection', (ws, req) => {
|
|
590
|
+
const origin = req.headers.origin
|
|
591
|
+
let role: 'sim' | 'cli' = origin ? 'sim' : 'cli'
|
|
592
|
+
let sim: BridgeSimConnection | null = null
|
|
593
|
+
|
|
594
|
+
// ws emits 'error' for late peer-side socket faults (write EIO after
|
|
595
|
+
// sleep/wake, abrupt sim close, etc). without a listener, node's
|
|
596
|
+
// EventEmitter rethrows as uncaughtException and crashes the host.
|
|
597
|
+
// 'close' fires right after and runs the cleanup below.
|
|
598
|
+
ws.on('error', () => {})
|
|
599
|
+
|
|
600
|
+
// ws-level heartbeat. pong responses come for free from the ws
|
|
601
|
+
// protocol — we just need to track them so the heartbeat sweep
|
|
602
|
+
// (started in afterBind) can terminate connections that stop
|
|
603
|
+
// answering. fresh connections start alive.
|
|
604
|
+
this.wsIsAlive.set(ws, true)
|
|
605
|
+
ws.on('pong', () => {
|
|
606
|
+
this.wsIsAlive.set(ws, true)
|
|
607
|
+
})
|
|
608
|
+
|
|
609
|
+
// register the socket with the agent host so session-status pushes
|
|
610
|
+
// reach it even before it subscribes to any specific session, and so
|
|
611
|
+
// subscriptions get cleaned up automatically on close.
|
|
612
|
+
this.agentHost.registerSocket(ws)
|
|
613
|
+
|
|
614
|
+
if (role === 'sim') {
|
|
615
|
+
sim = {
|
|
616
|
+
id: this.allocateSimId(),
|
|
617
|
+
ws,
|
|
618
|
+
origin,
|
|
619
|
+
connectedAt: Date.now(),
|
|
620
|
+
lastSeenAt: Date.now(),
|
|
621
|
+
lastActiveAt: 0,
|
|
622
|
+
recentActions: [],
|
|
623
|
+
}
|
|
624
|
+
this.sims.set(sim.id, sim)
|
|
625
|
+
this.writeConnectedRuntimeSnapshot()
|
|
626
|
+
if (this.shouldPromoteSim(sim)) {
|
|
627
|
+
this.primarySimId = sim.id
|
|
628
|
+
}
|
|
629
|
+
this.broadcastSimAssignments()
|
|
630
|
+
this.broadcastSimClientStates()
|
|
631
|
+
} else {
|
|
632
|
+
const fallbackKey = `ws-${this.nextCliFallbackId++}`
|
|
633
|
+
this.cliIdentityKeyBySocket.set(ws, fallbackKey)
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
ws.on('message', (data) => {
|
|
637
|
+
let msg: any
|
|
638
|
+
try {
|
|
639
|
+
msg = JSON.parse(data.toString())
|
|
640
|
+
} catch {
|
|
641
|
+
return
|
|
642
|
+
}
|
|
643
|
+
if (!msg || typeof msg !== 'object') return
|
|
644
|
+
|
|
645
|
+
// agent:* messages are routed uniformly regardless of socket role.
|
|
646
|
+
// CLI (`sootsim agent …`), electron main (daemon client), and
|
|
647
|
+
// sim shells all use the same envelope, and agent event
|
|
648
|
+
// subscriptions work from any of them — this is the whole point of
|
|
649
|
+
// moving session ownership into the daemon.
|
|
650
|
+
if (typeof msg.type === 'string' && msg.type.startsWith('agent:')) {
|
|
651
|
+
void this.agentHost.handleMessage(ws, msg)
|
|
652
|
+
return
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
// runtime:* messages manage installed engine runtimes. list / use
|
|
656
|
+
// are handled in-daemon; install runs entirely inside the CLI so
|
|
657
|
+
// we don't need a daemon-side handler for it.
|
|
658
|
+
if (msg.type === 'runtime:list') {
|
|
659
|
+
const versions = sootsimRuntime.listInstalled()
|
|
660
|
+
const active = this.getActiveRuntime()
|
|
661
|
+
const reply = {
|
|
662
|
+
type: 'runtime:list:ok',
|
|
663
|
+
id: msg.id,
|
|
664
|
+
installed: versions,
|
|
665
|
+
active: active.version,
|
|
666
|
+
activeRuntimeDir: active.runtimeDir,
|
|
667
|
+
}
|
|
668
|
+
try {
|
|
669
|
+
ws.send(JSON.stringify(reply))
|
|
670
|
+
} catch {}
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
if (msg.type === 'runtime:use') {
|
|
674
|
+
const version = typeof msg.version === 'string' ? msg.version : ''
|
|
675
|
+
const installed = sootsimRuntime.listInstalled()
|
|
676
|
+
if (!installed.includes(version)) {
|
|
677
|
+
try {
|
|
678
|
+
ws.send(
|
|
679
|
+
JSON.stringify({
|
|
680
|
+
type: 'runtime:use:error',
|
|
681
|
+
id: msg.id,
|
|
682
|
+
error: `runtime ${version || '(missing)'} is not installed`,
|
|
683
|
+
}),
|
|
684
|
+
)
|
|
685
|
+
} catch {}
|
|
686
|
+
return
|
|
687
|
+
}
|
|
688
|
+
const result = this.setActiveRuntime(version)
|
|
689
|
+
try {
|
|
690
|
+
ws.send(
|
|
691
|
+
JSON.stringify({
|
|
692
|
+
type: 'runtime:use:ok',
|
|
693
|
+
id: msg.id,
|
|
694
|
+
version: result.version,
|
|
695
|
+
runtimeDir: result.runtimeDir,
|
|
696
|
+
}),
|
|
697
|
+
)
|
|
698
|
+
} catch {}
|
|
699
|
+
return
|
|
700
|
+
}
|
|
701
|
+
if (msg.type === 'runtime:get') {
|
|
702
|
+
const active = this.getActiveRuntime()
|
|
703
|
+
try {
|
|
704
|
+
ws.send(
|
|
705
|
+
JSON.stringify({
|
|
706
|
+
type: 'runtime:get:ok',
|
|
707
|
+
id: msg.id,
|
|
708
|
+
active: active.version,
|
|
709
|
+
activeRuntimeDir: active.runtimeDir,
|
|
710
|
+
}),
|
|
711
|
+
)
|
|
712
|
+
} catch {}
|
|
713
|
+
return
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Browsers always send an Origin header, but the IDE needs a second,
|
|
717
|
+
// short-lived CLI-role socket to route typed app_* calls to an
|
|
718
|
+
// Contrast-dev-owned external runtime driver. `bridge:hello` is
|
|
719
|
+
// already the CLI handshake, so let it correct the provisional
|
|
720
|
+
// Origin-based role before this socket has registered a page. This reuses the existing
|
|
721
|
+
// daemon transport and call envelope; it does not add a second routing
|
|
722
|
+
// surface or let a registered sim switch roles mid-connection.
|
|
723
|
+
if (role === 'sim' && msg.type === 'bridge:hello' && sim && !sim.url) {
|
|
724
|
+
const provisionalId = sim.id
|
|
725
|
+
this.sims.delete(provisionalId)
|
|
726
|
+
if (this.primarySimId === provisionalId) {
|
|
727
|
+
this.primarySimId = this.getOpenSim()?.id ?? null
|
|
728
|
+
}
|
|
729
|
+
sim = null
|
|
730
|
+
role = 'cli'
|
|
731
|
+
this.cliIdentityKeyBySocket.set(ws, `ws-${this.nextCliFallbackId++}`)
|
|
732
|
+
this.writeConnectedRuntimeSnapshot()
|
|
733
|
+
this.broadcastSimAssignments()
|
|
734
|
+
this.broadcastSimClientStates()
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
if (role === 'sim') {
|
|
738
|
+
if (sim) {
|
|
739
|
+
sim.lastSeenAt = Date.now()
|
|
740
|
+
}
|
|
741
|
+
if (msg.type === 'bridge:register' && sim) {
|
|
742
|
+
const registration = msg as BridgeSimRegistrationMessage
|
|
743
|
+
const restored = this.tryRestoreSimId(sim, registration.simId)
|
|
744
|
+
sim.url = registration.url
|
|
745
|
+
sim.title = registration.title
|
|
746
|
+
sim.userAgent = registration.userAgent
|
|
747
|
+
this.writeConnectedRuntimeSnapshot()
|
|
748
|
+
// kind/meta are optional and free-form. only update if the
|
|
749
|
+
// sender supplied a value so we don't accidentally clear
|
|
750
|
+
// state on a heartbeat-style re-register.
|
|
751
|
+
if (typeof registration.kind === 'string' && registration.kind.trim()) {
|
|
752
|
+
sim.kind = registration.kind.trim()
|
|
753
|
+
}
|
|
754
|
+
if (registration.meta && typeof registration.meta === 'object') {
|
|
755
|
+
sim.meta = registration.meta as Record<string, unknown>
|
|
756
|
+
}
|
|
757
|
+
// re-evaluate primary now that the sim has a real page. promotion
|
|
758
|
+
// is otherwise decided once at bare-connect, before `url` is
|
|
759
|
+
// known — so a page-less sim that grabbed primary as the
|
|
760
|
+
// last-resort fallback (or a stale zombie still holding it)
|
|
761
|
+
// would never be superseded by this freshly-registered, actually
|
|
762
|
+
// driveable sim (QA F19-2). only adopt a *different* sim so a
|
|
763
|
+
// routine heartbeat re-register doesn't churn the assignment.
|
|
764
|
+
const reElected = this.primarySimId !== sim.id && this.shouldPromoteSim(sim)
|
|
765
|
+
if (reElected) this.primarySimId = sim.id
|
|
766
|
+
if (restored || reElected) {
|
|
767
|
+
this.broadcastSimAssignments()
|
|
768
|
+
this.broadcastSimClientStates()
|
|
769
|
+
}
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
if (msg.type === 'bridge:user-focus-state' && sim) {
|
|
774
|
+
const focusState = msg as BridgeSimUserFocusStateMessage
|
|
775
|
+
this.updateUserFocusLease(sim, focusState)
|
|
776
|
+
return
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
if (msg.type === 'bridge:user-interact' && sim) {
|
|
780
|
+
this.updateUserActivity(sim)
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
// write a partial patch into ~/.sootsim/config.json. fired by the
|
|
785
|
+
// engine's settingsStore when a persisted key flips in a browser
|
|
786
|
+
// tab (which has no fs access of its own). after the merge, we
|
|
787
|
+
// broadcast the new full snapshot to every connected sim so any
|
|
788
|
+
// tab that has the engine config global picks up the change live
|
|
789
|
+
// — same shape as electron's `config:changed` IPC.
|
|
790
|
+
if (msg.type === 'bridge:write-shared-config') {
|
|
791
|
+
if (!msg.patch || typeof msg.patch !== 'object' || Array.isArray(msg.patch)) {
|
|
792
|
+
return
|
|
793
|
+
}
|
|
794
|
+
const patch = Object.fromEntries(
|
|
795
|
+
Object.entries(msg.patch),
|
|
796
|
+
) satisfies Partial<SharedConfig>
|
|
797
|
+
try {
|
|
798
|
+
this.writeAndBroadcastSharedConfig(patch)
|
|
799
|
+
} catch (err) {
|
|
800
|
+
process.stderr.write(
|
|
801
|
+
`sootsim: bridge:write-shared-config failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
802
|
+
)
|
|
803
|
+
return
|
|
804
|
+
}
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// open a source file in the user's editor. triggered by tappable
|
|
809
|
+
// stack frames in sootsim's RedBox overlay.
|
|
810
|
+
if (msg.type === 'bridge:open-path') {
|
|
811
|
+
const filePath = typeof msg.path === 'string' ? msg.path : ''
|
|
812
|
+
const line =
|
|
813
|
+
typeof msg.line === 'number' && Number.isFinite(msg.line)
|
|
814
|
+
? msg.line
|
|
815
|
+
: undefined
|
|
816
|
+
const column =
|
|
817
|
+
typeof msg.column === 'number' && Number.isFinite(msg.column)
|
|
818
|
+
? msg.column
|
|
819
|
+
: undefined
|
|
820
|
+
if (filePath) {
|
|
821
|
+
void this.openPathInEditor(filePath, line, column)
|
|
822
|
+
}
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// boot-clients: disconnect all CLI clients attached to this sim
|
|
827
|
+
// and hand the sim to the user. an explicit Boot is a strong claim,
|
|
828
|
+
// so we install a user-active lease instead of clearing — otherwise
|
|
829
|
+
// a reconnecting agent (most spawn a fresh socket within ms of close)
|
|
830
|
+
// claims the empty slot before the user can interact and the boot
|
|
831
|
+
// becomes a no-op the user has to repeat indefinitely.
|
|
832
|
+
if (msg.type === 'bridge:boot-clients' && sim) {
|
|
833
|
+
const booted: WebSocket[] = []
|
|
834
|
+
for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
|
|
835
|
+
if (attachedSimId === sim.id) {
|
|
836
|
+
booted.push(cliWs)
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
for (const cliWs of booted) {
|
|
840
|
+
this.cliSimBySocket.delete(cliWs)
|
|
841
|
+
try {
|
|
842
|
+
cliWs.close(1000, 'booted by sim')
|
|
843
|
+
} catch {}
|
|
844
|
+
}
|
|
845
|
+
const hadLease = !!sim.cliLease
|
|
846
|
+
sim.cliLease = {
|
|
847
|
+
kind: 'user-active',
|
|
848
|
+
cliIdentityKey: '__user-active__',
|
|
849
|
+
cliLabel: 'active user',
|
|
850
|
+
expiresAt: Date.now() + SootSimBridgeHost.USER_BOOT_LEASE_TTL_MS,
|
|
851
|
+
}
|
|
852
|
+
process.stderr.write(
|
|
853
|
+
`rnx booted ${booted.length} cli client(s)${hadLease ? ' (overrode prior lease)' : ''}; held sim for user [${sim.id}]\n`,
|
|
854
|
+
)
|
|
855
|
+
this.recordSimAction(sim.id, 'sim booted cli clients')
|
|
856
|
+
this.broadcastSimClientStates()
|
|
857
|
+
return
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const internalPending = this.pendingCommands.get(msg.id)
|
|
861
|
+
if (internalPending) {
|
|
862
|
+
this.pendingCommands.delete(msg.id)
|
|
863
|
+
if (msg.error) internalPending.reject(new Error(msg.error))
|
|
864
|
+
else internalPending.resolve(msg.result)
|
|
865
|
+
return
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const entry = this.cliBySentId.get(msg.id)
|
|
869
|
+
if (entry) {
|
|
870
|
+
this.cliBySentId.delete(msg.id)
|
|
871
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
872
|
+
// include other CLI count so the client can warn about contention
|
|
873
|
+
const otherCliCount = this.getOtherCliIdentityCount(entry.ws, entry.simId)
|
|
874
|
+
const response =
|
|
875
|
+
otherCliCount > 0
|
|
876
|
+
? { ...msg, id: entry.originalId, _otherCliCount: otherCliCount }
|
|
877
|
+
: { ...msg, id: entry.originalId }
|
|
878
|
+
entry.ws.send(JSON.stringify(response))
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
return
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
void (async () => {
|
|
885
|
+
this.cliLastCommandAt.set(ws, Date.now())
|
|
886
|
+
try {
|
|
887
|
+
if (msg.type === 'bridge:bye') {
|
|
888
|
+
// explicit goodbye from a cli about to exit. drop its socket
|
|
889
|
+
// state immediately so the next invocation from the same agent
|
|
890
|
+
// doesn't see this one as a phantom peer. the subsequent tcp
|
|
891
|
+
// close event becomes a no-op.
|
|
892
|
+
const hadSim = this.cliSimBySocket.delete(ws)
|
|
893
|
+
this.cliLastCommandAt.delete(ws)
|
|
894
|
+
this.cliIdentityKeyBySocket.delete(ws)
|
|
895
|
+
this.cliLabelBySocket.delete(ws)
|
|
896
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
897
|
+
if (entry.ws === ws) this.cliBySentId.delete(sentId)
|
|
898
|
+
}
|
|
899
|
+
if (hadSim) this.broadcastSimClientStates()
|
|
900
|
+
return
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
if (msg.type === 'bridge:hello') {
|
|
904
|
+
const key =
|
|
905
|
+
typeof msg.cliIdentityKey === 'string' && msg.cliIdentityKey.trim()
|
|
906
|
+
? msg.cliIdentityKey.trim()
|
|
907
|
+
: this.cliIdentityKeyBySocket.get(ws) ||
|
|
908
|
+
`ws-${this.nextCliFallbackId++}`
|
|
909
|
+
this.cliIdentityKeyBySocket.set(ws, key)
|
|
910
|
+
if (typeof msg.cliLabel === 'string' && msg.cliLabel.trim()) {
|
|
911
|
+
this.cliLabelBySocket.set(ws, msg.cliLabel.trim())
|
|
912
|
+
}
|
|
913
|
+
// same-identity cli sockets are allowed to coexist. this avoids
|
|
914
|
+
// self-disconnects when agent tooling issues multiple commands
|
|
915
|
+
// in parallel from the same logical identity key.
|
|
916
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
917
|
+
ws.send(
|
|
918
|
+
JSON.stringify({
|
|
919
|
+
id: msg.id,
|
|
920
|
+
result: {
|
|
921
|
+
cliIdentityKey: key,
|
|
922
|
+
leaseTtlMs: SootSimBridgeHost.CLI_LEASE_TTL_MS,
|
|
923
|
+
leasing: true,
|
|
924
|
+
},
|
|
925
|
+
}),
|
|
926
|
+
)
|
|
927
|
+
}
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (msg.type === 'bridge:list-sims') {
|
|
932
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
933
|
+
ws.send(
|
|
934
|
+
JSON.stringify({
|
|
935
|
+
id: msg.id,
|
|
936
|
+
result: this.listSims(),
|
|
937
|
+
}),
|
|
938
|
+
)
|
|
939
|
+
}
|
|
940
|
+
return
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (msg.type === 'bridge:claim') {
|
|
944
|
+
const targetSim = await this.waitForSim(msg.simId)
|
|
945
|
+
const outcome = this.tryAcquireLease(ws, targetSim, {
|
|
946
|
+
force: msg.force === true,
|
|
947
|
+
})
|
|
948
|
+
if (!outcome.granted) {
|
|
949
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
950
|
+
ws.send(
|
|
951
|
+
JSON.stringify({
|
|
952
|
+
id: msg.id,
|
|
953
|
+
error: `sim ${targetSim.id} is locked by another cli`,
|
|
954
|
+
_locked: outcome.lock,
|
|
955
|
+
}),
|
|
956
|
+
)
|
|
957
|
+
}
|
|
958
|
+
return
|
|
959
|
+
}
|
|
960
|
+
this.setCliSimTarget(ws, targetSim.id)
|
|
961
|
+
this.recordSimAction(
|
|
962
|
+
targetSim.id,
|
|
963
|
+
outcome.bootedCount > 0
|
|
964
|
+
? `cli force-claimed sim (booted ${outcome.bootedCount})`
|
|
965
|
+
: 'cli claimed sim',
|
|
966
|
+
)
|
|
967
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
968
|
+
ws.send(
|
|
969
|
+
JSON.stringify({
|
|
970
|
+
id: msg.id,
|
|
971
|
+
result: {
|
|
972
|
+
simId: targetSim.id,
|
|
973
|
+
lockedBy: outcome.lease.cliIdentityKey,
|
|
974
|
+
lockExpiresAt: outcome.lease.expiresAt,
|
|
975
|
+
bootedCount: outcome.bootedCount,
|
|
976
|
+
},
|
|
977
|
+
}),
|
|
978
|
+
)
|
|
979
|
+
}
|
|
980
|
+
return
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const targetSim = await this.waitForSim(msg.simId)
|
|
984
|
+
if (shouldAcquireLease(msg)) {
|
|
985
|
+
const outcome = this.tryAcquireLease(ws, targetSim)
|
|
986
|
+
if (!outcome.granted) {
|
|
987
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
988
|
+
ws.send(
|
|
989
|
+
JSON.stringify({
|
|
990
|
+
id: msg.id,
|
|
991
|
+
error: `sim ${targetSim.id} is locked by another cli — use \`rnxsim claim ${targetSim.id} --force\` or \`rnxsim open --new\``,
|
|
992
|
+
_locked: outcome.lock,
|
|
993
|
+
}),
|
|
994
|
+
)
|
|
995
|
+
}
|
|
996
|
+
return
|
|
997
|
+
}
|
|
998
|
+
} else {
|
|
999
|
+
// read-only / observational pass-through: still register this
|
|
1000
|
+
// cli as attached so list/describe shows it, but never block.
|
|
1001
|
+
this.ensureCliIdentityKey(ws)
|
|
1002
|
+
}
|
|
1003
|
+
this.setCliSimTarget(ws, targetSim.id)
|
|
1004
|
+
this.recordSimAction(targetSim.id, this.describeForwardedCommand(msg))
|
|
1005
|
+
const sentId = this.nextCommandId++
|
|
1006
|
+
this.cliBySentId.set(sentId, {
|
|
1007
|
+
simId: targetSim.id,
|
|
1008
|
+
ws,
|
|
1009
|
+
originalId: msg.id,
|
|
1010
|
+
})
|
|
1011
|
+
const { simId: _simId, ...forwarded } = msg
|
|
1012
|
+
targetSim.ws.send(JSON.stringify({ ...forwarded, id: sentId }))
|
|
1013
|
+
// `close` must never hang on the sim page. a frozen sim never
|
|
1014
|
+
// processes the forwarded close and never replies, so the CLI
|
|
1015
|
+
// command would otherwise time out after the full command
|
|
1016
|
+
// window (M3). treat close as fire-and-forget: ack the CLI now,
|
|
1017
|
+
// drop the pending relay entry so a late sim reply is harmless,
|
|
1018
|
+
// and disconnect the sim socket with the terminal close code if
|
|
1019
|
+
// the page has not closed itself within the grace window.
|
|
1020
|
+
if (forwarded.type === 'close') {
|
|
1021
|
+
this.cliBySentId.delete(sentId)
|
|
1022
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1023
|
+
ws.send(
|
|
1024
|
+
JSON.stringify({
|
|
1025
|
+
id: msg.id,
|
|
1026
|
+
result: { requested: true, simId: targetSim.id },
|
|
1027
|
+
}),
|
|
1028
|
+
)
|
|
1029
|
+
}
|
|
1030
|
+
const simWs = targetSim.ws
|
|
1031
|
+
const closeTimer = setTimeout(() => {
|
|
1032
|
+
this.closeSimSocketFromHost(simWs)
|
|
1033
|
+
}, FORCE_CLOSE_GRACE_MS)
|
|
1034
|
+
unrefTimer(closeTimer)
|
|
1035
|
+
}
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
1038
|
+
ws.send(
|
|
1039
|
+
JSON.stringify({
|
|
1040
|
+
id: msg.id,
|
|
1041
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1042
|
+
}),
|
|
1043
|
+
)
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
})()
|
|
1047
|
+
})
|
|
1048
|
+
|
|
1049
|
+
ws.on('close', () => {
|
|
1050
|
+
// always drop agent subscriptions first — the FIFO refcount needs
|
|
1051
|
+
// to settle before any later broadcast fan-out fires.
|
|
1052
|
+
this.agentHost.unregisterSocket(ws)
|
|
1053
|
+
if (role === 'sim' && sim) {
|
|
1054
|
+
this.rememberDisconnectedSim(sim)
|
|
1055
|
+
if (this.primarySimId === sim.id) {
|
|
1056
|
+
this.primarySimId = this.getOpenSim()?.id ?? null
|
|
1057
|
+
}
|
|
1058
|
+
for (const [id, pending] of this.pendingCommands) {
|
|
1059
|
+
if (pending.simId !== sim.id) continue
|
|
1060
|
+
pending.reject(new Error('sim disconnected'))
|
|
1061
|
+
this.pendingCommands.delete(id)
|
|
1062
|
+
}
|
|
1063
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
1064
|
+
if (entry.simId !== sim.id) continue
|
|
1065
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
1066
|
+
entry.ws.send(
|
|
1067
|
+
JSON.stringify({
|
|
1068
|
+
id: entry.originalId,
|
|
1069
|
+
error: 'sim disconnected before responding',
|
|
1070
|
+
}),
|
|
1071
|
+
)
|
|
1072
|
+
}
|
|
1073
|
+
this.cliBySentId.delete(sentId)
|
|
1074
|
+
}
|
|
1075
|
+
this.broadcastSimAssignments()
|
|
1076
|
+
this.broadcastSimClientStates()
|
|
1077
|
+
} else if (role === 'cli') {
|
|
1078
|
+
const detached = this.cliSimBySocket.delete(ws)
|
|
1079
|
+
this.cliLastCommandAt.delete(ws)
|
|
1080
|
+
this.cliIdentityKeyBySocket.delete(ws)
|
|
1081
|
+
this.cliLabelBySocket.delete(ws)
|
|
1082
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
1083
|
+
if (entry.ws === ws) this.cliBySentId.delete(sentId)
|
|
1084
|
+
}
|
|
1085
|
+
if (detached) {
|
|
1086
|
+
this.broadcastSimClientStates()
|
|
1087
|
+
}
|
|
1088
|
+
}
|
|
1089
|
+
})
|
|
1090
|
+
})
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
/** after a successful bind: start the cli idle sweep, write the daemon/dev
|
|
1094
|
+
* lockfile (if this host owns it), seed the agent host, kick off the
|
|
1095
|
+
* heartbeat loop. idempotent across rebinds because close() tears down
|
|
1096
|
+
* every timer and the lockfile. */
|
|
1097
|
+
private afterBind() {
|
|
1098
|
+
process.stderr.write(`ws bridge listening on port ${this.port}\n`)
|
|
1099
|
+
|
|
1100
|
+
this.cliIdleTimer = setInterval(
|
|
1101
|
+
() => this.sweepIdleCliClients(),
|
|
1102
|
+
30_000,
|
|
1103
|
+
) as unknown as NodeJS.Timeout
|
|
1104
|
+
this.cliIdleTimer.unref()
|
|
1105
|
+
|
|
1106
|
+
this.wsHeartbeatTimer = setInterval(
|
|
1107
|
+
() => this.sweepDeadWebSockets(),
|
|
1108
|
+
SootSimBridgeHost.WS_HEARTBEAT_INTERVAL_MS,
|
|
1109
|
+
) as unknown as NodeJS.Timeout
|
|
1110
|
+
this.wsHeartbeatTimer.unref()
|
|
1111
|
+
|
|
1112
|
+
if (this.shouldWriteLockfile) {
|
|
1113
|
+
try {
|
|
1114
|
+
ensureSootsimHome()
|
|
1115
|
+
// atomic claim: bail if another fresh daemon's lockfile already
|
|
1116
|
+
// exists (stale ones are overwritten). last line of defense
|
|
1117
|
+
// against two daemons clobbering the same file between the
|
|
1118
|
+
// freshness check in serve.ts and here.
|
|
1119
|
+
const claimed = claimDaemonLockfile(this.buildLockfileSnapshot())
|
|
1120
|
+
if (!claimed) {
|
|
1121
|
+
throw new Error(
|
|
1122
|
+
'another rnx daemon wrote the lockfile during startup — aborting',
|
|
1123
|
+
)
|
|
1124
|
+
}
|
|
1125
|
+
} catch (err) {
|
|
1126
|
+
process.stderr.write(
|
|
1127
|
+
`ws bridge failed to claim daemon lockfile: ${String(err)}\n`,
|
|
1128
|
+
)
|
|
1129
|
+
throw err
|
|
1130
|
+
}
|
|
1131
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1132
|
+
try {
|
|
1133
|
+
this.writeLockfileSnapshot()
|
|
1134
|
+
} catch {}
|
|
1135
|
+
}, DAEMON_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout
|
|
1136
|
+
this.heartbeatTimer.unref()
|
|
1137
|
+
this.startRuntimeUpdater()
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
if (this.shouldWriteDevLockfile) {
|
|
1141
|
+
try {
|
|
1142
|
+
this.writeDevLockfileSnapshot()
|
|
1143
|
+
} catch (err) {
|
|
1144
|
+
process.stderr.write(
|
|
1145
|
+
`ws bridge failed to write dev bridge lockfile: ${String(err)}\n`,
|
|
1146
|
+
)
|
|
1147
|
+
}
|
|
1148
|
+
this.devHeartbeatTimer = setInterval(() => {
|
|
1149
|
+
try {
|
|
1150
|
+
this.writeDevLockfileSnapshot()
|
|
1151
|
+
} catch {}
|
|
1152
|
+
}, DAEMON_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout
|
|
1153
|
+
this.devHeartbeatTimer.unref()
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
// seed the attached-projects store from the demo registry on first
|
|
1157
|
+
// daemon boot. idempotent; no-ops once the store has anything in it.
|
|
1158
|
+
void this.agentHost.seedOnBoot()
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
private bootstrapping = true
|
|
1162
|
+
|
|
1163
|
+
private connectedRuntimeVersions(): string[] {
|
|
1164
|
+
const versions = new Set<string>()
|
|
1165
|
+
for (const sim of this.sims.values()) {
|
|
1166
|
+
try {
|
|
1167
|
+
const url = new URL(sim.url || sim.origin || 'http://localhost')
|
|
1168
|
+
const version = runtimeVersionFromHostname(url.hostname)
|
|
1169
|
+
if (version) versions.add(version)
|
|
1170
|
+
} catch {}
|
|
1171
|
+
}
|
|
1172
|
+
return [...versions].sort()
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
private buildLockfileSnapshot(): DaemonLockfile {
|
|
1176
|
+
return {
|
|
1177
|
+
schema: 1,
|
|
1178
|
+
pid: process.pid,
|
|
1179
|
+
platform: process.platform,
|
|
1180
|
+
bridgePort: this.effectivePort,
|
|
1181
|
+
runtimePort: this.effectivePort,
|
|
1182
|
+
activeRuntime: this.activeRuntimeVersion,
|
|
1183
|
+
activeRuntimeDir: this.activeRuntimeDirPath,
|
|
1184
|
+
servedRuntimes: this.connectedRuntimeVersions(),
|
|
1185
|
+
startedAt: this.startedAt,
|
|
1186
|
+
heartbeatAt: Date.now(),
|
|
1187
|
+
bootstrapping: this.bootstrapping,
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
private buildDevLockfileSnapshot(): DevBridgeLockfile {
|
|
1192
|
+
const shellPort = this.getShellPort?.() ?? null
|
|
1193
|
+
return {
|
|
1194
|
+
schema: 1,
|
|
1195
|
+
pid: process.pid,
|
|
1196
|
+
platform: process.platform,
|
|
1197
|
+
bridgePort: this.effectivePort,
|
|
1198
|
+
runtimePort: this.effectivePort,
|
|
1199
|
+
...(shellPort && shellPort > 0 ? { shellPort } : {}),
|
|
1200
|
+
cwd: process.cwd(),
|
|
1201
|
+
startedAt: this.startedAt,
|
|
1202
|
+
heartbeatAt: Date.now(),
|
|
1203
|
+
source: 'vite-dev',
|
|
1204
|
+
servedRuntimes: this.connectedRuntimeVersions(),
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
private writeLockfileSnapshot() {
|
|
1209
|
+
writeDaemonLockfile(this.buildLockfileSnapshot())
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
private writeDevLockfileSnapshot() {
|
|
1213
|
+
writeDevBridgeLockfile(this.buildDevLockfileSnapshot())
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
private writeConnectedRuntimeSnapshot() {
|
|
1217
|
+
if (this.shouldWriteLockfile && this.httpServer) {
|
|
1218
|
+
try {
|
|
1219
|
+
this.writeLockfileSnapshot()
|
|
1220
|
+
} catch {}
|
|
1221
|
+
}
|
|
1222
|
+
if (this.shouldWriteDevLockfile && this.httpServer) {
|
|
1223
|
+
try {
|
|
1224
|
+
this.writeDevLockfileSnapshot()
|
|
1225
|
+
} catch {}
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
private refreshActiveRuntime() {
|
|
1230
|
+
this.activeRuntimeVersion = readActiveRuntime()
|
|
1231
|
+
// mode boundary (see runtime-assets resolveDevCheckoutRuntimeRoot): inside
|
|
1232
|
+
// the Contrast monorepo dev checkout serve the fresh dev-stack build so local
|
|
1233
|
+
// engine edits reach previews without a publish; otherwise the installed
|
|
1234
|
+
// runtime. the version above stays the installed version for display.
|
|
1235
|
+
this.activeRuntimeDirPath = resolveDevCheckoutRuntimeRoot() ?? getActiveRuntimeDir()
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
private runServerScan(): Promise<DiscoveredServer[]> {
|
|
1239
|
+
if (this.inflightScan) return this.inflightScan
|
|
1240
|
+
const excludePorts = this.effectivePort > 0 ? [this.effectivePort] : []
|
|
1241
|
+
this.inflightScan = scanDevServers({
|
|
1242
|
+
excludePorts,
|
|
1243
|
+
buildIconProxyUrl: (externalUrl) =>
|
|
1244
|
+
`/__bundle-proxy?url=${encodeURIComponent(externalUrl)}`,
|
|
1245
|
+
})
|
|
1246
|
+
.then((results) => {
|
|
1247
|
+
this.scanCache = results
|
|
1248
|
+
this.scanCacheAt = Date.now()
|
|
1249
|
+
return results
|
|
1250
|
+
})
|
|
1251
|
+
.catch((err: unknown) => {
|
|
1252
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1253
|
+
console.error('[rnx] /__server-scan failed:', message)
|
|
1254
|
+
return this.scanCache ?? []
|
|
1255
|
+
})
|
|
1256
|
+
.finally(() => {
|
|
1257
|
+
this.inflightScan = null
|
|
1258
|
+
})
|
|
1259
|
+
return this.inflightScan
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
private handleServerScan(res: ServerResponse) {
|
|
1263
|
+
const sendJson = (body: DiscoveredServer[]) => {
|
|
1264
|
+
res.writeHead(200, {
|
|
1265
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1266
|
+
'Cache-Control': 'no-store',
|
|
1267
|
+
})
|
|
1268
|
+
res.end(JSON.stringify(body))
|
|
1269
|
+
}
|
|
1270
|
+
const age = Date.now() - this.scanCacheAt
|
|
1271
|
+
if (this.scanCache && age < SootSimBridgeHost.SCAN_FRESH_MS) {
|
|
1272
|
+
sendJson(this.scanCache)
|
|
1273
|
+
return
|
|
1274
|
+
}
|
|
1275
|
+
if (this.scanCache) {
|
|
1276
|
+
// stale-while-revalidate: return last known good immediately, kick a
|
|
1277
|
+
// fresh scan in the background.
|
|
1278
|
+
sendJson(this.scanCache)
|
|
1279
|
+
void this.runServerScan().catch(() => {})
|
|
1280
|
+
return
|
|
1281
|
+
}
|
|
1282
|
+
void this.runServerScan().then((results) => sendJson(results))
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
private resolveRuntimeUpdateIntervalMs() {
|
|
1286
|
+
const raw = Number(process.env[RUNTIME_UPDATE_INTERVAL_ENV])
|
|
1287
|
+
if (Number.isFinite(raw) && raw > 0) return Math.max(100, Math.round(raw))
|
|
1288
|
+
return DEFAULT_RUNTIME_UPDATE_INTERVAL_MS
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
private startRuntimeUpdater() {
|
|
1292
|
+
if (!this.shouldWriteLockfile || this.runtimeUpdateTimer) return
|
|
1293
|
+
if (fs.existsSync(path.join(sootsimHomeDir(), 'runtime-update-disabled'))) return
|
|
1294
|
+
// an isolated dev daemon (explicit SOOTSIM_HOME, dev checkout, nothing
|
|
1295
|
+
// staged) serves the repo's public/sootsim build. auto-installing the
|
|
1296
|
+
// published runtime here would stage it active and shadow that dev build
|
|
1297
|
+
// (runtime-assets prefers a staged runtime under an explicit home), so the
|
|
1298
|
+
// daemon would silently validate published code instead of the checkout.
|
|
1299
|
+
// local-publish writes the marker above when it stages a source build.
|
|
1300
|
+
if (process.env.SOOTSIM_HOME && isSootsimDevCheckout() && !readActiveRuntime()) {
|
|
1301
|
+
return
|
|
1302
|
+
}
|
|
1303
|
+
void this.runRuntimeUpdate('startup')
|
|
1304
|
+
const intervalMs = this.resolveRuntimeUpdateIntervalMs()
|
|
1305
|
+
this.runtimeUpdateTimer = setInterval(() => {
|
|
1306
|
+
void this.runRuntimeUpdate('periodic')
|
|
1307
|
+
}, intervalMs) as unknown as NodeJS.Timeout
|
|
1308
|
+
this.runtimeUpdateTimer.unref()
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
private runRuntimeUpdate(reason: 'startup' | 'periodic'): Promise<void> {
|
|
1312
|
+
if (this.runtimeUpdateInFlight) return this.runtimeUpdateInFlight
|
|
1313
|
+
this.runtimeUpdateInFlight = (async () => {
|
|
1314
|
+
try {
|
|
1315
|
+
if (reason === 'startup') {
|
|
1316
|
+
process.stderr.write('rnx: checking for runtime updates…\n')
|
|
1317
|
+
}
|
|
1318
|
+
const result = await sootsimRuntime.updateToLatest({
|
|
1319
|
+
protectVersions: this.connectedRuntimeVersions(),
|
|
1320
|
+
})
|
|
1321
|
+
if (!result.updated || !result.latestVersion) {
|
|
1322
|
+
if (reason === 'startup') {
|
|
1323
|
+
process.stderr.write(
|
|
1324
|
+
`rnx: runtime ${this.activeRuntimeVersion ?? '(none)'} is current\n`,
|
|
1325
|
+
)
|
|
1326
|
+
}
|
|
1327
|
+
return
|
|
1328
|
+
}
|
|
1329
|
+
const previousVersion = this.activeRuntimeVersion
|
|
1330
|
+
const active = this.setActiveRuntime(result.latestVersion)
|
|
1331
|
+
process.stderr.write(`rnx runtime updated to ${active.version} (${reason})\n`)
|
|
1332
|
+
// the daemon's stderr goes to a log nobody watches — drop a one-shot
|
|
1333
|
+
// notice so the next interactive CLI run tells the user their engine
|
|
1334
|
+
// moved (consumed + deleted by bin.ts).
|
|
1335
|
+
try {
|
|
1336
|
+
writeRuntimeUpgradeNotice({
|
|
1337
|
+
from: previousVersion,
|
|
1338
|
+
to: active.version,
|
|
1339
|
+
at: Date.now(),
|
|
1340
|
+
})
|
|
1341
|
+
} catch {}
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
process.stderr.write(
|
|
1344
|
+
`rnx runtime update failed (${reason}): ${
|
|
1345
|
+
err instanceof Error ? err.message : String(err)
|
|
1346
|
+
}\n`,
|
|
1347
|
+
)
|
|
1348
|
+
} finally {
|
|
1349
|
+
this.runtimeUpdateInFlight = null
|
|
1350
|
+
// first-boot gate flips off once the startup pass finishes (success
|
|
1351
|
+
// or fail) — clients can navigate even if the network update failed
|
|
1352
|
+
// as long as some runtime is on disk. without this the splash would
|
|
1353
|
+
// wait forever on a temporary network blip.
|
|
1354
|
+
if (reason === 'startup' && this.bootstrapping) {
|
|
1355
|
+
this.bootstrapping = false
|
|
1356
|
+
if (this.shouldWriteLockfile && this.httpServer) {
|
|
1357
|
+
try {
|
|
1358
|
+
this.writeLockfileSnapshot()
|
|
1359
|
+
} catch {}
|
|
1360
|
+
}
|
|
1361
|
+
process.stderr.write('rnx: ready\n')
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
})()
|
|
1365
|
+
return this.runtimeUpdateInFlight
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
/** update the active runtime on disk + in memory. the caller guarantees
|
|
1369
|
+
* the version directory exists. pushes a runtime:changed message to all
|
|
1370
|
+
* connected sims so electron (or any renderer) can reload. */
|
|
1371
|
+
setActiveRuntime(version: string): { version: string; runtimeDir: string | null } {
|
|
1372
|
+
writeActiveRuntime(version)
|
|
1373
|
+
this.refreshActiveRuntime()
|
|
1374
|
+
if (this.shouldWriteLockfile && this.httpServer) {
|
|
1375
|
+
try {
|
|
1376
|
+
this.writeLockfileSnapshot()
|
|
1377
|
+
} catch {}
|
|
1378
|
+
}
|
|
1379
|
+
// broadcast to sims so electron can reload its webContents without
|
|
1380
|
+
// a manual restart. CLI clients ignore this message.
|
|
1381
|
+
const payload = JSON.stringify({
|
|
1382
|
+
type: 'runtime:changed',
|
|
1383
|
+
version,
|
|
1384
|
+
runtimeDir: this.activeRuntimeDirPath,
|
|
1385
|
+
})
|
|
1386
|
+
for (const sim of this.sims.values()) {
|
|
1387
|
+
try {
|
|
1388
|
+
const simUrl = new URL(sim.url || sim.origin || 'http://localhost')
|
|
1389
|
+
if (runtimeVersionFromHostname(simUrl.hostname)) continue
|
|
1390
|
+
} catch {}
|
|
1391
|
+
if (sim.ws.readyState === WebSocket.OPEN) {
|
|
1392
|
+
try {
|
|
1393
|
+
sim.ws.send(payload)
|
|
1394
|
+
} catch {}
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
return { version, runtimeDir: this.activeRuntimeDirPath }
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
getActiveRuntime(): { version: string | null; runtimeDir: string | null } {
|
|
1401
|
+
return {
|
|
1402
|
+
version: this.activeRuntimeVersion,
|
|
1403
|
+
runtimeDir: this.activeRuntimeDirPath,
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/** last-ditch lockfile cleanup. safe to call from a synchronous
|
|
1408
|
+
* `process.on('exit', ...)` handler since it only does a fs.unlinkSync. */
|
|
1409
|
+
removeLockfile() {
|
|
1410
|
+
if (!this.shouldWriteLockfile) return
|
|
1411
|
+
try {
|
|
1412
|
+
removeDaemonLockfile()
|
|
1413
|
+
} catch {}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/** minimal HTTP request handler attached to the same node http server
|
|
1417
|
+
* that hosts the WS upgrade. handles:
|
|
1418
|
+
* GET /healthz json status for supervisors / curl
|
|
1419
|
+
* GET / + everything serves from the active runtime dist, SPA fallback
|
|
1420
|
+
* non-upgrade routes that don't match serve index.html (SPA behavior) so
|
|
1421
|
+
* electron's webContents can navigate freely inside the runtime. */
|
|
1422
|
+
private handleHttpRequest(req: IncomingMessage, res: ServerResponse) {
|
|
1423
|
+
// cross-origin isolation — without this, Electron / Chromium refuses
|
|
1424
|
+
// `SharedArrayBuffer`, and the engine's render-worker crashes on boot
|
|
1425
|
+
// ("render worker crashed during boot" / "app:one surface registration
|
|
1426
|
+
// failed"). SAB powers the shell-scene fast channel and the worklet
|
|
1427
|
+
// runtime's SharedValues. mirrors sootsim-shell/vite.config.ts (dev) and
|
|
1428
|
+
// contrast.dev app/_middleware.ts (prod) so the cli daemon serves the
|
|
1429
|
+
// same surface those two already enforce. set via setHeader so subsequent
|
|
1430
|
+
// writeHead() calls preserve them (writeHead only overrides keys it
|
|
1431
|
+
// explicitly passes). Safari/iOS WebKit does not isolate documents for
|
|
1432
|
+
// COEP=credentialless, so use require-corp and keep bridge-served/proxied
|
|
1433
|
+
// resources same-origin with CORP where needed. mirrors the SootSim shell
|
|
1434
|
+
// dev server and the /sootsim production override.
|
|
1435
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
|
1436
|
+
res.setHeader('Cross-Origin-Embedder-Policy', SOOTSIM_CROSS_ORIGIN_EMBEDDER_POLICY)
|
|
1437
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
|
|
1438
|
+
res.setHeader('Document-Policy', 'js-profiling')
|
|
1439
|
+
|
|
1440
|
+
// proxy + app-api routes need to accept POST/PUT/DELETE/PATCH/OPTIONS
|
|
1441
|
+
// because they forward the request to a real upstream. handle them
|
|
1442
|
+
// BEFORE the read-only method check below — otherwise tenant bundles
|
|
1443
|
+
// fetching cross-origin APIs through the daemon (e.g. when the shell
|
|
1444
|
+
// dev server isn't running) get a 405 instead of the proxied response,
|
|
1445
|
+
// and downstream NetInfo-style reachability probes flip to "offline".
|
|
1446
|
+
if (isFetchProxyRequestUrl(req.url)) {
|
|
1447
|
+
void handleFetchProxyRequest(req, res)
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
if (isAppApiRequestUrl(req.url) && handleAppApiRequest(req, res)) {
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
1455
|
+
let url: URL
|
|
1456
|
+
try {
|
|
1457
|
+
url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`)
|
|
1458
|
+
} catch {
|
|
1459
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
1460
|
+
res.end('invalid request URL or host')
|
|
1461
|
+
return
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// /__sootsim/shared-config is the one awaited HTTP write. ordinary
|
|
1465
|
+
// settings fan out over WS, but destructive reloads need confirmation
|
|
1466
|
+
// that the disk source of truth accepted their final reset first.
|
|
1467
|
+
if (url.pathname === '/__sootsim/shared-config') {
|
|
1468
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
1469
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
|
|
1470
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
1471
|
+
if (method === 'OPTIONS') {
|
|
1472
|
+
res.writeHead(204, { Allow: 'GET, HEAD, POST, OPTIONS' })
|
|
1473
|
+
res.end()
|
|
1474
|
+
return
|
|
1475
|
+
}
|
|
1476
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
1477
|
+
let body = '{}'
|
|
1478
|
+
try {
|
|
1479
|
+
body = JSON.stringify(readSharedConfig())
|
|
1480
|
+
} catch {}
|
|
1481
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1482
|
+
if (method === 'HEAD') res.end()
|
|
1483
|
+
else res.end(body)
|
|
1484
|
+
return
|
|
1485
|
+
}
|
|
1486
|
+
if (method === 'POST') {
|
|
1487
|
+
void (async () => {
|
|
1488
|
+
try {
|
|
1489
|
+
const chunks: Buffer[] = []
|
|
1490
|
+
let size = 0
|
|
1491
|
+
for await (const chunk of req) {
|
|
1492
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
|
|
1493
|
+
size += buffer.byteLength
|
|
1494
|
+
if (size > 8 * 1024 * 1024) {
|
|
1495
|
+
res.writeHead(413, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
1496
|
+
res.end('shared config patch is too large')
|
|
1497
|
+
return
|
|
1498
|
+
}
|
|
1499
|
+
chunks.push(buffer)
|
|
1500
|
+
}
|
|
1501
|
+
const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
|
|
1502
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1503
|
+
throw new Error('shared config patch must be an object')
|
|
1504
|
+
}
|
|
1505
|
+
const patch = Object.fromEntries(
|
|
1506
|
+
Object.entries(parsed),
|
|
1507
|
+
) satisfies Partial<SharedConfig>
|
|
1508
|
+
const next = this.writeAndBroadcastSharedConfig(patch)
|
|
1509
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1510
|
+
res.end(JSON.stringify(next))
|
|
1511
|
+
} catch (error) {
|
|
1512
|
+
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
1513
|
+
res.end(
|
|
1514
|
+
`invalid shared config patch: ${error instanceof Error ? error.message : String(error)}`,
|
|
1515
|
+
)
|
|
1516
|
+
}
|
|
1517
|
+
})()
|
|
1518
|
+
return
|
|
1519
|
+
}
|
|
1520
|
+
res.writeHead(405, { Allow: 'GET, HEAD, POST, OPTIONS' })
|
|
1521
|
+
res.end('method not allowed')
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// every other daemon route is read-only unless its owning proxy handled
|
|
1526
|
+
// the mutation above.
|
|
1527
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
1528
|
+
res.writeHead(405, { Allow: 'GET, HEAD' })
|
|
1529
|
+
res.end('method not allowed')
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
// /__bundle-proxy?url=<encoded> — runtime/worker fetches metro bundles +
|
|
1534
|
+
// assets through here. needed because workers can't directly fetch a
|
|
1535
|
+
// cross-origin URL without CORS headers, and metro doesn't set them.
|
|
1536
|
+
// we proxy the request and stream the response back.
|
|
1537
|
+
if (url.pathname === '/__bundle-proxy') {
|
|
1538
|
+
const target = url.searchParams.get('url')
|
|
1539
|
+
if (!target) {
|
|
1540
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
1541
|
+
res.end('bundle-proxy: missing url query param')
|
|
1542
|
+
return
|
|
1543
|
+
}
|
|
1544
|
+
let parsedTarget: URL
|
|
1545
|
+
try {
|
|
1546
|
+
parsedTarget = new URL(target)
|
|
1547
|
+
} catch {
|
|
1548
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
1549
|
+
res.end('bundle-proxy: invalid url')
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
// limit to local loopback targets — preventing the daemon from being
|
|
1553
|
+
// turned into an open proxy for arbitrary internet fetches.
|
|
1554
|
+
const host = parsedTarget.hostname
|
|
1555
|
+
const isLoopback =
|
|
1556
|
+
host === 'localhost' ||
|
|
1557
|
+
host === '127.0.0.1' ||
|
|
1558
|
+
host === '::1' ||
|
|
1559
|
+
host.endsWith('.localhost')
|
|
1560
|
+
if (!isLoopback) {
|
|
1561
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
1562
|
+
res.end('bundle-proxy: only loopback targets allowed')
|
|
1563
|
+
return
|
|
1564
|
+
}
|
|
1565
|
+
void (async () => {
|
|
1566
|
+
try {
|
|
1567
|
+
const upstream = await fetch(parsedTarget.toString(), {
|
|
1568
|
+
redirect: 'follow',
|
|
1569
|
+
})
|
|
1570
|
+
const headers: Record<string, string> = {}
|
|
1571
|
+
const ct = upstream.headers.get('content-type')
|
|
1572
|
+
if (ct) headers['Content-Type'] = ct
|
|
1573
|
+
headers['Cache-Control'] = 'no-store'
|
|
1574
|
+
res.writeHead(upstream.status, headers)
|
|
1575
|
+
if (!upstream.body) {
|
|
1576
|
+
res.end()
|
|
1577
|
+
return
|
|
1578
|
+
}
|
|
1579
|
+
const reader = upstream.body.getReader()
|
|
1580
|
+
while (true) {
|
|
1581
|
+
const { done, value } = await reader.read()
|
|
1582
|
+
if (done) break
|
|
1583
|
+
res.write(Buffer.from(value))
|
|
1584
|
+
}
|
|
1585
|
+
res.end()
|
|
1586
|
+
} catch (err) {
|
|
1587
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' })
|
|
1588
|
+
res.end(
|
|
1589
|
+
`bundle-proxy: upstream fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1590
|
+
)
|
|
1591
|
+
}
|
|
1592
|
+
})()
|
|
1593
|
+
return
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
// /__server-scan — list local metro/expo/vxrn/one dev servers. tenant
|
|
1597
|
+
// worker ConnectRN polls this every few seconds to populate the device
|
|
1598
|
+
// picker. mirrors packages/sootsim-shell/src/dev-middleware.ts so both
|
|
1599
|
+
// host environments (vite dev and the prod CLI daemon) return identical
|
|
1600
|
+
// JSON. icon URLs are rewritten through /__bundle-proxy so the browser
|
|
1601
|
+
// doesn't hit cross-origin loads against the discovered dev server.
|
|
1602
|
+
if (url.pathname === '/__server-scan') {
|
|
1603
|
+
this.handleServerScan(res)
|
|
1604
|
+
return
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
if (url.pathname === '/healthz') {
|
|
1608
|
+
res.writeHead(200, {
|
|
1609
|
+
'Content-Type': 'application/json',
|
|
1610
|
+
'Cache-Control': 'no-store',
|
|
1611
|
+
})
|
|
1612
|
+
res.end(
|
|
1613
|
+
JSON.stringify({
|
|
1614
|
+
ok: true,
|
|
1615
|
+
pid: process.pid,
|
|
1616
|
+
platform: process.platform,
|
|
1617
|
+
bridgePort: this.effectivePort,
|
|
1618
|
+
runtimePort: this.effectivePort,
|
|
1619
|
+
activeRuntime: this.activeRuntimeVersion,
|
|
1620
|
+
bridgeSource: this.shouldWriteDevLockfile
|
|
1621
|
+
? 'vite-dev'
|
|
1622
|
+
: this.shouldWriteLockfile
|
|
1623
|
+
? 'daemon'
|
|
1624
|
+
: 'embedded',
|
|
1625
|
+
startedAt: this.startedAt,
|
|
1626
|
+
uptimeMs: this.startedAt > 0 ? Date.now() - this.startedAt : 0,
|
|
1627
|
+
}),
|
|
1628
|
+
)
|
|
1629
|
+
return
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
// every installed-runtime shell opens on a version-owned localhost origin.
|
|
1633
|
+
// this keeps its root-relative chunks on one immutable tree while a
|
|
1634
|
+
// background update changes the active runtime for future shells.
|
|
1635
|
+
const versionedRuntimeRequest = isRuntimeVersionHostname(url.hostname)
|
|
1636
|
+
const requestedRuntime = runtimeVersionFromHostname(url.hostname)
|
|
1637
|
+
if (!versionedRuntimeRequest) this.refreshActiveRuntime()
|
|
1638
|
+
const baseDir = versionedRuntimeRequest
|
|
1639
|
+
? resolveRuntimeRootForHostname(url.hostname)
|
|
1640
|
+
: this.activeRuntimeDirPath
|
|
1641
|
+
if (!baseDir) {
|
|
1642
|
+
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
1643
|
+
res.end(
|
|
1644
|
+
versionedRuntimeRequest
|
|
1645
|
+
? `rnx: runtime ${requestedRuntime ?? '(invalid version origin)'} is not installed. run \`rnxsim open\` from that app again.`
|
|
1646
|
+
: 'rnx: no active runtime installed. run `rnxsim runtime install` to fetch one.',
|
|
1647
|
+
)
|
|
1648
|
+
return
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// strip optional runtime prefixes so the daemon can serve both the
|
|
1652
|
+
// local entrypoint and the production-built /sootsim asset graph.
|
|
1653
|
+
let rel = url.pathname
|
|
1654
|
+
if (rel === '/runtime' || rel === '/runtime/') rel = '/'
|
|
1655
|
+
else if (rel.startsWith('/runtime/')) rel = rel.slice('/runtime'.length)
|
|
1656
|
+
else if (rel === '/sootsim' || rel === '/sootsim/') rel = '/'
|
|
1657
|
+
else if (rel.startsWith('/sootsim/')) rel = rel.slice('/sootsim'.length)
|
|
1658
|
+
if (rel === '' || rel === '/') rel = '/index.html'
|
|
1659
|
+
|
|
1660
|
+
// reject obviously-malicious paths up front:
|
|
1661
|
+
// NUL bytes (some fs APIs truncate, confusing downstream readers)
|
|
1662
|
+
// backslashes on non-Windows (treated literally in names, but
|
|
1663
|
+
// frequently used to sneak past string-level .. checks)
|
|
1664
|
+
// raw .. segments (path.resolve collapses them, but reject on sight
|
|
1665
|
+
// so traversal attempts surface as 400s in logs)
|
|
1666
|
+
if (rel.includes('\0')) {
|
|
1667
|
+
res.writeHead(400)
|
|
1668
|
+
res.end('bad request')
|
|
1669
|
+
return
|
|
1670
|
+
}
|
|
1671
|
+
if (process.platform !== 'win32' && rel.includes('\\')) {
|
|
1672
|
+
res.writeHead(400)
|
|
1673
|
+
res.end('bad request')
|
|
1674
|
+
return
|
|
1675
|
+
}
|
|
1676
|
+
for (const segment of rel.split('/')) {
|
|
1677
|
+
if (segment === '..') {
|
|
1678
|
+
res.writeHead(403)
|
|
1679
|
+
res.end('forbidden')
|
|
1680
|
+
return
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
const resolved = path.resolve(baseDir, '.' + rel)
|
|
1685
|
+
const baseWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep
|
|
1686
|
+
if (!resolved.startsWith(baseWithSep) && resolved !== baseDir) {
|
|
1687
|
+
res.writeHead(403)
|
|
1688
|
+
res.end('forbidden')
|
|
1689
|
+
return
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// realpath the resolved path before serving. this catches symlinks
|
|
1693
|
+
// inside baseDir that point outside — without it, an attacker who can
|
|
1694
|
+
// drop a symlink in the runtime dir gets arbitrary-read through the
|
|
1695
|
+
// daemon. realpath also collapses any remaining normalizable segments.
|
|
1696
|
+
fs.realpath(resolved, (realErr, realResolved) => {
|
|
1697
|
+
const servePath = realErr ? resolved : realResolved
|
|
1698
|
+
const servePathWithSep = servePath.endsWith(path.sep)
|
|
1699
|
+
? servePath
|
|
1700
|
+
: servePath + path.sep
|
|
1701
|
+
if (!realErr) {
|
|
1702
|
+
const realBaseWithSep = (() => {
|
|
1703
|
+
try {
|
|
1704
|
+
const rb = fs.realpathSync(baseDir)
|
|
1705
|
+
return rb.endsWith(path.sep) ? rb : rb + path.sep
|
|
1706
|
+
} catch {
|
|
1707
|
+
return baseWithSep
|
|
1708
|
+
}
|
|
1709
|
+
})()
|
|
1710
|
+
if (
|
|
1711
|
+
!servePathWithSep.startsWith(realBaseWithSep) &&
|
|
1712
|
+
servePath + path.sep !== realBaseWithSep
|
|
1713
|
+
) {
|
|
1714
|
+
res.writeHead(403)
|
|
1715
|
+
res.end('forbidden')
|
|
1716
|
+
return
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
fs.stat(servePath, (err, stats) => {
|
|
1721
|
+
if (err || !stats?.isFile()) {
|
|
1722
|
+
// SPA fallback — but only for extensionless paths. a 404 on
|
|
1723
|
+
// /main.a1b2c3.js should 404, not return index.html as js,
|
|
1724
|
+
// which the sim then chokes on.
|
|
1725
|
+
const ext = path.extname(rel).toLowerCase()
|
|
1726
|
+
if (ext && ext !== '.html') {
|
|
1727
|
+
res.writeHead(404)
|
|
1728
|
+
res.end('not found')
|
|
1729
|
+
return
|
|
1730
|
+
}
|
|
1731
|
+
// never SPA-fallback internal endpoint namespaces — those are
|
|
1732
|
+
// expected to return JSON (or a real 404) and the engine calls
|
|
1733
|
+
// .json() on the response. without this, a tenant-worker fetch
|
|
1734
|
+
// to an unimplemented /__foo or /api/foo gets a 200 + index.html
|
|
1735
|
+
// and explodes with "Unexpected token '<', '<!doctype'... is not
|
|
1736
|
+
// valid JSON". /__server-scan is the canonical case (see the
|
|
1737
|
+
// handler above).
|
|
1738
|
+
if (rel.startsWith('/__') || rel.startsWith('/api/') || rel === '/api') {
|
|
1739
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
1740
|
+
res.end('not found')
|
|
1741
|
+
return
|
|
1742
|
+
}
|
|
1743
|
+
const indexPath = path.join(baseDir, 'index.html')
|
|
1744
|
+
fs.readFile(indexPath, (err2, data) => {
|
|
1745
|
+
if (err2) {
|
|
1746
|
+
res.writeHead(404)
|
|
1747
|
+
res.end('not found')
|
|
1748
|
+
return
|
|
1749
|
+
}
|
|
1750
|
+
res.writeHead(200, {
|
|
1751
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1752
|
+
'Cache-Control': 'no-store',
|
|
1753
|
+
})
|
|
1754
|
+
if (method === 'HEAD') {
|
|
1755
|
+
res.end()
|
|
1756
|
+
return
|
|
1757
|
+
}
|
|
1758
|
+
res.end(
|
|
1759
|
+
injectSharedConfigIntoHtml(data, this.effectivePort, this.contrastOrigin),
|
|
1760
|
+
)
|
|
1761
|
+
})
|
|
1762
|
+
return
|
|
1763
|
+
}
|
|
1764
|
+
const ext = path.extname(servePath).toLowerCase()
|
|
1765
|
+
const contentType = HTTP_MIME_TYPES[ext] || 'application/octet-stream'
|
|
1766
|
+
// no-store across the board: the runtime is served from a versioned
|
|
1767
|
+
// directory but the HTTP path doesn't include the version, so the
|
|
1768
|
+
// same URL serves different content after a runtime swap. caching
|
|
1769
|
+
// any asset means a stale-bundle reload after runtime:changed.
|
|
1770
|
+
res.writeHead(200, {
|
|
1771
|
+
'Content-Type': contentType,
|
|
1772
|
+
'Cache-Control': 'no-store',
|
|
1773
|
+
})
|
|
1774
|
+
if (method === 'HEAD') {
|
|
1775
|
+
res.end()
|
|
1776
|
+
return
|
|
1777
|
+
}
|
|
1778
|
+
// html responses inline the daemon-injected `__sootsimSharedConfig`
|
|
1779
|
+
// global so the engine's settingsStore initializes from disk on
|
|
1780
|
+
// boot. for everything else stream the file straight through.
|
|
1781
|
+
if (ext === '.html') {
|
|
1782
|
+
fs.readFile(servePath, (readErr, data) => {
|
|
1783
|
+
if (readErr) {
|
|
1784
|
+
try {
|
|
1785
|
+
res.end()
|
|
1786
|
+
} catch {}
|
|
1787
|
+
return
|
|
1788
|
+
}
|
|
1789
|
+
res.end(
|
|
1790
|
+
injectSharedConfigIntoHtml(data, this.effectivePort, this.contrastOrigin),
|
|
1791
|
+
)
|
|
1792
|
+
})
|
|
1793
|
+
return
|
|
1794
|
+
}
|
|
1795
|
+
const stream = fs.createReadStream(servePath)
|
|
1796
|
+
stream.pipe(res)
|
|
1797
|
+
stream.on('error', () => {
|
|
1798
|
+
try {
|
|
1799
|
+
res.end()
|
|
1800
|
+
} catch {}
|
|
1801
|
+
})
|
|
1802
|
+
})
|
|
1803
|
+
})
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
private sweepIdleCliClients() {
|
|
1807
|
+
const now = Date.now()
|
|
1808
|
+
let swept = false
|
|
1809
|
+
for (const [ws, simId] of this.cliSimBySocket) {
|
|
1810
|
+
const lastCommand = this.cliLastCommandAt.get(ws) ?? 0
|
|
1811
|
+
if (now - lastCommand < SootSimBridgeHost.CLI_IDLE_TIMEOUT_MS) continue
|
|
1812
|
+
// a long-running forwarded command (e.g. a multi-minute path-traced
|
|
1813
|
+
// three-mode render) is still activity: sweeping its socket while the
|
|
1814
|
+
// sim is working kills the render with "idle timeout". only sweep
|
|
1815
|
+
// sockets with no in-flight command awaiting a sim response.
|
|
1816
|
+
let hasPendingCommand = false
|
|
1817
|
+
for (const entry of this.cliBySentId.values()) {
|
|
1818
|
+
if (entry.ws === ws) {
|
|
1819
|
+
hasPendingCommand = true
|
|
1820
|
+
break
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
if (hasPendingCommand) continue
|
|
1824
|
+
this.cliSimBySocket.delete(ws)
|
|
1825
|
+
this.cliLastCommandAt.delete(ws)
|
|
1826
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
1827
|
+
if (entry.ws === ws) this.cliBySentId.delete(sentId)
|
|
1828
|
+
}
|
|
1829
|
+
try {
|
|
1830
|
+
ws.close(1000, 'idle timeout')
|
|
1831
|
+
} catch {}
|
|
1832
|
+
swept = true
|
|
1833
|
+
}
|
|
1834
|
+
if (swept) {
|
|
1835
|
+
this.broadcastSimClientStates()
|
|
1836
|
+
}
|
|
1837
|
+
this.sweepRestorableSims(now)
|
|
1838
|
+
this.reapIdleSims(now)
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
// GC abandoned sims so `sootsim list` stays usable across long dev / QA /
|
|
1842
|
+
// test sessions (F21-3). conservative on purpose: a sim is only reaped
|
|
1843
|
+
// when every "someone cares about this" signal is absent.
|
|
1844
|
+
//
|
|
1845
|
+
// NOTE: `sim.userFocused` is deliberately NOT an exemption. focus is
|
|
1846
|
+
// advisory in this design — see updateUserFocusLease: "focus alone never
|
|
1847
|
+
// creates a blocking lease … use updateUserActivity() to lock on real
|
|
1848
|
+
// interaction instead." real interaction (pointer/key/wheel/touch)
|
|
1849
|
+
// refreshes a `user-active` lease, which the getActiveLease() guard below
|
|
1850
|
+
// already honors. a headless playwright tab reports `focused:true` once
|
|
1851
|
+
// and never `false` (nothing else competes for focus in headless), so a
|
|
1852
|
+
// userFocused exemption permanently shielded exactly the abandoned-tab
|
|
1853
|
+
// zombie class F21-3 / F19-2 target (QA F22-2). a sim a human is genuinely
|
|
1854
|
+
// using is the primary and/or holds a fresh user-active lease; one that is
|
|
1855
|
+
// none of those and idle past the TTL is abandoned regardless of a stale
|
|
1856
|
+
// focus flag.
|
|
1857
|
+
// automation sims are spawned by the playwright driver (`sootsim open
|
|
1858
|
+
// --driver playwright`, the factory/screenshot capture path), which tags them
|
|
1859
|
+
// with `meta.sootsimHostDriver === 'playwright'` via the host's addInitScript.
|
|
1860
|
+
// they are unattended one-shot browsers, never a human's tab, so they get a
|
|
1861
|
+
// shorter idle TTL and a concurrent cap.
|
|
1862
|
+
private isAutomationSim(sim: BridgeSimConnection): boolean {
|
|
1863
|
+
return sim.meta?.sootsimHostDriver === 'playwright'
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// an automation sim whose spawning playwright host process is still alive is
|
|
1867
|
+
// an ACTIVE run, not an abandoned tab. mirrors the cli's playwrightHostPidForSim
|
|
1868
|
+
// (control.ts): extract the host pid from meta, then check liveness so the
|
|
1869
|
+
// host-side idle GC never kills a live capture/factory sim mid-run.
|
|
1870
|
+
private automationSimOwnerAlive(sim: BridgeSimConnection): boolean {
|
|
1871
|
+
const pid = Number(sim.meta?.sootsimHostPid)
|
|
1872
|
+
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid) return false
|
|
1873
|
+
try {
|
|
1874
|
+
process.kill(pid, 0) // ESRCH → dead; EPERM → alive but unsignalable
|
|
1875
|
+
return true
|
|
1876
|
+
} catch (err) {
|
|
1877
|
+
return (err as NodeJS.ErrnoException).code === 'EPERM'
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
private reapIdleSims(now = Date.now()) {
|
|
1882
|
+
const attachedSimIds = new Set(this.cliSimBySocket.values())
|
|
1883
|
+
// automation sims that pass every "someone cares" gate but are still under
|
|
1884
|
+
// their TTL — candidates for the concurrent-cap pass below.
|
|
1885
|
+
const automationLive: { sim: BridgeSimConnection; idleMs: number }[] = []
|
|
1886
|
+
for (const sim of this.sims.values()) {
|
|
1887
|
+
if (sim.id === this.primarySimId) continue
|
|
1888
|
+
if (attachedSimIds.has(sim.id)) continue
|
|
1889
|
+
if (this.getActiveLease(sim)) continue
|
|
1890
|
+
// never-CLI-driven sims have lastActiveAt 0 — fall back to connectedAt
|
|
1891
|
+
// so a freshly opened, not-yet-primary tab isn't reaped on the spot.
|
|
1892
|
+
const lastTouched = Math.max(sim.lastActiveAt, sim.connectedAt)
|
|
1893
|
+
const idleMs = now - lastTouched
|
|
1894
|
+
const automation = this.isAutomationSim(sim)
|
|
1895
|
+
// a live-owner automation sim is an ACTIVE run (its spawning playwright host
|
|
1896
|
+
// process is still alive) — never reap it. the host keeps Chrome alive
|
|
1897
|
+
// intentionally and tears it down itself when ITS owner driver dies; only
|
|
1898
|
+
// orphaned/host-dead autos are the leak this reaper targets. (2026-06: the
|
|
1899
|
+
// 10-min automation TTL was reaping live capture + factory sims mid-run —
|
|
1900
|
+
// too aggressive.) continue skips both the TTL reap and the cap pass below.
|
|
1901
|
+
if (automation && this.automationSimOwnerAlive(sim)) continue
|
|
1902
|
+
const ttl = automation ? this.automationSimIdleReapTtlMs : this.simIdleReapTtlMs
|
|
1903
|
+
if (idleMs >= ttl) {
|
|
1904
|
+
// terminal close → client closes the window, does not reconnect; the
|
|
1905
|
+
// 'close' handler runs rememberDisconnectedSim → this.sims.delete.
|
|
1906
|
+
this.closeSimSocketFromHost(sim.ws)
|
|
1907
|
+
continue
|
|
1908
|
+
}
|
|
1909
|
+
if (automation) automationLive.push({ sim, idleMs })
|
|
1910
|
+
}
|
|
1911
|
+
// concurrent cap: a burst of capture opens can pile up hundreds inside the
|
|
1912
|
+
// TTL window. keep at most maxConcurrentAutomationSims of the freshest;
|
|
1913
|
+
// reap the most-idle overflow, but never one active within the grace (an
|
|
1914
|
+
// agent mid-capture between screenshots).
|
|
1915
|
+
const cap = this.maxConcurrentAutomationSims
|
|
1916
|
+
if (automationLive.length > cap) {
|
|
1917
|
+
automationLive.sort((a, b) => b.idleMs - a.idleMs) // most-idle first
|
|
1918
|
+
for (const { sim, idleMs } of automationLive.slice(
|
|
1919
|
+
0,
|
|
1920
|
+
automationLive.length - cap,
|
|
1921
|
+
)) {
|
|
1922
|
+
if (idleMs < this.automationSimActiveGraceMs) continue
|
|
1923
|
+
this.closeSimSocketFromHost(sim.ws)
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
// ping every connected ws; if the previous round's ping went unanswered,
|
|
1929
|
+
// terminate the socket so 'close' fires and the sim cleanup path
|
|
1930
|
+
// runs. matches the recommended ws-library heartbeat pattern.
|
|
1931
|
+
private sweepDeadWebSockets() {
|
|
1932
|
+
if (!this.wss) return
|
|
1933
|
+
for (const ws of this.wss.clients) {
|
|
1934
|
+
if (ws.readyState !== WebSocket.OPEN) continue
|
|
1935
|
+
const alive = this.wsIsAlive.get(ws)
|
|
1936
|
+
if (alive === false) {
|
|
1937
|
+
try {
|
|
1938
|
+
ws.terminate()
|
|
1939
|
+
} catch {}
|
|
1940
|
+
continue
|
|
1941
|
+
}
|
|
1942
|
+
this.wsIsAlive.set(ws, false)
|
|
1943
|
+
try {
|
|
1944
|
+
ws.ping()
|
|
1945
|
+
} catch {
|
|
1946
|
+
try {
|
|
1947
|
+
ws.terminate()
|
|
1948
|
+
} catch {}
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
private closeSimSocketFromHost(ws: WebSocket) {
|
|
1954
|
+
if (ws.readyState !== WebSocket.OPEN) return
|
|
1955
|
+
try {
|
|
1956
|
+
ws.close(SOOTSIM_BRIDGE_SIM_CLOSE_CODE, SOOTSIM_BRIDGE_SIM_CLOSE_REASON)
|
|
1957
|
+
} catch {
|
|
1958
|
+
try {
|
|
1959
|
+
ws.terminate()
|
|
1960
|
+
} catch {}
|
|
1961
|
+
return
|
|
1962
|
+
}
|
|
1963
|
+
const terminateTimer = setTimeout(() => {
|
|
1964
|
+
if (ws.readyState === WebSocket.CLOSED) return
|
|
1965
|
+
try {
|
|
1966
|
+
ws.terminate()
|
|
1967
|
+
} catch {}
|
|
1968
|
+
}, FORCE_CLOSE_TERMINATE_MS)
|
|
1969
|
+
unrefTimer(terminateTimer)
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
listSims(): BridgeSimInfo[] {
|
|
1973
|
+
return Array.from(this.sims.values())
|
|
1974
|
+
.sort((a, b) => {
|
|
1975
|
+
if (a.id === this.primarySimId) return -1
|
|
1976
|
+
if (b.id === this.primarySimId) return 1
|
|
1977
|
+
return a.connectedAt - b.connectedAt
|
|
1978
|
+
})
|
|
1979
|
+
.map((sim) => this.describeSim(sim))
|
|
1980
|
+
}
|
|
1981
|
+
|
|
1982
|
+
async sendCommand(cmd: Omit<BridgeSimCommand, 'id'>): Promise<any> {
|
|
1983
|
+
const sim = await this.waitForSim(cmd.simId)
|
|
1984
|
+
const id = this.nextCommandId++
|
|
1985
|
+
return new Promise((resolve, reject) => {
|
|
1986
|
+
const timeout = setTimeout(() => {
|
|
1987
|
+
this.pendingCommands.delete(id)
|
|
1988
|
+
this.broadcastSimClientStates()
|
|
1989
|
+
reject(new Error('command timed out after 30s'))
|
|
1990
|
+
}, 30000)
|
|
1991
|
+
|
|
1992
|
+
this.pendingCommands.set(id, {
|
|
1993
|
+
simId: sim.id,
|
|
1994
|
+
resolve: (value) => {
|
|
1995
|
+
clearTimeout(timeout)
|
|
1996
|
+
this.pendingCommands.delete(id)
|
|
1997
|
+
this.broadcastSimClientStates()
|
|
1998
|
+
resolve(value)
|
|
1999
|
+
},
|
|
2000
|
+
reject: (error) => {
|
|
2001
|
+
clearTimeout(timeout)
|
|
2002
|
+
this.pendingCommands.delete(id)
|
|
2003
|
+
this.broadcastSimClientStates()
|
|
2004
|
+
reject(error)
|
|
2005
|
+
},
|
|
2006
|
+
})
|
|
2007
|
+
this.broadcastSimClientStates()
|
|
2008
|
+
|
|
2009
|
+
const { simId: _simId, ...forwarded } = cmd
|
|
2010
|
+
sim.ws.send(JSON.stringify({ ...forwarded, id }))
|
|
2011
|
+
})
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
async evaluate(code: string, simId?: string): Promise<any> {
|
|
2015
|
+
return this.sendCommand({ type: 'evaluate', code, simId })
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
async focusSim(simId?: string): Promise<any> {
|
|
2019
|
+
return this.sendCommand({ type: 'focus', simId })
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
async closeSim(simId?: string): Promise<any> {
|
|
2023
|
+
return this.sendCommand({ type: 'close', simId })
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
async openPathInEditor(
|
|
2027
|
+
filePath: string,
|
|
2028
|
+
line?: number,
|
|
2029
|
+
column?: number,
|
|
2030
|
+
): Promise<void> {
|
|
2031
|
+
const loc = line != null ? `:${line}${column != null ? `:${column}` : ''}` : ''
|
|
2032
|
+
const target = `${filePath}${loc}`
|
|
2033
|
+
|
|
2034
|
+
const trySpawn = (cmd: string, args: string[]) =>
|
|
2035
|
+
new Promise<boolean>((resolve) => {
|
|
2036
|
+
try {
|
|
2037
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' })
|
|
2038
|
+
let settled = false
|
|
2039
|
+
child.on('error', () => {
|
|
2040
|
+
if (settled) return
|
|
2041
|
+
settled = true
|
|
2042
|
+
resolve(false)
|
|
2043
|
+
})
|
|
2044
|
+
child.on('spawn', () => {
|
|
2045
|
+
if (settled) return
|
|
2046
|
+
settled = true
|
|
2047
|
+
child.unref()
|
|
2048
|
+
resolve(true)
|
|
2049
|
+
})
|
|
2050
|
+
} catch {
|
|
2051
|
+
resolve(false)
|
|
2052
|
+
}
|
|
2053
|
+
})
|
|
2054
|
+
|
|
2055
|
+
// prefer the editor the user told us about, then common cli wrappers,
|
|
2056
|
+
// then fall back to the plain `open` path.
|
|
2057
|
+
const envEditor = process.env.REACT_EDITOR || process.env.EDITOR
|
|
2058
|
+
if (envEditor) {
|
|
2059
|
+
const parts = envEditor.split(' ').filter(Boolean)
|
|
2060
|
+
if (parts.length && (await trySpawn(parts[0], [...parts.slice(1), '-g', target])))
|
|
2061
|
+
return
|
|
2062
|
+
}
|
|
2063
|
+
if (await trySpawn('cursor', ['-g', target])) return
|
|
2064
|
+
if (await trySpawn('code', ['-g', target])) return
|
|
2065
|
+
await this.openUrl(filePath)
|
|
2066
|
+
}
|
|
2067
|
+
|
|
2068
|
+
async openUrl(url: string, options: OpenUrlOptions = {}): Promise<void> {
|
|
2069
|
+
if (this.openUrlHandler) {
|
|
2070
|
+
await this.openUrlHandler(url, options)
|
|
2071
|
+
return
|
|
2072
|
+
}
|
|
2073
|
+
await openUrlInBrowser(url, options)
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
async close() {
|
|
2077
|
+
if (this.cliIdleTimer) {
|
|
2078
|
+
clearInterval(this.cliIdleTimer)
|
|
2079
|
+
this.cliIdleTimer = null
|
|
2080
|
+
}
|
|
2081
|
+
if (this.heartbeatTimer) {
|
|
2082
|
+
clearInterval(this.heartbeatTimer)
|
|
2083
|
+
this.heartbeatTimer = null
|
|
2084
|
+
}
|
|
2085
|
+
if (this.devHeartbeatTimer) {
|
|
2086
|
+
clearInterval(this.devHeartbeatTimer)
|
|
2087
|
+
this.devHeartbeatTimer = null
|
|
2088
|
+
}
|
|
2089
|
+
if (this.wsHeartbeatTimer) {
|
|
2090
|
+
clearInterval(this.wsHeartbeatTimer)
|
|
2091
|
+
this.wsHeartbeatTimer = null
|
|
2092
|
+
}
|
|
2093
|
+
if (this.runtimeUpdateTimer) {
|
|
2094
|
+
clearInterval(this.runtimeUpdateTimer)
|
|
2095
|
+
this.runtimeUpdateTimer = null
|
|
2096
|
+
}
|
|
2097
|
+
if (this.shouldWriteLockfile) {
|
|
2098
|
+
try {
|
|
2099
|
+
removeDaemonLockfile()
|
|
2100
|
+
} catch {}
|
|
2101
|
+
}
|
|
2102
|
+
if (this.shouldWriteDevLockfile) {
|
|
2103
|
+
try {
|
|
2104
|
+
removeDevBridgeLockfile()
|
|
2105
|
+
} catch {}
|
|
2106
|
+
}
|
|
2107
|
+
this.effectivePort = 0
|
|
2108
|
+
this.startedAt = 0
|
|
2109
|
+
this.agentHost.close()
|
|
2110
|
+
for (const [id, pending] of this.pendingCommands) {
|
|
2111
|
+
pending.reject(new Error('server closing'))
|
|
2112
|
+
this.pendingCommands.delete(id)
|
|
2113
|
+
}
|
|
2114
|
+
for (const sim of this.sims.values()) {
|
|
2115
|
+
sim.ws.close()
|
|
2116
|
+
}
|
|
2117
|
+
this.sims.clear()
|
|
2118
|
+
this.primarySimId = null
|
|
2119
|
+
const wss = this.wss
|
|
2120
|
+
const httpServer = this.httpServer
|
|
2121
|
+
this.wss = null
|
|
2122
|
+
this.httpServer = null
|
|
2123
|
+
if (wss) {
|
|
2124
|
+
try {
|
|
2125
|
+
wss.close()
|
|
2126
|
+
} catch {}
|
|
2127
|
+
}
|
|
2128
|
+
if (httpServer) {
|
|
2129
|
+
try {
|
|
2130
|
+
httpServer.close()
|
|
2131
|
+
} catch {}
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
|
|
2135
|
+
private describeSim(sim: BridgeSimConnection): BridgeSimInfo {
|
|
2136
|
+
let readyState: BridgeSimConnection['ws']['readyState']
|
|
2137
|
+
try {
|
|
2138
|
+
readyState = sim.ws.readyState
|
|
2139
|
+
} catch {
|
|
2140
|
+
readyState = WebSocket.CLOSED
|
|
2141
|
+
}
|
|
2142
|
+
const lease = this.getActiveLease(sim)
|
|
2143
|
+
return {
|
|
2144
|
+
id: sim.id,
|
|
2145
|
+
origin: sim.origin,
|
|
2146
|
+
url: sim.url,
|
|
2147
|
+
title: sim.title,
|
|
2148
|
+
userAgent: sim.userAgent,
|
|
2149
|
+
connectedAt: sim.connectedAt,
|
|
2150
|
+
lastSeenAt: sim.lastSeenAt,
|
|
2151
|
+
lastActiveAt: sim.lastActiveAt || undefined,
|
|
2152
|
+
isPrimary: sim.id === this.primarySimId,
|
|
2153
|
+
readyState:
|
|
2154
|
+
readyState === WebSocket.OPEN
|
|
2155
|
+
? 'open'
|
|
2156
|
+
: readyState === WebSocket.CLOSING
|
|
2157
|
+
? 'closing'
|
|
2158
|
+
: 'closed',
|
|
2159
|
+
attachedCliCount: this.getAttachedCliCount(sim.id),
|
|
2160
|
+
lockedBy: lease ? lease.cliLabel || lease.cliIdentityKey : undefined,
|
|
2161
|
+
lockedByKind: lease ? lease.kind : undefined,
|
|
2162
|
+
lockExpiresAt: lease ? lease.expiresAt : undefined,
|
|
2163
|
+
userFocused: sim.userFocused || undefined,
|
|
2164
|
+
userVisible: sim.userVisible,
|
|
2165
|
+
visibilityState: sim.visibilityState,
|
|
2166
|
+
documentFocused: sim.documentFocused,
|
|
2167
|
+
kind: sim.kind,
|
|
2168
|
+
meta: sim.meta,
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
private getActiveLease(sim: BridgeSimConnection): BridgeCliLease | null {
|
|
2173
|
+
const lease = sim.cliLease
|
|
2174
|
+
if (!lease) return null
|
|
2175
|
+
if (Date.now() >= lease.expiresAt) {
|
|
2176
|
+
sim.cliLease = undefined
|
|
2177
|
+
return null
|
|
2178
|
+
}
|
|
2179
|
+
return lease
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
private tryAcquireLease(
|
|
2183
|
+
ws: WebSocket,
|
|
2184
|
+
sim: BridgeSimConnection,
|
|
2185
|
+
opts: { force?: boolean } = {},
|
|
2186
|
+
): {
|
|
2187
|
+
granted: boolean
|
|
2188
|
+
lease: BridgeCliLease
|
|
2189
|
+
lock?: BridgeLockInfo
|
|
2190
|
+
bootedCount: number
|
|
2191
|
+
} {
|
|
2192
|
+
const cliIdentityKey =
|
|
2193
|
+
this.cliIdentityKeyBySocket.get(ws) ??
|
|
2194
|
+
(() => {
|
|
2195
|
+
const fallback = `ws-${this.nextCliFallbackId++}`
|
|
2196
|
+
this.cliIdentityKeyBySocket.set(ws, fallback)
|
|
2197
|
+
return fallback
|
|
2198
|
+
})()
|
|
2199
|
+
const cliLabel = this.cliLabelBySocket.get(ws)
|
|
2200
|
+
const now = Date.now()
|
|
2201
|
+
const existing = this.getActiveLease(sim)
|
|
2202
|
+
const ownerMatches = existing && existing.cliIdentityKey === cliIdentityKey
|
|
2203
|
+
let bootedCount = 0
|
|
2204
|
+
|
|
2205
|
+
if (existing && !ownerMatches && !opts.force) {
|
|
2206
|
+
return {
|
|
2207
|
+
granted: false,
|
|
2208
|
+
lease: existing,
|
|
2209
|
+
lock: {
|
|
2210
|
+
by: existing.cliLabel || existing.cliIdentityKey,
|
|
2211
|
+
expiresInMs: Math.max(0, existing.expiresAt - now),
|
|
2212
|
+
},
|
|
2213
|
+
bootedCount: 0,
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
if (existing && !ownerMatches && opts.force) {
|
|
2218
|
+
// boot other CLI sockets attached to this sim that don't share the lease key
|
|
2219
|
+
for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
|
|
2220
|
+
if (attachedSimId !== sim.id) continue
|
|
2221
|
+
const otherKey = this.cliIdentityKeyBySocket.get(cliWs)
|
|
2222
|
+
if (otherKey && otherKey !== cliIdentityKey) {
|
|
2223
|
+
this.cliSimBySocket.delete(cliWs)
|
|
2224
|
+
try {
|
|
2225
|
+
cliWs.close(1000, 'lease claimed by another cli')
|
|
2226
|
+
} catch {}
|
|
2227
|
+
bootedCount++
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
const lease: BridgeCliLease = {
|
|
2233
|
+
kind: 'cli',
|
|
2234
|
+
cliIdentityKey,
|
|
2235
|
+
cliLabel,
|
|
2236
|
+
expiresAt: now + SootSimBridgeHost.CLI_LEASE_TTL_MS,
|
|
2237
|
+
}
|
|
2238
|
+
sim.cliLease = lease
|
|
2239
|
+
return { granted: true, lease, bootedCount }
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
// user focus is advisory: we track it on the sim record so list/UI can
|
|
2243
|
+
// show "focused" alongside any cli lease, but focus alone never creates a
|
|
2244
|
+
// blocking lease. the old 15s user-focus lease meant clicking on the sim
|
|
2245
|
+
// locked out agent inspect calls for 15s — the opposite of what you want
|
|
2246
|
+
// when debugging something the user is actively looking at. use
|
|
2247
|
+
// updateUserActivity() to lock on real interaction instead.
|
|
2248
|
+
private updateUserFocusLease(
|
|
2249
|
+
sim: BridgeSimConnection,
|
|
2250
|
+
focusState: BridgeSimUserFocusStateMessage,
|
|
2251
|
+
) {
|
|
2252
|
+
const nextFocused = focusState.focused === true
|
|
2253
|
+
const nextVisible =
|
|
2254
|
+
typeof focusState.visible === 'boolean' ? focusState.visible : undefined
|
|
2255
|
+
const nextVisibilityState =
|
|
2256
|
+
typeof focusState.visibilityState === 'string'
|
|
2257
|
+
? focusState.visibilityState
|
|
2258
|
+
: undefined
|
|
2259
|
+
const nextDocumentFocused =
|
|
2260
|
+
typeof focusState.documentFocused === 'boolean'
|
|
2261
|
+
? focusState.documentFocused
|
|
2262
|
+
: undefined
|
|
2263
|
+
if (
|
|
2264
|
+
sim.userFocused === nextFocused &&
|
|
2265
|
+
sim.userVisible === nextVisible &&
|
|
2266
|
+
sim.visibilityState === nextVisibilityState &&
|
|
2267
|
+
sim.documentFocused === nextDocumentFocused
|
|
2268
|
+
) {
|
|
2269
|
+
return
|
|
2270
|
+
}
|
|
2271
|
+
sim.userFocused = nextFocused
|
|
2272
|
+
sim.userVisible = nextVisible
|
|
2273
|
+
sim.visibilityState = nextVisibilityState
|
|
2274
|
+
sim.documentFocused = nextDocumentFocused
|
|
2275
|
+
this.broadcastSimClientStates()
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
// called when the sim reports a real user interaction (pointerdown,
|
|
2279
|
+
// keydown, wheel, touch). creates or refreshes a short `user-active` lease
|
|
2280
|
+
// that keeps agent writes from trampling a user who is driving the sim.
|
|
2281
|
+
// reads still pass through — shouldAcquireLease only blocks on writes.
|
|
2282
|
+
private updateUserActivity(sim: BridgeSimConnection) {
|
|
2283
|
+
const existing = this.getActiveLease(sim)
|
|
2284
|
+
if (existing && existing.kind === 'cli') {
|
|
2285
|
+
// a real cli lease wins — don't shadow an agent that's actively
|
|
2286
|
+
// driving the sim with a user-active lease.
|
|
2287
|
+
return
|
|
2288
|
+
}
|
|
2289
|
+
const now = Date.now()
|
|
2290
|
+
const refreshed = now + SootSimBridgeHost.USER_ACTIVE_LEASE_TTL_MS
|
|
2291
|
+
// if a longer user-active hold is already in effect (e.g. from an explicit
|
|
2292
|
+
// boot), keep it — passive canvas interaction must not shorten it.
|
|
2293
|
+
const expiresAt =
|
|
2294
|
+
existing && existing.kind === 'user-active'
|
|
2295
|
+
? Math.max(existing.expiresAt, refreshed)
|
|
2296
|
+
: refreshed
|
|
2297
|
+
sim.cliLease = {
|
|
2298
|
+
kind: 'user-active',
|
|
2299
|
+
cliIdentityKey: '__user-active__',
|
|
2300
|
+
cliLabel: 'active user',
|
|
2301
|
+
expiresAt,
|
|
2302
|
+
}
|
|
2303
|
+
this.broadcastSimClientStates()
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
private ensureCliIdentityKey(ws: WebSocket): string {
|
|
2307
|
+
const existing = this.cliIdentityKeyBySocket.get(ws)
|
|
2308
|
+
if (existing) return existing
|
|
2309
|
+
const fallback = `ws-${this.nextCliFallbackId++}`
|
|
2310
|
+
this.cliIdentityKeyBySocket.set(ws, fallback)
|
|
2311
|
+
return fallback
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
private getOpenSim(simId?: string): BridgeSimConnection | null {
|
|
2315
|
+
if (simId) {
|
|
2316
|
+
const sim = this.sims.get(simId)
|
|
2317
|
+
if (sim?.ws.readyState === WebSocket.OPEN) return sim
|
|
2318
|
+
return null
|
|
2319
|
+
}
|
|
2320
|
+
const candidates = this.getDefaultSimCandidates()
|
|
2321
|
+
return candidates.find((sim) => sim.id === this.primarySimId) ?? candidates[0] ?? null
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
private getDefaultSimCandidates(): BridgeSimConnection[] {
|
|
2325
|
+
const open = Array.from(this.sims.values()).filter(
|
|
2326
|
+
(sim) => sim.ws.readyState === WebSocket.OPEN,
|
|
2327
|
+
)
|
|
2328
|
+
// a registered page is driveable; a bare host-shell socket is not. use
|
|
2329
|
+
// page-less sockets only when no registered page exists, matching the
|
|
2330
|
+
// default-routing behavior above.
|
|
2331
|
+
const registered = open.filter((sim) => sim.url)
|
|
2332
|
+
return registered.length > 0 ? registered : open
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
private async waitForSim(
|
|
2336
|
+
simId?: string,
|
|
2337
|
+
options: { attempts?: number; intervalMs?: number } = {},
|
|
2338
|
+
): Promise<BridgeSimConnection> {
|
|
2339
|
+
const attempts = options.attempts ?? 10
|
|
2340
|
+
const intervalMs = options.intervalMs ?? 200
|
|
2341
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
2342
|
+
if (!simId) {
|
|
2343
|
+
const candidates = this.getDefaultSimCandidates()
|
|
2344
|
+
if (candidates.length > 1) {
|
|
2345
|
+
throw new Error(
|
|
2346
|
+
`multiple sims are connected: ${candidates.map((sim) => sim.id).join(', ')}; ` +
|
|
2347
|
+
'run `rnxsim use <sim>` or pass `--sim <sim>`',
|
|
2348
|
+
)
|
|
2349
|
+
}
|
|
2350
|
+
const candidate = candidates[0]
|
|
2351
|
+
if (candidate) return candidate
|
|
2352
|
+
} else {
|
|
2353
|
+
const sim = this.getOpenSim(simId)
|
|
2354
|
+
if (sim) return sim
|
|
2355
|
+
}
|
|
2356
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
2357
|
+
}
|
|
2358
|
+
if (!simId) throw new Error('no sim connected')
|
|
2359
|
+
const openSimIds = this.getDefaultSimCandidates().map((sim) => sim.id)
|
|
2360
|
+
throw new Error(
|
|
2361
|
+
`no sim connected with id ${simId}` +
|
|
2362
|
+
(openSimIds.length > 0 ? `; connected sims: ${openSimIds.join(', ')}` : ''),
|
|
2363
|
+
)
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
private shouldPromoteSim(sim: BridgeSimConnection): boolean {
|
|
2367
|
+
const current = this.primarySimId ? this.sims.get(this.primarySimId) : null
|
|
2368
|
+
// a sim that has not yet reported a page (no `url`) is a bare WS
|
|
2369
|
+
// connection — it can't render or be driven. promoting one makes it the
|
|
2370
|
+
// default target and poisons every un-`--sim` command (QA F19-2): a
|
|
2371
|
+
// zombie that connected from the dev shell but never sent
|
|
2372
|
+
// `bridge:register` used to grab primary at connect-time and keep it
|
|
2373
|
+
// for hours. only let a page-less sim be primary as a last resort when
|
|
2374
|
+
// there is no current primary at all (transient — replaced as soon as a
|
|
2375
|
+
// real page registers, via the re-election in `bridge:register`).
|
|
2376
|
+
if (!sim.url) return !current
|
|
2377
|
+
const currentAlive = current?.ws.readyState === WebSocket.OPEN
|
|
2378
|
+
// a registered (url-bearing) sim always supersedes a dead or page-less
|
|
2379
|
+
// primary; among live page-bearing sims the dev-shell (:5173) origin
|
|
2380
|
+
// wins, matching the previous primary-candidate intent.
|
|
2381
|
+
if (!current || !currentAlive || !current.url) return true
|
|
2382
|
+
const isPrimaryCandidate = sim.origin?.includes(':5173')
|
|
2383
|
+
const currentIsPrimary = current.origin?.includes(':5173')
|
|
2384
|
+
return !!isPrimaryCandidate || !currentIsPrimary
|
|
2385
|
+
}
|
|
2386
|
+
|
|
2387
|
+
private broadcastSimAssignments() {
|
|
2388
|
+
for (const sim of this.sims.values()) {
|
|
2389
|
+
if (sim.ws.readyState !== WebSocket.OPEN) continue
|
|
2390
|
+
sim.ws.send(
|
|
2391
|
+
JSON.stringify({
|
|
2392
|
+
type: 'bridge:welcome',
|
|
2393
|
+
simId: sim.id,
|
|
2394
|
+
isPrimary: sim.id === this.primarySimId,
|
|
2395
|
+
}),
|
|
2396
|
+
)
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
|
|
2400
|
+
private writeAndBroadcastSharedConfig(patch: Partial<SharedConfig>): SharedConfig {
|
|
2401
|
+
const next = writeSharedConfig(patch)
|
|
2402
|
+
const payload = JSON.stringify({
|
|
2403
|
+
type: 'bridge:shared-config-changed',
|
|
2404
|
+
config: next,
|
|
2405
|
+
})
|
|
2406
|
+
for (const peer of this.sims.values()) {
|
|
2407
|
+
if (peer.ws.readyState !== WebSocket.OPEN) continue
|
|
2408
|
+
try {
|
|
2409
|
+
peer.ws.send(payload)
|
|
2410
|
+
} catch {}
|
|
2411
|
+
}
|
|
2412
|
+
return next
|
|
2413
|
+
}
|
|
2414
|
+
|
|
2415
|
+
private broadcastSimClientStates() {
|
|
2416
|
+
for (const sim of this.sims.values()) {
|
|
2417
|
+
if (sim.ws.readyState !== WebSocket.OPEN) continue
|
|
2418
|
+
const lease = this.getActiveLease(sim)
|
|
2419
|
+
const message: BridgeSimClientStateMessage = {
|
|
2420
|
+
type: 'bridge:client-state',
|
|
2421
|
+
attachedCliCount: this.getAttachedCliCount(sim.id),
|
|
2422
|
+
activeAgentCommandCount: this.getActiveAgentCommandCount(sim.id),
|
|
2423
|
+
recentActions: sim.recentActions,
|
|
2424
|
+
lockedBy: lease ? lease.cliLabel || lease.cliIdentityKey : undefined,
|
|
2425
|
+
lockedByKind: lease ? lease.kind : undefined,
|
|
2426
|
+
lockExpiresAt: lease ? lease.expiresAt : undefined,
|
|
2427
|
+
userFocused: sim.userFocused || undefined,
|
|
2428
|
+
userVisible: sim.userVisible,
|
|
2429
|
+
visibilityState: sim.visibilityState,
|
|
2430
|
+
documentFocused: sim.documentFocused,
|
|
2431
|
+
}
|
|
2432
|
+
sim.ws.send(JSON.stringify(message))
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
private setCliSimTarget(ws: WebSocket, simId: string) {
|
|
2437
|
+
const prevSimId = this.cliSimBySocket.get(ws)
|
|
2438
|
+
if (prevSimId === simId) return
|
|
2439
|
+
this.cliSimBySocket.set(ws, simId)
|
|
2440
|
+
this.recordSimAction(simId, prevSimId ? 'cli switched sims' : 'cli connected', false)
|
|
2441
|
+
this.broadcastSimClientStates()
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
private recordSimAction(
|
|
2445
|
+
simId: string,
|
|
2446
|
+
label: string | null | undefined,
|
|
2447
|
+
broadcast = true,
|
|
2448
|
+
) {
|
|
2449
|
+
const normalized = label?.trim()
|
|
2450
|
+
if (!normalized) return
|
|
2451
|
+
const sim = this.sims.get(simId)
|
|
2452
|
+
if (!sim) return
|
|
2453
|
+
const now = Date.now()
|
|
2454
|
+
sim.lastActiveAt = now
|
|
2455
|
+
sim.recentActions = [
|
|
2456
|
+
{ label: normalized, at: now },
|
|
2457
|
+
...sim.recentActions.filter((entry) => entry.label !== normalized),
|
|
2458
|
+
].slice(0, 4)
|
|
2459
|
+
if (broadcast) this.broadcastSimClientStates()
|
|
2460
|
+
}
|
|
2461
|
+
|
|
2462
|
+
private describeForwardedCommand(msg: any): string | null {
|
|
2463
|
+
switch (msg?.type) {
|
|
2464
|
+
case 'evaluate':
|
|
2465
|
+
return 'evaluated page state'
|
|
2466
|
+
case 'screenshot':
|
|
2467
|
+
return 'captured screenshot'
|
|
2468
|
+
case 'tap':
|
|
2469
|
+
return 'sent tap event'
|
|
2470
|
+
case 'keyboard':
|
|
2471
|
+
return msg?.action === 'type' ? 'typed text' : 'used keyboard'
|
|
2472
|
+
case 'tree':
|
|
2473
|
+
return 'dumped tree'
|
|
2474
|
+
case 'focus':
|
|
2475
|
+
return 'focused sim'
|
|
2476
|
+
case 'close':
|
|
2477
|
+
return 'requested close'
|
|
2478
|
+
default:
|
|
2479
|
+
return typeof msg?.type === 'string' ? msg.type : null
|
|
2480
|
+
}
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// count distinct cli identity keys attached to a sim, not raw sockets.
|
|
2484
|
+
// a single agent firing sequential cli commands opens a new ws per call —
|
|
2485
|
+
// counting sockets would report phantom peers until idle cleanup catches up.
|
|
2486
|
+
private getAttachedCliCount(simId: string): number {
|
|
2487
|
+
const keys = new Set<string>()
|
|
2488
|
+
for (const [ws, attachedSimId] of this.cliSimBySocket) {
|
|
2489
|
+
if (attachedSimId !== simId) continue
|
|
2490
|
+
if (ws.readyState !== WebSocket.OPEN) continue
|
|
2491
|
+
const key = this.cliIdentityKeyBySocket.get(ws)
|
|
2492
|
+
keys.add(key ?? `ws-unknown-${keys.size}`)
|
|
2493
|
+
}
|
|
2494
|
+
return keys.size
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
// count distinct identity keys attached to this sim other than `selfWs`.
|
|
2498
|
+
// used to warn a cli that other agents/identities are also targeting the sim.
|
|
2499
|
+
private getOtherCliIdentityCount(selfWs: WebSocket, simId: string): number {
|
|
2500
|
+
const selfKey = this.cliIdentityKeyBySocket.get(selfWs)
|
|
2501
|
+
const keys = new Set<string>()
|
|
2502
|
+
for (const [ws, attachedSimId] of this.cliSimBySocket) {
|
|
2503
|
+
if (attachedSimId !== simId) continue
|
|
2504
|
+
if (ws.readyState !== WebSocket.OPEN) continue
|
|
2505
|
+
const key = this.cliIdentityKeyBySocket.get(ws)
|
|
2506
|
+
if (key && key === selfKey) continue
|
|
2507
|
+
keys.add(key ?? `ws-unknown-${keys.size}`)
|
|
2508
|
+
}
|
|
2509
|
+
return keys.size
|
|
2510
|
+
}
|
|
2511
|
+
|
|
2512
|
+
private getActiveAgentCommandCount(simId: string): number {
|
|
2513
|
+
let count = 0
|
|
2514
|
+
for (const pending of this.pendingCommands.values()) {
|
|
2515
|
+
if (pending.simId === simId) count++
|
|
2516
|
+
}
|
|
2517
|
+
return count
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
private allocateSimId(): string {
|
|
2521
|
+
for (;;) {
|
|
2522
|
+
const id = this.nextSimNumber.toString(16)
|
|
2523
|
+
this.nextSimNumber++
|
|
2524
|
+
if (!this.sims.has(id) && !this.restorableSims.has(id)) return id
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
private tryRestoreSimId(
|
|
2529
|
+
sim: BridgeSimConnection,
|
|
2530
|
+
requestedId: string | undefined,
|
|
2531
|
+
): boolean {
|
|
2532
|
+
const nextId = requestedId?.trim()
|
|
2533
|
+
if (!nextId || nextId === sim.id) return false
|
|
2534
|
+
const existing = this.sims.get(nextId)
|
|
2535
|
+
if (existing && existing !== sim && existing.ws.readyState === WebSocket.OPEN) {
|
|
2536
|
+
return false
|
|
2537
|
+
}
|
|
2538
|
+
const restorable = this.getRestorableSimState(nextId)
|
|
2539
|
+
|
|
2540
|
+
const prevId = sim.id
|
|
2541
|
+
this.sims.delete(prevId)
|
|
2542
|
+
sim.id = nextId
|
|
2543
|
+
if (restorable) {
|
|
2544
|
+
sim.recentActions = restorable.recentActions.map((entry) => ({ ...entry }))
|
|
2545
|
+
sim.lastActiveAt = restorable.lastActiveAt
|
|
2546
|
+
sim.cliLease = restorable.cliLease ? { ...restorable.cliLease } : undefined
|
|
2547
|
+
this.restorableSims.delete(nextId)
|
|
2548
|
+
}
|
|
2549
|
+
this.sims.set(sim.id, sim)
|
|
2550
|
+
if (this.primarySimId === prevId) {
|
|
2551
|
+
this.primarySimId = sim.id
|
|
2552
|
+
}
|
|
2553
|
+
for (const [ws, attachedSimId] of this.cliSimBySocket) {
|
|
2554
|
+
if (attachedSimId === prevId) {
|
|
2555
|
+
this.cliSimBySocket.set(ws, sim.id)
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
return true
|
|
2559
|
+
}
|
|
2560
|
+
|
|
2561
|
+
private rememberDisconnectedSim(sim: BridgeSimConnection) {
|
|
2562
|
+
const lease = this.getActiveLease(sim)
|
|
2563
|
+
this.restorableSims.set(sim.id, {
|
|
2564
|
+
recentActions: sim.recentActions.map((entry) => ({ ...entry })),
|
|
2565
|
+
lastActiveAt: sim.lastActiveAt,
|
|
2566
|
+
cliLease: lease && lease.kind === 'cli' ? { ...lease } : undefined,
|
|
2567
|
+
expiresAt: Date.now() + SootSimBridgeHost.SIM_RECONNECT_TTL_MS,
|
|
2568
|
+
})
|
|
2569
|
+
this.sims.delete(sim.id)
|
|
2570
|
+
this.writeConnectedRuntimeSnapshot()
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
private getRestorableSimState(simId: string): BridgeRestorableSimState | null {
|
|
2574
|
+
const snapshot = this.restorableSims.get(simId)
|
|
2575
|
+
if (!snapshot) return null
|
|
2576
|
+
if (snapshot.expiresAt <= Date.now()) {
|
|
2577
|
+
this.restorableSims.delete(simId)
|
|
2578
|
+
return null
|
|
2579
|
+
}
|
|
2580
|
+
if (snapshot.cliLease && snapshot.cliLease.expiresAt <= Date.now()) {
|
|
2581
|
+
snapshot.cliLease = undefined
|
|
2582
|
+
}
|
|
2583
|
+
return snapshot
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
private sweepRestorableSims(now = Date.now()) {
|
|
2587
|
+
for (const [simId, snapshot] of this.restorableSims) {
|
|
2588
|
+
if (snapshot.expiresAt > now) continue
|
|
2589
|
+
this.restorableSims.delete(simId)
|
|
2590
|
+
for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
|
|
2591
|
+
if (attachedSimId === simId) {
|
|
2592
|
+
this.cliSimBySocket.delete(cliWs)
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
}
|
|
2597
|
+
|
|
2598
|
+
private resetServerState() {
|
|
2599
|
+
if (this.cliIdleTimer) {
|
|
2600
|
+
clearInterval(this.cliIdleTimer)
|
|
2601
|
+
this.cliIdleTimer = null
|
|
2602
|
+
}
|
|
2603
|
+
if (this.wsHeartbeatTimer) {
|
|
2604
|
+
clearInterval(this.wsHeartbeatTimer)
|
|
2605
|
+
this.wsHeartbeatTimer = null
|
|
2606
|
+
}
|
|
2607
|
+
if (this.runtimeUpdateTimer) {
|
|
2608
|
+
clearInterval(this.runtimeUpdateTimer)
|
|
2609
|
+
this.runtimeUpdateTimer = null
|
|
2610
|
+
}
|
|
2611
|
+
const wss = this.wss
|
|
2612
|
+
const httpServer = this.httpServer
|
|
2613
|
+
this.wss = null
|
|
2614
|
+
this.httpServer = null
|
|
2615
|
+
if (wss) {
|
|
2616
|
+
try {
|
|
2617
|
+
wss.close()
|
|
2618
|
+
} catch {}
|
|
2619
|
+
}
|
|
2620
|
+
if (httpServer) {
|
|
2621
|
+
try {
|
|
2622
|
+
httpServer.close()
|
|
2623
|
+
} catch {}
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
}
|