rnxsim 0.1.442 → 0.1.443

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 (71) hide show
  1. package/cli/bin.ts +3 -1
  2. package/cli/cloud-client.ts +153 -17
  3. package/cli/cloud-dispatch.ts +15 -0
  4. package/cli/cloud-session.ts +69 -17
  5. package/cli/commands/box/bundle-plane.ts +71 -0
  6. package/cli/commands/box.ts +274 -115
  7. package/cli/commands/control.ts +1 -137
  8. package/cli/commands/flow.ts +1 -1
  9. package/cli/commands/inspect/list.ts +119 -2
  10. package/cli/commands/inspect.ts +3 -2
  11. package/cli/commands/platform.ts +95 -12
  12. package/cli/commands/reset.ts +7 -3
  13. package/cli/commands/state.ts +7 -3
  14. package/cli/flow-export.ts +55 -0
  15. package/cli/flow-session.ts +0 -47
  16. package/cli/outbound-endpoints.ts +5 -0
  17. package/cli/rnx-in-process.ts +98 -0
  18. package/cli/ws-bridge.ts +30 -0
  19. package/dist-lib/agent-daemon-client.cjs +1 -1
  20. package/dist-lib/agent-events.cjs +1 -1
  21. package/dist-lib/agent-identity.cjs +1 -1
  22. package/dist-lib/agent-sessions.cjs +1 -1
  23. package/dist-lib/attached-projects.cjs +1 -1
  24. package/dist-lib/auth/shared-session.cjs +1 -1
  25. package/dist-lib/backend-origin.cjs +1 -1
  26. package/dist-lib/beta.cjs +1 -1
  27. package/dist-lib/beta.mjs +1 -1
  28. package/dist-lib/bridge-constants.cjs +1 -1
  29. package/dist-lib/bridge-contract-input.cjs +1 -1
  30. package/dist-lib/bridge-contract-input.mjs +1 -1
  31. package/dist-lib/bridge-contract.cjs +1 -1
  32. package/dist-lib/bridge-contract.mjs +1 -1
  33. package/dist-lib/capture-contract.cjs +1 -1
  34. package/dist-lib/capture-contract.mjs +1 -1
  35. package/dist-lib/cli-constants.cjs +1 -1
  36. package/dist-lib/cloud-contract.cjs +1 -1
  37. package/dist-lib/cloud-contract.mjs +1 -1
  38. package/dist-lib/cloud.cjs +1 -1
  39. package/dist-lib/cloud.mjs +1 -1
  40. package/dist-lib/config.cjs +1 -1
  41. package/dist-lib/detox/index.cjs +69 -14
  42. package/dist-lib/dev-bundle-resolution.cjs +1 -1
  43. package/dist-lib/home-paths.cjs +17 -1
  44. package/dist-lib/host/bridge-host.cjs +24 -13
  45. package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
  46. package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
  47. package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
  48. package/dist-lib/host/replacement-module-handler.cjs +1 -1
  49. package/dist-lib/host/websocket-proxy.cjs +1 -1
  50. package/dist-lib/index.cjs +1 -3
  51. package/dist-lib/jump-to-source-babel.cjs +1 -1
  52. package/dist-lib/menu.cjs +6 -19
  53. package/dist-lib/menu.mjs +6 -19
  54. package/dist-lib/metro-fingerprint-registry.cjs +1 -1
  55. package/dist-lib/metro-fingerprint-registry.mjs +1 -1
  56. package/dist-lib/metro-production-bundle.cjs +1 -1
  57. package/dist-lib/metro-production-bundle.mjs +1 -1
  58. package/dist-lib/metro.cjs +1 -1
  59. package/dist-lib/profiles.cjs +1 -1
  60. package/dist-lib/public-brand.cjs +1 -1
  61. package/dist-lib/react-native-host-modules.cjs +1 -1
  62. package/dist-lib/react-native-host-modules.mjs +1 -1
  63. package/dist-lib/render-mode.cjs +1 -1
  64. package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
  65. package/dist-lib/sdk.cjs +1 -1
  66. package/dist-lib/sdk.mjs +1 -1
  67. package/dist-lib/skills.cjs +274 -195
  68. package/dist-lib/vite.cjs +1 -1
  69. package/package.json +8 -1
  70. package/src/home-paths.ts +31 -1
  71. package/src/menu.ts +4 -17
