sootsim 0.1.83 → 0.1.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -1
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +36 -0
- package/detox/expectations.ts +477 -0
- package/detox/gestures.ts +442 -0
- package/detox/index.ts +1403 -0
- package/detox/jest-environment.ts +86 -0
- package/detox/jest-preset.cjs +50 -0
- package/detox/matchers.ts +29 -0
- package/detox/navigation.ts +43 -0
- package/detox/run-test.ts +113 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-cli/bin.js +3 -3
- package/dist-cli/chunks/{agent-MQ7GLVIB.js → agent-T3DUH5YJ.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-7KAFDQCN.js → agent-wrapper-NSBF4THI.js} +2 -2
- package/dist-cli/chunks/{assert-TV46GUNU.js → assert-X3F7TRCZ.js} +2 -2
- package/dist-cli/chunks/auto-bootstrap-47RN2V5G.js +2 -0
- package/dist-cli/chunks/beta-BRCGAF2N.js +2 -0
- package/dist-cli/chunks/chunk-36RPD6JI.js +2 -0
- package/dist-cli/chunks/{chunk-PM5NVKLP.js → chunk-3WGHC7JN.js} +2 -2
- package/dist-cli/chunks/chunk-4DBPNLGI.js +1 -0
- package/dist-cli/chunks/{chunk-J2GYISVJ.js → chunk-4EVSIUNB.js} +2 -2
- package/dist-cli/chunks/{chunk-JHJNODXN.js → chunk-4QZHZ6BC.js} +2 -2
- package/dist-cli/chunks/{chunk-F3HP444U.js → chunk-5DIGWOY7.js} +1 -1
- package/dist-cli/chunks/{chunk-DP7O5MHK.js → chunk-5N3V7OCG.js} +2 -2
- package/dist-cli/chunks/{chunk-Y4BUVURT.js → chunk-5S6D7K4L.js} +2 -2
- package/dist-cli/chunks/{chunk-ECJBV65H.js → chunk-7LKUN46F.js} +2 -2
- package/dist-cli/chunks/{chunk-WTKTOL3C.js → chunk-AC6QGW22.js} +2 -2
- package/dist-cli/chunks/{chunk-IBNRRAES.js → chunk-AFNDVS4E.js} +2 -2
- package/dist-cli/chunks/{chunk-6TNANCQC.js → chunk-BESAZ2HA.js} +2 -2
- package/dist-cli/chunks/{chunk-WN7M3QON.js → chunk-BHZJ6RIH.js} +2 -2
- package/dist-cli/chunks/{chunk-277XAALA.js → chunk-BZL6D4TV.js} +3 -3
- package/dist-cli/chunks/{chunk-CYV6Y6YV.js → chunk-CF2LPRXD.js} +2 -2
- package/dist-cli/chunks/chunk-DWTLRPEN.js +79 -0
- package/dist-cli/chunks/{chunk-CJY3AVI7.js → chunk-E2QE5FFP.js} +1 -1
- package/dist-cli/chunks/chunk-EBEL6TTJ.js +4 -0
- package/dist-cli/chunks/{chunk-DM6WT7QM.js → chunk-EFM53PZ5.js} +1 -1
- package/dist-cli/chunks/{chunk-YUELRHGB.js → chunk-EKXK3SWK.js} +2 -2
- package/dist-cli/chunks/{chunk-4LS5MZAI.js → chunk-G7CIZ5S3.js} +3 -3
- package/dist-cli/chunks/{chunk-6NN2D4EJ.js → chunk-GTAD6IUV.js} +1 -1
- package/dist-cli/chunks/{chunk-OYMFNU3M.js → chunk-H44IQHKZ.js} +1 -1
- package/dist-cli/chunks/{chunk-IP3QJLRH.js → chunk-HQDJ5BOF.js} +1 -1
- package/dist-cli/chunks/{chunk-5DJXZIFZ.js → chunk-KUSQ4NNJ.js} +1 -1
- package/dist-cli/chunks/{chunk-HAWOAQAG.js → chunk-MAO7F5PH.js} +3 -3
- package/dist-cli/chunks/{chunk-572VSFNP.js → chunk-NVTL3JQG.js} +1 -1
- package/dist-cli/chunks/{chunk-6XZOEBTZ.js → chunk-O6N2CEET.js} +2 -2
- package/dist-cli/chunks/{chunk-HNWEELAE.js → chunk-OISHLFON.js} +1 -1
- package/dist-cli/chunks/{chunk-2PY3UZVO.js → chunk-OUNLJM56.js} +2 -2
- package/dist-cli/chunks/chunk-OXOARRKR.js +67 -0
- package/dist-cli/chunks/{chunk-NXATOWWF.js → chunk-PHPXGLME.js} +1 -1
- package/dist-cli/chunks/{chunk-JQ7ZXOXJ.js → chunk-PQFFUJR6.js} +2 -2
- package/dist-cli/chunks/{chunk-KASUZ5XV.js → chunk-QLJNSOS7.js} +1 -1
- package/dist-cli/chunks/chunk-QQAECG5B.js +2 -0
- package/dist-cli/chunks/{chunk-FJYT7XL2.js → chunk-RZHREO3M.js} +2 -2
- package/dist-cli/chunks/{chunk-FRM355UL.js → chunk-SBGOUA6F.js} +2 -2
- package/dist-cli/chunks/chunk-SSCA2AEA.js +1 -0
- package/dist-cli/chunks/{chunk-Y2VJBRSP.js → chunk-UYRGCJ4N.js} +1 -1
- package/dist-cli/chunks/{chunk-2AWQ7OB2.js → chunk-WGDL5V6C.js} +1 -1
- package/dist-cli/chunks/{chunk-VMXWC2JO.js → chunk-Y5PLPEEU.js} +2 -2
- package/dist-cli/chunks/chunk-ZFAM4N5B.js +1 -0
- package/dist-cli/chunks/{chunk-RH4F2TF7.js → chunk-ZO3VHP6W.js} +1 -1
- package/dist-cli/chunks/cli-version-WPFDM2A6.js +2 -0
- package/dist-cli/chunks/{compat-QLLWBTS3.js → compat-PCXGGZBZ.js} +3 -3
- package/dist-cli/chunks/{config-2DSLDCXV.js → config-LULEVEYL.js} +2 -2
- package/dist-cli/chunks/control-6P6HY7UF.js +2 -0
- package/dist-cli/chunks/{cpu-profile-GEIKHCPC.js → cpu-profile-NOK73ZYW.js} +2 -2
- package/dist-cli/chunks/{daemon-4EBUFN4D.js → daemon-4A3DMUYL.js} +2 -2
- package/dist-cli/chunks/{debug-WGD6XWOF.js → debug-74BWB2ZG.js} +3 -3
- package/dist-cli/chunks/{detox-LNKGRZU6.js → detox-HEOMINSC.js} +2 -2
- package/dist-cli/chunks/{device-AYKXKVIQ.js → device-TTXXBJFZ.js} +2 -2
- package/dist-cli/chunks/{diagnose-TMXSDOOC.js → diagnose-QZ3GOHSE.js} +2 -2
- package/dist-cli/chunks/drivers-QRPWNOIT.js +2 -0
- package/dist-cli/chunks/{electron-QFPF7TBY.js → electron-QVOWV44R.js} +3 -3
- package/dist-cli/chunks/flow-QMA7GVN6.js +2 -0
- package/dist-cli/chunks/{hints-MXKRR4TG.js → hints-YKWRNMJC.js} +2 -2
- package/dist-cli/chunks/{home-paths-REMWQDAO.js → home-paths-SFADSTJM.js} +2 -2
- package/dist-cli/chunks/{inspect-XGSQNFV7.js → inspect-LEWGQCIU.js} +3 -3
- package/dist-cli/chunks/install-7N2N7Q32.js +2 -0
- package/dist-cli/chunks/{install-desktop-NQG3RZSA.js → install-desktop-22HYQZ2G.js} +3 -3
- package/dist-cli/chunks/{keys-5QZWXL3F.js → keys-3ZT3MICU.js} +2 -2
- package/dist-cli/chunks/{launch-SBXOZWKO.js → launch-ZXW2NFLG.js} +3 -3
- package/dist-cli/chunks/{login-EACQXE24.js → login-NJKJ7GZO.js} +4 -4
- package/dist-cli/chunks/{logout-IBQLMUML.js → logout-VMMQL7CB.js} +2 -2
- package/dist-cli/chunks/{maestro-LFYXUX7O.js → maestro-OJY4MTI7.js} +2 -2
- package/dist-cli/chunks/{preview-U4SBOEGQ.js → preview-QU2GXTEV.js} +2 -2
- package/dist-cli/chunks/{profile-GWS5ECMY.js → profile-7APWK47T.js} +2 -2
- package/dist-cli/chunks/{react-QDHLMVYL.js → react-RSVO5JZZ.js} +2 -2
- package/dist-cli/chunks/{record-BUEUWPDI.js → record-UWH4MDEO.js} +2 -2
- package/dist-cli/chunks/runtime-3FUENRHM.js +2 -0
- package/dist-cli/chunks/{runtime-delivery-G7L6RVZ7.js → runtime-delivery-QMKGRV7N.js} +2 -2
- package/dist-cli/chunks/{screenshot-T2HBA3VI.js → screenshot-43M27ALE.js} +2 -2
- package/dist-cli/chunks/{screenshot-mode-EG5HMIH3.js → screenshot-mode-EBYYN6TY.js} +2 -2
- package/dist-cli/chunks/{screenshots-S52AFHTV.js → screenshots-7TQZL6Z6.js} +2 -2
- package/dist-cli/chunks/{server-MFFVYUGG.js → server-VCFM25Z6.js} +2 -2
- package/dist-cli/chunks/setup-repo-HFH4VKJQ.js +2 -0
- package/dist-cli/chunks/{skills-HQGWBS2O.js → skills-RQA6EJQL.js} +2 -2
- package/dist-cli/chunks/{start-E3DRYY7W.js → start-ZT6MBYND.js} +4 -4
- package/dist-cli/chunks/store-BJBTDSZE.js +2 -0
- package/dist-cli/chunks/telemetry-ZZZKTILZ.js +2 -0
- package/dist-cli/chunks/{test-ZY3EF62K.js → test-RNRX5SWV.js} +3 -3
- package/dist-cli/chunks/{three-mode-WSPKQCJ5.js → three-mode-TQZH25ZO.js} +2 -2
- package/dist-cli/chunks/{timeline-3XAB5EWZ.js → timeline-GGN3AY6P.js} +2 -2
- package/dist-cli/chunks/{upgrade-WNENPFM5.js → upgrade-XT22D67C.js} +2 -2
- package/dist-cli/chunks/upload-NC2AYLC5.js +2 -0
- package/dist-cli/chunks/{web-D2AOZY44.js → web-KEHVF5MB.js} +2 -2
- package/dist-cli/chunks/{what-happened-F43KNSG6.js → what-happened-PATQRJ5T.js} +2 -2
- package/dist-cli/chunks/{whoami-T22VBR7C.js → whoami-CXVY26VV.js} +2 -2
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +44 -0
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1770 -0
- package/dist-lib/detox/jest-preset.cjs +50 -0
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/index.cjs +136 -138
- package/dist-lib/metro.cjs +31 -26
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/demo-app-registry.cjs +809 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1269 -0
- package/dist-lib/skills.cjs +17766 -0
- package/dist-lib/vite.cjs +129 -39
- package/package.json +39 -14
- package/scripts/demo-app-registry.ts +989 -0
- package/scripts/dev-server-scanner.ts +674 -0
- package/src/agent-daemon-client.ts +390 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +572 -0
- package/src/attached-projects.ts +536 -0
- package/src/auth/shared-session.ts +199 -0
- package/src/backend-origin.ts +49 -0
- package/src/beta.ts +21 -0
- package/src/bridge-constants.ts +10 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +30 -0
- package/src/codex-client.ts +215 -0
- package/src/config.ts +110 -0
- package/src/dev-bundle-resolution.ts +180 -0
- package/src/home-paths.ts +382 -0
- package/src/host/agent-host.ts +576 -0
- package/src/host/bridge-host.ts +2293 -0
- package/src/host/fetch-proxy-handler.ts +288 -0
- package/src/host/fetch-proxy-overrides.ts +39 -0
- package/src/host/open-url.ts +234 -0
- package/src/index.ts +9 -0
- package/src/metro-plugin.ts +139 -0
- package/src/native-dev-bundle-url.ts +62 -0
- package/src/native-seam-manifest.ts +313 -0
- package/src/profiles.ts +179 -0
- package/src/render-mode.ts +27 -0
- package/src/runtime-assets.ts +84 -0
- package/src/runtime-delivery.ts +334 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +438 -0
- package/src/screenshots/orchestrate.ts +244 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/skills/builtin/a11y-review.ts +126 -0
- package/src/skills/builtin/compat-check.ts +104 -0
- package/src/skills/builtin/perf-profile.ts +84 -0
- package/src/skills/builtin/screenshot-all.ts +46 -0
- package/src/skills/builtin/test-flow.ts +118 -0
- package/src/skills/builtin/visual-diff.ts +94 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/vite-plugin-one.ts +189 -0
- package/src/vite-plugin.ts +1381 -0
- package/src/worklets-babel.ts +132 -0
- package/dist-cli/chunks/auto-bootstrap-FQS4ZD2K.js +0 -2
- package/dist-cli/chunks/beta-VG7CDY2U.js +0 -2
- package/dist-cli/chunks/chunk-2OIBDYHW.js +0 -1
- package/dist-cli/chunks/chunk-6BNLVMXA.js +0 -1
- package/dist-cli/chunks/chunk-6XD6CBJM.js +0 -2
- package/dist-cli/chunks/chunk-CHQTO426.js +0 -1
- package/dist-cli/chunks/chunk-FAPYGVIU.js +0 -4
- package/dist-cli/chunks/chunk-PEHFE3LG.js +0 -64
- package/dist-cli/chunks/chunk-RXH2SLKF.js +0 -2
- package/dist-cli/chunks/chunk-UXQWC5ZR.js +0 -79
- package/dist-cli/chunks/chunk-XFQL74PF.js +0 -5
- package/dist-cli/chunks/cli-version-PWF6I6LY.js +0 -2
- package/dist-cli/chunks/control-UIOXGYXU.js +0 -2
- package/dist-cli/chunks/demo-app-registry-G3BDOFWC.js +0 -2
- package/dist-cli/chunks/drivers-IDQF34HP.js +0 -2
- package/dist-cli/chunks/flow-3JN3Y7RF.js +0 -2
- package/dist-cli/chunks/install-2N3YOOSN.js +0 -2
- package/dist-cli/chunks/runtime-PVB4VGUH.js +0 -2
- package/dist-cli/chunks/setup-repo-YOF7NV5D.js +0 -2
- package/dist-cli/chunks/store-MAI6D3UO.js +0 -2
- package/dist-cli/chunks/telemetry-RCQKCJTH.js +0 -2
- package/dist-cli/chunks/upload-YLJ4RA73.js +0 -2
- package/dist-lib/vite-base.cjs +0 -6937
|
@@ -0,0 +1,2293 @@
|
|
|
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
|
+
listInstalledRuntimes,
|
|
21
|
+
readActiveRuntime,
|
|
22
|
+
readSharedConfig,
|
|
23
|
+
removeDaemonLockfile,
|
|
24
|
+
writeActiveRuntime,
|
|
25
|
+
writeDaemonLockfile,
|
|
26
|
+
writeSharedConfig,
|
|
27
|
+
type DaemonLockfile,
|
|
28
|
+
type SharedConfig,
|
|
29
|
+
} from '../home-paths.ts'
|
|
30
|
+
import { updateRuntimeToLatest } from '../runtime-delivery.ts'
|
|
31
|
+
import { AgentHost, type AgentHostOptions } from './agent-host.ts'
|
|
32
|
+
import {
|
|
33
|
+
handleAppApiRequest,
|
|
34
|
+
handleFetchProxyRequest,
|
|
35
|
+
isAppApiRequestUrl,
|
|
36
|
+
isFetchProxyRequestUrl,
|
|
37
|
+
} from './fetch-proxy-handler'
|
|
38
|
+
import { openUrl as openUrlInBrowser, type OpenUrlOptions } from './open-url.ts'
|
|
39
|
+
|
|
40
|
+
export interface BridgeSimInfo {
|
|
41
|
+
id: string
|
|
42
|
+
origin?: string
|
|
43
|
+
url?: string
|
|
44
|
+
title?: string
|
|
45
|
+
userAgent?: string
|
|
46
|
+
connectedAt: number
|
|
47
|
+
lastSeenAt: number
|
|
48
|
+
lastActiveAt?: number
|
|
49
|
+
isPrimary: boolean
|
|
50
|
+
readyState: 'open' | 'closing' | 'closed'
|
|
51
|
+
attachedCliCount?: number
|
|
52
|
+
lockedBy?: string
|
|
53
|
+
lockedByKind?: 'cli' | 'user-active'
|
|
54
|
+
lockExpiresAt?: number
|
|
55
|
+
userFocused?: boolean
|
|
56
|
+
userVisible?: boolean
|
|
57
|
+
visibilityState?: string
|
|
58
|
+
documentFocused?: boolean
|
|
59
|
+
/** registration "kind" — lets a single daemon host multiple surface
|
|
60
|
+
* types. omitted / unknown defaults to 'sootsim'. the sootbean web
|
|
61
|
+
* IDE registers with kind='sootbean'; CLI consumers filter by this
|
|
62
|
+
* rather than running a parallel bridge. */
|
|
63
|
+
kind?: string
|
|
64
|
+
/** opaque metadata supplied at register time (e.g. projectId, route,
|
|
65
|
+
* attached iOS sim id for a sootbean tab). free-form by design — the
|
|
66
|
+
* daemon does not interpret it, only stores + reports it back. */
|
|
67
|
+
meta?: Record<string, unknown>
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface BridgeLockInfo {
|
|
71
|
+
by: string
|
|
72
|
+
expiresInMs: number
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface BridgeSimCommand {
|
|
76
|
+
id: number
|
|
77
|
+
type: 'evaluate' | 'screenshot' | 'tap' | 'keyboard' | 'tree' | 'focus' | 'close'
|
|
78
|
+
code?: string
|
|
79
|
+
x?: number
|
|
80
|
+
y?: number
|
|
81
|
+
action?: string
|
|
82
|
+
text?: string
|
|
83
|
+
depth?: number
|
|
84
|
+
simId?: string
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
interface BridgeSimRegistrationMessage {
|
|
88
|
+
type: 'bridge:register'
|
|
89
|
+
simId?: string
|
|
90
|
+
url?: string
|
|
91
|
+
title?: string
|
|
92
|
+
userAgent?: string
|
|
93
|
+
/** see BridgeSimInfo.kind — defaults to 'sootsim' when omitted. */
|
|
94
|
+
kind?: string
|
|
95
|
+
/** see BridgeSimInfo.meta — free-form metadata for filtering/routing. */
|
|
96
|
+
meta?: Record<string, unknown>
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
interface BridgeSimUserFocusStateMessage {
|
|
100
|
+
type: 'bridge:user-focus-state'
|
|
101
|
+
focused?: boolean
|
|
102
|
+
visible?: boolean
|
|
103
|
+
visibilityState?: string
|
|
104
|
+
documentFocused?: boolean
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface BridgeSimClientStateMessage {
|
|
108
|
+
type: 'bridge:client-state'
|
|
109
|
+
attachedCliCount: number
|
|
110
|
+
activeAgentCommandCount: number
|
|
111
|
+
recentActions: BridgeRecentAction[]
|
|
112
|
+
lockedBy?: string
|
|
113
|
+
lockedByKind?: 'cli' | 'user-active'
|
|
114
|
+
lockExpiresAt?: number
|
|
115
|
+
userFocused?: boolean
|
|
116
|
+
userVisible?: boolean
|
|
117
|
+
visibilityState?: string
|
|
118
|
+
documentFocused?: boolean
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface BridgeRecentAction {
|
|
122
|
+
label: string
|
|
123
|
+
at: number
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
interface BridgeSimConnection {
|
|
127
|
+
id: string
|
|
128
|
+
ws: WebSocket
|
|
129
|
+
origin?: string
|
|
130
|
+
url?: string
|
|
131
|
+
title?: string
|
|
132
|
+
userAgent?: string
|
|
133
|
+
connectedAt: number
|
|
134
|
+
lastSeenAt: number
|
|
135
|
+
lastActiveAt: number
|
|
136
|
+
recentActions: BridgeRecentAction[]
|
|
137
|
+
cliLease?: BridgeCliLease
|
|
138
|
+
userFocused?: boolean
|
|
139
|
+
userVisible?: boolean
|
|
140
|
+
visibilityState?: string
|
|
141
|
+
documentFocused?: boolean
|
|
142
|
+
/** see BridgeSimInfo.kind. */
|
|
143
|
+
kind?: string
|
|
144
|
+
/** see BridgeSimInfo.meta. */
|
|
145
|
+
meta?: Record<string, unknown>
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
interface BridgeCliLease {
|
|
149
|
+
kind: 'cli' | 'user-active'
|
|
150
|
+
cliIdentityKey: string
|
|
151
|
+
cliLabel?: string
|
|
152
|
+
expiresAt: number
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// interactive commands change app state and must respect the lease.
|
|
156
|
+
// observational/lifecycle commands (evaluate/tree/screenshot/focus/call/close)
|
|
157
|
+
// pass through without taking or checking a lease so cleanup never gets stuck
|
|
158
|
+
// behind a stale cli owner.
|
|
159
|
+
// `call` used to acquire a lease too, but most call paths are queries
|
|
160
|
+
// (`__sootsimTest.findByTestId`, state reads, etc.). the CLI opts write
|
|
161
|
+
// calls into lease acquisition via msg.acquireLock=true.
|
|
162
|
+
const WRITE_COMMAND_TYPES = new Set(['tap', 'keyboard'])
|
|
163
|
+
|
|
164
|
+
// how long a `close` waits for the sim page to tear itself down before the
|
|
165
|
+
// host disconnects the sim socket. the close code is part of the protocol:
|
|
166
|
+
// browser clients treat it as terminal and skip their normal reconnect loop.
|
|
167
|
+
const FORCE_CLOSE_GRACE_MS = 2000
|
|
168
|
+
const FORCE_CLOSE_TERMINATE_MS = 1000
|
|
169
|
+
|
|
170
|
+
// note: `unrefTimer` is declared once, below (with the `UnrefableTimer`
|
|
171
|
+
// type). a duplicate copy used to live here from a concurrent edit and
|
|
172
|
+
// broke the standalone CLI esbuild bundle ("symbol already declared").
|
|
173
|
+
|
|
174
|
+
function shouldAcquireLease(msg: any): boolean {
|
|
175
|
+
if (!msg || typeof msg.type !== 'string') return false
|
|
176
|
+
if (msg.acquireLock === true) return true
|
|
177
|
+
if (msg.readOnly === true) return false
|
|
178
|
+
return WRITE_COMMAND_TYPES.has(msg.type)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
interface BridgeRestorableSimState {
|
|
182
|
+
recentActions: BridgeRecentAction[]
|
|
183
|
+
lastActiveAt: number
|
|
184
|
+
cliLease?: BridgeCliLease
|
|
185
|
+
expiresAt: number
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface BridgePendingCommand {
|
|
189
|
+
simId: string
|
|
190
|
+
resolve: (value: any) => void
|
|
191
|
+
reject: (error: Error) => void
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
interface BridgeForwardedCommand {
|
|
195
|
+
simId: string
|
|
196
|
+
ws: WebSocket
|
|
197
|
+
originalId: number | string
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface BridgeHostOptions {
|
|
201
|
+
port?: number
|
|
202
|
+
openUrl?: (url: string, options?: OpenUrlOptions) => Promise<void> | void
|
|
203
|
+
/** soot-specific excludes for the agent host's dev-server scan. passed
|
|
204
|
+
* through to AgentHost; see packages/sootsim-engine/src/dev-scan-excludes.ts
|
|
205
|
+
* for the canonical list. when omitted, AgentHost reads env vars directly. */
|
|
206
|
+
agentScanExcludes?: AgentHostOptions['getExcludePorts']
|
|
207
|
+
/** try preferred port, then port+1, port+2, …, up to this many attempts
|
|
208
|
+
* before giving up. defaults to 10. set 1 to disable fallback. */
|
|
209
|
+
portFallbackCount?: number
|
|
210
|
+
/** when true, write ~/.sootsim/daemon.json on successful bind + update
|
|
211
|
+
* it on a heartbeat interval + remove it on close. only the standalone
|
|
212
|
+
* `sootsim server` process should set this — tests, vite plugin, and
|
|
213
|
+
* anything embedded should leave it off to avoid clobbering a real
|
|
214
|
+
* daemon's lockfile. defaults to false. */
|
|
215
|
+
writeLockfile?: boolean
|
|
216
|
+
/** the sootbean origin this daemon's runtimes should talk to for auth,
|
|
217
|
+
* billing, and preview uploads — inlined into served runtime html as
|
|
218
|
+
* `window.__sootsimSootbeanOrigin`. without it the engine's origin.ts
|
|
219
|
+
* resolves the daemon's own loopback host (localhost:<runtimePort>) to
|
|
220
|
+
* the dev `localhost:3000` stack, so a *prod* CLI user's preview
|
|
221
|
+
* recording would upload to a localhost stack that doesn't exist. the
|
|
222
|
+
* `sootsim server` command resolves this with the same probe the
|
|
223
|
+
* upload itself uses (`resolveDefaultUploadOrigin`). */
|
|
224
|
+
sootbeanOrigin?: string
|
|
225
|
+
/** override the abandoned-sim GC TTL (ms). defaults to
|
|
226
|
+
* SIM_IDLE_REAP_TTL_MS (30 min). exists so the reaper integration test
|
|
227
|
+
* can drive the real timer path deterministically instead of mocking
|
|
228
|
+
* time. production callers should never set this. */
|
|
229
|
+
simIdleReapTtlMs?: number
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const DAEMON_HEARTBEAT_INTERVAL_MS = 5_000
|
|
233
|
+
const DEFAULT_RUNTIME_UPDATE_INTERVAL_MS = 60 * 60 * 1000
|
|
234
|
+
const RUNTIME_UPDATE_INTERVAL_ENV = 'SOOTSIM_RUNTIME_UPDATE_INTERVAL_MS'
|
|
235
|
+
|
|
236
|
+
const HTTP_MIME_TYPES: Record<string, string> = {
|
|
237
|
+
'.html': 'text/html; charset=utf-8',
|
|
238
|
+
'.js': 'application/javascript',
|
|
239
|
+
'.cjs': 'application/javascript',
|
|
240
|
+
'.mjs': 'application/javascript',
|
|
241
|
+
'.css': 'text/css; charset=utf-8',
|
|
242
|
+
'.json': 'application/json; charset=utf-8',
|
|
243
|
+
'.png': 'image/png',
|
|
244
|
+
'.jpg': 'image/jpeg',
|
|
245
|
+
'.jpeg': 'image/jpeg',
|
|
246
|
+
'.gif': 'image/gif',
|
|
247
|
+
'.svg': 'image/svg+xml',
|
|
248
|
+
'.webp': 'image/webp',
|
|
249
|
+
'.avif': 'image/avif',
|
|
250
|
+
'.ico': 'image/x-icon',
|
|
251
|
+
'.wasm': 'application/wasm',
|
|
252
|
+
'.ttf': 'font/ttf',
|
|
253
|
+
'.otf': 'font/otf',
|
|
254
|
+
'.woff': 'font/woff',
|
|
255
|
+
'.woff2': 'font/woff2',
|
|
256
|
+
'.map': 'application/json',
|
|
257
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** inject host-provided globals into the served runtime html:
|
|
261
|
+
* - `window.__sootsimSharedConfig` — parsed `~/.sootsim/config.json`,
|
|
262
|
+
* read during engine settingsStore module init (see
|
|
263
|
+
* settings/persistence.ts). mirrors the inject-into-html plugin in the
|
|
264
|
+
* shell vite dev server so dev and prod surfaces both produce it.
|
|
265
|
+
* - `window.__sootsimCliVersion` — the CLI version serving this runtime,
|
|
266
|
+
* so the shell can surface CLI vs runtime version (MacMenuBar footer,
|
|
267
|
+
* ReportBody) and users can tell what they're actually running.
|
|
268
|
+
* - `window.__sootsimBridgePort` — the ws bridge port this daemon is
|
|
269
|
+
* actually listening on. the runtime http server and the ws bridge are
|
|
270
|
+
* the same server on the same port, but a daemon whose preferred 7668
|
|
271
|
+
* was taken falls back to 7669+. without this the runtime page would
|
|
272
|
+
* hardcode the 7668 default in `resolveBridgePort` and register on a
|
|
273
|
+
* *different* daemon (or none), so `sootsim open` times out waiting for
|
|
274
|
+
* a sim that connected to the wrong bridge.
|
|
275
|
+
* - `window.__sootsimSootbeanOrigin` — the sootbean origin for auth /
|
|
276
|
+
* billing / preview uploads. the daemon serves runtimes from its own
|
|
277
|
+
* loopback host, and engine `origin.ts` otherwise resolves any
|
|
278
|
+
* loopback host to the dev `localhost:3000` stack — so without this a
|
|
279
|
+
* prod CLI user's preview recording would upload to a localhost stack
|
|
280
|
+
* that does not exist. */
|
|
281
|
+
function injectSharedConfigIntoHtml(
|
|
282
|
+
data: Buffer,
|
|
283
|
+
bridgePort: number,
|
|
284
|
+
sootbeanOrigin: string | null,
|
|
285
|
+
): string {
|
|
286
|
+
let payload: string
|
|
287
|
+
try {
|
|
288
|
+
const cfg: SharedConfig = readSharedConfig()
|
|
289
|
+
payload = JSON.stringify(cfg)
|
|
290
|
+
} catch {
|
|
291
|
+
payload = '{}'
|
|
292
|
+
}
|
|
293
|
+
const bridgePortTag = bridgePort > 0 ? `window.__sootsimBridgePort=${bridgePort};` : ''
|
|
294
|
+
const sootbeanOriginTag = sootbeanOrigin
|
|
295
|
+
? `window.__sootsimSootbeanOrigin=${JSON.stringify(sootbeanOrigin)};`
|
|
296
|
+
: ''
|
|
297
|
+
const tag =
|
|
298
|
+
`<script>window.__sootsimSharedConfig=${payload};` +
|
|
299
|
+
bridgePortTag +
|
|
300
|
+
sootbeanOriginTag +
|
|
301
|
+
`window.__sootsimCliVersion=${JSON.stringify(getCliVersion())};</script>`
|
|
302
|
+
const html = data.toString('utf8')
|
|
303
|
+
if (html.includes('</head>')) return html.replace('</head>', tag + '</head>')
|
|
304
|
+
if (html.includes('</body>')) return html.replace('</body>', tag + '</body>')
|
|
305
|
+
// no recognized injection point — prepend so the global is defined
|
|
306
|
+
// before any inline scripts further down in the document.
|
|
307
|
+
return tag + html
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
type UnrefableTimer = { unref: () => void }
|
|
311
|
+
|
|
312
|
+
function unrefTimer(timer: ReturnType<typeof setTimeout>) {
|
|
313
|
+
if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
|
|
314
|
+
;(timer as UnrefableTimer).unref()
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export class SootSimBridgeHost {
|
|
319
|
+
private port: number
|
|
320
|
+
private openUrlHandler?: (url: string, options?: OpenUrlOptions) => Promise<void> | void
|
|
321
|
+
private httpServer: ReturnType<typeof createServer> | null = null
|
|
322
|
+
private wss: WebSocketServer | null = null
|
|
323
|
+
private nextCommandId = 1
|
|
324
|
+
private nextSimNumber = 0xa1
|
|
325
|
+
private sims = new Map<string, BridgeSimConnection>()
|
|
326
|
+
private primarySimId: string | null = null
|
|
327
|
+
private pendingCommands = new Map<number, BridgePendingCommand>()
|
|
328
|
+
private cliBySentId = new Map<number, BridgeForwardedCommand>()
|
|
329
|
+
private cliSimBySocket = new Map<WebSocket, string>()
|
|
330
|
+
private cliLastCommandAt = new Map<WebSocket, number>()
|
|
331
|
+
private cliIdentityKeyBySocket = new Map<WebSocket, string>()
|
|
332
|
+
private cliLabelBySocket = new Map<WebSocket, string>()
|
|
333
|
+
private restorableSims = new Map<string, BridgeRestorableSimState>()
|
|
334
|
+
private nextCliFallbackId = 1
|
|
335
|
+
private cliIdleTimer: NodeJS.Timeout | null = null
|
|
336
|
+
private agentHost: AgentHost
|
|
337
|
+
private static CLI_IDLE_TIMEOUT_MS = 60_000
|
|
338
|
+
private static CLI_LEASE_TTL_MS = 600_000
|
|
339
|
+
private static USER_ACTIVE_LEASE_TTL_MS = 8_000
|
|
340
|
+
// explicit user actions (clicking Boot, focusing the sim to take it over)
|
|
341
|
+
// hold the sim longer than passive canvas interaction so reconnecting clis
|
|
342
|
+
// can't immediately reclaim while the user gets oriented.
|
|
343
|
+
private static USER_BOOT_LEASE_TTL_MS = 60_000
|
|
344
|
+
private static SIM_RECONNECT_TTL_MS = 30_000
|
|
345
|
+
// abandoned-tab GC. the ws heartbeat only reaps sims whose socket went
|
|
346
|
+
// dead — but a background tab from a prior `sootsim open` / QA / test run
|
|
347
|
+
// keeps its socket alive (the page still pongs) forever, so `sootsim list`
|
|
348
|
+
// accretes dozens of zombie sims that all time out on every command (QA
|
|
349
|
+
// F21-3, carried F19-2). reap a sim only when it is provably nobody's:
|
|
350
|
+
// not primary, not user-focused, no CLI attached, no active lease, and no
|
|
351
|
+
// CLI-driven activity for this long. closeSimSocketFromHost sends the
|
|
352
|
+
// terminal 4001 code, so the client closes the abandoned window instead
|
|
353
|
+
// of reconnecting the zombie straight back in.
|
|
354
|
+
private static SIM_IDLE_REAP_TTL_MS = 30 * 60_000
|
|
355
|
+
|
|
356
|
+
private preferredPort: number
|
|
357
|
+
private portFallbackCount: number
|
|
358
|
+
private simIdleReapTtlMs: number
|
|
359
|
+
private shouldWriteLockfile: boolean
|
|
360
|
+
private sootbeanOrigin: string | null = null
|
|
361
|
+
private effectivePort = 0
|
|
362
|
+
private startedAt = 0
|
|
363
|
+
private heartbeatTimer: NodeJS.Timeout | null = null
|
|
364
|
+
// ws-level heartbeat: sims that hang up uncleanly (page navigated, network
|
|
365
|
+
// dropped, sim crashed) leave their server-side WebSocket sitting "open"
|
|
366
|
+
// forever. ping every WS_HEARTBEAT_INTERVAL_MS; if the previous round's
|
|
367
|
+
// ping was never answered, terminate(). that fires 'close' which runs the
|
|
368
|
+
// sim-cleanup path and stops `sootsim list` from showing 8 zombie
|
|
369
|
+
// sims that all time out on every command.
|
|
370
|
+
private wsHeartbeatTimer: NodeJS.Timeout | null = null
|
|
371
|
+
private wsIsAlive = new WeakMap<WebSocket, boolean>()
|
|
372
|
+
private static WS_HEARTBEAT_INTERVAL_MS = 30_000
|
|
373
|
+
private runtimeUpdateTimer: NodeJS.Timeout | null = null
|
|
374
|
+
private runtimeUpdateInFlight: Promise<void> | null = null
|
|
375
|
+
private activeRuntimeVersion: string | null = null
|
|
376
|
+
private activeRuntimeDirPath: string | null = null
|
|
377
|
+
// /__server-scan cache. mirrors the shell vite dev-middleware so engine
|
|
378
|
+
// ConnectRN / DemoConnectApp see the same JSON shape whether they boot
|
|
379
|
+
// from vite dev or from this daemon. without this, the SPA fallback below
|
|
380
|
+
// would serve index.html for /__server-scan and tenant-worker .json()
|
|
381
|
+
// crashes with "Unexpected token '<', "<!doctype "... is not valid JSON".
|
|
382
|
+
private scanCache: DiscoveredServer[] | null = null
|
|
383
|
+
private scanCacheAt = 0
|
|
384
|
+
private inflightScan: Promise<DiscoveredServer[]> | null = null
|
|
385
|
+
private static SCAN_FRESH_MS = 2000
|
|
386
|
+
|
|
387
|
+
constructor(opts: BridgeHostOptions = {}) {
|
|
388
|
+
this.preferredPort = opts.port || DEFAULT_SOOTSIM_BRIDGE_PORT
|
|
389
|
+
this.port = this.preferredPort
|
|
390
|
+
// default off — callers that want the lockfile (sootsim server) opt in.
|
|
391
|
+
this.shouldWriteLockfile = opts.writeLockfile === true
|
|
392
|
+
// fallback freely on port collision regardless of who owns the lockfile.
|
|
393
|
+
// server.ts already refuses to start if a fresh daemon.json exists, so
|
|
394
|
+
// two daemons can't race the lockfile. without this, an unrelated process
|
|
395
|
+
// on 7668 (another worktree's vite dev fallback bridge, etc.) was enough
|
|
396
|
+
// to keep the daemon from binding at all — and launchd's KeepAlive then
|
|
397
|
+
// respawned it forever.
|
|
398
|
+
this.portFallbackCount = Math.max(1, opts.portFallbackCount ?? 10)
|
|
399
|
+
this.openUrlHandler = opts.openUrl
|
|
400
|
+
this.agentHost = new AgentHost({ getExcludePorts: opts.agentScanExcludes })
|
|
401
|
+
this.sootbeanOrigin = opts.sootbeanOrigin?.replace(/\/$/, '') || null
|
|
402
|
+
this.simIdleReapTtlMs =
|
|
403
|
+
opts.simIdleReapTtlMs ?? SootSimBridgeHost.SIM_IDLE_REAP_TTL_MS
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** expose the agent host so tests and embedders can inspect state or
|
|
407
|
+
* inject behavior. not part of the public WS protocol. */
|
|
408
|
+
getAgentHost(): AgentHost {
|
|
409
|
+
return this.agentHost
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** run the abandoned-sim GC pass on demand. the reaper normally fires off
|
|
413
|
+
* the 30s idle-sweep timer; this lets the F21-3 integration test drive
|
|
414
|
+
* the real path without waiting for the timer. not part of the public
|
|
415
|
+
* WS protocol. */
|
|
416
|
+
reapIdleSimsForTest(now = Date.now()): void {
|
|
417
|
+
this.reapIdleSims(now)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** synchronous wrapper around startAsync for callers that don't care
|
|
421
|
+
* about port fallback outcomes. returns immediately; actual binding
|
|
422
|
+
* happens on the event loop. callers that need to know the bound port
|
|
423
|
+
* should await startAsync() instead. */
|
|
424
|
+
start(options?: { silent?: boolean }): void {
|
|
425
|
+
void this.startAsync(options)
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async startAsync(options?: { silent?: boolean }): Promise<number> {
|
|
429
|
+
if (this.httpServer || this.wss) return this.effectivePort
|
|
430
|
+
|
|
431
|
+
// seed active runtime state from disk so the http routes + lockfile
|
|
432
|
+
// reflect reality at boot. we reread this on runtime:use messages.
|
|
433
|
+
this.refreshActiveRuntime()
|
|
434
|
+
|
|
435
|
+
for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
|
|
436
|
+
const candidate = this.preferredPort + attempt
|
|
437
|
+
try {
|
|
438
|
+
await this.bindOnce(candidate, options?.silent === true)
|
|
439
|
+
this.effectivePort = candidate
|
|
440
|
+
this.port = candidate
|
|
441
|
+
this.startedAt = Date.now()
|
|
442
|
+
if (attempt > 0 && !options?.silent) {
|
|
443
|
+
process.stderr.write(
|
|
444
|
+
`ws bridge bound to port ${candidate} (preferred ${this.preferredPort} was taken)\n`,
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
this.afterBind()
|
|
448
|
+
return candidate
|
|
449
|
+
} catch (err: unknown) {
|
|
450
|
+
const e = err as NodeJS.ErrnoException
|
|
451
|
+
if (e?.code !== 'EADDRINUSE') {
|
|
452
|
+
throw err
|
|
453
|
+
}
|
|
454
|
+
if (!options?.silent) {
|
|
455
|
+
process.stderr.write(
|
|
456
|
+
`ws bridge port ${candidate} already in use, trying ${candidate + 1}\n`,
|
|
457
|
+
)
|
|
458
|
+
}
|
|
459
|
+
// bindOnce already cleaned up httpServer/wss on failure
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
throw new Error(
|
|
463
|
+
`could not bind ws bridge after ${this.portFallbackCount} attempts starting at ${this.preferredPort}`,
|
|
464
|
+
)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private bindOnce(port: number, _silent: boolean): Promise<void> {
|
|
468
|
+
return new Promise<void>((resolve, reject) => {
|
|
469
|
+
const server = createServer((req, res) => this.handleHttpRequest(req, res))
|
|
470
|
+
let settled = false
|
|
471
|
+
|
|
472
|
+
const onError = (err: NodeJS.ErrnoException) => {
|
|
473
|
+
if (settled) return
|
|
474
|
+
settled = true
|
|
475
|
+
try {
|
|
476
|
+
server.close()
|
|
477
|
+
} catch {}
|
|
478
|
+
this.httpServer = null
|
|
479
|
+
this.wss = null
|
|
480
|
+
reject(err)
|
|
481
|
+
}
|
|
482
|
+
server.once('error', onError)
|
|
483
|
+
|
|
484
|
+
// loopback-only bind. we listen on 127.0.0.1 explicitly because the
|
|
485
|
+
// daemon serves the active runtime's dist as plain static files —
|
|
486
|
+
// never expose that to LAN peers. tests + the electron renderer use
|
|
487
|
+
// 127.0.0.1 explicitly to avoid the localhost-vs-::1 DNS coinflip.
|
|
488
|
+
server.listen(port, '127.0.0.1', () => {
|
|
489
|
+
if (settled) return
|
|
490
|
+
settled = true
|
|
491
|
+
server.removeListener('error', onError)
|
|
492
|
+
server.on('error', (err) => {
|
|
493
|
+
process.stderr.write(`ws bridge http error: ${String(err)}\n`)
|
|
494
|
+
})
|
|
495
|
+
this.httpServer = server
|
|
496
|
+
this.wss = new WebSocketServer({ server })
|
|
497
|
+
this.wireWebSocketServer()
|
|
498
|
+
resolve()
|
|
499
|
+
})
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** attach the WS connection handler to the current wss. called from
|
|
504
|
+
* bindOnce() after WebSocketServer is freshly created. */
|
|
505
|
+
private wireWebSocketServer() {
|
|
506
|
+
if (!this.wss) return
|
|
507
|
+
this.wss.on('connection', (ws, req) => {
|
|
508
|
+
const origin = req.headers.origin
|
|
509
|
+
const role: 'sim' | 'cli' = origin ? 'sim' : 'cli'
|
|
510
|
+
let sim: BridgeSimConnection | null = null
|
|
511
|
+
|
|
512
|
+
// ws emits 'error' for late peer-side socket faults (write EIO after
|
|
513
|
+
// sleep/wake, abrupt sim close, etc). without a listener, node's
|
|
514
|
+
// EventEmitter rethrows as uncaughtException and crashes the host.
|
|
515
|
+
// 'close' fires right after and runs the cleanup below.
|
|
516
|
+
ws.on('error', () => {})
|
|
517
|
+
|
|
518
|
+
// ws-level heartbeat. pong responses come for free from the ws
|
|
519
|
+
// protocol — we just need to track them so the heartbeat sweep
|
|
520
|
+
// (started in afterBind) can terminate connections that stop
|
|
521
|
+
// answering. fresh connections start alive.
|
|
522
|
+
this.wsIsAlive.set(ws, true)
|
|
523
|
+
ws.on('pong', () => {
|
|
524
|
+
this.wsIsAlive.set(ws, true)
|
|
525
|
+
})
|
|
526
|
+
|
|
527
|
+
// register the socket with the agent host so session-status pushes
|
|
528
|
+
// reach it even before it subscribes to any specific session, and so
|
|
529
|
+
// subscriptions get cleaned up automatically on close.
|
|
530
|
+
this.agentHost.registerSocket(ws)
|
|
531
|
+
|
|
532
|
+
if (role === 'sim') {
|
|
533
|
+
sim = {
|
|
534
|
+
id: this.allocateSimId(),
|
|
535
|
+
ws,
|
|
536
|
+
origin,
|
|
537
|
+
connectedAt: Date.now(),
|
|
538
|
+
lastSeenAt: Date.now(),
|
|
539
|
+
lastActiveAt: 0,
|
|
540
|
+
recentActions: [],
|
|
541
|
+
}
|
|
542
|
+
this.sims.set(sim.id, sim)
|
|
543
|
+
if (this.shouldPromoteSim(sim)) {
|
|
544
|
+
this.primarySimId = sim.id
|
|
545
|
+
}
|
|
546
|
+
this.broadcastSimAssignments()
|
|
547
|
+
this.broadcastSimClientStates()
|
|
548
|
+
} else {
|
|
549
|
+
const fallbackKey = `ws-${this.nextCliFallbackId++}`
|
|
550
|
+
this.cliIdentityKeyBySocket.set(ws, fallbackKey)
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
ws.on('message', (data) => {
|
|
554
|
+
let msg: any
|
|
555
|
+
try {
|
|
556
|
+
msg = JSON.parse(data.toString())
|
|
557
|
+
} catch {
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
if (!msg || typeof msg !== 'object') return
|
|
561
|
+
|
|
562
|
+
// agent:* messages are routed uniformly regardless of socket role.
|
|
563
|
+
// CLI (`sootsim agent …`), electron main (daemon client), and
|
|
564
|
+
// sim shells all use the same envelope, and agent event
|
|
565
|
+
// subscriptions work from any of them — this is the whole point of
|
|
566
|
+
// moving session ownership into the daemon.
|
|
567
|
+
if (typeof msg.type === 'string' && msg.type.startsWith('agent:')) {
|
|
568
|
+
void this.agentHost.handleMessage(ws, msg)
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// runtime:* messages manage installed engine runtimes. list / use
|
|
573
|
+
// are handled in-daemon; install runs entirely inside the CLI so
|
|
574
|
+
// we don't need a daemon-side handler for it.
|
|
575
|
+
if (msg.type === 'runtime:list') {
|
|
576
|
+
const versions = listInstalledRuntimes()
|
|
577
|
+
const active = this.getActiveRuntime()
|
|
578
|
+
const reply = {
|
|
579
|
+
type: 'runtime:list:ok',
|
|
580
|
+
id: msg.id,
|
|
581
|
+
installed: versions,
|
|
582
|
+
active: active.version,
|
|
583
|
+
activeRuntimeDir: active.runtimeDir,
|
|
584
|
+
}
|
|
585
|
+
try {
|
|
586
|
+
ws.send(JSON.stringify(reply))
|
|
587
|
+
} catch {}
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
if (msg.type === 'runtime:use') {
|
|
591
|
+
const version = typeof msg.version === 'string' ? msg.version : ''
|
|
592
|
+
const installed = listInstalledRuntimes()
|
|
593
|
+
if (!installed.includes(version)) {
|
|
594
|
+
try {
|
|
595
|
+
ws.send(
|
|
596
|
+
JSON.stringify({
|
|
597
|
+
type: 'runtime:use:error',
|
|
598
|
+
id: msg.id,
|
|
599
|
+
error: `runtime ${version || '(missing)'} is not installed`,
|
|
600
|
+
}),
|
|
601
|
+
)
|
|
602
|
+
} catch {}
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
const result = this.setActiveRuntime(version)
|
|
606
|
+
try {
|
|
607
|
+
ws.send(
|
|
608
|
+
JSON.stringify({
|
|
609
|
+
type: 'runtime:use:ok',
|
|
610
|
+
id: msg.id,
|
|
611
|
+
version: result.version,
|
|
612
|
+
runtimeDir: result.runtimeDir,
|
|
613
|
+
}),
|
|
614
|
+
)
|
|
615
|
+
} catch {}
|
|
616
|
+
return
|
|
617
|
+
}
|
|
618
|
+
if (msg.type === 'runtime:get') {
|
|
619
|
+
const active = this.getActiveRuntime()
|
|
620
|
+
try {
|
|
621
|
+
ws.send(
|
|
622
|
+
JSON.stringify({
|
|
623
|
+
type: 'runtime:get:ok',
|
|
624
|
+
id: msg.id,
|
|
625
|
+
active: active.version,
|
|
626
|
+
activeRuntimeDir: active.runtimeDir,
|
|
627
|
+
}),
|
|
628
|
+
)
|
|
629
|
+
} catch {}
|
|
630
|
+
return
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (role === 'sim') {
|
|
634
|
+
if (sim) {
|
|
635
|
+
sim.lastSeenAt = Date.now()
|
|
636
|
+
}
|
|
637
|
+
if (msg.type === 'bridge:register' && sim) {
|
|
638
|
+
const registration = msg as BridgeSimRegistrationMessage
|
|
639
|
+
const restored = this.tryRestoreSimId(sim, registration.simId)
|
|
640
|
+
sim.url = registration.url
|
|
641
|
+
sim.title = registration.title
|
|
642
|
+
sim.userAgent = registration.userAgent
|
|
643
|
+
// kind/meta are optional and free-form. only update if the
|
|
644
|
+
// sender supplied a value so we don't accidentally clear
|
|
645
|
+
// state on a heartbeat-style re-register.
|
|
646
|
+
if (typeof registration.kind === 'string' && registration.kind.trim()) {
|
|
647
|
+
sim.kind = registration.kind.trim()
|
|
648
|
+
}
|
|
649
|
+
if (registration.meta && typeof registration.meta === 'object') {
|
|
650
|
+
sim.meta = registration.meta as Record<string, unknown>
|
|
651
|
+
}
|
|
652
|
+
// re-evaluate primary now that the sim has a real page. promotion
|
|
653
|
+
// is otherwise decided once at bare-connect, before `url` is
|
|
654
|
+
// known — so a page-less sim that grabbed primary as the
|
|
655
|
+
// last-resort fallback (or a stale zombie still holding it)
|
|
656
|
+
// would never be superseded by this freshly-registered, actually
|
|
657
|
+
// driveable sim (QA F19-2). only adopt a *different* sim so a
|
|
658
|
+
// routine heartbeat re-register doesn't churn the assignment.
|
|
659
|
+
const reElected = this.primarySimId !== sim.id && this.shouldPromoteSim(sim)
|
|
660
|
+
if (reElected) this.primarySimId = sim.id
|
|
661
|
+
if (restored || reElected) {
|
|
662
|
+
this.broadcastSimAssignments()
|
|
663
|
+
this.broadcastSimClientStates()
|
|
664
|
+
}
|
|
665
|
+
return
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
if (msg.type === 'bridge:user-focus-state' && sim) {
|
|
669
|
+
const focusState = msg as BridgeSimUserFocusStateMessage
|
|
670
|
+
this.updateUserFocusLease(sim, focusState)
|
|
671
|
+
return
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (msg.type === 'bridge:user-interact' && sim) {
|
|
675
|
+
this.updateUserActivity(sim)
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// write a partial patch into ~/.sootsim/config.json. fired by the
|
|
680
|
+
// engine's settingsStore when a persisted key flips in a browser
|
|
681
|
+
// tab (which has no fs access of its own). after the merge, we
|
|
682
|
+
// broadcast the new full snapshot to every connected sim so any
|
|
683
|
+
// tab that has the engine config global picks up the change live
|
|
684
|
+
// — same shape as electron's `config:changed` IPC.
|
|
685
|
+
if (msg.type === 'bridge:write-shared-config') {
|
|
686
|
+
const patch =
|
|
687
|
+
msg.patch && typeof msg.patch === 'object'
|
|
688
|
+
? (msg.patch as Partial<SharedConfig>)
|
|
689
|
+
: null
|
|
690
|
+
if (!patch) return
|
|
691
|
+
let next: SharedConfig
|
|
692
|
+
try {
|
|
693
|
+
next = writeSharedConfig(patch)
|
|
694
|
+
} catch (err) {
|
|
695
|
+
process.stderr.write(
|
|
696
|
+
`sootsim: bridge:write-shared-config failed: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
697
|
+
)
|
|
698
|
+
return
|
|
699
|
+
}
|
|
700
|
+
const payload = JSON.stringify({
|
|
701
|
+
type: 'bridge:shared-config-changed',
|
|
702
|
+
config: next,
|
|
703
|
+
})
|
|
704
|
+
for (const peer of this.sims.values()) {
|
|
705
|
+
if (peer.ws.readyState !== WebSocket.OPEN) continue
|
|
706
|
+
try {
|
|
707
|
+
peer.ws.send(payload)
|
|
708
|
+
} catch {}
|
|
709
|
+
}
|
|
710
|
+
return
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// open a source file in the user's editor. triggered by tappable
|
|
714
|
+
// stack frames in sootsim's RedBox overlay.
|
|
715
|
+
if (msg.type === 'bridge:open-path') {
|
|
716
|
+
const filePath = typeof msg.path === 'string' ? msg.path : ''
|
|
717
|
+
const line =
|
|
718
|
+
typeof msg.line === 'number' && Number.isFinite(msg.line)
|
|
719
|
+
? msg.line
|
|
720
|
+
: undefined
|
|
721
|
+
const column =
|
|
722
|
+
typeof msg.column === 'number' && Number.isFinite(msg.column)
|
|
723
|
+
? msg.column
|
|
724
|
+
: undefined
|
|
725
|
+
if (filePath) {
|
|
726
|
+
void this.openPathInEditor(filePath, line, column)
|
|
727
|
+
}
|
|
728
|
+
return
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// boot-clients: disconnect all CLI clients attached to this sim
|
|
732
|
+
// and hand the sim to the user. an explicit Boot is a strong claim,
|
|
733
|
+
// so we install a user-active lease instead of clearing — otherwise
|
|
734
|
+
// a reconnecting agent (most spawn a fresh socket within ms of close)
|
|
735
|
+
// claims the empty slot before the user can interact and the boot
|
|
736
|
+
// becomes a no-op the user has to repeat indefinitely.
|
|
737
|
+
if (msg.type === 'bridge:boot-clients' && sim) {
|
|
738
|
+
const booted: WebSocket[] = []
|
|
739
|
+
for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
|
|
740
|
+
if (attachedSimId === sim.id) {
|
|
741
|
+
booted.push(cliWs)
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
for (const cliWs of booted) {
|
|
745
|
+
this.cliSimBySocket.delete(cliWs)
|
|
746
|
+
try {
|
|
747
|
+
cliWs.close(1000, 'booted by sim')
|
|
748
|
+
} catch {}
|
|
749
|
+
}
|
|
750
|
+
const hadLease = !!sim.cliLease
|
|
751
|
+
sim.cliLease = {
|
|
752
|
+
kind: 'user-active',
|
|
753
|
+
cliIdentityKey: '__user-active__',
|
|
754
|
+
cliLabel: 'active user',
|
|
755
|
+
expiresAt: Date.now() + SootSimBridgeHost.USER_BOOT_LEASE_TTL_MS,
|
|
756
|
+
}
|
|
757
|
+
process.stderr.write(
|
|
758
|
+
`sootsim booted ${booted.length} cli client(s)${hadLease ? ' (overrode prior lease)' : ''}; held sim for user [${sim.id}]\n`,
|
|
759
|
+
)
|
|
760
|
+
this.recordSimAction(sim.id, 'sim booted cli clients')
|
|
761
|
+
this.broadcastSimClientStates()
|
|
762
|
+
return
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
const internalPending = this.pendingCommands.get(msg.id)
|
|
766
|
+
if (internalPending) {
|
|
767
|
+
this.pendingCommands.delete(msg.id)
|
|
768
|
+
if (msg.error) internalPending.reject(new Error(msg.error))
|
|
769
|
+
else internalPending.resolve(msg.result)
|
|
770
|
+
return
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
const entry = this.cliBySentId.get(msg.id)
|
|
774
|
+
if (entry) {
|
|
775
|
+
this.cliBySentId.delete(msg.id)
|
|
776
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
777
|
+
// include other CLI count so the client can warn about contention
|
|
778
|
+
const otherCliCount = this.getOtherCliIdentityCount(entry.ws, entry.simId)
|
|
779
|
+
const response =
|
|
780
|
+
otherCliCount > 0
|
|
781
|
+
? { ...msg, id: entry.originalId, _otherCliCount: otherCliCount }
|
|
782
|
+
: { ...msg, id: entry.originalId }
|
|
783
|
+
entry.ws.send(JSON.stringify(response))
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
void (async () => {
|
|
790
|
+
this.cliLastCommandAt.set(ws, Date.now())
|
|
791
|
+
try {
|
|
792
|
+
if (msg.type === 'bridge:bye') {
|
|
793
|
+
// explicit goodbye from a cli about to exit. drop its socket
|
|
794
|
+
// state immediately so the next invocation from the same agent
|
|
795
|
+
// doesn't see this one as a phantom peer. the subsequent tcp
|
|
796
|
+
// close event becomes a no-op.
|
|
797
|
+
const hadSim = this.cliSimBySocket.delete(ws)
|
|
798
|
+
this.cliLastCommandAt.delete(ws)
|
|
799
|
+
this.cliIdentityKeyBySocket.delete(ws)
|
|
800
|
+
this.cliLabelBySocket.delete(ws)
|
|
801
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
802
|
+
if (entry.ws === ws) this.cliBySentId.delete(sentId)
|
|
803
|
+
}
|
|
804
|
+
if (hadSim) this.broadcastSimClientStates()
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (msg.type === 'bridge:hello') {
|
|
809
|
+
const key =
|
|
810
|
+
typeof msg.cliIdentityKey === 'string' && msg.cliIdentityKey.trim()
|
|
811
|
+
? msg.cliIdentityKey.trim()
|
|
812
|
+
: this.cliIdentityKeyBySocket.get(ws) ||
|
|
813
|
+
`ws-${this.nextCliFallbackId++}`
|
|
814
|
+
this.cliIdentityKeyBySocket.set(ws, key)
|
|
815
|
+
if (typeof msg.cliLabel === 'string' && msg.cliLabel.trim()) {
|
|
816
|
+
this.cliLabelBySocket.set(ws, msg.cliLabel.trim())
|
|
817
|
+
}
|
|
818
|
+
// same-identity cli sockets are allowed to coexist. this avoids
|
|
819
|
+
// self-disconnects when agent tooling issues multiple commands
|
|
820
|
+
// in parallel from the same logical identity key.
|
|
821
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
822
|
+
ws.send(
|
|
823
|
+
JSON.stringify({
|
|
824
|
+
id: msg.id,
|
|
825
|
+
result: {
|
|
826
|
+
cliIdentityKey: key,
|
|
827
|
+
leaseTtlMs: SootSimBridgeHost.CLI_LEASE_TTL_MS,
|
|
828
|
+
leasing: true,
|
|
829
|
+
},
|
|
830
|
+
}),
|
|
831
|
+
)
|
|
832
|
+
}
|
|
833
|
+
return
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (msg.type === 'bridge:list-sims') {
|
|
837
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
838
|
+
ws.send(
|
|
839
|
+
JSON.stringify({
|
|
840
|
+
id: msg.id,
|
|
841
|
+
result: this.listSims(),
|
|
842
|
+
}),
|
|
843
|
+
)
|
|
844
|
+
}
|
|
845
|
+
return
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
if (msg.type === 'bridge:open') {
|
|
849
|
+
if (typeof msg.url !== 'string' || !msg.url) {
|
|
850
|
+
throw new Error('bridge:open requires a url')
|
|
851
|
+
}
|
|
852
|
+
await this.openUrl(msg.url, { newWindow: msg.newWindow === true })
|
|
853
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
854
|
+
ws.send(
|
|
855
|
+
JSON.stringify({
|
|
856
|
+
id: msg.id,
|
|
857
|
+
result: { ok: true, url: msg.url },
|
|
858
|
+
}),
|
|
859
|
+
)
|
|
860
|
+
}
|
|
861
|
+
return
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (msg.type === 'bridge:claim') {
|
|
865
|
+
const targetSim = await this.waitForSim(msg.simId)
|
|
866
|
+
const outcome = this.tryAcquireLease(ws, targetSim, {
|
|
867
|
+
force: msg.force === true,
|
|
868
|
+
})
|
|
869
|
+
if (!outcome.granted) {
|
|
870
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
871
|
+
ws.send(
|
|
872
|
+
JSON.stringify({
|
|
873
|
+
id: msg.id,
|
|
874
|
+
error: `sim ${targetSim.id} is locked by another cli`,
|
|
875
|
+
_locked: outcome.lock,
|
|
876
|
+
}),
|
|
877
|
+
)
|
|
878
|
+
}
|
|
879
|
+
return
|
|
880
|
+
}
|
|
881
|
+
this.setCliSimTarget(ws, targetSim.id)
|
|
882
|
+
this.recordSimAction(
|
|
883
|
+
targetSim.id,
|
|
884
|
+
outcome.bootedCount > 0
|
|
885
|
+
? `cli force-claimed sim (booted ${outcome.bootedCount})`
|
|
886
|
+
: 'cli claimed sim',
|
|
887
|
+
)
|
|
888
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
889
|
+
ws.send(
|
|
890
|
+
JSON.stringify({
|
|
891
|
+
id: msg.id,
|
|
892
|
+
result: {
|
|
893
|
+
simId: targetSim.id,
|
|
894
|
+
lockedBy: outcome.lease.cliIdentityKey,
|
|
895
|
+
lockExpiresAt: outcome.lease.expiresAt,
|
|
896
|
+
bootedCount: outcome.bootedCount,
|
|
897
|
+
},
|
|
898
|
+
}),
|
|
899
|
+
)
|
|
900
|
+
}
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
const targetSim = await this.waitForSim(msg.simId)
|
|
905
|
+
if (shouldAcquireLease(msg)) {
|
|
906
|
+
const outcome = this.tryAcquireLease(ws, targetSim)
|
|
907
|
+
if (!outcome.granted) {
|
|
908
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
909
|
+
ws.send(
|
|
910
|
+
JSON.stringify({
|
|
911
|
+
id: msg.id,
|
|
912
|
+
error: `sim ${targetSim.id} is locked by another cli — use \`sootsim claim ${targetSim.id} --force\` or \`sootsim open --new\``,
|
|
913
|
+
_locked: outcome.lock,
|
|
914
|
+
}),
|
|
915
|
+
)
|
|
916
|
+
}
|
|
917
|
+
return
|
|
918
|
+
}
|
|
919
|
+
} else {
|
|
920
|
+
// read-only / observational pass-through: still register this
|
|
921
|
+
// cli as attached so list/describe shows it, but never block.
|
|
922
|
+
this.ensureCliIdentityKey(ws)
|
|
923
|
+
}
|
|
924
|
+
this.setCliSimTarget(ws, targetSim.id)
|
|
925
|
+
this.recordSimAction(targetSim.id, this.describeForwardedCommand(msg))
|
|
926
|
+
const sentId = this.nextCommandId++
|
|
927
|
+
this.cliBySentId.set(sentId, {
|
|
928
|
+
simId: targetSim.id,
|
|
929
|
+
ws,
|
|
930
|
+
originalId: msg.id,
|
|
931
|
+
})
|
|
932
|
+
const { simId: _simId, ...forwarded } = msg
|
|
933
|
+
targetSim.ws.send(JSON.stringify({ ...forwarded, id: sentId }))
|
|
934
|
+
// `close` must never hang on the sim page. a frozen sim never
|
|
935
|
+
// processes the forwarded close and never replies, so the CLI
|
|
936
|
+
// command would otherwise time out after the full command
|
|
937
|
+
// window (M3). treat close as fire-and-forget: ack the CLI now,
|
|
938
|
+
// drop the pending relay entry so a late sim reply is harmless,
|
|
939
|
+
// and disconnect the sim socket with the terminal close code if
|
|
940
|
+
// the page has not closed itself within the grace window.
|
|
941
|
+
if (forwarded.type === 'close') {
|
|
942
|
+
this.cliBySentId.delete(sentId)
|
|
943
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
944
|
+
ws.send(
|
|
945
|
+
JSON.stringify({
|
|
946
|
+
id: msg.id,
|
|
947
|
+
result: { requested: true, simId: targetSim.id },
|
|
948
|
+
}),
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
const simWs = targetSim.ws
|
|
952
|
+
const closeTimer = setTimeout(() => {
|
|
953
|
+
this.closeSimSocketFromHost(simWs)
|
|
954
|
+
}, FORCE_CLOSE_GRACE_MS)
|
|
955
|
+
unrefTimer(closeTimer)
|
|
956
|
+
}
|
|
957
|
+
} catch (err) {
|
|
958
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
959
|
+
ws.send(
|
|
960
|
+
JSON.stringify({
|
|
961
|
+
id: msg.id,
|
|
962
|
+
error: err instanceof Error ? err.message : String(err),
|
|
963
|
+
}),
|
|
964
|
+
)
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
})()
|
|
968
|
+
})
|
|
969
|
+
|
|
970
|
+
ws.on('close', () => {
|
|
971
|
+
// always drop agent subscriptions first — the FIFO refcount needs
|
|
972
|
+
// to settle before any later broadcast fan-out fires.
|
|
973
|
+
this.agentHost.unregisterSocket(ws)
|
|
974
|
+
if (role === 'sim' && sim) {
|
|
975
|
+
this.rememberDisconnectedSim(sim)
|
|
976
|
+
if (this.primarySimId === sim.id) {
|
|
977
|
+
this.primarySimId = this.getOpenSim()?.id ?? null
|
|
978
|
+
}
|
|
979
|
+
for (const [id, pending] of this.pendingCommands) {
|
|
980
|
+
if (pending.simId !== sim.id) continue
|
|
981
|
+
pending.reject(new Error('sim disconnected'))
|
|
982
|
+
this.pendingCommands.delete(id)
|
|
983
|
+
}
|
|
984
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
985
|
+
if (entry.simId !== sim.id) continue
|
|
986
|
+
if (entry.ws.readyState === WebSocket.OPEN) {
|
|
987
|
+
entry.ws.send(
|
|
988
|
+
JSON.stringify({
|
|
989
|
+
id: entry.originalId,
|
|
990
|
+
error: 'sim disconnected before responding',
|
|
991
|
+
}),
|
|
992
|
+
)
|
|
993
|
+
}
|
|
994
|
+
this.cliBySentId.delete(sentId)
|
|
995
|
+
}
|
|
996
|
+
this.broadcastSimAssignments()
|
|
997
|
+
this.broadcastSimClientStates()
|
|
998
|
+
} else if (role === 'cli') {
|
|
999
|
+
const detached = this.cliSimBySocket.delete(ws)
|
|
1000
|
+
this.cliLastCommandAt.delete(ws)
|
|
1001
|
+
this.cliIdentityKeyBySocket.delete(ws)
|
|
1002
|
+
this.cliLabelBySocket.delete(ws)
|
|
1003
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
1004
|
+
if (entry.ws === ws) this.cliBySentId.delete(sentId)
|
|
1005
|
+
}
|
|
1006
|
+
if (detached) {
|
|
1007
|
+
this.broadcastSimClientStates()
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
})
|
|
1011
|
+
})
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/** after a successful bind: start the cli idle sweep, write the daemon
|
|
1015
|
+
* lockfile (if this host owns it), seed the agent host, kick off the
|
|
1016
|
+
* heartbeat loop. idempotent across rebinds because close() tears down
|
|
1017
|
+
* every timer and the lockfile. */
|
|
1018
|
+
private afterBind() {
|
|
1019
|
+
process.stderr.write(`ws bridge listening on port ${this.port}\n`)
|
|
1020
|
+
|
|
1021
|
+
this.cliIdleTimer = setInterval(
|
|
1022
|
+
() => this.sweepIdleCliClients(),
|
|
1023
|
+
30_000,
|
|
1024
|
+
) as unknown as NodeJS.Timeout
|
|
1025
|
+
this.cliIdleTimer.unref()
|
|
1026
|
+
|
|
1027
|
+
this.wsHeartbeatTimer = setInterval(
|
|
1028
|
+
() => this.sweepDeadWebSockets(),
|
|
1029
|
+
SootSimBridgeHost.WS_HEARTBEAT_INTERVAL_MS,
|
|
1030
|
+
) as unknown as NodeJS.Timeout
|
|
1031
|
+
this.wsHeartbeatTimer.unref()
|
|
1032
|
+
|
|
1033
|
+
if (this.shouldWriteLockfile) {
|
|
1034
|
+
try {
|
|
1035
|
+
ensureSootsimHome()
|
|
1036
|
+
// atomic claim: bail if another fresh daemon's lockfile already
|
|
1037
|
+
// exists (stale ones are overwritten). last line of defense
|
|
1038
|
+
// against two daemons clobbering the same file between the
|
|
1039
|
+
// freshness check in server.ts and here.
|
|
1040
|
+
const claimed = claimDaemonLockfile(this.buildLockfileSnapshot())
|
|
1041
|
+
if (!claimed) {
|
|
1042
|
+
throw new Error(
|
|
1043
|
+
'another sootsim daemon wrote the lockfile during startup — aborting',
|
|
1044
|
+
)
|
|
1045
|
+
}
|
|
1046
|
+
} catch (err) {
|
|
1047
|
+
process.stderr.write(
|
|
1048
|
+
`ws bridge failed to claim daemon lockfile: ${String(err)}\n`,
|
|
1049
|
+
)
|
|
1050
|
+
throw err
|
|
1051
|
+
}
|
|
1052
|
+
this.heartbeatTimer = setInterval(() => {
|
|
1053
|
+
try {
|
|
1054
|
+
this.writeLockfileSnapshot()
|
|
1055
|
+
} catch {}
|
|
1056
|
+
}, DAEMON_HEARTBEAT_INTERVAL_MS) as unknown as NodeJS.Timeout
|
|
1057
|
+
this.heartbeatTimer.unref()
|
|
1058
|
+
this.startRuntimeUpdater()
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// seed the attached-projects store from the demo registry on first
|
|
1062
|
+
// daemon boot. idempotent; no-ops once the store has anything in it.
|
|
1063
|
+
void this.agentHost.seedOnBoot()
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
private bootstrapping = true
|
|
1067
|
+
|
|
1068
|
+
private buildLockfileSnapshot(): DaemonLockfile {
|
|
1069
|
+
return {
|
|
1070
|
+
schema: 1,
|
|
1071
|
+
pid: process.pid,
|
|
1072
|
+
platform: process.platform,
|
|
1073
|
+
bridgePort: this.effectivePort,
|
|
1074
|
+
runtimePort: this.effectivePort,
|
|
1075
|
+
activeRuntime: this.activeRuntimeVersion,
|
|
1076
|
+
activeRuntimeDir: this.activeRuntimeDirPath,
|
|
1077
|
+
startedAt: this.startedAt,
|
|
1078
|
+
heartbeatAt: Date.now(),
|
|
1079
|
+
bootstrapping: this.bootstrapping,
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
private writeLockfileSnapshot() {
|
|
1084
|
+
writeDaemonLockfile(this.buildLockfileSnapshot())
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
private refreshActiveRuntime() {
|
|
1088
|
+
this.activeRuntimeVersion = readActiveRuntime()
|
|
1089
|
+
this.activeRuntimeDirPath = getActiveRuntimeDir()
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
private runServerScan(): Promise<DiscoveredServer[]> {
|
|
1093
|
+
if (this.inflightScan) return this.inflightScan
|
|
1094
|
+
const excludePorts = this.effectivePort > 0 ? [this.effectivePort] : []
|
|
1095
|
+
this.inflightScan = scanDevServers({
|
|
1096
|
+
excludePorts,
|
|
1097
|
+
buildIconProxyUrl: (externalUrl) =>
|
|
1098
|
+
`/__bundle-proxy?url=${encodeURIComponent(externalUrl)}`,
|
|
1099
|
+
})
|
|
1100
|
+
.then((results) => {
|
|
1101
|
+
this.scanCache = results
|
|
1102
|
+
this.scanCacheAt = Date.now()
|
|
1103
|
+
return results
|
|
1104
|
+
})
|
|
1105
|
+
.catch((err: unknown) => {
|
|
1106
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
1107
|
+
console.error('[sootsim] /__server-scan failed:', message)
|
|
1108
|
+
return this.scanCache ?? []
|
|
1109
|
+
})
|
|
1110
|
+
.finally(() => {
|
|
1111
|
+
this.inflightScan = null
|
|
1112
|
+
})
|
|
1113
|
+
return this.inflightScan
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
private handleServerScan(res: ServerResponse) {
|
|
1117
|
+
const sendJson = (body: DiscoveredServer[]) => {
|
|
1118
|
+
res.writeHead(200, {
|
|
1119
|
+
'Content-Type': 'application/json; charset=utf-8',
|
|
1120
|
+
'Cache-Control': 'no-store',
|
|
1121
|
+
})
|
|
1122
|
+
res.end(JSON.stringify(body))
|
|
1123
|
+
}
|
|
1124
|
+
const age = Date.now() - this.scanCacheAt
|
|
1125
|
+
if (this.scanCache && age < SootSimBridgeHost.SCAN_FRESH_MS) {
|
|
1126
|
+
sendJson(this.scanCache)
|
|
1127
|
+
return
|
|
1128
|
+
}
|
|
1129
|
+
if (this.scanCache) {
|
|
1130
|
+
// stale-while-revalidate: return last known good immediately, kick a
|
|
1131
|
+
// fresh scan in the background.
|
|
1132
|
+
sendJson(this.scanCache)
|
|
1133
|
+
void this.runServerScan().catch(() => {})
|
|
1134
|
+
return
|
|
1135
|
+
}
|
|
1136
|
+
void this.runServerScan().then((results) => sendJson(results))
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
private resolveRuntimeUpdateIntervalMs() {
|
|
1140
|
+
const raw = Number(process.env[RUNTIME_UPDATE_INTERVAL_ENV])
|
|
1141
|
+
if (Number.isFinite(raw) && raw > 0) return Math.max(100, Math.round(raw))
|
|
1142
|
+
return DEFAULT_RUNTIME_UPDATE_INTERVAL_MS
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
private startRuntimeUpdater() {
|
|
1146
|
+
if (!this.shouldWriteLockfile || this.runtimeUpdateTimer) return
|
|
1147
|
+
void this.runRuntimeUpdate('startup')
|
|
1148
|
+
const intervalMs = this.resolveRuntimeUpdateIntervalMs()
|
|
1149
|
+
this.runtimeUpdateTimer = setInterval(() => {
|
|
1150
|
+
void this.runRuntimeUpdate('periodic')
|
|
1151
|
+
}, intervalMs) as unknown as NodeJS.Timeout
|
|
1152
|
+
this.runtimeUpdateTimer.unref()
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
private runRuntimeUpdate(reason: 'startup' | 'periodic'): Promise<void> {
|
|
1156
|
+
if (this.runtimeUpdateInFlight) return this.runtimeUpdateInFlight
|
|
1157
|
+
this.runtimeUpdateInFlight = (async () => {
|
|
1158
|
+
try {
|
|
1159
|
+
if (reason === 'startup') {
|
|
1160
|
+
process.stderr.write('sootsim: checking for runtime updates…\n')
|
|
1161
|
+
}
|
|
1162
|
+
const result = await updateRuntimeToLatest()
|
|
1163
|
+
if (!result.updated || !result.latestVersion) {
|
|
1164
|
+
if (reason === 'startup') {
|
|
1165
|
+
process.stderr.write(
|
|
1166
|
+
`sootsim: runtime ${this.activeRuntimeVersion ?? '(none)'} is current\n`,
|
|
1167
|
+
)
|
|
1168
|
+
}
|
|
1169
|
+
return
|
|
1170
|
+
}
|
|
1171
|
+
const active = this.setActiveRuntime(result.latestVersion)
|
|
1172
|
+
process.stderr.write(`sootsim runtime updated to ${active.version} (${reason})\n`)
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
process.stderr.write(
|
|
1175
|
+
`sootsim runtime update failed (${reason}): ${
|
|
1176
|
+
err instanceof Error ? err.message : String(err)
|
|
1177
|
+
}\n`,
|
|
1178
|
+
)
|
|
1179
|
+
} finally {
|
|
1180
|
+
this.runtimeUpdateInFlight = null
|
|
1181
|
+
// first-boot gate flips off once the startup pass finishes (success
|
|
1182
|
+
// or fail) — clients can navigate even if the network update failed
|
|
1183
|
+
// as long as some runtime is on disk. without this the splash would
|
|
1184
|
+
// wait forever on a temporary network blip.
|
|
1185
|
+
if (reason === 'startup' && this.bootstrapping) {
|
|
1186
|
+
this.bootstrapping = false
|
|
1187
|
+
if (this.shouldWriteLockfile && this.httpServer) {
|
|
1188
|
+
try {
|
|
1189
|
+
this.writeLockfileSnapshot()
|
|
1190
|
+
} catch {}
|
|
1191
|
+
}
|
|
1192
|
+
process.stderr.write('sootsim: ready\n')
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
})()
|
|
1196
|
+
return this.runtimeUpdateInFlight
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** update the active runtime on disk + in memory. the caller guarantees
|
|
1200
|
+
* the version directory exists. pushes a runtime:changed message to all
|
|
1201
|
+
* connected sims so electron (or any renderer) can reload. */
|
|
1202
|
+
setActiveRuntime(version: string): { version: string; runtimeDir: string | null } {
|
|
1203
|
+
writeActiveRuntime(version)
|
|
1204
|
+
this.refreshActiveRuntime()
|
|
1205
|
+
if (this.shouldWriteLockfile && this.httpServer) {
|
|
1206
|
+
try {
|
|
1207
|
+
this.writeLockfileSnapshot()
|
|
1208
|
+
} catch {}
|
|
1209
|
+
}
|
|
1210
|
+
// broadcast to sims so electron can reload its webContents without
|
|
1211
|
+
// a manual restart. CLI clients ignore this message.
|
|
1212
|
+
const payload = JSON.stringify({
|
|
1213
|
+
type: 'runtime:changed',
|
|
1214
|
+
version,
|
|
1215
|
+
runtimeDir: this.activeRuntimeDirPath,
|
|
1216
|
+
})
|
|
1217
|
+
for (const sim of this.sims.values()) {
|
|
1218
|
+
if (sim.ws.readyState === WebSocket.OPEN) {
|
|
1219
|
+
try {
|
|
1220
|
+
sim.ws.send(payload)
|
|
1221
|
+
} catch {}
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
return { version, runtimeDir: this.activeRuntimeDirPath }
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
getActiveRuntime(): { version: string | null; runtimeDir: string | null } {
|
|
1228
|
+
return {
|
|
1229
|
+
version: this.activeRuntimeVersion,
|
|
1230
|
+
runtimeDir: this.activeRuntimeDirPath,
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/** last-ditch lockfile cleanup. safe to call from a synchronous
|
|
1235
|
+
* `process.on('exit', ...)` handler since it only does a fs.unlinkSync. */
|
|
1236
|
+
removeLockfile() {
|
|
1237
|
+
if (!this.shouldWriteLockfile) return
|
|
1238
|
+
try {
|
|
1239
|
+
removeDaemonLockfile()
|
|
1240
|
+
} catch {}
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
/** minimal HTTP request handler attached to the same node http server
|
|
1244
|
+
* that hosts the WS upgrade. handles:
|
|
1245
|
+
* GET /healthz json status for supervisors / curl
|
|
1246
|
+
* GET / + everything serves from the active runtime dist, SPA fallback
|
|
1247
|
+
* non-upgrade routes that don't match serve index.html (SPA behavior) so
|
|
1248
|
+
* electron's webContents can navigate freely inside the runtime. */
|
|
1249
|
+
private handleHttpRequest(req: IncomingMessage, res: ServerResponse) {
|
|
1250
|
+
// cross-origin isolation — without this, Electron / Chromium refuses
|
|
1251
|
+
// `SharedArrayBuffer`, and the engine's render-worker crashes on boot
|
|
1252
|
+
// ("render worker crashed during boot" / "app:one surface registration
|
|
1253
|
+
// failed"). SAB powers the shell-scene fast channel and the worklet
|
|
1254
|
+
// runtime's SharedValues. mirrors sootsim-shell/vite.config.ts (dev) and
|
|
1255
|
+
// sootbean.com app/_middleware.ts (prod) so the cli daemon serves the
|
|
1256
|
+
// same surface those two already enforce. set via setHeader so subsequent
|
|
1257
|
+
// writeHead() calls preserve them (writeHead only overrides keys it
|
|
1258
|
+
// explicitly passes). credentialless lets cross-origin sub-resources
|
|
1259
|
+
// (metro bundles, font files) load without requiring CORP from every
|
|
1260
|
+
// upstream — same trade-off prod already accepts.
|
|
1261
|
+
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin')
|
|
1262
|
+
res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless')
|
|
1263
|
+
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin')
|
|
1264
|
+
res.setHeader('Document-Policy', 'js-profiling')
|
|
1265
|
+
|
|
1266
|
+
// proxy + app-api routes need to accept POST/PUT/DELETE/PATCH/OPTIONS
|
|
1267
|
+
// because they forward the request to a real upstream. handle them
|
|
1268
|
+
// BEFORE the read-only method check below — otherwise tenant bundles
|
|
1269
|
+
// fetching cross-origin APIs through the daemon (e.g. when the shell
|
|
1270
|
+
// dev server isn't running) get a 405 instead of the proxied response,
|
|
1271
|
+
// and downstream NetInfo-style reachability probes flip to "offline".
|
|
1272
|
+
if (isFetchProxyRequestUrl(req.url)) {
|
|
1273
|
+
void handleFetchProxyRequest(req, res)
|
|
1274
|
+
return
|
|
1275
|
+
}
|
|
1276
|
+
if (isAppApiRequestUrl(req.url) && handleAppApiRequest(req, res)) {
|
|
1277
|
+
return
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
// only accept read methods. anything else returns 405 — we never
|
|
1281
|
+
// mutate via http; the WS channel owns all writes.
|
|
1282
|
+
const method = (req.method || 'GET').toUpperCase()
|
|
1283
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
1284
|
+
res.writeHead(405, { Allow: 'GET, HEAD' })
|
|
1285
|
+
res.end('method not allowed')
|
|
1286
|
+
return
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
const url = new URL(req.url || '/', 'http://localhost')
|
|
1290
|
+
|
|
1291
|
+
// /__bundle-proxy?url=<encoded> — runtime/worker fetches metro bundles +
|
|
1292
|
+
// assets through here. needed because workers can't directly fetch a
|
|
1293
|
+
// cross-origin URL without CORS headers, and metro doesn't set them.
|
|
1294
|
+
// we proxy the request and stream the response back.
|
|
1295
|
+
if (url.pathname === '/__bundle-proxy') {
|
|
1296
|
+
const target = url.searchParams.get('url')
|
|
1297
|
+
if (!target) {
|
|
1298
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
1299
|
+
res.end('bundle-proxy: missing url query param')
|
|
1300
|
+
return
|
|
1301
|
+
}
|
|
1302
|
+
let parsedTarget: URL
|
|
1303
|
+
try {
|
|
1304
|
+
parsedTarget = new URL(target)
|
|
1305
|
+
} catch {
|
|
1306
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' })
|
|
1307
|
+
res.end('bundle-proxy: invalid url')
|
|
1308
|
+
return
|
|
1309
|
+
}
|
|
1310
|
+
// limit to local loopback targets — preventing the daemon from being
|
|
1311
|
+
// turned into an open proxy for arbitrary internet fetches.
|
|
1312
|
+
const host = parsedTarget.hostname
|
|
1313
|
+
const isLoopback =
|
|
1314
|
+
host === 'localhost' ||
|
|
1315
|
+
host === '127.0.0.1' ||
|
|
1316
|
+
host === '::1' ||
|
|
1317
|
+
host.endsWith('.localhost')
|
|
1318
|
+
if (!isLoopback) {
|
|
1319
|
+
res.writeHead(403, { 'Content-Type': 'text/plain' })
|
|
1320
|
+
res.end('bundle-proxy: only loopback targets allowed')
|
|
1321
|
+
return
|
|
1322
|
+
}
|
|
1323
|
+
void (async () => {
|
|
1324
|
+
try {
|
|
1325
|
+
const upstream = await fetch(parsedTarget.toString(), {
|
|
1326
|
+
redirect: 'follow',
|
|
1327
|
+
})
|
|
1328
|
+
const headers: Record<string, string> = {}
|
|
1329
|
+
const ct = upstream.headers.get('content-type')
|
|
1330
|
+
if (ct) headers['Content-Type'] = ct
|
|
1331
|
+
headers['Cache-Control'] = 'no-store'
|
|
1332
|
+
res.writeHead(upstream.status, headers)
|
|
1333
|
+
if (!upstream.body) {
|
|
1334
|
+
res.end()
|
|
1335
|
+
return
|
|
1336
|
+
}
|
|
1337
|
+
const reader = upstream.body.getReader()
|
|
1338
|
+
while (true) {
|
|
1339
|
+
const { done, value } = await reader.read()
|
|
1340
|
+
if (done) break
|
|
1341
|
+
res.write(Buffer.from(value))
|
|
1342
|
+
}
|
|
1343
|
+
res.end()
|
|
1344
|
+
} catch (err) {
|
|
1345
|
+
res.writeHead(502, { 'Content-Type': 'text/plain' })
|
|
1346
|
+
res.end(
|
|
1347
|
+
`bundle-proxy: upstream fetch failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
1348
|
+
)
|
|
1349
|
+
}
|
|
1350
|
+
})()
|
|
1351
|
+
return
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// /__server-scan — list local metro/expo/vxrn/one dev servers. tenant
|
|
1355
|
+
// worker ConnectRN polls this every few seconds to populate the device
|
|
1356
|
+
// picker. mirrors packages/sootsim-shell/src/dev-middleware.ts so both
|
|
1357
|
+
// host environments (vite dev and the prod CLI daemon) return identical
|
|
1358
|
+
// JSON. icon URLs are rewritten through /__bundle-proxy so the browser
|
|
1359
|
+
// doesn't hit cross-origin loads against the discovered dev server.
|
|
1360
|
+
if (url.pathname === '/__server-scan') {
|
|
1361
|
+
this.handleServerScan(res)
|
|
1362
|
+
return
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
if (url.pathname === '/healthz') {
|
|
1366
|
+
res.writeHead(200, {
|
|
1367
|
+
'Content-Type': 'application/json',
|
|
1368
|
+
'Cache-Control': 'no-store',
|
|
1369
|
+
})
|
|
1370
|
+
res.end(
|
|
1371
|
+
JSON.stringify({
|
|
1372
|
+
ok: true,
|
|
1373
|
+
pid: process.pid,
|
|
1374
|
+
platform: process.platform,
|
|
1375
|
+
bridgePort: this.effectivePort,
|
|
1376
|
+
runtimePort: this.effectivePort,
|
|
1377
|
+
activeRuntime: this.activeRuntimeVersion,
|
|
1378
|
+
startedAt: this.startedAt,
|
|
1379
|
+
uptimeMs: this.startedAt > 0 ? Date.now() - this.startedAt : 0,
|
|
1380
|
+
}),
|
|
1381
|
+
)
|
|
1382
|
+
return
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// /__sootsim/shared-config — bridge over ~/.sootsim/config.json for
|
|
1386
|
+
// browser tabs that have no other path to disk (private windows,
|
|
1387
|
+
// statically hosted runtime). GET returns the parsed config; POST
|
|
1388
|
+
// merges a partial patch via writeSharedConfig (same shallow-merge
|
|
1389
|
+
// semantics used by electron). matches the shell dev server's
|
|
1390
|
+
// route at the same path so the engine's persistence layer can use
|
|
1391
|
+
// one url regardless of host. cors-permissive because the runtime
|
|
1392
|
+
// and the page may have different origins (electron loads from
|
|
1393
|
+
// bridgePort; demo apps may iframe across origins).
|
|
1394
|
+
if (url.pathname === '/__sootsim/shared-config') {
|
|
1395
|
+
res.setHeader('Access-Control-Allow-Origin', '*')
|
|
1396
|
+
res.setHeader('Cache-Control', 'no-store')
|
|
1397
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
1398
|
+
let body = '{}'
|
|
1399
|
+
try {
|
|
1400
|
+
body = JSON.stringify(readSharedConfig())
|
|
1401
|
+
} catch {}
|
|
1402
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
1403
|
+
if (method === 'HEAD') res.end()
|
|
1404
|
+
else res.end(body)
|
|
1405
|
+
return
|
|
1406
|
+
}
|
|
1407
|
+
res.writeHead(405, { Allow: 'GET, HEAD' })
|
|
1408
|
+
res.end('method not allowed (use the bridge over WS for writes)')
|
|
1409
|
+
return
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
// runtime may have been swapped on disk (sootsim runtime use via file
|
|
1413
|
+
// write, or another cli process) — re-read per-request so we serve
|
|
1414
|
+
// the current active version. cost is one readFileSync + one statSync.
|
|
1415
|
+
this.refreshActiveRuntime()
|
|
1416
|
+
const baseDir = this.activeRuntimeDirPath
|
|
1417
|
+
if (!baseDir) {
|
|
1418
|
+
res.writeHead(503, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
1419
|
+
res.end(
|
|
1420
|
+
'sootsim: no active runtime installed. run `sootsim runtime install` to fetch one.',
|
|
1421
|
+
)
|
|
1422
|
+
return
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// strip optional runtime prefixes so the daemon can serve both the
|
|
1426
|
+
// local entrypoint and the production-built /sootsim asset graph.
|
|
1427
|
+
let rel = url.pathname
|
|
1428
|
+
if (rel === '/runtime' || rel === '/runtime/') rel = '/'
|
|
1429
|
+
else if (rel.startsWith('/runtime/')) rel = rel.slice('/runtime'.length)
|
|
1430
|
+
else if (rel === '/sootsim' || rel === '/sootsim/') rel = '/'
|
|
1431
|
+
else if (rel.startsWith('/sootsim/')) rel = rel.slice('/sootsim'.length)
|
|
1432
|
+
if (rel === '' || rel === '/') rel = '/index.html'
|
|
1433
|
+
|
|
1434
|
+
// reject obviously-malicious paths up front:
|
|
1435
|
+
// NUL bytes (some fs APIs truncate, confusing downstream readers)
|
|
1436
|
+
// backslashes on non-Windows (treated literally in names, but
|
|
1437
|
+
// frequently used to sneak past string-level .. checks)
|
|
1438
|
+
// raw .. segments (path.resolve collapses them, but reject on sight
|
|
1439
|
+
// so traversal attempts surface as 400s in logs)
|
|
1440
|
+
if (rel.includes('\0')) {
|
|
1441
|
+
res.writeHead(400)
|
|
1442
|
+
res.end('bad request')
|
|
1443
|
+
return
|
|
1444
|
+
}
|
|
1445
|
+
if (process.platform !== 'win32' && rel.includes('\\')) {
|
|
1446
|
+
res.writeHead(400)
|
|
1447
|
+
res.end('bad request')
|
|
1448
|
+
return
|
|
1449
|
+
}
|
|
1450
|
+
for (const segment of rel.split('/')) {
|
|
1451
|
+
if (segment === '..') {
|
|
1452
|
+
res.writeHead(403)
|
|
1453
|
+
res.end('forbidden')
|
|
1454
|
+
return
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
const resolved = path.resolve(baseDir, '.' + rel)
|
|
1459
|
+
const baseWithSep = baseDir.endsWith(path.sep) ? baseDir : baseDir + path.sep
|
|
1460
|
+
if (!resolved.startsWith(baseWithSep) && resolved !== baseDir) {
|
|
1461
|
+
res.writeHead(403)
|
|
1462
|
+
res.end('forbidden')
|
|
1463
|
+
return
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
// realpath the resolved path before serving. this catches symlinks
|
|
1467
|
+
// inside baseDir that point outside — without it, an attacker who can
|
|
1468
|
+
// drop a symlink in the runtime dir gets arbitrary-read through the
|
|
1469
|
+
// daemon. realpath also collapses any remaining normalizable segments.
|
|
1470
|
+
fs.realpath(resolved, (realErr, realResolved) => {
|
|
1471
|
+
const servePath = realErr ? resolved : realResolved
|
|
1472
|
+
const servePathWithSep = servePath.endsWith(path.sep)
|
|
1473
|
+
? servePath
|
|
1474
|
+
: servePath + path.sep
|
|
1475
|
+
if (!realErr) {
|
|
1476
|
+
const realBaseWithSep = (() => {
|
|
1477
|
+
try {
|
|
1478
|
+
const rb = fs.realpathSync(baseDir)
|
|
1479
|
+
return rb.endsWith(path.sep) ? rb : rb + path.sep
|
|
1480
|
+
} catch {
|
|
1481
|
+
return baseWithSep
|
|
1482
|
+
}
|
|
1483
|
+
})()
|
|
1484
|
+
if (
|
|
1485
|
+
!servePathWithSep.startsWith(realBaseWithSep) &&
|
|
1486
|
+
servePath + path.sep !== realBaseWithSep
|
|
1487
|
+
) {
|
|
1488
|
+
res.writeHead(403)
|
|
1489
|
+
res.end('forbidden')
|
|
1490
|
+
return
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
fs.stat(servePath, (err, stats) => {
|
|
1495
|
+
if (err || !stats?.isFile()) {
|
|
1496
|
+
// SPA fallback — but only for extensionless paths. a 404 on
|
|
1497
|
+
// /main.a1b2c3.js should 404, not return index.html as js,
|
|
1498
|
+
// which the sim then chokes on.
|
|
1499
|
+
const ext = path.extname(rel).toLowerCase()
|
|
1500
|
+
if (ext && ext !== '.html') {
|
|
1501
|
+
res.writeHead(404)
|
|
1502
|
+
res.end('not found')
|
|
1503
|
+
return
|
|
1504
|
+
}
|
|
1505
|
+
// never SPA-fallback internal endpoint namespaces — those are
|
|
1506
|
+
// expected to return JSON (or a real 404) and the engine calls
|
|
1507
|
+
// .json() on the response. without this, a tenant-worker fetch
|
|
1508
|
+
// to an unimplemented /__foo or /api/foo gets a 200 + index.html
|
|
1509
|
+
// and explodes with "Unexpected token '<', '<!doctype'... is not
|
|
1510
|
+
// valid JSON". /__server-scan is the canonical case (see the
|
|
1511
|
+
// handler above).
|
|
1512
|
+
if (rel.startsWith('/__') || rel.startsWith('/api/') || rel === '/api') {
|
|
1513
|
+
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
|
|
1514
|
+
res.end('not found')
|
|
1515
|
+
return
|
|
1516
|
+
}
|
|
1517
|
+
const indexPath = path.join(baseDir, 'index.html')
|
|
1518
|
+
fs.readFile(indexPath, (err2, data) => {
|
|
1519
|
+
if (err2) {
|
|
1520
|
+
res.writeHead(404)
|
|
1521
|
+
res.end('not found')
|
|
1522
|
+
return
|
|
1523
|
+
}
|
|
1524
|
+
res.writeHead(200, {
|
|
1525
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
1526
|
+
'Cache-Control': 'no-store',
|
|
1527
|
+
})
|
|
1528
|
+
if (method === 'HEAD') {
|
|
1529
|
+
res.end()
|
|
1530
|
+
return
|
|
1531
|
+
}
|
|
1532
|
+
res.end(
|
|
1533
|
+
injectSharedConfigIntoHtml(data, this.effectivePort, this.sootbeanOrigin),
|
|
1534
|
+
)
|
|
1535
|
+
})
|
|
1536
|
+
return
|
|
1537
|
+
}
|
|
1538
|
+
const ext = path.extname(servePath).toLowerCase()
|
|
1539
|
+
const contentType = HTTP_MIME_TYPES[ext] || 'application/octet-stream'
|
|
1540
|
+
// no-store across the board: the runtime is served from a versioned
|
|
1541
|
+
// directory but the HTTP path doesn't include the version, so the
|
|
1542
|
+
// same URL serves different content after a runtime swap. caching
|
|
1543
|
+
// any asset means a stale-bundle reload after runtime:changed.
|
|
1544
|
+
res.writeHead(200, {
|
|
1545
|
+
'Content-Type': contentType,
|
|
1546
|
+
'Cache-Control': 'no-store',
|
|
1547
|
+
})
|
|
1548
|
+
if (method === 'HEAD') {
|
|
1549
|
+
res.end()
|
|
1550
|
+
return
|
|
1551
|
+
}
|
|
1552
|
+
// html responses inline the daemon-injected `__sootsimSharedConfig`
|
|
1553
|
+
// global so the engine's settingsStore initializes from disk on
|
|
1554
|
+
// boot. for everything else stream the file straight through.
|
|
1555
|
+
if (ext === '.html') {
|
|
1556
|
+
fs.readFile(servePath, (readErr, data) => {
|
|
1557
|
+
if (readErr) {
|
|
1558
|
+
try {
|
|
1559
|
+
res.end()
|
|
1560
|
+
} catch {}
|
|
1561
|
+
return
|
|
1562
|
+
}
|
|
1563
|
+
res.end(
|
|
1564
|
+
injectSharedConfigIntoHtml(data, this.effectivePort, this.sootbeanOrigin),
|
|
1565
|
+
)
|
|
1566
|
+
})
|
|
1567
|
+
return
|
|
1568
|
+
}
|
|
1569
|
+
const stream = fs.createReadStream(servePath)
|
|
1570
|
+
stream.pipe(res)
|
|
1571
|
+
stream.on('error', () => {
|
|
1572
|
+
try {
|
|
1573
|
+
res.end()
|
|
1574
|
+
} catch {}
|
|
1575
|
+
})
|
|
1576
|
+
})
|
|
1577
|
+
})
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
private sweepIdleCliClients() {
|
|
1581
|
+
const now = Date.now()
|
|
1582
|
+
let swept = false
|
|
1583
|
+
for (const [ws, simId] of this.cliSimBySocket) {
|
|
1584
|
+
const lastCommand = this.cliLastCommandAt.get(ws) ?? 0
|
|
1585
|
+
if (now - lastCommand < SootSimBridgeHost.CLI_IDLE_TIMEOUT_MS) continue
|
|
1586
|
+
this.cliSimBySocket.delete(ws)
|
|
1587
|
+
this.cliLastCommandAt.delete(ws)
|
|
1588
|
+
for (const [sentId, entry] of this.cliBySentId) {
|
|
1589
|
+
if (entry.ws === ws) this.cliBySentId.delete(sentId)
|
|
1590
|
+
}
|
|
1591
|
+
try {
|
|
1592
|
+
ws.close(1000, 'idle timeout')
|
|
1593
|
+
} catch {}
|
|
1594
|
+
swept = true
|
|
1595
|
+
}
|
|
1596
|
+
if (swept) {
|
|
1597
|
+
this.broadcastSimClientStates()
|
|
1598
|
+
}
|
|
1599
|
+
this.sweepRestorableSims(now)
|
|
1600
|
+
this.reapIdleSims(now)
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
// GC abandoned sims so `sootsim list` stays usable across long dev / QA /
|
|
1604
|
+
// test sessions (F21-3). conservative on purpose: a sim is only reaped
|
|
1605
|
+
// when every "someone cares about this" signal is absent.
|
|
1606
|
+
//
|
|
1607
|
+
// NOTE: `sim.userFocused` is deliberately NOT an exemption. focus is
|
|
1608
|
+
// advisory in this design — see updateUserFocusLease: "focus alone never
|
|
1609
|
+
// creates a blocking lease … use updateUserActivity() to lock on real
|
|
1610
|
+
// interaction instead." real interaction (pointer/key/wheel/touch)
|
|
1611
|
+
// refreshes a `user-active` lease, which the getActiveLease() guard below
|
|
1612
|
+
// already honors. a headless playwright tab reports `focused:true` once
|
|
1613
|
+
// and never `false` (nothing else competes for focus in headless), so a
|
|
1614
|
+
// userFocused exemption permanently shielded exactly the abandoned-tab
|
|
1615
|
+
// zombie class F21-3 / F19-2 target (QA F22-2). a sim a human is genuinely
|
|
1616
|
+
// using is the primary and/or holds a fresh user-active lease; one that is
|
|
1617
|
+
// none of those and idle past the TTL is abandoned regardless of a stale
|
|
1618
|
+
// focus flag.
|
|
1619
|
+
private reapIdleSims(now = Date.now()) {
|
|
1620
|
+
const attachedSimIds = new Set(this.cliSimBySocket.values())
|
|
1621
|
+
for (const sim of this.sims.values()) {
|
|
1622
|
+
if (sim.id === this.primarySimId) continue
|
|
1623
|
+
if (attachedSimIds.has(sim.id)) continue
|
|
1624
|
+
if (this.getActiveLease(sim)) continue
|
|
1625
|
+
// never-CLI-driven sims have lastActiveAt 0 — fall back to connectedAt
|
|
1626
|
+
// so a freshly opened, not-yet-primary tab isn't reaped on the spot.
|
|
1627
|
+
const lastTouched = Math.max(sim.lastActiveAt, sim.connectedAt)
|
|
1628
|
+
if (now - lastTouched < this.simIdleReapTtlMs) continue
|
|
1629
|
+
// terminal close → client closes the window, does not reconnect; the
|
|
1630
|
+
// 'close' handler runs rememberDisconnectedSim → this.sims.delete.
|
|
1631
|
+
this.closeSimSocketFromHost(sim.ws)
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
|
|
1635
|
+
// ping every connected ws; if the previous round's ping went unanswered,
|
|
1636
|
+
// terminate the socket so 'close' fires and the sim cleanup path
|
|
1637
|
+
// runs. matches the recommended ws-library heartbeat pattern.
|
|
1638
|
+
private sweepDeadWebSockets() {
|
|
1639
|
+
if (!this.wss) return
|
|
1640
|
+
for (const ws of this.wss.clients) {
|
|
1641
|
+
if (ws.readyState !== WebSocket.OPEN) continue
|
|
1642
|
+
const alive = this.wsIsAlive.get(ws)
|
|
1643
|
+
if (alive === false) {
|
|
1644
|
+
try {
|
|
1645
|
+
ws.terminate()
|
|
1646
|
+
} catch {}
|
|
1647
|
+
continue
|
|
1648
|
+
}
|
|
1649
|
+
this.wsIsAlive.set(ws, false)
|
|
1650
|
+
try {
|
|
1651
|
+
ws.ping()
|
|
1652
|
+
} catch {
|
|
1653
|
+
try {
|
|
1654
|
+
ws.terminate()
|
|
1655
|
+
} catch {}
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
private closeSimSocketFromHost(ws: WebSocket) {
|
|
1661
|
+
if (ws.readyState !== WebSocket.OPEN) return
|
|
1662
|
+
try {
|
|
1663
|
+
ws.close(SOOTSIM_BRIDGE_SIM_CLOSE_CODE, SOOTSIM_BRIDGE_SIM_CLOSE_REASON)
|
|
1664
|
+
} catch {
|
|
1665
|
+
try {
|
|
1666
|
+
ws.terminate()
|
|
1667
|
+
} catch {}
|
|
1668
|
+
return
|
|
1669
|
+
}
|
|
1670
|
+
const terminateTimer = setTimeout(() => {
|
|
1671
|
+
if (ws.readyState === WebSocket.CLOSED) return
|
|
1672
|
+
try {
|
|
1673
|
+
ws.terminate()
|
|
1674
|
+
} catch {}
|
|
1675
|
+
}, FORCE_CLOSE_TERMINATE_MS)
|
|
1676
|
+
unrefTimer(terminateTimer)
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
listSims(): BridgeSimInfo[] {
|
|
1680
|
+
return Array.from(this.sims.values())
|
|
1681
|
+
.sort((a, b) => {
|
|
1682
|
+
if (a.id === this.primarySimId) return -1
|
|
1683
|
+
if (b.id === this.primarySimId) return 1
|
|
1684
|
+
return a.connectedAt - b.connectedAt
|
|
1685
|
+
})
|
|
1686
|
+
.map((sim) => this.describeSim(sim))
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
async sendCommand(cmd: Omit<BridgeSimCommand, 'id'>): Promise<any> {
|
|
1690
|
+
const sim = await this.waitForSim(cmd.simId)
|
|
1691
|
+
const id = this.nextCommandId++
|
|
1692
|
+
return new Promise((resolve, reject) => {
|
|
1693
|
+
const timeout = setTimeout(() => {
|
|
1694
|
+
this.pendingCommands.delete(id)
|
|
1695
|
+
this.broadcastSimClientStates()
|
|
1696
|
+
reject(new Error('command timed out after 30s'))
|
|
1697
|
+
}, 30000)
|
|
1698
|
+
|
|
1699
|
+
this.pendingCommands.set(id, {
|
|
1700
|
+
simId: sim.id,
|
|
1701
|
+
resolve: (value) => {
|
|
1702
|
+
clearTimeout(timeout)
|
|
1703
|
+
this.pendingCommands.delete(id)
|
|
1704
|
+
this.broadcastSimClientStates()
|
|
1705
|
+
resolve(value)
|
|
1706
|
+
},
|
|
1707
|
+
reject: (error) => {
|
|
1708
|
+
clearTimeout(timeout)
|
|
1709
|
+
this.pendingCommands.delete(id)
|
|
1710
|
+
this.broadcastSimClientStates()
|
|
1711
|
+
reject(error)
|
|
1712
|
+
},
|
|
1713
|
+
})
|
|
1714
|
+
this.broadcastSimClientStates()
|
|
1715
|
+
|
|
1716
|
+
const { simId: _simId, ...forwarded } = cmd
|
|
1717
|
+
sim.ws.send(JSON.stringify({ ...forwarded, id }))
|
|
1718
|
+
})
|
|
1719
|
+
}
|
|
1720
|
+
|
|
1721
|
+
async evaluate(code: string, simId?: string): Promise<any> {
|
|
1722
|
+
return this.sendCommand({ type: 'evaluate', code, simId })
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
async focusSim(simId?: string): Promise<any> {
|
|
1726
|
+
return this.sendCommand({ type: 'focus', simId })
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
async closeSim(simId?: string): Promise<any> {
|
|
1730
|
+
return this.sendCommand({ type: 'close', simId })
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
async openPathInEditor(
|
|
1734
|
+
filePath: string,
|
|
1735
|
+
line?: number,
|
|
1736
|
+
column?: number,
|
|
1737
|
+
): Promise<void> {
|
|
1738
|
+
const loc = line != null ? `:${line}${column != null ? `:${column}` : ''}` : ''
|
|
1739
|
+
const target = `${filePath}${loc}`
|
|
1740
|
+
|
|
1741
|
+
const trySpawn = (cmd: string, args: string[]) =>
|
|
1742
|
+
new Promise<boolean>((resolve) => {
|
|
1743
|
+
try {
|
|
1744
|
+
const child = spawn(cmd, args, { detached: true, stdio: 'ignore' })
|
|
1745
|
+
let settled = false
|
|
1746
|
+
child.on('error', () => {
|
|
1747
|
+
if (settled) return
|
|
1748
|
+
settled = true
|
|
1749
|
+
resolve(false)
|
|
1750
|
+
})
|
|
1751
|
+
child.on('spawn', () => {
|
|
1752
|
+
if (settled) return
|
|
1753
|
+
settled = true
|
|
1754
|
+
child.unref()
|
|
1755
|
+
resolve(true)
|
|
1756
|
+
})
|
|
1757
|
+
} catch {
|
|
1758
|
+
resolve(false)
|
|
1759
|
+
}
|
|
1760
|
+
})
|
|
1761
|
+
|
|
1762
|
+
// prefer the editor the user told us about, then common cli wrappers,
|
|
1763
|
+
// then fall back to the plain `open` path.
|
|
1764
|
+
const envEditor = process.env.REACT_EDITOR || process.env.EDITOR
|
|
1765
|
+
if (envEditor) {
|
|
1766
|
+
const parts = envEditor.split(' ').filter(Boolean)
|
|
1767
|
+
if (parts.length && (await trySpawn(parts[0], [...parts.slice(1), '-g', target])))
|
|
1768
|
+
return
|
|
1769
|
+
}
|
|
1770
|
+
if (await trySpawn('cursor', ['-g', target])) return
|
|
1771
|
+
if (await trySpawn('code', ['-g', target])) return
|
|
1772
|
+
await this.openUrl(filePath)
|
|
1773
|
+
}
|
|
1774
|
+
|
|
1775
|
+
async openUrl(url: string, options: OpenUrlOptions = {}): Promise<void> {
|
|
1776
|
+
if (this.openUrlHandler) {
|
|
1777
|
+
await this.openUrlHandler(url, options)
|
|
1778
|
+
return
|
|
1779
|
+
}
|
|
1780
|
+
await openUrlInBrowser(url, options)
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
async close() {
|
|
1784
|
+
if (this.cliIdleTimer) {
|
|
1785
|
+
clearInterval(this.cliIdleTimer)
|
|
1786
|
+
this.cliIdleTimer = null
|
|
1787
|
+
}
|
|
1788
|
+
if (this.heartbeatTimer) {
|
|
1789
|
+
clearInterval(this.heartbeatTimer)
|
|
1790
|
+
this.heartbeatTimer = null
|
|
1791
|
+
}
|
|
1792
|
+
if (this.wsHeartbeatTimer) {
|
|
1793
|
+
clearInterval(this.wsHeartbeatTimer)
|
|
1794
|
+
this.wsHeartbeatTimer = null
|
|
1795
|
+
}
|
|
1796
|
+
if (this.runtimeUpdateTimer) {
|
|
1797
|
+
clearInterval(this.runtimeUpdateTimer)
|
|
1798
|
+
this.runtimeUpdateTimer = null
|
|
1799
|
+
}
|
|
1800
|
+
if (this.shouldWriteLockfile) {
|
|
1801
|
+
try {
|
|
1802
|
+
removeDaemonLockfile()
|
|
1803
|
+
} catch {}
|
|
1804
|
+
}
|
|
1805
|
+
this.effectivePort = 0
|
|
1806
|
+
this.startedAt = 0
|
|
1807
|
+
this.agentHost.close()
|
|
1808
|
+
for (const [id, pending] of this.pendingCommands) {
|
|
1809
|
+
pending.reject(new Error('server closing'))
|
|
1810
|
+
this.pendingCommands.delete(id)
|
|
1811
|
+
}
|
|
1812
|
+
for (const sim of this.sims.values()) {
|
|
1813
|
+
sim.ws.close()
|
|
1814
|
+
}
|
|
1815
|
+
this.sims.clear()
|
|
1816
|
+
this.primarySimId = null
|
|
1817
|
+
const wss = this.wss
|
|
1818
|
+
const httpServer = this.httpServer
|
|
1819
|
+
this.wss = null
|
|
1820
|
+
this.httpServer = null
|
|
1821
|
+
if (wss) {
|
|
1822
|
+
try {
|
|
1823
|
+
wss.close()
|
|
1824
|
+
} catch {}
|
|
1825
|
+
}
|
|
1826
|
+
if (httpServer) {
|
|
1827
|
+
try {
|
|
1828
|
+
httpServer.close()
|
|
1829
|
+
} catch {}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
private describeSim(sim: BridgeSimConnection): BridgeSimInfo {
|
|
1834
|
+
let readyState: BridgeSimConnection['ws']['readyState']
|
|
1835
|
+
try {
|
|
1836
|
+
readyState = sim.ws.readyState
|
|
1837
|
+
} catch {
|
|
1838
|
+
readyState = WebSocket.CLOSED
|
|
1839
|
+
}
|
|
1840
|
+
const lease = this.getActiveLease(sim)
|
|
1841
|
+
return {
|
|
1842
|
+
id: sim.id,
|
|
1843
|
+
origin: sim.origin,
|
|
1844
|
+
url: sim.url,
|
|
1845
|
+
title: sim.title,
|
|
1846
|
+
userAgent: sim.userAgent,
|
|
1847
|
+
connectedAt: sim.connectedAt,
|
|
1848
|
+
lastSeenAt: sim.lastSeenAt,
|
|
1849
|
+
lastActiveAt: sim.lastActiveAt || undefined,
|
|
1850
|
+
isPrimary: sim.id === this.primarySimId,
|
|
1851
|
+
readyState:
|
|
1852
|
+
readyState === WebSocket.OPEN
|
|
1853
|
+
? 'open'
|
|
1854
|
+
: readyState === WebSocket.CLOSING
|
|
1855
|
+
? 'closing'
|
|
1856
|
+
: 'closed',
|
|
1857
|
+
attachedCliCount: this.getAttachedCliCount(sim.id),
|
|
1858
|
+
lockedBy: lease ? lease.cliLabel || lease.cliIdentityKey : undefined,
|
|
1859
|
+
lockedByKind: lease ? lease.kind : undefined,
|
|
1860
|
+
lockExpiresAt: lease ? lease.expiresAt : undefined,
|
|
1861
|
+
userFocused: sim.userFocused || undefined,
|
|
1862
|
+
userVisible: sim.userVisible,
|
|
1863
|
+
visibilityState: sim.visibilityState,
|
|
1864
|
+
documentFocused: sim.documentFocused,
|
|
1865
|
+
kind: sim.kind,
|
|
1866
|
+
meta: sim.meta,
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
private getActiveLease(sim: BridgeSimConnection): BridgeCliLease | null {
|
|
1871
|
+
const lease = sim.cliLease
|
|
1872
|
+
if (!lease) return null
|
|
1873
|
+
if (Date.now() >= lease.expiresAt) {
|
|
1874
|
+
sim.cliLease = undefined
|
|
1875
|
+
return null
|
|
1876
|
+
}
|
|
1877
|
+
return lease
|
|
1878
|
+
}
|
|
1879
|
+
|
|
1880
|
+
private tryAcquireLease(
|
|
1881
|
+
ws: WebSocket,
|
|
1882
|
+
sim: BridgeSimConnection,
|
|
1883
|
+
opts: { force?: boolean } = {},
|
|
1884
|
+
): {
|
|
1885
|
+
granted: boolean
|
|
1886
|
+
lease: BridgeCliLease
|
|
1887
|
+
lock?: BridgeLockInfo
|
|
1888
|
+
bootedCount: number
|
|
1889
|
+
} {
|
|
1890
|
+
const cliIdentityKey =
|
|
1891
|
+
this.cliIdentityKeyBySocket.get(ws) ??
|
|
1892
|
+
(() => {
|
|
1893
|
+
const fallback = `ws-${this.nextCliFallbackId++}`
|
|
1894
|
+
this.cliIdentityKeyBySocket.set(ws, fallback)
|
|
1895
|
+
return fallback
|
|
1896
|
+
})()
|
|
1897
|
+
const cliLabel = this.cliLabelBySocket.get(ws)
|
|
1898
|
+
const now = Date.now()
|
|
1899
|
+
const existing = this.getActiveLease(sim)
|
|
1900
|
+
const ownerMatches = existing && existing.cliIdentityKey === cliIdentityKey
|
|
1901
|
+
let bootedCount = 0
|
|
1902
|
+
|
|
1903
|
+
if (existing && !ownerMatches && !opts.force) {
|
|
1904
|
+
return {
|
|
1905
|
+
granted: false,
|
|
1906
|
+
lease: existing,
|
|
1907
|
+
lock: {
|
|
1908
|
+
by: existing.cliLabel || existing.cliIdentityKey,
|
|
1909
|
+
expiresInMs: Math.max(0, existing.expiresAt - now),
|
|
1910
|
+
},
|
|
1911
|
+
bootedCount: 0,
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
if (existing && !ownerMatches && opts.force) {
|
|
1916
|
+
// boot other CLI sockets attached to this sim that don't share the lease key
|
|
1917
|
+
for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
|
|
1918
|
+
if (attachedSimId !== sim.id) continue
|
|
1919
|
+
const otherKey = this.cliIdentityKeyBySocket.get(cliWs)
|
|
1920
|
+
if (otherKey && otherKey !== cliIdentityKey) {
|
|
1921
|
+
this.cliSimBySocket.delete(cliWs)
|
|
1922
|
+
try {
|
|
1923
|
+
cliWs.close(1000, 'lease claimed by another cli')
|
|
1924
|
+
} catch {}
|
|
1925
|
+
bootedCount++
|
|
1926
|
+
}
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
const lease: BridgeCliLease = {
|
|
1931
|
+
kind: 'cli',
|
|
1932
|
+
cliIdentityKey,
|
|
1933
|
+
cliLabel,
|
|
1934
|
+
expiresAt: now + SootSimBridgeHost.CLI_LEASE_TTL_MS,
|
|
1935
|
+
}
|
|
1936
|
+
sim.cliLease = lease
|
|
1937
|
+
return { granted: true, lease, bootedCount }
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
// user focus is advisory: we track it on the sim record so list/UI can
|
|
1941
|
+
// show "focused" alongside any cli lease, but focus alone never creates a
|
|
1942
|
+
// blocking lease. the old 15s user-focus lease meant clicking on the sim
|
|
1943
|
+
// locked out agent inspect calls for 15s — the opposite of what you want
|
|
1944
|
+
// when debugging something the user is actively looking at. use
|
|
1945
|
+
// updateUserActivity() to lock on real interaction instead.
|
|
1946
|
+
private updateUserFocusLease(
|
|
1947
|
+
sim: BridgeSimConnection,
|
|
1948
|
+
focusState: BridgeSimUserFocusStateMessage,
|
|
1949
|
+
) {
|
|
1950
|
+
const nextFocused = focusState.focused === true
|
|
1951
|
+
const nextVisible =
|
|
1952
|
+
typeof focusState.visible === 'boolean' ? focusState.visible : undefined
|
|
1953
|
+
const nextVisibilityState =
|
|
1954
|
+
typeof focusState.visibilityState === 'string'
|
|
1955
|
+
? focusState.visibilityState
|
|
1956
|
+
: undefined
|
|
1957
|
+
const nextDocumentFocused =
|
|
1958
|
+
typeof focusState.documentFocused === 'boolean'
|
|
1959
|
+
? focusState.documentFocused
|
|
1960
|
+
: undefined
|
|
1961
|
+
if (
|
|
1962
|
+
sim.userFocused === nextFocused &&
|
|
1963
|
+
sim.userVisible === nextVisible &&
|
|
1964
|
+
sim.visibilityState === nextVisibilityState &&
|
|
1965
|
+
sim.documentFocused === nextDocumentFocused
|
|
1966
|
+
) {
|
|
1967
|
+
return
|
|
1968
|
+
}
|
|
1969
|
+
sim.userFocused = nextFocused
|
|
1970
|
+
sim.userVisible = nextVisible
|
|
1971
|
+
sim.visibilityState = nextVisibilityState
|
|
1972
|
+
sim.documentFocused = nextDocumentFocused
|
|
1973
|
+
this.broadcastSimClientStates()
|
|
1974
|
+
}
|
|
1975
|
+
|
|
1976
|
+
// called when the sim reports a real user interaction (pointerdown,
|
|
1977
|
+
// keydown, wheel, touch). creates or refreshes a short `user-active` lease
|
|
1978
|
+
// that keeps agent writes from trampling a user who is driving the sim.
|
|
1979
|
+
// reads still pass through — shouldAcquireLease only blocks on writes.
|
|
1980
|
+
private updateUserActivity(sim: BridgeSimConnection) {
|
|
1981
|
+
const existing = this.getActiveLease(sim)
|
|
1982
|
+
if (existing && existing.kind === 'cli') {
|
|
1983
|
+
// a real cli lease wins — don't shadow an agent that's actively
|
|
1984
|
+
// driving the sim with a user-active lease.
|
|
1985
|
+
return
|
|
1986
|
+
}
|
|
1987
|
+
const now = Date.now()
|
|
1988
|
+
const refreshed = now + SootSimBridgeHost.USER_ACTIVE_LEASE_TTL_MS
|
|
1989
|
+
// if a longer user-active hold is already in effect (e.g. from an explicit
|
|
1990
|
+
// boot), keep it — passive canvas interaction must not shorten it.
|
|
1991
|
+
const expiresAt =
|
|
1992
|
+
existing && existing.kind === 'user-active'
|
|
1993
|
+
? Math.max(existing.expiresAt, refreshed)
|
|
1994
|
+
: refreshed
|
|
1995
|
+
sim.cliLease = {
|
|
1996
|
+
kind: 'user-active',
|
|
1997
|
+
cliIdentityKey: '__user-active__',
|
|
1998
|
+
cliLabel: 'active user',
|
|
1999
|
+
expiresAt,
|
|
2000
|
+
}
|
|
2001
|
+
this.broadcastSimClientStates()
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
private ensureCliIdentityKey(ws: WebSocket): string {
|
|
2005
|
+
const existing = this.cliIdentityKeyBySocket.get(ws)
|
|
2006
|
+
if (existing) return existing
|
|
2007
|
+
const fallback = `ws-${this.nextCliFallbackId++}`
|
|
2008
|
+
this.cliIdentityKeyBySocket.set(ws, fallback)
|
|
2009
|
+
return fallback
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
private getOpenSim(simId?: string): BridgeSimConnection | null {
|
|
2013
|
+
if (simId) {
|
|
2014
|
+
const sim = this.sims.get(simId)
|
|
2015
|
+
if (sim?.ws.readyState === WebSocket.OPEN) return sim
|
|
2016
|
+
return null
|
|
2017
|
+
}
|
|
2018
|
+
const primary = this.primarySimId != null ? this.sims.get(this.primarySimId) : null
|
|
2019
|
+
if (primary?.ws.readyState === WebSocket.OPEN && primary.url) return primary
|
|
2020
|
+
// prefer a sim that has actually reported a page over a bare-connected
|
|
2021
|
+
// zombie — falling back to a url-less socket hands every default-target
|
|
2022
|
+
// command to something that can't render or be driven (QA F19-2).
|
|
2023
|
+
let pagelessFallback: BridgeSimConnection | null = null
|
|
2024
|
+
for (const sim of this.sims.values()) {
|
|
2025
|
+
if (sim.ws.readyState !== WebSocket.OPEN) continue
|
|
2026
|
+
if (sim.url) return sim
|
|
2027
|
+
pagelessFallback ??= sim
|
|
2028
|
+
}
|
|
2029
|
+
// a live primary with no url still beats nothing, and so does any other
|
|
2030
|
+
// page-less open socket — only as the last resort.
|
|
2031
|
+
if (primary?.ws.readyState === WebSocket.OPEN) return primary
|
|
2032
|
+
return pagelessFallback
|
|
2033
|
+
}
|
|
2034
|
+
|
|
2035
|
+
private async waitForSim(
|
|
2036
|
+
simId?: string,
|
|
2037
|
+
options: { attempts?: number; intervalMs?: number } = {},
|
|
2038
|
+
): Promise<BridgeSimConnection> {
|
|
2039
|
+
const attempts = options.attempts ?? 10
|
|
2040
|
+
const intervalMs = options.intervalMs ?? 200
|
|
2041
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
2042
|
+
const sim = this.getOpenSim(simId)
|
|
2043
|
+
if (sim) return sim
|
|
2044
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs))
|
|
2045
|
+
}
|
|
2046
|
+
throw new Error(simId ? `no sim connected with id ${simId}` : 'no sim connected')
|
|
2047
|
+
}
|
|
2048
|
+
|
|
2049
|
+
private shouldPromoteSim(sim: BridgeSimConnection): boolean {
|
|
2050
|
+
const current = this.primarySimId ? this.sims.get(this.primarySimId) : null
|
|
2051
|
+
// a sim that has not yet reported a page (no `url`) is a bare WS
|
|
2052
|
+
// connection — it can't render or be driven. promoting one makes it the
|
|
2053
|
+
// default target and poisons every un-`--sim` command (QA F19-2): a
|
|
2054
|
+
// zombie that connected from the dev shell but never sent
|
|
2055
|
+
// `bridge:register` used to grab primary at connect-time and keep it
|
|
2056
|
+
// for hours. only let a page-less sim be primary as a last resort when
|
|
2057
|
+
// there is no current primary at all (transient — replaced as soon as a
|
|
2058
|
+
// real page registers, via the re-election in `bridge:register`).
|
|
2059
|
+
if (!sim.url) return !current
|
|
2060
|
+
const currentAlive = current?.ws.readyState === WebSocket.OPEN
|
|
2061
|
+
// a registered (url-bearing) sim always supersedes a dead or page-less
|
|
2062
|
+
// primary; among live page-bearing sims the dev-shell (:5173) origin
|
|
2063
|
+
// wins, matching the previous primary-candidate intent.
|
|
2064
|
+
if (!current || !currentAlive || !current.url) return true
|
|
2065
|
+
const isPrimaryCandidate = sim.origin?.includes(':5173')
|
|
2066
|
+
const currentIsPrimary = current.origin?.includes(':5173')
|
|
2067
|
+
return !!isPrimaryCandidate || !currentIsPrimary
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
private broadcastSimAssignments() {
|
|
2071
|
+
for (const sim of this.sims.values()) {
|
|
2072
|
+
if (sim.ws.readyState !== WebSocket.OPEN) continue
|
|
2073
|
+
sim.ws.send(
|
|
2074
|
+
JSON.stringify({
|
|
2075
|
+
type: 'bridge:welcome',
|
|
2076
|
+
simId: sim.id,
|
|
2077
|
+
isPrimary: sim.id === this.primarySimId,
|
|
2078
|
+
}),
|
|
2079
|
+
)
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
private broadcastSimClientStates() {
|
|
2084
|
+
for (const sim of this.sims.values()) {
|
|
2085
|
+
if (sim.ws.readyState !== WebSocket.OPEN) continue
|
|
2086
|
+
const lease = this.getActiveLease(sim)
|
|
2087
|
+
const message: BridgeSimClientStateMessage = {
|
|
2088
|
+
type: 'bridge:client-state',
|
|
2089
|
+
attachedCliCount: this.getAttachedCliCount(sim.id),
|
|
2090
|
+
activeAgentCommandCount: this.getActiveAgentCommandCount(sim.id),
|
|
2091
|
+
recentActions: sim.recentActions,
|
|
2092
|
+
lockedBy: lease ? lease.cliLabel || lease.cliIdentityKey : undefined,
|
|
2093
|
+
lockedByKind: lease ? lease.kind : undefined,
|
|
2094
|
+
lockExpiresAt: lease ? lease.expiresAt : undefined,
|
|
2095
|
+
userFocused: sim.userFocused || undefined,
|
|
2096
|
+
userVisible: sim.userVisible,
|
|
2097
|
+
visibilityState: sim.visibilityState,
|
|
2098
|
+
documentFocused: sim.documentFocused,
|
|
2099
|
+
}
|
|
2100
|
+
sim.ws.send(JSON.stringify(message))
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
|
|
2104
|
+
private setCliSimTarget(ws: WebSocket, simId: string) {
|
|
2105
|
+
const prevSimId = this.cliSimBySocket.get(ws)
|
|
2106
|
+
if (prevSimId === simId) return
|
|
2107
|
+
this.cliSimBySocket.set(ws, simId)
|
|
2108
|
+
this.recordSimAction(simId, prevSimId ? 'cli switched sims' : 'cli connected', false)
|
|
2109
|
+
this.broadcastSimClientStates()
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
private recordSimAction(
|
|
2113
|
+
simId: string,
|
|
2114
|
+
label: string | null | undefined,
|
|
2115
|
+
broadcast = true,
|
|
2116
|
+
) {
|
|
2117
|
+
const normalized = label?.trim()
|
|
2118
|
+
if (!normalized) return
|
|
2119
|
+
const sim = this.sims.get(simId)
|
|
2120
|
+
if (!sim) return
|
|
2121
|
+
const now = Date.now()
|
|
2122
|
+
sim.lastActiveAt = now
|
|
2123
|
+
sim.recentActions = [
|
|
2124
|
+
{ label: normalized, at: now },
|
|
2125
|
+
...sim.recentActions.filter((entry) => entry.label !== normalized),
|
|
2126
|
+
].slice(0, 4)
|
|
2127
|
+
if (broadcast) this.broadcastSimClientStates()
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
private describeForwardedCommand(msg: any): string | null {
|
|
2131
|
+
switch (msg?.type) {
|
|
2132
|
+
case 'evaluate':
|
|
2133
|
+
return 'evaluated page state'
|
|
2134
|
+
case 'screenshot':
|
|
2135
|
+
return 'captured screenshot'
|
|
2136
|
+
case 'tap':
|
|
2137
|
+
return 'sent tap event'
|
|
2138
|
+
case 'keyboard':
|
|
2139
|
+
return msg?.action === 'type' ? 'typed text' : 'used keyboard'
|
|
2140
|
+
case 'tree':
|
|
2141
|
+
return 'dumped tree'
|
|
2142
|
+
case 'focus':
|
|
2143
|
+
return 'focused sim'
|
|
2144
|
+
case 'close':
|
|
2145
|
+
return 'requested close'
|
|
2146
|
+
default:
|
|
2147
|
+
return typeof msg?.type === 'string' ? msg.type : null
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
// count distinct cli identity keys attached to a sim, not raw sockets.
|
|
2152
|
+
// a single agent firing sequential cli commands opens a new ws per call —
|
|
2153
|
+
// counting sockets would report phantom peers until idle cleanup catches up.
|
|
2154
|
+
private getAttachedCliCount(simId: string): number {
|
|
2155
|
+
const keys = new Set<string>()
|
|
2156
|
+
for (const [ws, attachedSimId] of this.cliSimBySocket) {
|
|
2157
|
+
if (attachedSimId !== simId) continue
|
|
2158
|
+
if (ws.readyState !== WebSocket.OPEN) continue
|
|
2159
|
+
const key = this.cliIdentityKeyBySocket.get(ws)
|
|
2160
|
+
keys.add(key ?? `ws-unknown-${keys.size}`)
|
|
2161
|
+
}
|
|
2162
|
+
return keys.size
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// count distinct identity keys attached to this sim other than `selfWs`.
|
|
2166
|
+
// used to warn a cli that other agents/identities are also targeting the sim.
|
|
2167
|
+
private getOtherCliIdentityCount(selfWs: WebSocket, simId: string): number {
|
|
2168
|
+
const selfKey = this.cliIdentityKeyBySocket.get(selfWs)
|
|
2169
|
+
const keys = new Set<string>()
|
|
2170
|
+
for (const [ws, attachedSimId] of this.cliSimBySocket) {
|
|
2171
|
+
if (attachedSimId !== simId) continue
|
|
2172
|
+
if (ws.readyState !== WebSocket.OPEN) continue
|
|
2173
|
+
const key = this.cliIdentityKeyBySocket.get(ws)
|
|
2174
|
+
if (key && key === selfKey) continue
|
|
2175
|
+
keys.add(key ?? `ws-unknown-${keys.size}`)
|
|
2176
|
+
}
|
|
2177
|
+
return keys.size
|
|
2178
|
+
}
|
|
2179
|
+
|
|
2180
|
+
private getActiveAgentCommandCount(simId: string): number {
|
|
2181
|
+
let count = 0
|
|
2182
|
+
for (const pending of this.pendingCommands.values()) {
|
|
2183
|
+
if (pending.simId === simId) count++
|
|
2184
|
+
}
|
|
2185
|
+
return count
|
|
2186
|
+
}
|
|
2187
|
+
|
|
2188
|
+
private allocateSimId(): string {
|
|
2189
|
+
for (;;) {
|
|
2190
|
+
const id = this.nextSimNumber.toString(16)
|
|
2191
|
+
this.nextSimNumber++
|
|
2192
|
+
if (!this.sims.has(id) && !this.restorableSims.has(id)) return id
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
private tryRestoreSimId(
|
|
2197
|
+
sim: BridgeSimConnection,
|
|
2198
|
+
requestedId: string | undefined,
|
|
2199
|
+
): boolean {
|
|
2200
|
+
const nextId = requestedId?.trim()
|
|
2201
|
+
if (!nextId || nextId === sim.id) return false
|
|
2202
|
+
const existing = this.sims.get(nextId)
|
|
2203
|
+
if (existing && existing !== sim && existing.ws.readyState === WebSocket.OPEN) {
|
|
2204
|
+
return false
|
|
2205
|
+
}
|
|
2206
|
+
const restorable = this.getRestorableSimState(nextId)
|
|
2207
|
+
|
|
2208
|
+
const prevId = sim.id
|
|
2209
|
+
this.sims.delete(prevId)
|
|
2210
|
+
sim.id = nextId
|
|
2211
|
+
if (restorable) {
|
|
2212
|
+
sim.recentActions = restorable.recentActions.map((entry) => ({ ...entry }))
|
|
2213
|
+
sim.lastActiveAt = restorable.lastActiveAt
|
|
2214
|
+
sim.cliLease = restorable.cliLease ? { ...restorable.cliLease } : undefined
|
|
2215
|
+
this.restorableSims.delete(nextId)
|
|
2216
|
+
}
|
|
2217
|
+
this.sims.set(sim.id, sim)
|
|
2218
|
+
if (this.primarySimId === prevId) {
|
|
2219
|
+
this.primarySimId = sim.id
|
|
2220
|
+
}
|
|
2221
|
+
for (const [ws, attachedSimId] of this.cliSimBySocket) {
|
|
2222
|
+
if (attachedSimId === prevId) {
|
|
2223
|
+
this.cliSimBySocket.set(ws, sim.id)
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
return true
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
private rememberDisconnectedSim(sim: BridgeSimConnection) {
|
|
2230
|
+
const lease = this.getActiveLease(sim)
|
|
2231
|
+
this.restorableSims.set(sim.id, {
|
|
2232
|
+
recentActions: sim.recentActions.map((entry) => ({ ...entry })),
|
|
2233
|
+
lastActiveAt: sim.lastActiveAt,
|
|
2234
|
+
cliLease: lease && lease.kind === 'cli' ? { ...lease } : undefined,
|
|
2235
|
+
expiresAt: Date.now() + SootSimBridgeHost.SIM_RECONNECT_TTL_MS,
|
|
2236
|
+
})
|
|
2237
|
+
this.sims.delete(sim.id)
|
|
2238
|
+
}
|
|
2239
|
+
|
|
2240
|
+
private getRestorableSimState(simId: string): BridgeRestorableSimState | null {
|
|
2241
|
+
const snapshot = this.restorableSims.get(simId)
|
|
2242
|
+
if (!snapshot) return null
|
|
2243
|
+
if (snapshot.expiresAt <= Date.now()) {
|
|
2244
|
+
this.restorableSims.delete(simId)
|
|
2245
|
+
return null
|
|
2246
|
+
}
|
|
2247
|
+
if (snapshot.cliLease && snapshot.cliLease.expiresAt <= Date.now()) {
|
|
2248
|
+
snapshot.cliLease = undefined
|
|
2249
|
+
}
|
|
2250
|
+
return snapshot
|
|
2251
|
+
}
|
|
2252
|
+
|
|
2253
|
+
private sweepRestorableSims(now = Date.now()) {
|
|
2254
|
+
for (const [simId, snapshot] of this.restorableSims) {
|
|
2255
|
+
if (snapshot.expiresAt > now) continue
|
|
2256
|
+
this.restorableSims.delete(simId)
|
|
2257
|
+
for (const [cliWs, attachedSimId] of this.cliSimBySocket) {
|
|
2258
|
+
if (attachedSimId === simId) {
|
|
2259
|
+
this.cliSimBySocket.delete(cliWs)
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
|
|
2265
|
+
private resetServerState() {
|
|
2266
|
+
if (this.cliIdleTimer) {
|
|
2267
|
+
clearInterval(this.cliIdleTimer)
|
|
2268
|
+
this.cliIdleTimer = null
|
|
2269
|
+
}
|
|
2270
|
+
if (this.wsHeartbeatTimer) {
|
|
2271
|
+
clearInterval(this.wsHeartbeatTimer)
|
|
2272
|
+
this.wsHeartbeatTimer = null
|
|
2273
|
+
}
|
|
2274
|
+
if (this.runtimeUpdateTimer) {
|
|
2275
|
+
clearInterval(this.runtimeUpdateTimer)
|
|
2276
|
+
this.runtimeUpdateTimer = null
|
|
2277
|
+
}
|
|
2278
|
+
const wss = this.wss
|
|
2279
|
+
const httpServer = this.httpServer
|
|
2280
|
+
this.wss = null
|
|
2281
|
+
this.httpServer = null
|
|
2282
|
+
if (wss) {
|
|
2283
|
+
try {
|
|
2284
|
+
wss.close()
|
|
2285
|
+
} catch {}
|
|
2286
|
+
}
|
|
2287
|
+
if (httpServer) {
|
|
2288
|
+
try {
|
|
2289
|
+
httpServer.close()
|
|
2290
|
+
} catch {}
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
}
|