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,1530 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import {
3
+ closeSync,
4
+ existsSync,
5
+ fstatSync,
6
+ openSync,
7
+ readdirSync,
8
+ readFileSync,
9
+ } from 'node:fs'
10
+ import { dirname, extname, join, resolve } from 'node:path'
11
+ import {
12
+ embedMetroAssetFiles,
13
+ extractAssetDescriptors,
14
+ isEmbeddedAssetDescriptor,
15
+ metroAssetPlaceholderBytes,
16
+ metroAssetScaleFilename,
17
+ metroIosAssetDestRelatives,
18
+ resolveMetroIosAssetDestFiles,
19
+ type MetroArtifactAssetSize,
20
+ type MetroAssetDescriptor,
21
+ } from '../../contrast-bundler/src/metroAssetArtifact.ts'
22
+ import {
23
+ buildRnxCloudArtifact,
24
+ type RnxCloudArtifactReceipt,
25
+ } from '../../contrast-bundler/src/metroCloudArtifact.ts'
26
+ import { loadMetroFingerprintRegistry } from '../../sootsim-engine/src/metro-fingerprint-registry-client.ts'
27
+ import { inferMetroModuleIdentity } from '../../sootsim-engine/src/metro-fingerprint.ts'
28
+ import { isLoopbackHost } from '../src/backend-origin.ts'
29
+ import {
30
+ isRnxCloudCommandType,
31
+ isRnxCloudSimStatus,
32
+ RNX_CLOUD_MAX_ARTIFACT_BYTES,
33
+ RNX_CLOUD_TOKEN_SCOPES,
34
+ rnxCloudCommandScope,
35
+ rnxCloudScopesAllowCommand,
36
+ type RnxCloudBoxCreateReceipt,
37
+ type RnxCloudClaim,
38
+ type RnxCloudSimCreateResponse,
39
+ type RnxCloudSimInstance,
40
+ type RnxCloudSimView,
41
+ } from '../src/cloud-contract.ts'
42
+ import {
43
+ RNX_METRO_MODULE_IDENTITY_VERSION,
44
+ type RNXMetroModuleIdentityMetadata,
45
+ } from '../src/metro-production-bundle.ts'
46
+ import { rnxPublicBrand } from '../src/public-brand.ts'
47
+ import { CHOOSE_ACCOUNT_HINT } from './auth.ts'
48
+ import {
49
+ createCloudSession,
50
+ normalizeCloudOrigin,
51
+ readCloudSession,
52
+ type CloudSession,
53
+ type CloudSimSession,
54
+ type RemotePlatform,
55
+ } from './cloud-session.ts'
56
+ import { detectProject } from './commands/detect.ts'
57
+ import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract.ts'
58
+ import type {
59
+ BridgeClaimResult,
60
+ BridgeSimInfo,
61
+ ParsedBridgeCliArgs,
62
+ WsBridge,
63
+ } from './ws-bridge.ts'
64
+
65
+ export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin
66
+ export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
67
+
68
+ // every message this module writes names the command through this constant.
69
+ const REMOTE_COMMAND = 'rnx remote'
70
+ const PREVIEW_COMMAND = 'rnx preview'
71
+
72
+ // a Metro asset dest is a shallow tree of images. a walk that runs past this
73
+ // is pointed at something else entirely, and its extra entries cannot resolve
74
+ // an asset anyway.
75
+ const MAX_ASSET_DEST_FILES = 25_000
76
+
77
+ interface CreateCloudBoxOptions {
78
+ bundlePath: string
79
+ device: string
80
+ authorization: string
81
+ /** the account to bill when the authorization is a session token; an api key names its own */
82
+ accountId: string | null
83
+ platform?: RemotePlatform
84
+ apiOrigin?: string
85
+ /** the name this box carries in the account's box list. */
86
+ boxName?: string | null
87
+ /**
88
+ * the storage and route the first simulator boots with, captured from a
89
+ * running app. a snapshot reaches a megabyte, so a create that carries one
90
+ * is multipart rather than a bare artifact body.
91
+ */
92
+ storage?: StorageSnapshot | null
93
+ }
94
+
95
+ interface CloudCommandEnvelope {
96
+ id: string
97
+ claimId: string
98
+ command: Record<string, unknown>
99
+ }
100
+
101
+ function isRecord(value: unknown): value is Record<string, unknown> {
102
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
103
+ }
104
+
105
+ // three services answer this client and each states a failure its own way:
106
+ // the box service says { error: '<sentence>' }, the site's public api says
107
+ // { error: '<code>', message: '<sentence>' }, and its service-to-service api
108
+ // says { error: { code, message } }. all three carry one sentence meant for
109
+ // the person at the terminal, so read whichever field holds it. without this
110
+ // the sentence never reaches them and they get the raw json body instead.
111
+ function failureSentence(text: string): string | null {
112
+ let parsed: unknown
113
+ try {
114
+ parsed = JSON.parse(text)
115
+ } catch {
116
+ return null
117
+ }
118
+ if (!isRecord(parsed)) return null
119
+ if (isRecord(parsed.error) && typeof parsed.error.message === 'string') {
120
+ // the code is the contract a script matches on; the message is for people.
121
+ const code =
122
+ typeof parsed.error.code === 'string' && parsed.error.code
123
+ ? ` [${parsed.error.code}]`
124
+ : ''
125
+ return `${parsed.error.message}${code}`
126
+ }
127
+ if (typeof parsed.message === 'string') return parsed.message
128
+ if (typeof parsed.error === 'string') return parsed.error
129
+ return null
130
+ }
131
+
132
+ // the account service says what it needs in its own words; the CLI says what
133
+ // the person can do about it, which is choose the account at `rnx login`.
134
+ const ACCOUNT_CHOICE_REFUSALS = new Set([
135
+ 'accountId is required',
136
+ 'you do not have access to this account',
137
+ ])
138
+
139
+ function responseError(status: number, text: string): Error {
140
+ const parsed = text ? failureSentence(text) : null
141
+ const sentence =
142
+ parsed && ACCOUNT_CHOICE_REFUSALS.has(parsed) ? CHOOSE_ACCOUNT_HINT : parsed
143
+ return new Error(
144
+ `remote simulator request failed (${status})${
145
+ sentence ? `: ${sentence}` : text.trim() ? `: ${text.trim()}` : ''
146
+ }`,
147
+ )
148
+ }
149
+
150
+ function resolveCloudOrigin(value?: string): string {
151
+ const configured = value?.trim() || process.env.RNX_CLOUD_ORIGIN?.trim()
152
+ const source = configured || DEFAULT_RNX_CLOUD_ORIGIN
153
+ return normalizeCloudOrigin(source)
154
+ }
155
+
156
+ function readImmutableBundleSource(bundlePath: string): string {
157
+ const filepath = resolve(bundlePath)
158
+ if (extname(filepath) !== '.js') {
159
+ throw new Error(`${REMOTE_COMMAND} requires one local Metro .js bundle`)
160
+ }
161
+ const descriptor = openSync(filepath, 'r')
162
+ try {
163
+ const before = fstatSync(descriptor, { bigint: true })
164
+ if (!before.isFile()) {
165
+ throw new Error(`${REMOTE_COMMAND} bundle must be a regular local file`)
166
+ }
167
+ if (before.size <= 0n) {
168
+ throw new Error(`${REMOTE_COMMAND} bundle is empty`)
169
+ }
170
+ const bytes = readFileSync(descriptor)
171
+ const after = fstatSync(descriptor, { bigint: true })
172
+ if (
173
+ before.dev !== after.dev ||
174
+ before.ino !== after.ino ||
175
+ before.size !== after.size ||
176
+ before.mtimeNs !== after.mtimeNs ||
177
+ bytes.length !== Number(before.size)
178
+ ) {
179
+ throw new Error(`${REMOTE_COMMAND} bundle changed while it was being read`)
180
+ }
181
+ let source: string
182
+ try {
183
+ source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
184
+ } catch {
185
+ throw new Error(`${REMOTE_COMMAND} bundle must be valid UTF-8 JavaScript`)
186
+ }
187
+ if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
188
+ throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
189
+ }
190
+ return source
191
+ } finally {
192
+ closeSync(descriptor)
193
+ }
194
+ }
195
+
196
+ async function fetchBundleSource(bundleUrl: string): Promise<string> {
197
+ let response: Response
198
+ try {
199
+ response = await fetch(bundleUrl)
200
+ } catch (error) {
201
+ throw new Error(
202
+ `${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: ${
203
+ error instanceof Error ? error.message : String(error)
204
+ }`,
205
+ )
206
+ }
207
+ if (!response.ok) {
208
+ throw new Error(
209
+ `${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${
210
+ response.statusText ? ` ${response.statusText}` : ''
211
+ }`,
212
+ )
213
+ }
214
+ const text = await response.text()
215
+ if (text.length === 0) {
216
+ throw new Error(`${REMOTE_COMMAND} bundle is empty`)
217
+ }
218
+ if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) {
219
+ throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
220
+ }
221
+ return text
222
+ }
223
+
224
+ // a bundle served over plain http is only trustworthy when it comes from this
225
+ // machine, which is what "send to box" reads: the Metro dev server the app is
226
+ // already running on.
227
+ function isLoopbackBundleUrl(bundleInput: string): boolean {
228
+ if (!bundleInput.startsWith('http://')) return false
229
+ try {
230
+ return isLoopbackHost(new URL(bundleInput).hostname)
231
+ } catch {
232
+ return false
233
+ }
234
+ }
235
+
236
+ function isRemoteBundleInput(bundleInput: string): boolean {
237
+ return bundleInput.startsWith('https://') || bundleInput.startsWith('http://')
238
+ }
239
+
240
+ async function loadBundleSource(bundleInput: string): Promise<string> {
241
+ if (bundleInput.startsWith('http://') && !isLoopbackBundleUrl(bundleInput)) {
242
+ throw new Error(`${REMOTE_COMMAND} bundle URL must use HTTPS`)
243
+ }
244
+ if (isRemoteBundleInput(bundleInput)) {
245
+ return fetchBundleSource(bundleInput)
246
+ }
247
+ return readImmutableBundleSource(bundleInput)
248
+ }
249
+
250
+ // `react-native bundle --assets-dest` dest paths are not always the JS
251
+ // descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
252
+ // that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
253
+ // project-root-relative app path. resolveMetroIosAssetDestFiles matches both.
254
+ interface MetroAssetDestListing {
255
+ directory: string
256
+ files: string[]
257
+ /** the walk stopped at the cap, so "not in this listing" proves nothing. */
258
+ truncated: boolean
259
+ }
260
+
261
+ // a Metro asset dest never holds these, but a project root does, and a
262
+ // candidate can be either. `node_modules` is not in the list on purpose: a real
263
+ // dest DOES carry one, because that is where `react-native bundle` copies the
264
+ // assets that live inside packages. it is skipped only under a project root,
265
+ // which is what a package.json at the candidate marks.
266
+ const PROJECT_ONLY_DIRECTORIES = new Set([
267
+ 'node_modules',
268
+ 'Pods',
269
+ 'DerivedData',
270
+ '.expo',
271
+ '.next',
272
+ '.turbo',
273
+ '.cache',
274
+ '.gradle',
275
+ ])
276
+
277
+ function listMetroAssetDest(assetsPath: string): MetroAssetDestListing {
278
+ const directory = resolve(assetsPath)
279
+ if (!existsSync(directory)) return { directory, files: [], truncated: false }
280
+ const skipNested = existsSync(join(directory, 'package.json'))
281
+ const files: string[] = []
282
+ let truncated = false
283
+ const walk = (dir: string, prefix: string) => {
284
+ let entries
285
+ try {
286
+ entries = readdirSync(dir, { withFileTypes: true })
287
+ } catch {
288
+ return
289
+ }
290
+ for (const entry of entries) {
291
+ if (files.length >= MAX_ASSET_DEST_FILES) {
292
+ truncated = true
293
+ return
294
+ }
295
+ if (entry.isDirectory()) {
296
+ if (entry.name === '.git') continue
297
+ if (skipNested && PROJECT_ONLY_DIRECTORIES.has(entry.name)) continue
298
+ walk(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name)
299
+ } else if (entry.isFile()) {
300
+ files.push(prefix ? `${prefix}/${entry.name}` : entry.name)
301
+ }
302
+ }
303
+ }
304
+ walk(directory, '')
305
+ return { directory, files, truncated }
306
+ }
307
+
308
+ /**
309
+ * Metro's own identity for an asset. `getAbsoluteAssetInfo` in metro/src/Assets
310
+ * md5s every scale file's bytes into ONE digest, in the ascending scale order
311
+ * `buildAssetMap` keeps `scales` and `files` parallel in, and that digest is
312
+ * the descriptor's `hash`. so a directory can be asked whether it holds THIS
313
+ * asset instead of merely a file with the same name.
314
+ */
315
+ function metroDescriptorHash(scaleBytes: readonly Buffer[]): string {
316
+ const hasher = createHash('md5')
317
+ for (const bytes of scaleBytes) hasher.update(bytes)
318
+ return hasher.digest('hex')
319
+ }
320
+
321
+ function metroAssetKey(descriptor: MetroAssetDescriptor): string {
322
+ return `${descriptor.httpServerLocation}/${descriptor.name}.${descriptor.type}`
323
+ }
324
+
325
+ /** where one descriptor's scales sit inside one candidate directory. */
326
+ interface DescriptorPlacement {
327
+ /** the dest-relative file chosen for each declared scale that is present. */
328
+ files: Map<number, string>
329
+ /** files a scale matched that hold different images, so none was chosen. */
330
+ ambiguous: string[]
331
+ /** every declared scale is present and their bytes are Metro's `hash`. */
332
+ verified: boolean
333
+ /** every declared scale is present and their bytes are something else. */
334
+ mismatched: boolean
335
+ }
336
+
337
+ // an ambiguous scale is rare and its file count is small, so trying every
338
+ // combination against the descriptor hash is cheap. the cap only stops a
339
+ // pathological dest from turning identity into a search.
340
+ const MAX_PLACEMENT_COMBINATIONS = 64
341
+
342
+ function placementCombinations(perScale: readonly string[][]): string[][] {
343
+ let total = 1
344
+ for (const options of perScale) total *= options.length
345
+ if (total > MAX_PLACEMENT_COMBINATIONS) return []
346
+ let combinations: string[][] = [[]]
347
+ for (const options of perScale) {
348
+ combinations = combinations.flatMap((prefix) =>
349
+ options.map((option) => [...prefix, option]),
350
+ )
351
+ }
352
+ return combinations
353
+ }
354
+
355
+ /**
356
+ * one file per distinct image, out of the files a scale matched.
357
+ *
358
+ * two paths holding byte-identical images are not a choice to make, and the
359
+ * descriptor's hash could not separate them if they were.
360
+ */
361
+ function distinctByContent(directory: string, options: readonly string[]): string[] {
362
+ if (options.length < 2) return [...options]
363
+ const byContent = new Map<string, string>()
364
+ for (const relative of options) {
365
+ let bytes: Buffer
366
+ try {
367
+ bytes = readFileSync(join(directory, relative))
368
+ } catch {
369
+ continue
370
+ }
371
+ const digest = createHash('md5').update(bytes).digest('hex')
372
+ if (!byContent.has(digest)) byContent.set(digest, relative)
373
+ }
374
+ return [...byContent.values()]
375
+ }
376
+
377
+ /**
378
+ * decides what a candidate directory holds for one descriptor, by bytes.
379
+ *
380
+ * Metro hashes the asset's SOURCE files, and `react-native bundle` copies only
381
+ * @1x..@3x to an iOS dest while leaving `scales` unfiltered. so a descriptor
382
+ * that declares an Android rung such as @1.5x records a digest no iOS dest can
383
+ * reproduce, however complete that dest is. those come back neither verified
384
+ * nor mismatched, and the caller refuses when a whole bundle is like that
385
+ * rather than picking a directory it cannot check.
386
+ *
387
+ * a source tree holding both `icon.png` and `icon@1x.png` declares scale 1
388
+ * twice, and Metro hashes both files while a dest keeps one file per scale. the
389
+ * expo asset plugin records one md5 per declared file in `fileHashes`, and
390
+ * that is the identity check for such a descriptor: the file chosen for a
391
+ * scale must be one of the files Metro hashed at that scale.
392
+ */
393
+ function placeMetroDescriptor(
394
+ listing: MetroAssetDestListing,
395
+ descriptor: MetroAssetDescriptor,
396
+ ): DescriptorPlacement {
397
+ const scales = [...new Set(descriptor.scales)]
398
+ const perScale = scales.map((scale) =>
399
+ distinctByContent(
400
+ listing.directory,
401
+ resolveMetroIosAssetDestFiles(listing.files, descriptor, scale),
402
+ ),
403
+ )
404
+ // a scale that resolved to one image names its file whatever the verdict
405
+ // below is. one that resolved to several is left for the hash to settle.
406
+ const files = new Map<number, string>()
407
+ const ambiguous: string[] = []
408
+ scales.forEach((scale, index) => {
409
+ const options = perScale[index] ?? []
410
+ if (options.length === 1 && options[0]) files.set(scale, options[0])
411
+ else if (options.length > 1) ambiguous.push(...options)
412
+ })
413
+ if (perScale.length === 0 || perScale.some((options) => options.length === 0)) {
414
+ return { files, ambiguous, verified: false, mismatched: false }
415
+ }
416
+ // a descriptor that is already embedded carries the embedder's sha256s in
417
+ // `fileHashes`; only Metro's own md5 per source file is an identity here.
418
+ const fileHashes =
419
+ !descriptor.fileUris && descriptor.fileHashes?.length === descriptor.scales.length
420
+ ? descriptor.fileHashes
421
+ : null
422
+ for (const combination of placementCombinations(perScale)) {
423
+ let bytes: Buffer[]
424
+ try {
425
+ bytes = combination.map((relative) =>
426
+ readFileSync(join(listing.directory, relative)),
427
+ )
428
+ } catch {
429
+ continue
430
+ }
431
+ const identical = fileHashes
432
+ ? bytes.every((file, index) => {
433
+ const digest = createHash('md5').update(file).digest('hex')
434
+ return descriptor.scales.some(
435
+ (scale, declared) =>
436
+ scale === scales[index] && fileHashes[declared] === digest,
437
+ )
438
+ })
439
+ : scales.length === descriptor.scales.length &&
440
+ metroDescriptorHash(bytes) === descriptor.hash
441
+ if (!identical) continue
442
+ const chosen = new Map<number, string>()
443
+ scales.forEach((scale, index) => {
444
+ const relative = combination[index]
445
+ if (relative) chosen.set(scale, relative)
446
+ })
447
+ return { files: chosen, ambiguous: [], verified: true, mismatched: false }
448
+ }
449
+ // a duplicated scale with no per-file md5 leaves Metro's digest covering a
450
+ // file this dest does not hold, so nothing here can be called wrong.
451
+ if (!fileHashes && scales.length !== descriptor.scales.length) {
452
+ return { files, ambiguous, verified: false, mismatched: false }
453
+ }
454
+ // every declared scale is here and no arrangement of them is this asset, so
455
+ // the directory holds different images under these names.
456
+ return { files, ambiguous, verified: false, mismatched: true }
457
+ }
458
+
459
+ interface MetroAssetDest {
460
+ directory: string
461
+ /** the placement of each descriptor, in the order the bundle declares them. */
462
+ placements: DescriptorPlacement[]
463
+ }
464
+
465
+ interface MetroAssetSearch {
466
+ /** the directory the producer reads asset bytes from, or null if none held any. */
467
+ dest: MetroAssetDest | null
468
+ /** every directory the search considered, in order, for the warning text. */
469
+ searched: string[]
470
+ /** a walk stopped at the cap, so no directory can be called fully looked at. */
471
+ truncated: boolean
472
+ /**
473
+ * the dest holds every filename but not one of Metro's digests could be
474
+ * reproduced from it, so it was taken on layout alone. ordinary when every
475
+ * asset in the bundle carries an Android rung such as @1.5x that an iOS dest
476
+ * never receives, and indistinguishable from a previous build's images left
477
+ * in place, so the producer says so rather than staying quiet.
478
+ */
479
+ unconfirmed: boolean
480
+ }
481
+
482
+ /**
483
+ * what two candidates could still disagree about.
484
+ *
485
+ * a verified placement's bytes reproduce Metro's own digest, so it is the right
486
+ * content wherever it was read from and two directories that both verified it
487
+ * are not in conflict. only the unverified files are a real fork, and they are
488
+ * keyed by absolute path because `<dir>/assets` and `<dir>` are both candidates
489
+ * and list the same files under different dest-relative names.
490
+ */
491
+ function unverifiedFileKey(dest: MetroAssetDest): string {
492
+ return dest.placements
493
+ .filter((placement) => !placement.verified)
494
+ .flatMap((placement) =>
495
+ [...placement.files.values()].map((relative) => join(dest.directory, relative)),
496
+ )
497
+ .sort()
498
+ .join('\n')
499
+ }
500
+
501
+ /**
502
+ * finds the `--assets-dest` directory by RESOLVING the bundle's own asset
503
+ * descriptors against a short ordered list of the places Metro writes one, and
504
+ * CONFIRMING the bytes it finds against the md5 Metro recorded in each
505
+ * descriptor. name and layout alone are a popularity contest an unrelated build
506
+ * output can win; the hash makes it an identity check.
507
+ */
508
+ function searchMetroAssetDest(
509
+ bundlePath: string,
510
+ descriptors: readonly MetroAssetDescriptor[],
511
+ ): MetroAssetSearch {
512
+ const bundleDirectory = dirname(resolve(bundlePath))
513
+ const candidates = [
514
+ join(bundleDirectory, 'assets'),
515
+ bundleDirectory,
516
+ resolve(bundleDirectory, '..', 'assets'),
517
+ ]
518
+ const cwd = process.cwd()
519
+ let projectDirectory: string | null = null
520
+ for (const start of [bundleDirectory, cwd]) {
521
+ let directory = resolve(start)
522
+ while (true) {
523
+ if (detectProject(directory)?.hasMetroConfig) {
524
+ projectDirectory = directory
525
+ break
526
+ }
527
+ const parent = dirname(directory)
528
+ if (parent === directory) break
529
+ directory = parent
530
+ }
531
+ if (projectDirectory) break
532
+ }
533
+ if (projectDirectory) {
534
+ candidates.push(
535
+ join(projectDirectory, 'assets'),
536
+ join(projectDirectory, 'dist'),
537
+ join(projectDirectory, 'dist', 'assets'),
538
+ join(projectDirectory, 'build'),
539
+ join(projectDirectory, 'build', 'assets'),
540
+ )
541
+ }
542
+ const searched: string[] = []
543
+ const holders: { dest: MetroAssetDest; verified: number }[] = []
544
+ const stale: string[] = []
545
+ let truncated = false
546
+ for (const candidate of candidates) {
547
+ const directory = resolve(candidate)
548
+ if (searched.includes(directory)) continue
549
+ searched.push(directory)
550
+ const listing = listMetroAssetDest(directory)
551
+ // a cut-short walk is remembered even when the candidate resolves nothing,
552
+ // because the reason it resolved nothing may be that it never finished.
553
+ if (listing.truncated) truncated = true
554
+ if (listing.files.length === 0) continue
555
+ const placements = descriptors.map((descriptor) =>
556
+ placeMetroDescriptor(listing, descriptor),
557
+ )
558
+ // one wrong asset is enough: a directory that provably holds different
559
+ // bytes under these names is a different build, not a partial dest.
560
+ if (placements.some((placement) => placement.mismatched)) {
561
+ stale.push(directory)
562
+ continue
563
+ }
564
+ const verified = placements.filter((placement) => placement.verified).length
565
+ if (verified === 0 && placements.every((placement) => placement.files.size === 0))
566
+ continue
567
+ holders.push({ dest: { directory, placements }, verified })
568
+ }
569
+ if (holders.length === 0) {
570
+ // every scale is on disk and none of it is this bundle's. saying the
571
+ // assets are missing would be false, and a stand-in over a stale image is
572
+ // the wrong repair for a dest that only needs rebuilding.
573
+ if (stale.length > 0) {
574
+ throw new Error(
575
+ `${REMOTE_COMMAND} found this bundle's assets in ${stale.join(', ')}, but their bytes are not the ones it was built against. Rebuild with \`react-native bundle --assets-dest ${stale[0]}\` so the bundle and the images match.`,
576
+ )
577
+ }
578
+ return { dest: null, searched, truncated, unconfirmed: false }
579
+ }
580
+ const best = Math.max(...holders.map((holder) => holder.verified))
581
+ // a directory that confirmed nothing cannot outrank one that confirmed
582
+ // something, and when nothing anywhere confirmed, every claimant is a rival.
583
+ const rivals = holders.filter((holder) => holder.verified === best)
584
+ const distinct = new Set(rivals.map((rival) => unverifiedFileKey(rival.dest)))
585
+ const winner = rivals[0]
586
+ if (!winner) return { dest: null, searched, truncated, unconfirmed: false }
587
+ if (distinct.size > 1) {
588
+ throw new Error(
589
+ `${REMOTE_COMMAND} found this bundle's assets in more than one directory and cannot tell which build wrote them: ${rivals
590
+ .map((rival) => rival.dest.directory)
591
+ .join(
592
+ ', ',
593
+ )}. Delete the stale one, or rebuild with \`react-native bundle --assets-dest\` into a directory beside the bundle.`,
594
+ )
595
+ }
596
+ // `best === 0` means no digest could be reproduced, which is ordinary for an
597
+ // asset carrying an Android rung such as @1.5x. with one claimant there is
598
+ // nothing to choose between, and a genuinely half-copied dest still fails
599
+ // per asset below, naming the file it could not read.
600
+ return { dest: winner.dest, searched, truncated, unconfirmed: best === 0 }
601
+ }
602
+
603
+ function largestEmbeddedAssets(
604
+ sizes: readonly MetroArtifactAssetSize[],
605
+ count: number,
606
+ ): string {
607
+ if (sizes.length === 0) return ''
608
+ const largest = sizes
609
+ .slice(0, count)
610
+ .map((entry) => `${entry.asset} ${entry.bytes} bytes`)
611
+ .join(', ')
612
+ return ` Largest embedded assets: ${largest}.`
613
+ }
614
+
615
+ async function inferCloudModuleIdentity(
616
+ source: string,
617
+ ): Promise<RNXMetroModuleIdentityMetadata> {
618
+ let receipt
619
+ try {
620
+ receipt = await loadMetroFingerprintRegistry()
621
+ } catch (error) {
622
+ throw new Error(
623
+ `${REMOTE_COMMAND} could not load the Metro fingerprint registry needed to identify this bundle: ${
624
+ error instanceof Error ? error.message : String(error)
625
+ }. Build it through the published Metro plugin: withRNX(config).`,
626
+ )
627
+ }
628
+ try {
629
+ const inferred = await inferMetroModuleIdentity({
630
+ source,
631
+ registry: receipt.registry,
632
+ registryIntegrity: receipt.integrity,
633
+ })
634
+ return {
635
+ version: RNX_METRO_MODULE_IDENTITY_VERSION,
636
+ identitySource: 'contrast-bundler',
637
+ modulePaths: inferred.modulePaths,
638
+ logicalSpecifiers: {},
639
+ }
640
+ } catch (error) {
641
+ throw new Error(
642
+ `${REMOTE_COMMAND} could not infer module identity for this Metro bundle: ${
643
+ error instanceof Error ? error.message : String(error)
644
+ }. Build it through the published Metro plugin: withRNX(config).`,
645
+ )
646
+ }
647
+ }
648
+
649
+ /**
650
+ * turns the Metro production bundle a caller built into the immutable artifact
651
+ * the runner executes: every asset scale is read from the asset graph and
652
+ * embedded, module identity is normalized, and the JavaScript is minified.
653
+ * an ordinary unannotated Metro production bundle is identified through the
654
+ * published fingerprint registry; a withRNX footer remains the fast path.
655
+ */
656
+ export interface CloudArtifactProduction {
657
+ bytes: Buffer
658
+ sha256: string
659
+ receipt: RnxCloudArtifactReceipt
660
+ /** the asset dest directory the producer read, or null when it read none. */
661
+ assetsDirectory: string | null
662
+ /** what the CLI prints after the receipt. */
663
+ warnings: string[]
664
+ }
665
+
666
+ export async function produceCloudArtifact(
667
+ bundlePath: string,
668
+ /**
669
+ * the largest artifact the consumer accepts. the nano simulator's limit is
670
+ * the default; a box that accepts a larger artifact passes its own.
671
+ */
672
+ maxBytes: number = MAX_RNX_CLOUD_BUNDLE_BYTES,
673
+ ): Promise<CloudArtifactProduction> {
674
+ return produceArtifact(bundlePath, maxBytes, REMOTE_COMMAND, 'allow-placeholders')
675
+ }
676
+
677
+ /** prepares a standalone preview whose bytes must represent every Metro asset. */
678
+ export async function producePreviewArtifact(
679
+ bundlePath: string,
680
+ ): Promise<CloudArtifactProduction> {
681
+ return produceArtifact(
682
+ bundlePath,
683
+ Number.POSITIVE_INFINITY,
684
+ PREVIEW_COMMAND,
685
+ 'require-assets',
686
+ )
687
+ }
688
+
689
+ export interface CloudBoxArtifactProduction {
690
+ bytes: Buffer
691
+ assetsDirectory: string | null
692
+ warnings: string[]
693
+ }
694
+
695
+ /** prepares a prebuilt bundle for a Box by embedding any beside-the-bundle Metro assets. */
696
+ export async function produceCloudBoxArtifact(
697
+ bundlePath: string,
698
+ ): Promise<CloudBoxArtifactProduction> {
699
+ const prepared = await embedBundleAssets(
700
+ bundlePath,
701
+ 'rnx box create',
702
+ 'allow-placeholders',
703
+ )
704
+ const warnings: string[] = []
705
+ if (prepared.search.unconfirmed && prepared.dest) {
706
+ warnings.push(
707
+ `rnx box create read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`,
708
+ )
709
+ }
710
+ if (prepared.substituted.length > 0) {
711
+ warnings.push(
712
+ `rnx box create embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`,
713
+ )
714
+ }
715
+ return {
716
+ bytes: Buffer.from(prepared.code, 'utf8'),
717
+ assetsDirectory: prepared.dest?.directory ?? null,
718
+ warnings,
719
+ }
720
+ }
721
+
722
+ async function embedBundleAssets(
723
+ bundlePath: string,
724
+ command: string,
725
+ assetPolicy: 'allow-placeholders' | 'require-assets',
726
+ ): Promise<{
727
+ source: string
728
+ code: string
729
+ search: MetroAssetSearch
730
+ dest: MetroAssetDest | null
731
+ substituted: string[]
732
+ }> {
733
+ const source = await loadBundleSource(bundlePath)
734
+ const descriptors = extractAssetDescriptors(source).filter(
735
+ (descriptor) => !isEmbeddedAssetDescriptor(descriptor),
736
+ )
737
+ if (descriptors.length === 0) {
738
+ return {
739
+ source,
740
+ code: source,
741
+ search: { dest: null, searched: [], truncated: false, unconfirmed: false },
742
+ dest: null,
743
+ substituted: [],
744
+ }
745
+ }
746
+ // an https bundle has no local tree to search, and a bundle with no assets
747
+ // has nothing to search for.
748
+ const search: MetroAssetSearch = !isRemoteBundleInput(bundlePath)
749
+ ? searchMetroAssetDest(bundlePath, descriptors)
750
+ : { dest: null, searched: [], truncated: false, unconfirmed: false }
751
+ const dest = search.dest
752
+ // `embedMetroAssetFiles` re-parses the bundle, so its descriptors are equal
753
+ // to the ones the search placed but not the same objects.
754
+ const placements = new Map<string, DescriptorPlacement>()
755
+ descriptors.forEach((descriptor, index) => {
756
+ const placement = dest?.placements[index]
757
+ if (placement) placements.set(metroAssetKey(descriptor), placement)
758
+ })
759
+ const substituted: string[] = []
760
+ const embedded = await embedMetroAssetFiles(source, async (variant) => {
761
+ const label = metroAssetKey(variant.descriptor)
762
+ const placement = placements.get(label)
763
+ if (placement?.mismatched && dest) {
764
+ throw new Error(
765
+ `${command} found ${variant.descriptor.name}.${variant.descriptor.type} in ${dest.directory}, but its bytes are not the ones this bundle was built against. Rebuild with \`react-native bundle --assets-dest ${dest.directory}\` so the bundle and the images match.`,
766
+ )
767
+ }
768
+ const relative = placement?.files.get(variant.scale)
769
+ if (relative && dest) return readFileSync(join(dest.directory, relative))
770
+ // iOS copies only @1x..@3x, so a declared scale with no file is ordinary
771
+ // and the producer narrows the descriptor.
772
+ if (placement && placement.files.size > 0) return null
773
+ if (placement && placement.ambiguous.length > 0 && dest) {
774
+ throw new Error(
775
+ `${command} found more than one image for ${label} in ${dest.directory} and none of them is the one Metro hashed: ${placement.ambiguous.sort().join(', ')}. Rebuild with \`react-native bundle --assets-dest\` into a directory that holds only this build's assets.`,
776
+ )
777
+ }
778
+ // a stand-in is only honest when the asset is genuinely absent from a
779
+ // directory we know is the right one. a truncated walk never finished
780
+ // looking, so it refuses instead of shipping bytes nobody chose.
781
+ if (search.truncated) {
782
+ throw new Error(
783
+ `${command} stopped listing ${search.searched.join(', ')} after ${MAX_ASSET_DEST_FILES} files and never reached ${label}, so it cannot tell a missing asset from an unread one. Rebuild with \`react-native bundle --assets-dest\` into a directory beside the bundle.`,
784
+ )
785
+ }
786
+ if (assetPolicy === 'require-assets') {
787
+ throw new Error(
788
+ `${command} could not find ${label}. Build the bundle with \`react-native bundle --assets-dest <dir>\` so its assets land beside it.`,
789
+ )
790
+ }
791
+ // no scale of this asset exists anywhere the search looked. an image can
792
+ // stand in visibly and let the app run; every declared scale gets the same
793
+ // stand-in, because narrowing to one would rewrite a `scales` list the
794
+ // descriptor may not even carry.
795
+ const placeholder = metroAssetPlaceholderBytes(variant.descriptor.type)
796
+ if (placeholder) {
797
+ if (!substituted.includes(label)) substituted.push(label)
798
+ return placeholder
799
+ }
800
+ const filename = metroAssetScaleFilename(
801
+ variant.descriptor.name,
802
+ variant.descriptor.type,
803
+ variant.scale,
804
+ )
805
+ const classic =
806
+ metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ??
807
+ filename
808
+ throw new Error(
809
+ `${command} could not read ${join(dest?.directory ?? '.', classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}, and has no stand-in for a .${variant.descriptor.type} asset. Rebuild with \`react-native bundle --assets-dest\`.`,
810
+ )
811
+ })
812
+ return {
813
+ source,
814
+ code: embedded.code,
815
+ search,
816
+ dest,
817
+ substituted,
818
+ }
819
+ }
820
+
821
+ async function produceArtifact(
822
+ bundlePath: string,
823
+ maxBytes: number,
824
+ command: string,
825
+ assetPolicy: 'allow-placeholders' | 'require-assets',
826
+ ): Promise<CloudArtifactProduction> {
827
+ const prepared = await embedBundleAssets(bundlePath, command, assetPolicy)
828
+ const artifact = await buildRnxCloudArtifact(prepared.code, {
829
+ inferModuleIdentity: inferCloudModuleIdentity,
830
+ })
831
+ const bytes = Buffer.from(artifact.code, 'utf8')
832
+ const assets = artifact.receipt.assets
833
+ if (bytes.length > maxBytes) {
834
+ throw new Error(
835
+ `${REMOTE_COMMAND} artifact is ${bytes.length} bytes and exceeds the ${maxBytes}-byte limit. Embedded assets weigh ${assets.bytes} bytes on disk, and base64 embedding costs about a third on top of that, so the practical asset budget is roughly ${Math.round((maxBytes * 0.75) / 1e6)} MB.${largestEmbeddedAssets(assets.sizes, 10)}`,
836
+ )
837
+ }
838
+ const warnings: string[] = []
839
+ if (prepared.search.unconfirmed && prepared.dest) {
840
+ warnings.push(
841
+ `${command} read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`,
842
+ )
843
+ }
844
+ if (prepared.substituted.length > 0) {
845
+ warnings.push(
846
+ `${command} embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`,
847
+ )
848
+ }
849
+ // an artifact this close to the ceiling is one asset away from being refused,
850
+ // and the producer knows which assets it would be.
851
+ if (bytes.length >= Math.floor(maxBytes * 0.8)) {
852
+ warnings.push(
853
+ `${command} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / maxBytes) * 100)}% of the ${maxBytes}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
854
+ )
855
+ }
856
+ return {
857
+ bytes,
858
+ sha256: createHash('sha256').update(bytes).digest('hex'),
859
+ receipt: artifact.receipt,
860
+ assetsDirectory: prepared.dest?.directory ?? null,
861
+ warnings,
862
+ }
863
+ }
864
+
865
+ function readCreateResponse(
866
+ value: unknown,
867
+ expectedBytes: number,
868
+ expectedSha256: string,
869
+ ): {
870
+ receipt: RnxCloudBoxCreateReceipt
871
+ boxToken: string
872
+ simulatorToken: string
873
+ } {
874
+ if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
875
+ const artifactId = `sha256:${expectedSha256}` as const
876
+ const isExpectedArtifact = (artifact: unknown): artifact is { bytes: number } =>
877
+ isRecord(artifact) &&
878
+ artifact.id === artifactId &&
879
+ typeof artifact.bytes === 'number' &&
880
+ Number.isInteger(artifact.bytes) &&
881
+ artifact.bytes === expectedBytes
882
+ const boxId = value.boxId
883
+ const boxToken = value.token
884
+ const simulator = value.simulator
885
+ const simId = isRecord(simulator) ? simulator.simId : null
886
+ const claim = isRecord(simulator) ? simulator.claim : null
887
+ const simulatorToken = isRecord(simulator) ? simulator.token : null
888
+ if (
889
+ typeof boxId !== 'string' ||
890
+ !boxId ||
891
+ value.size !== 'nano' ||
892
+ typeof boxToken !== 'string' ||
893
+ !boxToken ||
894
+ !isExpectedArtifact(value.artifact) ||
895
+ !isRecord(simulator) ||
896
+ typeof simId !== 'string' ||
897
+ !simId ||
898
+ !isExpectedArtifact(simulator.artifact) ||
899
+ !isRecord(claim) ||
900
+ typeof claim.id !== 'string' ||
901
+ !claim.id ||
902
+ typeof claim.expiresAt !== 'number' ||
903
+ typeof simulatorToken !== 'string' ||
904
+ !simulatorToken
905
+ ) {
906
+ throw new Error('remote simulator create returned an invalid receipt')
907
+ }
908
+ return {
909
+ receipt: {
910
+ boxId,
911
+ size: 'nano',
912
+ artifact: { id: artifactId, bytes: expectedBytes },
913
+ simulator: {
914
+ simId,
915
+ artifact: { id: artifactId, bytes: expectedBytes },
916
+ claim: { id: claim.id, expiresAt: claim.expiresAt },
917
+ },
918
+ },
919
+ boxToken,
920
+ simulatorToken,
921
+ }
922
+ }
923
+
924
+ // "send to box" wraps the running app in a nano box with its first simulator
925
+ // in one request, and the shell then targets that simulator
926
+ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
927
+ receipt: RnxCloudBoxCreateReceipt
928
+ session: CloudSession
929
+ artifact: RnxCloudArtifactReceipt
930
+ assetsDirectory: string | null
931
+ warnings: string[]
932
+ }> {
933
+ const platform = options.platform ?? 'ios'
934
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
935
+ const artifact = await produceCloudArtifact(options.bundlePath)
936
+ const boxName = options.boxName?.trim()
937
+ const headers: Record<string, string> = {
938
+ authorization: options.authorization,
939
+ 'x-rnx-box-size': 'nano',
940
+ 'x-rnx-artifact-sha256': artifact.sha256,
941
+ 'x-rnx-platform': platform,
942
+ 'x-rnx-device': options.device,
943
+ ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
944
+ ...(boxName ? { 'x-rnx-box-name': boxName } : null),
945
+ }
946
+ let body: BodyInit
947
+ if (options.storage) {
948
+ const form = new FormData()
949
+ form.append(
950
+ 'artifact',
951
+ new Blob([new Uint8Array(artifact.bytes)], { type: 'application/javascript' }),
952
+ 'bundle.js',
953
+ )
954
+ form.append('storage', JSON.stringify(options.storage))
955
+ body = form
956
+ } else {
957
+ headers['content-type'] = 'application/javascript'
958
+ body = new Uint8Array(artifact.bytes)
959
+ }
960
+ let response: Response
961
+ try {
962
+ response = await fetch(`${apiOrigin}/v1/boxes`, {
963
+ method: 'POST',
964
+ headers,
965
+ body,
966
+ })
967
+ } catch (error) {
968
+ throw new Error(
969
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
970
+ error instanceof Error ? error.message : String(error)
971
+ }`,
972
+ )
973
+ }
974
+ const responseText = await response.text()
975
+ if (!response.ok) throw responseError(response.status, responseText)
976
+ let parsed: unknown
977
+ try {
978
+ parsed = JSON.parse(responseText)
979
+ } catch {
980
+ throw new Error('remote simulator create returned unreadable JSON')
981
+ }
982
+ const created = readCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
983
+ const session = createCloudSession({
984
+ boxId: created.receipt.boxId,
985
+ boxToken: created.boxToken,
986
+ simId: created.receipt.simulator.simId,
987
+ apiOrigin,
988
+ claimId: created.receipt.simulator.claim.id,
989
+ token: created.simulatorToken,
990
+ platform,
991
+ device: options.device,
992
+ artifact: {
993
+ sha256: artifact.sha256,
994
+ bytes: artifact.bytes.length,
995
+ },
996
+ })
997
+ return {
998
+ receipt: created.receipt,
999
+ session,
1000
+ artifact: artifact.receipt,
1001
+ assetsDirectory: artifact.assetsDirectory,
1002
+ warnings: artifact.warnings,
1003
+ }
1004
+ }
1005
+
1006
+ // one customer plane: the sim-service instance API. every `rnx remote`
1007
+ // simulator is created, listed, read, and deleted here; the box stays the
1008
+ // host behind it and is never the addressed object.
1009
+
1010
+ export interface CreateRemoteSimOptions {
1011
+ bundlePath: string
1012
+ device: string
1013
+ authorization: string
1014
+ /** the account to bill when the authorization is a session token; an api key names its own */
1015
+ accountId: string | null
1016
+ platform: RemotePlatform
1017
+ apiOrigin?: string
1018
+ /** match an existing simulator for the same artifact and labels instead of creating */
1019
+ reuse?: boolean
1020
+ }
1021
+
1022
+ function readStringMap(value: unknown): Record<string, string> {
1023
+ if (!isRecord(value)) return {}
1024
+ const labels: Record<string, string> = {}
1025
+ for (const [key, label] of Object.entries(value)) {
1026
+ if (typeof label === 'string') labels[key] = label
1027
+ }
1028
+ return labels
1029
+ }
1030
+
1031
+ function readSimCreateResponse(
1032
+ value: unknown,
1033
+ expectedBytes: number,
1034
+ expectedSha256: string,
1035
+ ): RnxCloudSimCreateResponse {
1036
+ if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
1037
+ const artifactId = `sha256:${expectedSha256}` as const
1038
+ const artifact = isRecord(value.artifact) ? value.artifact : null
1039
+ const claim = isRecord(value.claim) ? value.claim : null
1040
+ if (
1041
+ typeof value.simId !== 'string' ||
1042
+ !value.simId ||
1043
+ !artifact ||
1044
+ artifact.id !== artifactId ||
1045
+ artifact.bytes !== expectedBytes ||
1046
+ !claim ||
1047
+ typeof claim.id !== 'string' ||
1048
+ !claim.id ||
1049
+ typeof claim.expiresAt !== 'number' ||
1050
+ typeof value.token !== 'string' ||
1051
+ !value.token ||
1052
+ typeof value.status !== 'string' ||
1053
+ !isRnxCloudSimStatus(value.status) ||
1054
+ (value.streamUrl !== null && typeof value.streamUrl !== 'string')
1055
+ ) {
1056
+ throw new Error('remote simulator create returned an invalid receipt')
1057
+ }
1058
+ return {
1059
+ simId: value.simId,
1060
+ artifact: { id: artifactId, bytes: expectedBytes },
1061
+ claim: { id: claim.id, expiresAt: claim.expiresAt },
1062
+ token: value.token,
1063
+ status: value.status,
1064
+ labels: readStringMap(value.labels),
1065
+ streamUrl: value.streamUrl,
1066
+ }
1067
+ }
1068
+
1069
+ // `rnx remote ios|android <bundle>` creates one simulator through the
1070
+ // instance API and waits for its app to answer ready. both platforms run
1071
+ // this same typed path: an android create is refused by the service with a
1072
+ // typed 400, never by a cli-side branch.
1073
+ export async function createRemoteSim(options: CreateRemoteSimOptions): Promise<{
1074
+ receipt: RnxCloudSimCreateResponse
1075
+ session: CloudSession
1076
+ artifact: RnxCloudArtifactReceipt
1077
+ assetsDirectory: string | null
1078
+ warnings: string[]
1079
+ reused: boolean
1080
+ }> {
1081
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1082
+ const artifact = await produceCloudArtifact(options.bundlePath)
1083
+ const query = new URLSearchParams({
1084
+ labels: `rnx.remote=1,rnx.platform=${options.platform},rnx.bundle=${artifact.sha256.slice(0, 12)}`,
1085
+ wait: 'true',
1086
+ })
1087
+ if (options.reuse) query.set('reuseIfExists', 'true')
1088
+ const headers: Record<string, string> = {
1089
+ authorization: options.authorization,
1090
+ 'content-type': 'application/javascript',
1091
+ 'x-rnx-artifact-sha256': artifact.sha256,
1092
+ 'x-rnx-platform': options.platform,
1093
+ 'x-rnx-device': options.device,
1094
+ ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
1095
+ }
1096
+ let response: Response
1097
+ try {
1098
+ response = await fetch(`${apiOrigin}/v1/sims?${query}`, {
1099
+ method: 'POST',
1100
+ headers,
1101
+ body: new Uint8Array(artifact.bytes),
1102
+ })
1103
+ } catch (error) {
1104
+ throw new Error(
1105
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1106
+ error instanceof Error ? error.message : String(error)
1107
+ }`,
1108
+ )
1109
+ }
1110
+ const responseText = await response.text()
1111
+ if (!response.ok) throw responseError(response.status, responseText)
1112
+ let parsed: unknown
1113
+ try {
1114
+ parsed = JSON.parse(responseText)
1115
+ } catch {
1116
+ throw new Error('remote simulator create returned unreadable JSON')
1117
+ }
1118
+ const receipt = readSimCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
1119
+ const session = createCloudSession({
1120
+ simId: receipt.simId,
1121
+ apiOrigin,
1122
+ claimId: receipt.claim.id,
1123
+ token: receipt.token,
1124
+ streamUrl: receipt.streamUrl,
1125
+ platform: options.platform,
1126
+ device: options.device,
1127
+ artifact: {
1128
+ sha256: artifact.sha256,
1129
+ bytes: artifact.bytes.length,
1130
+ },
1131
+ })
1132
+ return {
1133
+ receipt,
1134
+ session,
1135
+ artifact: artifact.receipt,
1136
+ assetsDirectory: artifact.assetsDirectory,
1137
+ warnings: artifact.warnings,
1138
+ reused: response.status === 200,
1139
+ }
1140
+ }
1141
+
1142
+ function readSimView(value: unknown): RnxCloudSimView {
1143
+ if (!isRecord(value) || !isRecord(value.labels)) {
1144
+ throw new Error('remote simulator list returned an invalid simulator')
1145
+ }
1146
+ const labels: Record<string, string> = {}
1147
+ for (const [key, label] of Object.entries(value.labels)) {
1148
+ if (typeof label !== 'string') {
1149
+ throw new Error('remote simulator list returned an invalid simulator')
1150
+ }
1151
+ labels[key] = label
1152
+ }
1153
+ const artifact = isRecord(value.artifact) ? value.artifact : null
1154
+ const claimValue = isRecord(value.claim) ? value.claim : null
1155
+ let claim: RnxCloudClaim | null = null
1156
+ if (claimValue !== null) {
1157
+ if (
1158
+ typeof claimValue.id !== 'string' ||
1159
+ !claimValue.id ||
1160
+ typeof claimValue.expiresAt !== 'number'
1161
+ ) {
1162
+ throw new Error('remote simulator list returned an invalid simulator')
1163
+ }
1164
+ claim = { id: claimValue.id, expiresAt: claimValue.expiresAt }
1165
+ }
1166
+ const inactivityTimeoutMs = value.inactivityTimeoutMs
1167
+ const hardTimeoutAt = value.hardTimeoutAt
1168
+ if (
1169
+ typeof value.simId !== 'string' ||
1170
+ !value.simId ||
1171
+ typeof value.status !== 'string' ||
1172
+ !isRnxCloudSimStatus(value.status) ||
1173
+ !artifact ||
1174
+ typeof artifact.sha256 !== 'string' ||
1175
+ typeof artifact.bytes !== 'number' ||
1176
+ typeof value.createdAt !== 'number' ||
1177
+ typeof value.lastActiveAt !== 'number' ||
1178
+ (typeof inactivityTimeoutMs !== 'number' && inactivityTimeoutMs !== null) ||
1179
+ (typeof hardTimeoutAt !== 'number' && hardTimeoutAt !== null)
1180
+ ) {
1181
+ throw new Error('remote simulator list returned an invalid simulator')
1182
+ }
1183
+ return {
1184
+ simId: value.simId,
1185
+ status: value.status,
1186
+ labels,
1187
+ artifact: { sha256: artifact.sha256, bytes: artifact.bytes },
1188
+ claim,
1189
+ createdAt: value.createdAt,
1190
+ lastActiveAt: value.lastActiveAt,
1191
+ inactivityTimeoutMs,
1192
+ hardTimeoutAt,
1193
+ }
1194
+ }
1195
+
1196
+ // `rnx remote list`: the simulators this account runs that `rnx remote` made.
1197
+ export async function listRemoteSims(options: {
1198
+ apiOrigin?: string
1199
+ authorization: string
1200
+ accountId?: string | null
1201
+ }): Promise<RnxCloudSimView[]> {
1202
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1203
+ const selector = new URLSearchParams({ labelSelector: 'rnx.remote=1' })
1204
+ const headers: Record<string, string> = { authorization: options.authorization }
1205
+ if (options.accountId) headers['x-rnx-account-id'] = options.accountId
1206
+ let response: Response
1207
+ try {
1208
+ response = await fetch(`${apiOrigin}/v1/sims?${selector}`, { headers })
1209
+ } catch (error) {
1210
+ throw new Error(
1211
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1212
+ error instanceof Error ? error.message : String(error)
1213
+ }`,
1214
+ )
1215
+ }
1216
+ const responseText = await response.text()
1217
+ if (!response.ok) throw responseError(response.status, responseText)
1218
+ let parsed: unknown
1219
+ try {
1220
+ parsed = JSON.parse(responseText)
1221
+ } catch {
1222
+ throw new Error('remote simulator list returned unreadable JSON')
1223
+ }
1224
+ if (!isRecord(parsed) || !Array.isArray(parsed.sims)) {
1225
+ throw new Error('remote simulator list returned invalid JSON')
1226
+ }
1227
+ return parsed.sims.map(readSimView)
1228
+ }
1229
+
1230
+ // one simulator reread by id: the reattach liveness probe, and the fresh
1231
+ // watch url an api-key caller mints with it. a simulator this account no
1232
+ // longer runs reads back as missing rather than forbidden, so 404 is null
1233
+ // and anything else refused throws.
1234
+ export async function getRemoteSim(options: {
1235
+ apiOrigin?: string
1236
+ simId: string
1237
+ authorization: string
1238
+ }): Promise<RnxCloudSimInstance | null> {
1239
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1240
+ let response: Response
1241
+ try {
1242
+ response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, {
1243
+ headers: { authorization: options.authorization },
1244
+ })
1245
+ } catch (error) {
1246
+ throw new Error(
1247
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1248
+ error instanceof Error ? error.message : String(error)
1249
+ }`,
1250
+ )
1251
+ }
1252
+ const responseText = await response.text()
1253
+ if (response.status === 404) return null
1254
+ if (!response.ok) throw responseError(response.status, responseText)
1255
+ let parsed: unknown
1256
+ try {
1257
+ parsed = JSON.parse(responseText)
1258
+ } catch {
1259
+ throw new Error('remote simulator read returned unreadable JSON')
1260
+ }
1261
+ if (!isRecord(parsed) || !isRecord(parsed.sim)) {
1262
+ throw new Error('remote simulator read returned invalid JSON')
1263
+ }
1264
+ const view = readSimView(parsed.sim)
1265
+ const streamUrl = parsed.sim.streamUrl
1266
+ if (streamUrl !== null && streamUrl !== undefined && typeof streamUrl !== 'string') {
1267
+ throw new Error('remote simulator read returned an invalid watch url')
1268
+ }
1269
+ return {
1270
+ ...view,
1271
+ streamUrl: typeof streamUrl === 'string' ? streamUrl : null,
1272
+ }
1273
+ }
1274
+
1275
+ // `rnx remote stop <id>`: delete one simulator by id. missing is null so the
1276
+ // caller can name what was not there; anything else refused throws.
1277
+ export async function deleteRemoteSim(options: {
1278
+ apiOrigin?: string
1279
+ simId: string
1280
+ authorization: string
1281
+ accountId?: string | null
1282
+ }): Promise<{ simId: string; status: string } | null> {
1283
+ const apiOrigin = resolveCloudOrigin(options.apiOrigin)
1284
+ const headers: Record<string, string> = { authorization: options.authorization }
1285
+ if (options.accountId) headers['x-rnx-account-id'] = options.accountId
1286
+ let response: Response
1287
+ try {
1288
+ response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, {
1289
+ method: 'DELETE',
1290
+ headers,
1291
+ })
1292
+ } catch (error) {
1293
+ throw new Error(
1294
+ `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
1295
+ error instanceof Error ? error.message : String(error)
1296
+ }`,
1297
+ )
1298
+ }
1299
+ const responseText = await response.text()
1300
+ if (response.status === 404) return null
1301
+ if (!response.ok) throw responseError(response.status, responseText)
1302
+ let parsed: unknown
1303
+ try {
1304
+ parsed = JSON.parse(responseText)
1305
+ } catch {
1306
+ throw new Error('remote simulator delete returned unreadable JSON')
1307
+ }
1308
+ if (
1309
+ !isRecord(parsed) ||
1310
+ typeof parsed.simId !== 'string' ||
1311
+ typeof parsed.status !== 'string'
1312
+ ) {
1313
+ throw new Error('remote simulator delete returned invalid JSON')
1314
+ }
1315
+ return { simId: parsed.simId, status: parsed.status }
1316
+ }
1317
+
1318
+ export function resolveCloudSessionForParsed(
1319
+ parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
1320
+ ): CloudSession | null {
1321
+ const session = readCloudSession()
1322
+ if (!session) return null
1323
+ if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
1324
+ throw new Error(
1325
+ `this shell is connected to remote simulator ${session.simId}; ${
1326
+ parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
1327
+ } is not available in this shell`,
1328
+ )
1329
+ }
1330
+ return session
1331
+ }
1332
+
1333
+ function cloudCommand(command: {
1334
+ type: string
1335
+ [key: string]: unknown
1336
+ }): Record<string, unknown> {
1337
+ const clean: Record<string, unknown> = {}
1338
+ for (const [key, value] of Object.entries(command)) {
1339
+ if (key !== 'id' && key !== 'simId') clean[key] = value
1340
+ }
1341
+ if (!isRnxCloudCommandType(command.type)) {
1342
+ throw new Error(
1343
+ `a remote simulator does not support bridge command type ${command.type}`,
1344
+ )
1345
+ }
1346
+ if (!rnxCloudScopesAllowCommand(RNX_CLOUD_TOKEN_SCOPES, command.type)) {
1347
+ throw new Error(
1348
+ `a remote simulator command ${command.type} requires the ${rnxCloudCommandScope(command.type)} scope`,
1349
+ )
1350
+ }
1351
+ return clean
1352
+ }
1353
+
1354
+ class CloudBridge implements WsBridge {
1355
+ readonly plane = 'cloud' as const
1356
+
1357
+ constructor(private readonly session: CloudSimSession) {}
1358
+
1359
+ async send(
1360
+ command: { type: string; [key: string]: unknown },
1361
+ options?: { timeoutMs?: number },
1362
+ ): Promise<unknown> {
1363
+ const body: CloudCommandEnvelope = {
1364
+ id: randomUUID(),
1365
+ claimId: this.session.claimId,
1366
+ command: cloudCommand(command),
1367
+ }
1368
+ let response: Response
1369
+ try {
1370
+ response = await fetch(
1371
+ `${this.session.apiOrigin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`,
1372
+ {
1373
+ method: 'POST',
1374
+ headers: {
1375
+ authorization: `Bearer ${this.session.token}`,
1376
+ 'content-type': 'application/json',
1377
+ },
1378
+ body: JSON.stringify(body),
1379
+ ...(options?.timeoutMs
1380
+ ? { signal: AbortSignal.timeout(options.timeoutMs) }
1381
+ : {}),
1382
+ },
1383
+ )
1384
+ } catch (error) {
1385
+ throw new Error(
1386
+ `could not reach the remote simulator at ${this.session.apiOrigin}: ${
1387
+ error instanceof Error ? error.message : String(error)
1388
+ }`,
1389
+ )
1390
+ }
1391
+ const text = await response.text()
1392
+ if (!response.ok) throw responseError(response.status, text)
1393
+ let parsed: unknown
1394
+ try {
1395
+ parsed = JSON.parse(text)
1396
+ } catch {
1397
+ throw new Error('remote simulator command returned unreadable JSON')
1398
+ }
1399
+ if (!isRecord(parsed) || parsed.id !== body.id) {
1400
+ throw new Error('remote simulator command returned an invalid command receipt')
1401
+ }
1402
+ if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
1403
+ if (!Object.hasOwn(parsed, 'result')) {
1404
+ throw new Error('remote simulator command receipt has no result')
1405
+ }
1406
+ return parsed.result
1407
+ }
1408
+
1409
+ listSims(): Promise<BridgeSimInfo[]> {
1410
+ return Promise.reject(
1411
+ new Error('a remote simulator does not expose a local simulator list'),
1412
+ )
1413
+ }
1414
+
1415
+ focusSim(): Promise<never> {
1416
+ return Promise.reject(new Error('remote simulators have no browser window to focus'))
1417
+ }
1418
+
1419
+ resolveReloadedSim(): Promise<string | null> {
1420
+ // cloud sims carry no local browser host, so there is no lineage to follow
1421
+ return Promise.resolve(null)
1422
+ }
1423
+
1424
+ closeSim(): Promise<never> {
1425
+ return Promise.reject(
1426
+ new Error('remote simulator close uses the simulator DELETE route'),
1427
+ )
1428
+ }
1429
+
1430
+ claim(): Promise<BridgeClaimResult> {
1431
+ return Promise.reject(
1432
+ new Error('remote simulator claim uses the simulator claim route'),
1433
+ )
1434
+ }
1435
+
1436
+ close(): void {}
1437
+ }
1438
+
1439
+ export function createCloudBridgeForParsed(
1440
+ parsed: Pick<ParsedBridgeCliArgs, 'simId' | 'simIdSource'>,
1441
+ ): WsBridge | null {
1442
+ const session = resolveCloudSessionForParsed(parsed)
1443
+ if (!session) return null
1444
+ return new CloudBridge(session)
1445
+ }
1446
+
1447
+ function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
1448
+ const claim = isRecord(value) ? value.claim : null
1449
+ if (
1450
+ !isRecord(claim) ||
1451
+ typeof claim.id !== 'string' ||
1452
+ !claim.id ||
1453
+ typeof claim.expiresAt !== 'number'
1454
+ ) {
1455
+ throw new Error('remote simulator claim returned an invalid receipt')
1456
+ }
1457
+ return { id: claim.id, expiresAt: claim.expiresAt }
1458
+ }
1459
+
1460
+ export async function claimCloudSimulator(
1461
+ session: CloudSimSession,
1462
+ options: { force?: boolean } = {},
1463
+ ): Promise<{
1464
+ claim: { id: string; expiresAt: number }
1465
+ session: CloudSimSession
1466
+ }> {
1467
+ const response = await fetch(
1468
+ `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}/claim`,
1469
+ {
1470
+ method: 'POST',
1471
+ headers: {
1472
+ authorization: `Bearer ${session.token}`,
1473
+ 'content-type': 'application/json',
1474
+ },
1475
+ body: JSON.stringify(
1476
+ options.force ? { force: true } : { claimId: session.claimId },
1477
+ ),
1478
+ },
1479
+ )
1480
+ const text = await response.text()
1481
+ if (!response.ok) throw responseError(response.status, text)
1482
+ let parsed: unknown
1483
+ try {
1484
+ parsed = JSON.parse(text)
1485
+ } catch {
1486
+ throw new Error('remote simulator claim returned unreadable JSON')
1487
+ }
1488
+ const claim = readClaimResponse(parsed)
1489
+ return {
1490
+ claim,
1491
+ session: createCloudSession({ ...session, claimId: claim.id }),
1492
+ }
1493
+ }
1494
+
1495
+ export async function confirmCloudSimulator(session: CloudSimSession): Promise<void> {
1496
+ const confirmed = await claimCloudSimulator(session)
1497
+ if (confirmed.session.claimId !== session.claimId) {
1498
+ throw new Error('remote simulator confirmation replaced the initial claim')
1499
+ }
1500
+ }
1501
+
1502
+ export async function closeCloudSession(session: CloudSession): Promise<void> {
1503
+ const boxId = session.boxId
1504
+ const boxToken = session.boxToken
1505
+ if (boxId && boxToken) {
1506
+ const response = await fetch(
1507
+ `${session.apiOrigin}/v1/boxes/${encodeURIComponent(boxId)}`,
1508
+ {
1509
+ method: 'DELETE',
1510
+ headers: { authorization: `Bearer ${boxToken}` },
1511
+ },
1512
+ )
1513
+ const text = await response.text()
1514
+ if (!response.ok && response.status !== 404) {
1515
+ throw responseError(response.status, text)
1516
+ }
1517
+ return
1518
+ }
1519
+ const response = await fetch(
1520
+ `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}`,
1521
+ {
1522
+ method: 'DELETE',
1523
+ headers: { authorization: `Bearer ${session.token}` },
1524
+ },
1525
+ )
1526
+ const text = await response.text()
1527
+ if (!response.ok && response.status !== 404) {
1528
+ throw responseError(response.status, text)
1529
+ }
1530
+ }