package/cli/bin.ts CHANGED
@@ -31,7 +31,9 @@ if (process.argv[2] === '__cloud-confirm') {
31
31
  ])
32
32
  const session = readCloudSession()
33
33
  if (!session) throw new Error('RNX cloud confirmation has no shell session')
34
- await confirmCloudSimulator(session)
34
+ // a box has no claim to keep alive: its simulator is held by the Box page
35
+ // for as long as the box runs, so a shell on a box confirms nothing.
36
+ if (session.service === 'sim') await confirmCloudSimulator(session)
35
37
  process.exit(0)
36
38
  } catch (error) {
37
39
  process.stderr.write(
@@ -41,7 +41,9 @@ import {
41
41
  createCloudSession,
42
42
  normalizeCloudOrigin,
43
43
  readCloudSession,
44
+ type CloudBoxSession,
44
45
  type CloudSession,
46
+ type CloudSimSession,
45
47
  } from './cloud-session'
46
48
  import { detectProject, findProjectRoot } from './commands/detect'
47
49
  import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract'
@@ -58,10 +60,6 @@ export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
58
60
  // every message this module writes names the command through this constant.
59
61
  const REMOTE_COMMAND = 'rnx ios --remote'
60
62
 
61
- // an artifact this close to the ceiling is one asset away from being refused,
62
- // and the producer knows which assets it would be.
63
- const WARN_ARTIFACT_BYTES = Math.floor(MAX_RNX_CLOUD_BUNDLE_BYTES * 0.8)
64
-
65
63
  // a Metro asset dest is a shallow tree of images. a walk that runs past this
66
64
  // is pointed at something else entirely, and its extra entries cannot resolve
67
65
  // an asset anyway.
@@ -372,12 +370,19 @@ function distinctByContent(directory: string, options: readonly string[]): strin
372
370
  * reproduce, however complete that dest is. those come back neither verified
373
371
  * nor mismatched, and the caller refuses when a whole bundle is like that
374
372
  * rather than picking a directory it cannot check.
373
+ *
374
+ * a source tree holding both `icon.png` and `icon@1x.png` declares scale 1
375
+ * twice, and Metro hashes both files while a dest keeps one file per scale. the
376
+ * expo asset plugin records one md5 per declared file in `fileHashes`, and
377
+ * that is the identity check for such a descriptor: the file chosen for a
378
+ * scale must be one of the files Metro hashed at that scale.
375
379
  */
376
380
  function placeMetroDescriptor(
377
381
  listing: MetroAssetDestListing,
378
382
  descriptor: MetroAssetDescriptor,
379
383
  ): DescriptorPlacement {
380
- const perScale = descriptor.scales.map((scale) =>
384
+ const scales = [...new Set(descriptor.scales)]
385
+ const perScale = scales.map((scale) =>
381
386
  distinctByContent(
382
387
  listing.directory,
383
388
  resolveMetroIosAssetDestFiles(listing.files, descriptor, scale),
@@ -387,7 +392,7 @@ function placeMetroDescriptor(
387
392
  // below is. one that resolved to several is left for the hash to settle.
388
393
  const files = new Map<number, string>()
389
394
  const ambiguous: string[] = []
390
- descriptor.scales.forEach((scale, index) => {
395
+ scales.forEach((scale, index) => {
391
396
  const options = perScale[index] ?? []
392
397
  if (options.length === 1 && options[0]) files.set(scale, options[0])
393
398
  else if (options.length > 1) ambiguous.push(...options)
@@ -395,6 +400,12 @@ function placeMetroDescriptor(
395
400
  if (perScale.length === 0 || perScale.some((options) => options.length === 0)) {
396
401
  return { files, ambiguous, verified: false, mismatched: false }
397
402
  }
403
+ // a descriptor that is already embedded carries the embedder's sha256s in
404
+ // `fileHashes`; only Metro's own md5 per source file is an identity here.
405
+ const fileHashes =
406
+ !descriptor.fileUris && descriptor.fileHashes?.length === descriptor.scales.length
407
+ ? descriptor.fileHashes
408
+ : null
398
409
  for (const combination of placementCombinations(perScale)) {
399
410
  let bytes: Buffer[]
400
411
  try {
@@ -404,14 +415,29 @@ function placeMetroDescriptor(
404
415
  } catch {
405
416
  continue
406
417
  }
407
- if (metroDescriptorHash(bytes) !== descriptor.hash) continue
418
+ const identical = fileHashes
419
+ ? bytes.every((file, index) => {
420
+ const digest = createHash('md5').update(file).digest('hex')
421
+ return descriptor.scales.some(
422
+ (scale, declared) =>
423
+ scale === scales[index] && fileHashes[declared] === digest,
424
+ )
425
+ })
426
+ : scales.length === descriptor.scales.length &&
427
+ metroDescriptorHash(bytes) === descriptor.hash
428
+ if (!identical) continue
408
429
  const chosen = new Map<number, string>()
409
- descriptor.scales.forEach((scale, index) => {
430
+ scales.forEach((scale, index) => {
410
431
  const relative = combination[index]
411
432
  if (relative) chosen.set(scale, relative)
412
433
  })
413
434
  return { files: chosen, ambiguous: [], verified: true, mismatched: false }
414
435
  }
436
+ // a duplicated scale with no per-file md5 leaves Metro's digest covering a
437
+ // file this dest does not hold, so nothing here can be called wrong.
438
+ if (!fileHashes && scales.length !== descriptor.scales.length) {
439
+ return { files, ambiguous, verified: false, mismatched: false }
440
+ }
415
441
  // every declared scale is here and no arrangement of them is this asset, so
416
442
  // the directory holds different images under these names.
417
443
  return { files, ambiguous, verified: false, mismatched: true }
@@ -625,6 +651,11 @@ export interface CloudArtifactProduction {
625
651
  export async function produceCloudArtifact(
626
652
  bundlePath: string,
627
653
  assetsPath?: string,
654
+ /**
655
+ * the largest artifact the consumer accepts. the nano simulator's limit is
656
+ * the default; a box that accepts a larger artifact passes its own.
657
+ */
658
+ maxBytes: number = MAX_RNX_CLOUD_BUNDLE_BYTES,
628
659
  ): Promise<CloudArtifactProduction> {
629
660
  const source = await loadBundleSource(bundlePath)
630
661
  const descriptors = extractAssetDescriptors(source)
@@ -703,9 +734,9 @@ export async function produceCloudArtifact(
703
734
  })
704
735
  const bytes = Buffer.from(artifact.code, 'utf8')
705
736
  const assets = artifact.receipt.assets
706
- if (bytes.length > MAX_RNX_CLOUD_BUNDLE_BYTES) {
737
+ if (bytes.length > maxBytes) {
707
738
  throw new Error(
708
- `${REMOTE_COMMAND} artifact is ${bytes.length} bytes and exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-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 15 MB.${largestEmbeddedAssets(assets.sizes, 10)}`,
739
+ `${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)}`,
709
740
  )
710
741
  }
711
742
  const warnings: string[] = []
@@ -714,9 +745,11 @@ export async function produceCloudArtifact(
714
745
  `${REMOTE_COMMAND} embedded a placeholder image for ${substituted.length} asset(s) no scale of which is in ${search.searched.join(', ') || 'any asset dest directory'}: ${substituted.join(', ')}. Build the bundle with --assets-dest, or pass --assets <dir>, to ship the real images.`,
715
746
  )
716
747
  }
717
- if (bytes.length >= WARN_ARTIFACT_BYTES) {
748
+ // an artifact this close to the ceiling is one asset away from being refused,
749
+ // and the producer knows which assets it would be.
750
+ if (bytes.length >= Math.floor(maxBytes * 0.8)) {
718
751
  warnings.push(
719
- `${REMOTE_COMMAND} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / MAX_RNX_CLOUD_BUNDLE_BYTES) * 100)}% of the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
752
+ `${REMOTE_COMMAND} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / maxBytes) * 100)}% of the ${maxBytes}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`,
720
753
  )
721
754
  }
722
755
  return {
@@ -867,6 +900,18 @@ export function resolveCloudSessionForParsed(
867
900
  ): CloudSession | null {
868
901
  const session = readCloudSession()
869
902
  if (!session) return null
903
+ // a box holds one simulator and names it by the box, so --sim names a box
904
+ // rather than a sim there and this shell already knows which box it is on.
905
+ if (session.service === 'box') {
906
+ if (parsed.simIdSource === 'flag' && parsed.simId !== session.boxId) {
907
+ throw new Error(
908
+ `this shell is connected to box ${session.boxId}; ${
909
+ parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
910
+ } is not available in this shell`,
911
+ )
912
+ }
913
+ return session
914
+ }
870
915
  if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
871
916
  throw new Error(
872
917
  `this shell is connected to remote simulator ${session.simId}; ${
@@ -894,7 +939,7 @@ function cloudCommand(command: {
894
939
  class CloudBridge implements WsBridge {
895
940
  readonly plane = 'cloud' as const
896
941
 
897
- constructor(private readonly session: CloudSession) {}
942
+ constructor(private readonly session: CloudSimSession) {}
898
943
 
899
944
  async send(
900
945
  command: { type: string; [key: string]: unknown },
@@ -971,9 +1016,100 @@ class CloudBridge implements WsBridge {
971
1016
  close(): void {}
972
1017
  }
973
1018
 
1019
+ /**
1020
+ * a micro box's bridge.
1021
+ *
1022
+ * the box holds no simulator of its own: the one it shows lives in its Box
1023
+ * page, which runs the same shell the local CLI drives over the daemon bridge.
1024
+ * so this transport moves the whole bridge vocabulary rather than the nano
1025
+ * subset (every verb that works against a local sim works against a box), and
1026
+ * the box service is only the relay in the middle. that is also why there is
1027
+ * no claim: the box token addresses the box, and the page answers for it.
1028
+ */
1029
+ class CloudBoxBridge implements WsBridge {
1030
+ readonly plane = 'cloud' as const
1031
+
1032
+ constructor(private readonly session: CloudBoxSession) {}
1033
+
1034
+ async send(
1035
+ command: { type: string; [key: string]: unknown },
1036
+ options?: { timeoutMs?: number },
1037
+ ): Promise<unknown> {
1038
+ const id = randomUUID()
1039
+ // the wire id is this request's, so the command carries none. `simId` names
1040
+ // a local sim among several and a box has exactly one.
1041
+ const payload: Record<string, unknown> = {}
1042
+ for (const [key, value] of Object.entries(command)) {
1043
+ if (key !== 'id' && key !== 'simId') payload[key] = value
1044
+ }
1045
+ let response: Response
1046
+ try {
1047
+ response = await fetch(
1048
+ `${this.session.apiOrigin}/v1/boxes/${encodeURIComponent(this.session.boxId)}/commands`,
1049
+ {
1050
+ method: 'POST',
1051
+ headers: {
1052
+ authorization: `Bearer ${this.session.boxToken}`,
1053
+ 'content-type': 'application/json',
1054
+ },
1055
+ body: JSON.stringify({ id, command: payload }),
1056
+ ...(options?.timeoutMs
1057
+ ? { signal: AbortSignal.timeout(options.timeoutMs) }
1058
+ : {}),
1059
+ },
1060
+ )
1061
+ } catch (error) {
1062
+ throw new Error(
1063
+ `could not reach box ${this.session.boxId} at ${this.session.apiOrigin}: ${
1064
+ error instanceof Error ? error.message : String(error)
1065
+ }`,
1066
+ )
1067
+ }
1068
+ const text = await response.text()
1069
+ if (!response.ok) throw responseError(response.status, text)
1070
+ let parsed: unknown
1071
+ try {
1072
+ parsed = JSON.parse(text)
1073
+ } catch {
1074
+ throw new Error('box command returned unreadable JSON')
1075
+ }
1076
+ if (!isRecord(parsed) || parsed.id !== id) {
1077
+ throw new Error('box command returned an invalid command receipt')
1078
+ }
1079
+ if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
1080
+ if (!Object.hasOwn(parsed, 'result')) {
1081
+ throw new Error('box command receipt has no result')
1082
+ }
1083
+ return parsed.result
1084
+ }
1085
+
1086
+ listSims(): Promise<BridgeSimInfo[]> {
1087
+ return Promise.reject(new Error('a box does not expose a local simulator list'))
1088
+ }
1089
+
1090
+ focusSim(): Promise<never> {
1091
+ return Promise.reject(new Error('a box simulator has no browser window to focus'))
1092
+ }
1093
+
1094
+ closeSim(): Promise<never> {
1095
+ return Promise.reject(new Error('close a box simulator with rnx box stop'))
1096
+ }
1097
+
1098
+ claim(): Promise<BridgeClaimResult> {
1099
+ return Promise.reject(
1100
+ new Error('a box simulator is held by its Box page and has nothing to claim'),
1101
+ )
1102
+ }
1103
+
1104
+ close(): void {}
1105
+ }
1106
+
974
1107
  export function createCloudBridgeForParsed(parsed: ParsedBridgeCliArgs): WsBridge | null {
975
1108
  const session = resolveCloudSessionForParsed(parsed)
976
- return session ? new CloudBridge(session) : null
1109
+ if (!session) return null
1110
+ return session.service === 'box'
1111
+ ? new CloudBoxBridge(session)
1112
+ : new CloudBridge(session)
977
1113
  }
978
1114
 
979
1115
  function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
@@ -990,11 +1126,11 @@ function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
990
1126
  }
991
1127
 
992
1128
  export async function claimCloudSimulator(
993
- session: CloudSession,
1129
+ session: CloudSimSession,
994
1130
  options: { force?: boolean } = {},
995
1131
  ): Promise<{
996
1132
  claim: { id: string; expiresAt: number }
997
- session: CloudSession
1133
+ session: CloudSimSession
998
1134
  }> {
999
1135
  const response = await fetch(
1000
1136
  `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}/claim`,
@@ -1024,7 +1160,7 @@ export async function claimCloudSimulator(
1024
1160
  }
1025
1161
  }
1026
1162
 
1027
- export async function confirmCloudSimulator(session: CloudSession): Promise<void> {
1163
+ export async function confirmCloudSimulator(session: CloudSimSession): Promise<void> {
1028
1164
  const confirmed = await claimCloudSimulator(session)
1029
1165
  if (confirmed.session.claimId !== session.claimId) {
1030
1166
  throw new Error('remote simulator confirmation replaced the initial claim')
@@ -158,6 +158,21 @@ export async function dispatchCloudBoundary({
158
158
  }
159
159
  if (!session) return { handled: false }
160
160
 
161
+ // a box relays the whole bridge vocabulary to the page hosting its
162
+ // simulator, which runs the same shell the local CLI drives. so there is no
163
+ // subset to police: every command below reaches it through the ordinary
164
+ // bridge, which createBridgeFromParsed points at the box. the two exceptions
165
+ // are the two commands that are about a nano claim, which a box has none of.
166
+ if (session.service === 'box') {
167
+ if (command === 'claim' || command === 'close') {
168
+ console.error(
169
+ ` rnx ${command} is not available for a box; its simulator is held by the Box page. use rnx box stop to end the box.`,
170
+ )
171
+ return { handled: true, code: 1 }
172
+ }
173
+ return { handled: false }
174
+ }
175
+
161
176
  if (command === 'claim') {
162
177
  try {
163
178
  const descriptor = cloudSession.requireCloudSessionShellUpdate()
@@ -1,7 +1,7 @@
1
1
  import { fstatSync, writeSync } from 'node:fs'
2
2
  import { isLoopbackHost } from '../src/backend-origin'
3
3
 
4
- const CLOUD_SESSION_VERSION = 2 as const
4
+ const CLOUD_SESSION_VERSION = 3 as const
5
5
  const SHELL_UPDATE_FD_ENV = 'RNX_SHELL_UPDATE_FD'
6
6
  const MAX_ENCODED_SESSION_BYTES = 32 * 1024
7
7
 
@@ -10,10 +10,19 @@ export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__'
10
10
  export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:`
11
11
  export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset`
12
12
 
13
- // the shell's target: one remote host, addressed by the box token, and the
14
- // simulator inside it that ordinary commands reach with the simulator token
15
- export interface CloudSession {
16
- version: 2
13
+ // the shell's remote target. two services answer rnx commands and they are
14
+ // addressed differently, so the session names which one it is rather than
15
+ // leaving a reader to infer it from which fields happen to be filled in.
16
+ export type CloudSession = CloudSimSession | CloudBoxSession
17
+
18
+ /**
19
+ * one remote host, addressed by the box token, and the simulator inside it
20
+ * that ordinary commands reach with the simulator token. this is what
21
+ * `rnx ios --remote` produces.
22
+ */
23
+ export interface CloudSimSession {
24
+ version: 3
25
+ service: 'sim'
17
26
  boxId: string
18
27
  boxToken: string
19
28
  simId: string
@@ -22,6 +31,20 @@ export interface CloudSession {
22
31
  token: string
23
32
  }
24
33
 
34
+ /**
35
+ * a micro box, whose simulator lives in its own Box page rather than in the
36
+ * service. there is nothing to claim and no separate simulator token: the box
37
+ * token addresses the box, and the box relays each command to the page that
38
+ * is hosting it.
39
+ */
40
+ export interface CloudBoxSession {
41
+ version: 3
42
+ service: 'box'
43
+ boxId: string
44
+ boxToken: string
45
+ apiOrigin: string
46
+ }
47
+
25
48
  function isRecord(value: unknown): value is Record<string, unknown> {
26
49
  return value !== null && typeof value === 'object' && !Array.isArray(value)
27
50
  }
@@ -56,16 +79,17 @@ export function createCloudSession(input: {
56
79
  apiOrigin: string
57
80
  claimId: string
58
81
  token: string
59
- }): CloudSession {
82
+ }): CloudSimSession {
60
83
  const session = {
61
84
  version: CLOUD_SESSION_VERSION,
85
+ service: 'sim',
62
86
  boxId: input.boxId.trim(),
63
87
  boxToken: input.boxToken,
64
88
  simId: input.simId.trim(),
65
89
  apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
66
90
  claimId: input.claimId.trim(),
67
91
  token: input.token,
68
- } satisfies CloudSession
92
+ } satisfies CloudSimSession
69
93
  if (
70
94
  !session.boxId ||
71
95
  !session.boxToken ||
@@ -78,10 +102,30 @@ export function createCloudSession(input: {
78
102
  return session
79
103
  }
80
104
 
105
+ export function createCloudBoxSession(input: {
106
+ boxId: string
107
+ boxToken: string
108
+ apiOrigin: string
109
+ }): CloudBoxSession {
110
+ const session = {
111
+ version: CLOUD_SESSION_VERSION,
112
+ service: 'box',
113
+ boxId: input.boxId.trim(),
114
+ boxToken: input.boxToken,
115
+ apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()),
116
+ } satisfies CloudBoxSession
117
+ if (!session.boxId || !session.boxToken) {
118
+ throw new Error('cloud box session requires a box, an origin, and a box token')
119
+ }
120
+ return session
121
+ }
122
+
81
123
  export function serializeCloudSession(session: CloudSession): string {
82
- return Buffer.from(JSON.stringify(createCloudSession(session)), 'utf8').toString(
83
- 'base64url',
84
- )
124
+ const validated =
125
+ session.service === 'sim'
126
+ ? createCloudSession(session)
127
+ : createCloudBoxSession(session)
128
+ return Buffer.from(JSON.stringify(validated), 'utf8').toString('base64url')
85
129
  }
86
130
 
87
131
  export function parseCloudSession(encoded: string): CloudSession {
@@ -108,21 +152,29 @@ export function parseCloudSession(encoded: string): CloudSession {
108
152
  }
109
153
  const boxId = parsed.boxId
110
154
  const boxToken = parsed.boxToken
111
- const simId = parsed.simId
112
155
  const apiOrigin = parsed.apiOrigin
113
- const claimId = parsed.claimId
114
- const token = parsed.token
115
156
  if (
116
157
  typeof boxId !== 'string' ||
117
158
  typeof boxToken !== 'string' ||
118
- typeof simId !== 'string' ||
119
- typeof apiOrigin !== 'string' ||
120
- typeof claimId !== 'string' ||
121
- typeof token !== 'string'
159
+ typeof apiOrigin !== 'string'
122
160
  ) {
123
161
  throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
124
162
  }
125
163
  try {
164
+ if (parsed.service === 'box') {
165
+ return createCloudBoxSession({ boxId, boxToken, apiOrigin })
166
+ }
167
+ const simId = parsed.simId
168
+ const claimId = parsed.claimId
169
+ const token = parsed.token
170
+ if (
171
+ parsed.service !== 'sim' ||
172
+ typeof simId !== 'string' ||
173
+ typeof claimId !== 'string' ||
174
+ typeof token !== 'string'
175
+ ) {
176
+ throw new Error('unreadable cloud session')
177
+ }
126
178
  return createCloudSession({ boxId, boxToken, simId, apiOrigin, claimId, token })
127
179
  } catch {
128
180
  throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`)
@@ -0,0 +1,71 @@
1
+ // ProjectFilePlane over an already-built bundle held in memory.
2
+ //
3
+ // a prebuilt Box has no sources, so there is no checkout to index and nothing
4
+ // on disk to stay in step with: the whole project is the bundle the page boots.
5
+ // CheckoutFilePlane cannot serve this because it asks git what belongs to the
6
+ // project, and a bundle produced by a build lives outside any checkout.
7
+ //
8
+ // the bundle is split here rather than by the caller, so the one place that
9
+ // decides where the parts go is the one place that decides how big they are.
10
+
11
+ import { PREBUILT_BUNDLE_PART_BYTES, prebuiltBundlePartPath } from '@rnx/box/template'
12
+ import type { ProjectFileMeta, ProjectFilePlane } from '@rnx/box/plane'
13
+
14
+ export class BundleFilePlane implements ProjectFilePlane {
15
+ private readonly parts = new Map<string, Uint8Array>()
16
+ private readonly entries = new Map<string, ProjectFileMeta>()
17
+
18
+ constructor(bundle: Uint8Array) {
19
+ if (bundle.byteLength === 0) throw new Error('a Box bundle cannot be empty')
20
+ for (
21
+ let offset = 0, index = 0;
22
+ offset < bundle.byteLength;
23
+ offset += PREBUILT_BUNDLE_PART_BYTES, index++
24
+ ) {
25
+ const part = bundle.subarray(offset, offset + PREBUILT_BUNDLE_PART_BYTES)
26
+ this.record(prebuiltBundlePartPath(index), part)
27
+ }
28
+ }
29
+
30
+ private record(path: string, content: Uint8Array): void {
31
+ this.parts.set(path, content)
32
+ this.entries.set(path, {
33
+ size: content.byteLength,
34
+ readOnly: false,
35
+ // a Metro bundle is JavaScript. the flag is what decides whether a shell
36
+ // may overwrite a file with text, and marking it binary would make the
37
+ // bundle unwritable from inside the box for no reason.
38
+ binary: false,
39
+ })
40
+ }
41
+
42
+ index(): ReadonlyMap<string, ProjectFileMeta> {
43
+ return this.entries
44
+ }
45
+
46
+ // nothing else can change this plane, so the index is already current.
47
+ refresh(): Promise<void> {
48
+ return Promise.resolve()
49
+ }
50
+
51
+ async read(path: string): Promise<string> {
52
+ return new TextDecoder().decode(await this.readBytes(path))
53
+ }
54
+
55
+ readBytes(path: string): Promise<Uint8Array> {
56
+ const part = this.parts.get(path)
57
+ if (!part) return Promise.reject(new Error(`no such file: ${path}`))
58
+ return Promise.resolve(part)
59
+ }
60
+
61
+ write(path: string, content: Uint8Array): Promise<void> {
62
+ this.record(path, content)
63
+ return Promise.resolve()
64
+ }
65
+
66
+ delete(path: string): Promise<void> {
67
+ this.parts.delete(path)
68
+ this.entries.delete(path)
69
+ return Promise.resolve()
70
+ }
71
+ }