peachsim 0.1.527

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,203 @@
1
+ // `plane:*` — the project file plane over the loopback bridge, so a browser
2
+ // tab can reach a real local box. the envelope and its constraints are decided
3
+ // in plans/rnx-cloud/transport-decision.md; this is the local half.
4
+ //
5
+ // the vocabulary is exactly ProjectFilePlane's members, because the point is
6
+ // that a remote plane is a third provider behind that seam rather than a
7
+ // second file API. it is remotable without changing the interface because
8
+ // `index()` is synchronous but `refresh()` is the async step the contract
9
+ // already requires before an index read: a remote plane refreshes into a local
10
+ // cache and reads that.
11
+ //
12
+ // WHY A ROOT MUST BE REGISTERED FIRST. this listener is loopback-only, but a
13
+ // WebSocket to 127.0.0.1 is not subject to CORS, so any page the user visits
14
+ // can open one. if `plane:open` took an arbitrary path, that page could read
15
+ // any file on the machine through this daemon. a root is reachable only while
16
+ // a box has registered it, and the registration dies with its socket.
17
+
18
+ import fs from 'node:fs'
19
+ import path from 'node:path'
20
+ import { dispatchPlaneCommand, indexVersion } from '@rnx/box/plane'
21
+ import { CheckoutFilePlane } from '../../cli/commands/box/checkout-plane.ts'
22
+ import type { ProjectFilePlane } from '@rnx/box/plane'
23
+ import type { WebSocket } from 'ws'
24
+
25
+ const WS_OPEN = 1
26
+
27
+ interface OpenPlane {
28
+ root: string
29
+ plane: ProjectFilePlane
30
+ version: string
31
+ sockets: Set<WebSocket>
32
+ }
33
+
34
+ export class PlaneNotRegisteredError extends Error {}
35
+
36
+ export class PlaneHost {
37
+ // roots a box authorized, each tied to the socket that authorized it
38
+ private registered = new Map<string, Set<WebSocket>>()
39
+ private open = new Map<string, OpenPlane>()
40
+ private byRoot = new Map<string, string>()
41
+ private nextPlaneId = 1
42
+
43
+ /** handle a plane:* message. returns true iff it was one. */
44
+ async handleMessage(ws: WebSocket, msg: unknown): Promise<boolean> {
45
+ if (typeof msg !== 'object' || msg === null) return false
46
+ const record: Record<string, unknown> = { ...msg }
47
+ const type = record.type
48
+ if (typeof type !== 'string' || !type.startsWith('plane:')) return false
49
+ const id = record.id
50
+ try {
51
+ this.respond(ws, id, await this.dispatch(ws, type, record))
52
+ } catch (err) {
53
+ const code =
54
+ err instanceof PlaneNotRegisteredError ? 'plane-not-registered' : undefined
55
+ this.respondError(ws, id, err instanceof Error ? err.message : String(err), code)
56
+ }
57
+ return true
58
+ }
59
+
60
+ unregisterSocket(ws: WebSocket): void {
61
+ for (const [root, sockets] of [...this.registered]) {
62
+ if (!sockets.delete(ws)) continue
63
+ if (sockets.size > 0) continue
64
+ // the box that authorized this root is gone, so drop the plane with it
65
+ // rather than leaving a reachable handle behind.
66
+ this.registered.delete(root)
67
+ const planeId = this.byRoot.get(root)
68
+ if (planeId) {
69
+ this.open.delete(planeId)
70
+ this.byRoot.delete(root)
71
+ }
72
+ }
73
+ for (const [planeId, entry] of [...this.open]) {
74
+ entry.sockets.delete(ws)
75
+ if (entry.sockets.size === 0 && !this.registered.has(entry.root)) {
76
+ this.open.delete(planeId)
77
+ this.byRoot.delete(entry.root)
78
+ }
79
+ }
80
+ }
81
+
82
+ /** roots currently reachable, for the daemon's status output. */
83
+ registeredRoots(): string[] {
84
+ return [...this.registered.keys()]
85
+ }
86
+
87
+ private async dispatch(
88
+ ws: WebSocket,
89
+ type: string,
90
+ msg: Record<string, unknown>,
91
+ ): Promise<unknown> {
92
+ switch (type) {
93
+ case 'plane:register': {
94
+ const root = this.realRoot(String(msg.root ?? ''))
95
+ const sockets = this.registered.get(root) ?? new Set<WebSocket>()
96
+ sockets.add(ws)
97
+ this.registered.set(root, sockets)
98
+ // `holders` INCLUDING this one, because two boxes over one checkout is
99
+ // a mistake only the daemon can see: CheckoutFilePlane re-runs
100
+ // `git ls-files` before every command, so each box changes the file
101
+ // set the other is working from mid-run. the daemon reports it rather
102
+ // than refusing, because a box whose socket dropped and reconnected
103
+ // can briefly race its own stale entry, and a refusal there would
104
+ // brick a legitimate restart. the caller decides; see box.ts, which
105
+ // already refuses a box inside a box for the same reason.
106
+ return { root, holders: sockets.size }
107
+ }
108
+ // READ ONLY, and that is the whole point of it existing beside the
109
+ // `holders` field on plane:register.
110
+ //
111
+ // registration is the AUTHORIZATION step: it is what permits any page on
112
+ // this machine to open that root over a loopback socket that CORS does
113
+ // not protect. so a caller asking "is anyone else in this checkout"
114
+ // cannot be made to authorize the root in order to find out, even
115
+ // briefly. it also means the count is the honest one: no box reads 0 and
116
+ // a single box reads 1, where asking through register would have made
117
+ // every answer one higher and forced a threshold that counts the asker.
118
+ case 'plane:status': {
119
+ const root = this.realRoot(String(msg.root ?? ''))
120
+ const sockets = this.registered.get(root)
121
+ return { root, holders: sockets?.size ?? 0, registered: Boolean(sockets) }
122
+ }
123
+ case 'plane:open': {
124
+ const root = this.realRoot(String(msg.root ?? ''))
125
+ if (!this.registered.has(root)) {
126
+ throw new PlaneNotRegisteredError(
127
+ `no box has registered ${root}; start one with \`rnx box\` there`,
128
+ )
129
+ }
130
+ const existingId = this.byRoot.get(root)
131
+ const existing = existingId ? this.open.get(existingId) : undefined
132
+ if (existingId && existing) {
133
+ existing.sockets.add(ws)
134
+ return { planeId: existingId, version: existing.version }
135
+ }
136
+ const plane = new CheckoutFilePlane(root)
137
+ await plane.refresh()
138
+ const planeId = `plane-${this.nextPlaneId++}`
139
+ const entry: OpenPlane = {
140
+ root,
141
+ plane,
142
+ version: indexVersion(plane.index()),
143
+ sockets: new Set([ws]),
144
+ }
145
+ this.open.set(planeId, entry)
146
+ this.byRoot.set(root, planeId)
147
+ return { planeId, version: entry.version }
148
+ }
149
+ case 'plane:close': {
150
+ const entry = this.open.get(String(msg.planeId ?? ''))
151
+ if (entry) entry.sockets.delete(ws)
152
+ return { ok: true }
153
+ }
154
+ default: {
155
+ const entry = this.entry(msg)
156
+ const handled = await dispatchPlaneCommand(entry.plane, type, msg)
157
+ if (!handled) throw new Error(`unknown plane command: ${type}`)
158
+ // no companion change event on a write. the write lands on the real
159
+ // checkout and the dev server's own watcher sees it, so a notification
160
+ // here would be a second channel for something the filesystem already
161
+ // reports. the cloud placement, whose store nothing can watch, is where
162
+ // a write has to fan one out.
163
+ if (handled.version) entry.version = handled.version
164
+ return handled.result
165
+ }
166
+ }
167
+ }
168
+
169
+ private entry(msg: Record<string, unknown>): OpenPlane {
170
+ const entry = this.open.get(String(msg.planeId ?? ''))
171
+ if (!entry) throw new Error(`unknown planeId: ${String(msg.planeId)}`)
172
+ return entry
173
+ }
174
+
175
+ // resolve symlinks and relative segments before comparing, so a registered
176
+ // root and an opened one cannot disagree about the same directory.
177
+ private realRoot(root: string): string {
178
+ if (!root) throw new Error('root is required')
179
+ const resolved = path.resolve(root)
180
+ let real: string
181
+ try {
182
+ real = fs.realpathSync(resolved)
183
+ } catch {
184
+ throw new Error(`no such directory: ${resolved}`)
185
+ }
186
+ if (!fs.statSync(real).isDirectory()) throw new Error(`not a directory: ${real}`)
187
+ return real
188
+ }
189
+
190
+ private respond(ws: WebSocket, id: unknown, result: unknown): void {
191
+ if (ws.readyState !== WS_OPEN) return
192
+ try {
193
+ ws.send(JSON.stringify({ id, result }))
194
+ } catch {}
195
+ }
196
+
197
+ private respondError(ws: WebSocket, id: unknown, error: string, code?: string): void {
198
+ if (ws.readyState !== WS_OPEN) return
199
+ try {
200
+ ws.send(JSON.stringify({ id, error, ...(code ? { code } : {}) }))
201
+ } catch {}
202
+ }
203
+ }
@@ -0,0 +1,158 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import {
4
+ compileReplacementModule,
5
+ REPLACEMENT_MODULE_EXTENSIONS,
6
+ } from '../../../contrast-bundler/src/replacementModule.ts'
7
+ import { isLoopbackHost } from '../backend-origin.ts'
8
+ import type { IncomingMessage, ServerResponse } from 'node:http'
9
+
10
+ export const REPLACEMENT_MODULE_ROUTE = '/__sootsim-replacement-module'
11
+
12
+ export interface ReplacementModuleHandlerOptions {
13
+ projectRoot?: string
14
+ requireLoopbackOrigin?: boolean
15
+ requestToken?: string
16
+ }
17
+
18
+ function sendJavaScript(req: IncomingMessage, res: ServerResponse, source: string): void {
19
+ const origin = req.headers.origin
20
+ let allowedOrigin: string | undefined
21
+ if (origin) {
22
+ try {
23
+ if (isLoopbackHost(new URL(origin).hostname)) allowedOrigin = origin
24
+ } catch {}
25
+ }
26
+ res.writeHead(200, {
27
+ 'Content-Type': 'application/javascript; charset=utf-8',
28
+ 'Cache-Control': 'no-cache',
29
+ ...(allowedOrigin
30
+ ? { 'Access-Control-Allow-Origin': allowedOrigin, Vary: 'Origin' }
31
+ : {}),
32
+ })
33
+ res.end(req.method === 'HEAD' ? undefined : source)
34
+ }
35
+
36
+ export function isReplacementModuleRequestUrl(rawUrl?: string): boolean {
37
+ if (!rawUrl) return false
38
+ try {
39
+ const pathname = new URL(rawUrl, 'http://rnx.local').pathname
40
+ return pathname === REPLACEMENT_MODULE_ROUTE
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ export async function handleReplacementModuleRequest(
47
+ req: IncomingMessage,
48
+ res: ServerResponse,
49
+ options: ReplacementModuleHandlerOptions = {},
50
+ ): Promise<boolean> {
51
+ const rawUrl = req.url || ''
52
+ let url: URL
53
+ try {
54
+ url = new URL(rawUrl, 'http://rnx.local')
55
+ } catch {
56
+ return false
57
+ }
58
+
59
+ if (options.requireLoopbackOrigin) {
60
+ const origin = req.headers.origin
61
+ const remoteAddress = req.socket.remoteAddress?.replace(/^::ffff:/, '')
62
+ let allowed = Boolean(remoteAddress && isLoopbackHost(remoteAddress))
63
+ if (allowed && origin) {
64
+ try {
65
+ allowed = isLoopbackHost(new URL(origin).hostname)
66
+ } catch {
67
+ allowed = false
68
+ }
69
+ }
70
+ if (!allowed) {
71
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
72
+ res.end('replacement modules are available only to loopback origins')
73
+ return true
74
+ }
75
+ }
76
+
77
+ if (url.pathname !== REPLACEMENT_MODULE_ROUTE) return false
78
+
79
+ if (options.requestToken && url.searchParams.get('token') !== options.requestToken) {
80
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
81
+ res.end('replacement module request token is invalid')
82
+ return true
83
+ }
84
+
85
+ const targetPath = url.searchParams.get('path')
86
+ if (!targetPath) {
87
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
88
+ res.end('missing path param')
89
+ return true
90
+ }
91
+
92
+ const extension = path.extname(targetPath).toLowerCase()
93
+ if (!REPLACEMENT_MODULE_EXTENSIONS.some((allowed) => allowed === extension)) {
94
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
95
+ res.end(`replacement files must be ${REPLACEMENT_MODULE_EXTENSIONS.join(', ')}`)
96
+ return true
97
+ }
98
+
99
+ if (!path.isAbsolute(targetPath)) {
100
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' })
101
+ res.end('replacement file path must be absolute')
102
+ return true
103
+ }
104
+
105
+ const runtimePackages = new Set(url.searchParams.getAll('runtimePackage'))
106
+
107
+ let resolvedProjectRoot: string | undefined
108
+ if (options.projectRoot) {
109
+ const lexicalProjectRoot = path.resolve(options.projectRoot)
110
+ resolvedProjectRoot = await fs.realpath(options.projectRoot)
111
+ const lexicalTarget = path.relative(lexicalProjectRoot, targetPath)
112
+ if (lexicalTarget.startsWith('..') || path.isAbsolute(lexicalTarget)) {
113
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
114
+ res.end('replacement file must be inside the project root')
115
+ return true
116
+ }
117
+ }
118
+
119
+ let resolvedTargetPath: string
120
+ try {
121
+ resolvedTargetPath = await fs.realpath(targetPath)
122
+ } catch (error) {
123
+ const message = error instanceof Error ? error.message : String(error)
124
+ res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' })
125
+ res.end(`replacement file not found: ${message}`)
126
+ return true
127
+ }
128
+
129
+ if (resolvedProjectRoot) {
130
+ const relativeTarget = path.relative(resolvedProjectRoot, resolvedTargetPath)
131
+ if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) {
132
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' })
133
+ res.end('replacement file must be inside the project root')
134
+ return true
135
+ }
136
+ }
137
+
138
+ try {
139
+ const compiled = compileReplacementModule({
140
+ filePath: resolvedTargetPath,
141
+ source: await fs.readFile(resolvedTargetPath, 'utf8'),
142
+ runtimePackages,
143
+ })
144
+ // inline the map: the file is the app author's own source, and the
145
+ // simulator page is the only thing that ever loads it.
146
+ const map = Buffer.from(JSON.stringify(compiled.sourceMap), 'utf8').toString('base64')
147
+ sendJavaScript(
148
+ req,
149
+ res,
150
+ `${compiled.code}\n//# sourceMappingURL=data:application/json;base64,${map}\n`,
151
+ )
152
+ } catch (error) {
153
+ const message = error instanceof Error ? error.message : String(error)
154
+ res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' })
155
+ res.end(`replacement compile failed: ${message}`)
156
+ }
157
+ return true
158
+ }
@@ -0,0 +1,315 @@
1
+ import { createHash } from 'crypto'
2
+ import { request as requestHttp, type IncomingMessage } from 'http'
3
+ import { request as requestHttps } from 'https'
4
+ import type { Duplex } from 'stream'
5
+
6
+ export const WEBSOCKET_PROXY_PATH = '/__websocket-proxy'
7
+ export const WEBSOCKET_PROXY_HANDSHAKE_TIMEOUT_MS = 10_000
8
+
9
+ const WEBSOCKET_PROTOCOL_TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
10
+ const WEBSOCKET_KEY = /^[+/0-9A-Za-z]{22}==$/
11
+
12
+ type UpgradeRejection = {
13
+ status: number
14
+ message: string
15
+ headers?: Record<string, string>
16
+ }
17
+
18
+ const STRIP_UPSTREAM_HEADERS = new Set([
19
+ 'host',
20
+ 'connection',
21
+ 'upgrade',
22
+ 'transfer-encoding',
23
+ 'content-length',
24
+ 'sec-websocket-accept',
25
+ 'sec-websocket-extensions',
26
+ 'sec-websocket-key',
27
+ 'sec-websocket-protocol',
28
+ 'sec-websocket-version',
29
+ ])
30
+
31
+ function rejectUpgrade(
32
+ socket: Duplex,
33
+ status: number,
34
+ message: string,
35
+ headers: Record<string, string> = {},
36
+ ) {
37
+ try {
38
+ const extraHeaders = Object.entries(headers)
39
+ .map(([name, value]) => `${name}: ${value}\r\n`)
40
+ .join('')
41
+ socket.write(
42
+ `HTTP/1.1 ${status} ${message}\r\nConnection: close\r\nContent-Type: text/plain\r\nContent-Length: ${message.length}\r\n${extraHeaders}\r\n${message}`,
43
+ )
44
+ } catch {}
45
+ socket.destroy()
46
+ }
47
+
48
+ function hasHeaderToken(value: string | undefined, token: string): boolean {
49
+ return (value || '').split(',').some((part) => part.trim().toLowerCase() === token)
50
+ }
51
+
52
+ function hasValidProtocolOffer(value: string | undefined): boolean {
53
+ if (value === undefined) return true
54
+ if (!WEBSOCKET_PROTOCOL_TOKEN.test(value[0] || '')) return false
55
+ if (!WEBSOCKET_PROTOCOL_TOKEN.test(value[value.length - 1] || '')) return false
56
+ const protocols = new Set<string>()
57
+ for (const part of value.split(',')) {
58
+ const protocol = part.trim()
59
+ if (!WEBSOCKET_PROTOCOL_TOKEN.test(protocol) || protocols.has(protocol)) {
60
+ return false
61
+ }
62
+ protocols.add(protocol)
63
+ }
64
+ return true
65
+ }
66
+
67
+ function validateUpgradeRequest(req: IncomingMessage): UpgradeRejection | null {
68
+ if (req.method !== 'GET') {
69
+ return { status: 405, message: 'invalid websocket http method' }
70
+ }
71
+ if (
72
+ !hasHeaderToken(req.headers.connection, 'upgrade') ||
73
+ !hasHeaderToken(req.headers.upgrade, 'websocket')
74
+ ) {
75
+ return { status: 400, message: 'invalid websocket upgrade headers' }
76
+ }
77
+ const key = req.headers['sec-websocket-key']
78
+ if (
79
+ typeof key !== 'string' ||
80
+ !WEBSOCKET_KEY.test(key) ||
81
+ Buffer.from(key, 'base64').byteLength !== 16 ||
82
+ Buffer.from(key, 'base64').toString('base64') !== key
83
+ ) {
84
+ return { status: 400, message: 'invalid websocket key' }
85
+ }
86
+ if (req.headers['sec-websocket-version'] !== '13') {
87
+ return {
88
+ status: 400,
89
+ message: 'invalid websocket version',
90
+ headers: { 'Sec-WebSocket-Version': '13' },
91
+ }
92
+ }
93
+ if (!hasValidProtocolOffer(req.headers['sec-websocket-protocol'])) {
94
+ return { status: 400, message: 'invalid websocket protocol offer' }
95
+ }
96
+ return null
97
+ }
98
+
99
+ function isSameOriginUpgrade(req: IncomingMessage): boolean {
100
+ const origin = req.headers.origin
101
+ const host = req.headers.host
102
+ if (!origin || !host) return false
103
+ try {
104
+ return new URL(origin).host === host
105
+ } catch {
106
+ return false
107
+ }
108
+ }
109
+
110
+ function decodeProxyHeaders(encoded: string | null): Record<string, string> {
111
+ if (!encoded) return {}
112
+ const base64 = encoded.replace(/-/g, '+').replace(/_/g, '/')
113
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
114
+ const parsed = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
115
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {}
116
+ const headers: Record<string, string> = {}
117
+ for (const [key, value] of Object.entries(parsed)) {
118
+ if (value == null) continue
119
+ if (STRIP_UPSTREAM_HEADERS.has(key.toLowerCase())) continue
120
+ headers[key] = Array.isArray(value) ? value.join(', ') : String(value)
121
+ }
122
+ return headers
123
+ }
124
+
125
+ function getDefaultWebSocketOrigin(targetUrl: URL): string {
126
+ const origin = new URL(targetUrl.href)
127
+ origin.protocol = targetUrl.protocol === 'wss:' ? 'https:' : 'http:'
128
+ return origin.origin
129
+ }
130
+
131
+ function createUpstreamHeaders(
132
+ req: IncomingMessage,
133
+ targetUrl: URL,
134
+ headers: Record<string, string>,
135
+ forwardOrigin: boolean,
136
+ ): Record<string, string> {
137
+ const upstreamHeaders = { ...headers }
138
+ const originKeys = Object.keys(upstreamHeaders).filter(
139
+ (key) => key.toLowerCase() === 'origin',
140
+ )
141
+ if (forwardOrigin && originKeys.length === 0) {
142
+ upstreamHeaders.origin = getDefaultWebSocketOrigin(targetUrl)
143
+ } else if (!forwardOrigin) {
144
+ for (const key of originKeys) delete upstreamHeaders[key]
145
+ }
146
+ upstreamHeaders.connection = 'Upgrade'
147
+ upstreamHeaders.upgrade = 'websocket'
148
+ const key = req.headers['sec-websocket-key']
149
+ if (typeof key === 'string') upstreamHeaders['sec-websocket-key'] = key
150
+ const version = req.headers['sec-websocket-version']
151
+ if (typeof version === 'string') upstreamHeaders['sec-websocket-version'] = version
152
+ const protocol = req.headers['sec-websocket-protocol']
153
+ if (typeof protocol === 'string') upstreamHeaders['sec-websocket-protocol'] = protocol
154
+ const extensions = req.headers['sec-websocket-extensions']
155
+ if (typeof extensions === 'string') {
156
+ upstreamHeaders['sec-websocket-extensions'] = extensions
157
+ }
158
+ return upstreamHeaders
159
+ }
160
+
161
+ export function isWebSocketProxyRequestUrl(rawUrl: string | undefined): boolean {
162
+ if (!rawUrl) return false
163
+ try {
164
+ return new URL(rawUrl, 'http://localhost').pathname === WEBSOCKET_PROXY_PATH
165
+ } catch {
166
+ return false
167
+ }
168
+ }
169
+
170
+ export function handleWebSocketProxyUpgrade(
171
+ req: IncomingMessage,
172
+ socket: Duplex,
173
+ head: Buffer,
174
+ forwardOrigin = true,
175
+ ): boolean {
176
+ if (!isWebSocketProxyRequestUrl(req.url)) return false
177
+ if (!isSameOriginUpgrade(req)) {
178
+ rejectUpgrade(socket, 403, 'forbidden websocket proxy origin')
179
+ return true
180
+ }
181
+ const upgradeRejection = validateUpgradeRequest(req)
182
+ if (upgradeRejection) {
183
+ rejectUpgrade(
184
+ socket,
185
+ upgradeRejection.status,
186
+ upgradeRejection.message,
187
+ upgradeRejection.headers,
188
+ )
189
+ return true
190
+ }
191
+
192
+ let targetUrl: URL
193
+ let headers: Record<string, string>
194
+ try {
195
+ const requestUrl = new URL(req.url || '/', 'http://localhost')
196
+ const target = requestUrl.searchParams.get('url')
197
+ if (!target) {
198
+ rejectUpgrade(socket, 400, 'missing websocket proxy url')
199
+ return true
200
+ }
201
+ targetUrl = new URL(target)
202
+ if (targetUrl.protocol !== 'ws:' && targetUrl.protocol !== 'wss:') {
203
+ rejectUpgrade(socket, 400, 'invalid websocket proxy protocol')
204
+ return true
205
+ }
206
+ headers = decodeProxyHeaders(requestUrl.searchParams.get('headers'))
207
+ } catch {
208
+ rejectUpgrade(socket, 400, 'invalid websocket proxy request')
209
+ return true
210
+ }
211
+
212
+ const upstreamHeaders = createUpstreamHeaders(req, targetUrl, headers, forwardOrigin)
213
+ let settled = false
214
+ const request = targetUrl.protocol === 'wss:' ? requestHttps : requestHttp
215
+ const upstreamRequestUrl = new URL(targetUrl)
216
+ upstreamRequestUrl.protocol = targetUrl.protocol === 'wss:' ? 'https:' : 'http:'
217
+ const upstreamRequest = request(upstreamRequestUrl, {
218
+ method: 'GET',
219
+ headers: upstreamHeaders,
220
+ })
221
+ let destroyUpstreamSocket = () => {}
222
+ upstreamRequest.once('socket', (upstreamSocket) => {
223
+ destroyUpstreamSocket = () => upstreamSocket.destroy()
224
+ })
225
+ const fail = (status: number, message: string) => {
226
+ if (settled) return
227
+ settled = true
228
+ clearTimeout(timer)
229
+ upstreamRequest.destroy()
230
+ destroyUpstreamSocket()
231
+ rejectUpgrade(socket, status, message)
232
+ }
233
+ const timer = setTimeout(() => {
234
+ fail(504, 'upstream websocket handshake timeout')
235
+ }, WEBSOCKET_PROXY_HANDSHAKE_TIMEOUT_MS)
236
+ socket.once('close', () => {
237
+ if (!settled) {
238
+ settled = true
239
+ clearTimeout(timer)
240
+ }
241
+ upstreamRequest.destroy()
242
+ destroyUpstreamSocket()
243
+ })
244
+ upstreamRequest.once('response', (response) => {
245
+ response.resume()
246
+ fail(502, 'upstream websocket rejected upgrade')
247
+ })
248
+ upstreamRequest.once('error', () => {
249
+ fail(502, 'upstream websocket error')
250
+ })
251
+ upstreamRequest.once('upgrade', (response, upstreamSocket, upstreamHead) => {
252
+ if (settled) {
253
+ upstreamSocket.destroy()
254
+ return
255
+ }
256
+ const accept = response.headers['sec-websocket-accept']
257
+ const upgrade = response.headers.upgrade
258
+ const requestKey = req.headers['sec-websocket-key']
259
+ const expectedAccept =
260
+ typeof requestKey === 'string'
261
+ ? createHash('sha1')
262
+ .update(`${requestKey}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`)
263
+ .digest('base64')
264
+ : ''
265
+ if (
266
+ typeof accept !== 'string' ||
267
+ accept !== expectedAccept ||
268
+ typeof upgrade !== 'string' ||
269
+ upgrade.toLowerCase() !== 'websocket'
270
+ ) {
271
+ upstreamSocket.destroy()
272
+ fail(502, 'upstream websocket handshake is invalid')
273
+ return
274
+ }
275
+ const selectedProtocol = response.headers['sec-websocket-protocol']
276
+ const requestedProtocols = (req.headers['sec-websocket-protocol'] || '')
277
+ .split(',')
278
+ .map((protocol) => protocol.trim())
279
+ if (
280
+ typeof selectedProtocol === 'string' &&
281
+ !requestedProtocols.includes(selectedProtocol)
282
+ ) {
283
+ upstreamSocket.destroy()
284
+ fail(502, 'upstream websocket selected an unrequested protocol')
285
+ return
286
+ }
287
+ settled = true
288
+ clearTimeout(timer)
289
+ const responseHeaders = [
290
+ 'HTTP/1.1 101 Switching Protocols',
291
+ 'Upgrade: websocket',
292
+ 'Connection: Upgrade',
293
+ `Sec-WebSocket-Accept: ${accept}`,
294
+ ]
295
+ if (typeof selectedProtocol === 'string') {
296
+ responseHeaders.push(`Sec-WebSocket-Protocol: ${selectedProtocol}`)
297
+ }
298
+ const selectedExtensions = response.headers['sec-websocket-extensions']
299
+ if (typeof selectedExtensions === 'string') {
300
+ responseHeaders.push(`Sec-WebSocket-Extensions: ${selectedExtensions}`)
301
+ }
302
+ // the upstream and browser used the same websocket key and extension offer,
303
+ // so their negotiated byte streams can be relayed without decoding frames.
304
+ socket.write(`${responseHeaders.join('\r\n')}\r\n\r\n`)
305
+ if (head.length > 0) upstreamSocket.write(head)
306
+ if (upstreamHead.length > 0) socket.write(upstreamHead)
307
+ socket.once('close', () => upstreamSocket.destroy())
308
+ socket.once('error', () => upstreamSocket.destroy())
309
+ upstreamSocket.once('error', () => socket.destroy())
310
+ socket.pipe(upstreamSocket)
311
+ upstreamSocket.pipe(socket)
312
+ })
313
+ upstreamRequest.end()
314
+ return true
315
+ }