rnxsim 0.1.432 → 0.1.433
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -50
- package/cli/auth.ts +33 -3
- package/cli/cloud-client.ts +524 -88
- package/cli/cloud-dispatch.ts +5 -10
- package/cli/cloud-session.ts +5 -5
- package/cli/commands/auth.ts +13 -2
- package/cli/commands/box.ts +189 -20
- package/cli/commands/control.ts +40 -13
- package/cli/commands/device.ts +1 -1
- package/cli/commands/flow.ts +189 -43
- package/cli/commands/inspect/get-layout.ts +1 -1
- package/cli/commands/inspect.ts +8 -1
- package/cli/commands/login.ts +79 -14
- package/cli/commands/maestro-generate.ts +70 -54
- package/cli/commands/maestro.ts +69 -20
- package/cli/commands/platform.ts +30 -32
- package/cli/commands/screenshot.ts +3 -2
- package/cli/commands/upload.ts +28 -10
- package/cli/help-core.ts +11 -1
- package/cli/outbound-endpoints.ts +27 -5
- package/cli/parse-args.ts +17 -0
- package/cli/shell-init.ts +4 -4
- package/cli/ws-bridge.ts +109 -62
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +17 -4
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud.cjs +108 -93
- package/dist-lib/cloud.mjs +106 -91
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +1 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +58 -139
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +33 -212
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +436 -351
- package/dist-lib/vite.cjs +1 -1
- package/package.json +2 -1
- package/src/auth/shared-session.ts +31 -0
- package/src/cloud.ts +83 -113
- package/src/metro-plugin.ts +30 -113
- package/src/vite-plugin.ts +11 -2
package/cli/cloud-client.ts
CHANGED
|
@@ -10,9 +10,13 @@ import {
|
|
|
10
10
|
import { dirname, extname, join, resolve } from 'node:path'
|
|
11
11
|
import {
|
|
12
12
|
embedMetroAssetFiles,
|
|
13
|
+
extractAssetDescriptors,
|
|
14
|
+
metroAssetPlaceholderBytes,
|
|
13
15
|
metroAssetScaleFilename,
|
|
14
16
|
metroIosAssetDestRelatives,
|
|
15
|
-
|
|
17
|
+
resolveMetroIosAssetDestFiles,
|
|
18
|
+
type MetroArtifactAssetSize,
|
|
19
|
+
type MetroAssetDescriptor,
|
|
16
20
|
} from '../../contrast-bundler/src/metroAssetArtifact'
|
|
17
21
|
import {
|
|
18
22
|
buildRnxCloudArtifact,
|
|
@@ -31,12 +35,14 @@ import {
|
|
|
31
35
|
type RNXMetroModuleIdentityMetadata,
|
|
32
36
|
} from '../src/metro-production-bundle'
|
|
33
37
|
import { rnxPublicBrand } from '../src/public-brand'
|
|
38
|
+
import { CHOOSE_ACCOUNT_HINT } from './auth'
|
|
34
39
|
import {
|
|
35
40
|
createCloudSession,
|
|
36
41
|
normalizeCloudOrigin,
|
|
37
42
|
readCloudSession,
|
|
38
43
|
type CloudSession,
|
|
39
44
|
} from './cloud-session'
|
|
45
|
+
import { detectProject, findProjectRoot } from './commands/detect'
|
|
40
46
|
import type {
|
|
41
47
|
BridgeClaimResult,
|
|
42
48
|
BridgeSimInfo,
|
|
@@ -47,10 +53,24 @@ import type {
|
|
|
47
53
|
export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin
|
|
48
54
|
export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
|
|
49
55
|
|
|
56
|
+
// every message this module writes names the command through this constant.
|
|
57
|
+
const REMOTE_COMMAND = 'rnx ios --remote'
|
|
58
|
+
|
|
59
|
+
// an artifact this close to the ceiling is one asset away from being refused,
|
|
60
|
+
// and the producer knows which assets it would be.
|
|
61
|
+
const WARN_ARTIFACT_BYTES = Math.floor(MAX_RNX_CLOUD_BUNDLE_BYTES * 0.8)
|
|
62
|
+
|
|
63
|
+
// a Metro asset dest is a shallow tree of images. a walk that runs past this
|
|
64
|
+
// is pointed at something else entirely, and its extra entries cannot resolve
|
|
65
|
+
// an asset anyway.
|
|
66
|
+
const MAX_ASSET_DEST_FILES = 25_000
|
|
67
|
+
|
|
50
68
|
interface CreateCloudBoxOptions {
|
|
51
69
|
bundlePath: string
|
|
52
70
|
device: string
|
|
53
71
|
authorization: string
|
|
72
|
+
/** the account to bill when the authorization is a session token; an api key names its own */
|
|
73
|
+
accountId: string | null
|
|
54
74
|
/** the `--assets-dest` directory Metro wrote beside the bundle. */
|
|
55
75
|
assetsPath?: string
|
|
56
76
|
apiOrigin?: string
|
|
@@ -66,18 +86,43 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
66
86
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
67
87
|
}
|
|
68
88
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
89
|
+
// three services answer this client and each states a failure its own way:
|
|
90
|
+
// the box service says { error: '<sentence>' }, the site's public api says
|
|
91
|
+
// { error: '<code>', message: '<sentence>' }, and its service-to-service api
|
|
92
|
+
// says { error: { code, message } }. all three carry one sentence meant for
|
|
93
|
+
// the person at the terminal, so read whichever field holds it. without this
|
|
94
|
+
// the sentence never reaches them and they get the raw json body instead.
|
|
95
|
+
function failureSentence(text: string): string | null {
|
|
96
|
+
let parsed: unknown
|
|
97
|
+
try {
|
|
98
|
+
parsed = JSON.parse(text)
|
|
99
|
+
} catch {
|
|
100
|
+
return null
|
|
78
101
|
}
|
|
102
|
+
if (!isRecord(parsed)) return null
|
|
103
|
+
if (isRecord(parsed.error) && typeof parsed.error.message === 'string') {
|
|
104
|
+
return parsed.error.message
|
|
105
|
+
}
|
|
106
|
+
if (typeof parsed.message === 'string') return parsed.message
|
|
107
|
+
if (typeof parsed.error === 'string') return parsed.error
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// the account service says what it needs in its own words; the CLI says what
|
|
112
|
+
// the person can do about it, which is choose the account at `rnx login`.
|
|
113
|
+
const ACCOUNT_CHOICE_REFUSALS = new Set([
|
|
114
|
+
'accountId is required',
|
|
115
|
+
'you do not have access to this account',
|
|
116
|
+
])
|
|
117
|
+
|
|
118
|
+
function responseError(status: number, text: string): Error {
|
|
119
|
+
const parsed = text ? failureSentence(text) : null
|
|
120
|
+
const sentence =
|
|
121
|
+
parsed && ACCOUNT_CHOICE_REFUSALS.has(parsed) ? CHOOSE_ACCOUNT_HINT : parsed
|
|
79
122
|
return new Error(
|
|
80
|
-
`
|
|
123
|
+
`remote simulator request failed (${status})${
|
|
124
|
+
sentence ? `: ${sentence}` : text.trim() ? `: ${text.trim()}` : ''
|
|
125
|
+
}`,
|
|
81
126
|
)
|
|
82
127
|
}
|
|
83
128
|
|
|
@@ -90,16 +135,16 @@ function resolveCloudOrigin(value?: string): string {
|
|
|
90
135
|
function readImmutableBundleSource(bundlePath: string): string {
|
|
91
136
|
const filepath = resolve(bundlePath)
|
|
92
137
|
if (extname(filepath) !== '.js') {
|
|
93
|
-
throw new Error(
|
|
138
|
+
throw new Error(`${REMOTE_COMMAND} requires one local Metro .js bundle`)
|
|
94
139
|
}
|
|
95
140
|
const descriptor = openSync(filepath, 'r')
|
|
96
141
|
try {
|
|
97
142
|
const before = fstatSync(descriptor, { bigint: true })
|
|
98
143
|
if (!before.isFile()) {
|
|
99
|
-
throw new Error(
|
|
144
|
+
throw new Error(`${REMOTE_COMMAND} bundle must be a regular local file`)
|
|
100
145
|
}
|
|
101
146
|
if (before.size <= 0n) {
|
|
102
|
-
throw new Error(
|
|
147
|
+
throw new Error(`${REMOTE_COMMAND} bundle is empty`)
|
|
103
148
|
}
|
|
104
149
|
const bytes = readFileSync(descriptor)
|
|
105
150
|
const after = fstatSync(descriptor, { bigint: true })
|
|
@@ -110,16 +155,16 @@ function readImmutableBundleSource(bundlePath: string): string {
|
|
|
110
155
|
before.mtimeNs !== after.mtimeNs ||
|
|
111
156
|
bytes.length !== Number(before.size)
|
|
112
157
|
) {
|
|
113
|
-
throw new Error(
|
|
158
|
+
throw new Error(`${REMOTE_COMMAND} bundle changed while it was being read`)
|
|
114
159
|
}
|
|
115
160
|
let source: string
|
|
116
161
|
try {
|
|
117
162
|
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
118
163
|
} catch {
|
|
119
|
-
throw new Error(
|
|
164
|
+
throw new Error(`${REMOTE_COMMAND} bundle must be valid UTF-8 JavaScript`)
|
|
120
165
|
}
|
|
121
166
|
if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
|
|
122
|
-
throw new Error(
|
|
167
|
+
throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
|
|
123
168
|
}
|
|
124
169
|
return source
|
|
125
170
|
} finally {
|
|
@@ -133,31 +178,31 @@ async function fetchBundleSource(bundleUrl: string): Promise<string> {
|
|
|
133
178
|
response = await fetch(bundleUrl)
|
|
134
179
|
} catch (error) {
|
|
135
180
|
throw new Error(
|
|
136
|
-
|
|
181
|
+
`${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: ${
|
|
137
182
|
error instanceof Error ? error.message : String(error)
|
|
138
183
|
}`,
|
|
139
184
|
)
|
|
140
185
|
}
|
|
141
186
|
if (!response.ok) {
|
|
142
187
|
throw new Error(
|
|
143
|
-
|
|
188
|
+
`${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${
|
|
144
189
|
response.statusText ? ` ${response.statusText}` : ''
|
|
145
190
|
}`,
|
|
146
191
|
)
|
|
147
192
|
}
|
|
148
193
|
const text = await response.text()
|
|
149
194
|
if (text.length === 0) {
|
|
150
|
-
throw new Error(
|
|
195
|
+
throw new Error(`${REMOTE_COMMAND} bundle is empty`)
|
|
151
196
|
}
|
|
152
197
|
if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) {
|
|
153
|
-
throw new Error(
|
|
198
|
+
throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
|
|
154
199
|
}
|
|
155
200
|
return text
|
|
156
201
|
}
|
|
157
202
|
|
|
158
203
|
async function loadBundleSource(bundleInput: string): Promise<string> {
|
|
159
204
|
if (bundleInput.startsWith('http://')) {
|
|
160
|
-
throw new Error(
|
|
205
|
+
throw new Error(`${REMOTE_COMMAND} bundle URL must use HTTPS`)
|
|
161
206
|
}
|
|
162
207
|
if (bundleInput.startsWith('https://')) {
|
|
163
208
|
return fetchBundleSource(bundleInput)
|
|
@@ -168,10 +213,36 @@ async function loadBundleSource(bundleInput: string): Promise<string> {
|
|
|
168
213
|
// `react-native bundle --assets-dest` dest paths are not always the JS
|
|
169
214
|
// descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
|
|
170
215
|
// that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
|
|
171
|
-
// project-root-relative app path.
|
|
172
|
-
|
|
173
|
-
|
|
216
|
+
// project-root-relative app path. resolveMetroIosAssetDestFiles matches both.
|
|
217
|
+
interface MetroAssetDestListing {
|
|
218
|
+
directory: string
|
|
219
|
+
files: string[]
|
|
220
|
+
/** the walk stopped at the cap, so "not in this listing" proves nothing. */
|
|
221
|
+
truncated: boolean
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// a Metro asset dest never holds these, but a project root does, and a
|
|
225
|
+
// candidate can be either. `node_modules` is not in the list on purpose: a real
|
|
226
|
+
// dest DOES carry one, because that is where `react-native bundle` copies the
|
|
227
|
+
// assets that live inside packages. it is skipped only under a project root,
|
|
228
|
+
// which is what a package.json at the candidate marks.
|
|
229
|
+
const PROJECT_ONLY_DIRECTORIES = new Set([
|
|
230
|
+
'node_modules',
|
|
231
|
+
'Pods',
|
|
232
|
+
'DerivedData',
|
|
233
|
+
'.expo',
|
|
234
|
+
'.next',
|
|
235
|
+
'.turbo',
|
|
236
|
+
'.cache',
|
|
237
|
+
'.gradle',
|
|
238
|
+
])
|
|
239
|
+
|
|
240
|
+
function listMetroAssetDest(assetsPath: string): MetroAssetDestListing {
|
|
241
|
+
const directory = resolve(assetsPath)
|
|
242
|
+
if (!existsSync(directory)) return { directory, files: [], truncated: false }
|
|
243
|
+
const skipNested = existsSync(join(directory, 'package.json'))
|
|
174
244
|
const files: string[] = []
|
|
245
|
+
let truncated = false
|
|
175
246
|
const walk = (dir: string, prefix: string) => {
|
|
176
247
|
let entries
|
|
177
248
|
try {
|
|
@@ -180,13 +251,298 @@ function listMetroAssetDestFiles(assetsPath?: string): string[] {
|
|
|
180
251
|
return
|
|
181
252
|
}
|
|
182
253
|
for (const entry of entries) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
254
|
+
if (files.length >= MAX_ASSET_DEST_FILES) {
|
|
255
|
+
truncated = true
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
if (entry.isDirectory()) {
|
|
259
|
+
if (entry.name === '.git') continue
|
|
260
|
+
if (skipNested && PROJECT_ONLY_DIRECTORIES.has(entry.name)) continue
|
|
261
|
+
walk(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name)
|
|
262
|
+
} else if (entry.isFile()) {
|
|
263
|
+
files.push(prefix ? `${prefix}/${entry.name}` : entry.name)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
walk(directory, '')
|
|
268
|
+
return { directory, files, truncated }
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Metro's own identity for an asset. `getAbsoluteAssetInfo` in metro/src/Assets
|
|
273
|
+
* md5s every scale file's bytes into ONE digest, in the ascending scale order
|
|
274
|
+
* `buildAssetMap` keeps `scales` and `files` parallel in, and that digest is
|
|
275
|
+
* the descriptor's `hash`. so a directory can be asked whether it holds THIS
|
|
276
|
+
* asset instead of merely a file with the same name.
|
|
277
|
+
*/
|
|
278
|
+
function metroDescriptorHash(scaleBytes: readonly Buffer[]): string {
|
|
279
|
+
const hasher = createHash('md5')
|
|
280
|
+
for (const bytes of scaleBytes) hasher.update(bytes)
|
|
281
|
+
return hasher.digest('hex')
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function metroAssetKey(descriptor: MetroAssetDescriptor): string {
|
|
285
|
+
return `${descriptor.httpServerLocation}/${descriptor.name}.${descriptor.type}`
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** where one descriptor's scales sit inside one candidate directory. */
|
|
289
|
+
interface DescriptorPlacement {
|
|
290
|
+
/** the dest-relative file chosen for each declared scale that is present. */
|
|
291
|
+
files: Map<number, string>
|
|
292
|
+
/** files a scale matched that hold different images, so none was chosen. */
|
|
293
|
+
ambiguous: string[]
|
|
294
|
+
/** every declared scale is present and their bytes are Metro's `hash`. */
|
|
295
|
+
verified: boolean
|
|
296
|
+
/** every declared scale is present and their bytes are something else. */
|
|
297
|
+
mismatched: boolean
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// an ambiguous scale is rare and its file count is small, so trying every
|
|
301
|
+
// combination against the descriptor hash is cheap. the cap only stops a
|
|
302
|
+
// pathological dest from turning identity into a search.
|
|
303
|
+
const MAX_PLACEMENT_COMBINATIONS = 64
|
|
304
|
+
|
|
305
|
+
function placementCombinations(perScale: readonly string[][]): string[][] {
|
|
306
|
+
let total = 1
|
|
307
|
+
for (const options of perScale) total *= options.length
|
|
308
|
+
if (total > MAX_PLACEMENT_COMBINATIONS) return []
|
|
309
|
+
let combinations: string[][] = [[]]
|
|
310
|
+
for (const options of perScale) {
|
|
311
|
+
combinations = combinations.flatMap((prefix) =>
|
|
312
|
+
options.map((option) => [...prefix, option]),
|
|
313
|
+
)
|
|
314
|
+
}
|
|
315
|
+
return combinations
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* one file per distinct image, out of the files a scale matched.
|
|
320
|
+
*
|
|
321
|
+
* two paths holding byte-identical images are not a choice to make, and the
|
|
322
|
+
* descriptor's hash could not separate them if they were.
|
|
323
|
+
*/
|
|
324
|
+
function distinctByContent(directory: string, options: readonly string[]): string[] {
|
|
325
|
+
if (options.length < 2) return [...options]
|
|
326
|
+
const byContent = new Map<string, string>()
|
|
327
|
+
for (const relative of options) {
|
|
328
|
+
let bytes: Buffer
|
|
329
|
+
try {
|
|
330
|
+
bytes = readFileSync(join(directory, relative))
|
|
331
|
+
} catch {
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
const digest = createHash('md5').update(bytes).digest('hex')
|
|
335
|
+
if (!byContent.has(digest)) byContent.set(digest, relative)
|
|
336
|
+
}
|
|
337
|
+
return [...byContent.values()]
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* decides what a candidate directory holds for one descriptor, by bytes.
|
|
342
|
+
*
|
|
343
|
+
* Metro hashes the asset's SOURCE files, and `react-native bundle` copies only
|
|
344
|
+
* @1x..@3x to an iOS dest while leaving `scales` unfiltered. so a descriptor
|
|
345
|
+
* that declares an Android rung such as @1.5x records a digest no iOS dest can
|
|
346
|
+
* reproduce, however complete that dest is. those come back neither verified
|
|
347
|
+
* nor mismatched, and the caller refuses when a whole bundle is like that
|
|
348
|
+
* rather than picking a directory it cannot check.
|
|
349
|
+
*/
|
|
350
|
+
function placeMetroDescriptor(
|
|
351
|
+
listing: MetroAssetDestListing,
|
|
352
|
+
descriptor: MetroAssetDescriptor,
|
|
353
|
+
): DescriptorPlacement {
|
|
354
|
+
const perScale = descriptor.scales.map((scale) =>
|
|
355
|
+
distinctByContent(
|
|
356
|
+
listing.directory,
|
|
357
|
+
resolveMetroIosAssetDestFiles(listing.files, descriptor, scale),
|
|
358
|
+
),
|
|
359
|
+
)
|
|
360
|
+
// a scale that resolved to one image names its file whatever the verdict
|
|
361
|
+
// below is. one that resolved to several is left for the hash to settle.
|
|
362
|
+
const files = new Map<number, string>()
|
|
363
|
+
const ambiguous: string[] = []
|
|
364
|
+
descriptor.scales.forEach((scale, index) => {
|
|
365
|
+
const options = perScale[index] ?? []
|
|
366
|
+
if (options.length === 1 && options[0]) files.set(scale, options[0])
|
|
367
|
+
else if (options.length > 1) ambiguous.push(...options)
|
|
368
|
+
})
|
|
369
|
+
if (perScale.length === 0 || perScale.some((options) => options.length === 0)) {
|
|
370
|
+
return { files, ambiguous, verified: false, mismatched: false }
|
|
371
|
+
}
|
|
372
|
+
for (const combination of placementCombinations(perScale)) {
|
|
373
|
+
let bytes: Buffer[]
|
|
374
|
+
try {
|
|
375
|
+
bytes = combination.map((relative) =>
|
|
376
|
+
readFileSync(join(listing.directory, relative)),
|
|
377
|
+
)
|
|
378
|
+
} catch {
|
|
379
|
+
continue
|
|
186
380
|
}
|
|
381
|
+
if (metroDescriptorHash(bytes) !== descriptor.hash) continue
|
|
382
|
+
const chosen = new Map<number, string>()
|
|
383
|
+
descriptor.scales.forEach((scale, index) => {
|
|
384
|
+
const relative = combination[index]
|
|
385
|
+
if (relative) chosen.set(scale, relative)
|
|
386
|
+
})
|
|
387
|
+
return { files: chosen, ambiguous: [], verified: true, mismatched: false }
|
|
187
388
|
}
|
|
188
|
-
|
|
189
|
-
|
|
389
|
+
// every declared scale is here and no arrangement of them is this asset, so
|
|
390
|
+
// the directory holds different images under these names.
|
|
391
|
+
return { files, ambiguous, verified: false, mismatched: true }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
interface MetroAssetDest {
|
|
395
|
+
directory: string
|
|
396
|
+
/** the placement of each descriptor, in the order the bundle declares them. */
|
|
397
|
+
placements: DescriptorPlacement[]
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
interface MetroAssetSearch {
|
|
401
|
+
/** the directory the producer reads asset bytes from, or null if none held any. */
|
|
402
|
+
dest: MetroAssetDest | null
|
|
403
|
+
/** every directory the search considered, in order, for the warning text. */
|
|
404
|
+
searched: string[]
|
|
405
|
+
/** a walk stopped at the cap, so no directory can be called fully looked at. */
|
|
406
|
+
truncated: boolean
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* what two candidates could still disagree about.
|
|
411
|
+
*
|
|
412
|
+
* a verified placement's bytes reproduce Metro's own digest, so it is the right
|
|
413
|
+
* content wherever it was read from and two directories that both verified it
|
|
414
|
+
* are not in conflict. only the unverified files are a real fork, and they are
|
|
415
|
+
* keyed by absolute path because `<dir>/assets` and `<dir>` are both candidates
|
|
416
|
+
* and list the same files under different dest-relative names.
|
|
417
|
+
*/
|
|
418
|
+
function unverifiedFileKey(dest: MetroAssetDest): string {
|
|
419
|
+
return dest.placements
|
|
420
|
+
.filter((placement) => !placement.verified)
|
|
421
|
+
.flatMap((placement) =>
|
|
422
|
+
[...placement.files.values()].map((relative) => join(dest.directory, relative)),
|
|
423
|
+
)
|
|
424
|
+
.sort()
|
|
425
|
+
.join('\n')
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* finds the `--assets-dest` directory by RESOLVING the bundle's own asset
|
|
430
|
+
* descriptors against a short ordered list of the places Metro writes one, and
|
|
431
|
+
* CONFIRMING the bytes it finds against the md5 Metro recorded in each
|
|
432
|
+
* descriptor. name and layout alone are a popularity contest an unrelated build
|
|
433
|
+
* output can win; the hash makes it an identity check.
|
|
434
|
+
*/
|
|
435
|
+
function searchMetroAssetDest(
|
|
436
|
+
bundlePath: string,
|
|
437
|
+
descriptors: readonly MetroAssetDescriptor[],
|
|
438
|
+
): MetroAssetSearch {
|
|
439
|
+
const bundleDirectory = dirname(resolve(bundlePath))
|
|
440
|
+
const candidates = [
|
|
441
|
+
join(bundleDirectory, 'assets'),
|
|
442
|
+
bundleDirectory,
|
|
443
|
+
resolve(bundleDirectory, '..', 'assets'),
|
|
444
|
+
]
|
|
445
|
+
const cwd = process.cwd()
|
|
446
|
+
if (detectProject(cwd)?.hasMetroConfig) {
|
|
447
|
+
const root = findProjectRoot(cwd)
|
|
448
|
+
candidates.push(join(root, 'dist'), join(root, 'build'))
|
|
449
|
+
}
|
|
450
|
+
const searched: string[] = []
|
|
451
|
+
const holders: { dest: MetroAssetDest; verified: number }[] = []
|
|
452
|
+
const stale: string[] = []
|
|
453
|
+
let truncated = false
|
|
454
|
+
for (const candidate of candidates) {
|
|
455
|
+
const directory = resolve(candidate)
|
|
456
|
+
if (searched.includes(directory)) continue
|
|
457
|
+
searched.push(directory)
|
|
458
|
+
const listing = listMetroAssetDest(directory)
|
|
459
|
+
// a cut-short walk is remembered even when the candidate resolves nothing,
|
|
460
|
+
// because the reason it resolved nothing may be that it never finished.
|
|
461
|
+
if (listing.truncated) truncated = true
|
|
462
|
+
if (listing.files.length === 0) continue
|
|
463
|
+
const placements = descriptors.map((descriptor) =>
|
|
464
|
+
placeMetroDescriptor(listing, descriptor),
|
|
465
|
+
)
|
|
466
|
+
// one wrong asset is enough: a directory that provably holds different
|
|
467
|
+
// bytes under these names is a different build, not a partial dest.
|
|
468
|
+
if (placements.some((placement) => placement.mismatched)) {
|
|
469
|
+
stale.push(directory)
|
|
470
|
+
continue
|
|
471
|
+
}
|
|
472
|
+
const verified = placements.filter((placement) => placement.verified).length
|
|
473
|
+
if (verified === 0 && placements.every((placement) => placement.files.size === 0))
|
|
474
|
+
continue
|
|
475
|
+
holders.push({ dest: { directory, placements }, verified })
|
|
476
|
+
}
|
|
477
|
+
if (holders.length === 0) {
|
|
478
|
+
// every scale is on disk and none of it is this bundle's. saying the
|
|
479
|
+
// assets are missing would be false, and a stand-in over a stale image is
|
|
480
|
+
// the wrong repair for a dest that only needs rebuilding.
|
|
481
|
+
if (stale.length > 0) {
|
|
482
|
+
throw new Error(
|
|
483
|
+
`${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.`,
|
|
484
|
+
)
|
|
485
|
+
}
|
|
486
|
+
return { dest: null, searched, truncated }
|
|
487
|
+
}
|
|
488
|
+
const best = Math.max(...holders.map((holder) => holder.verified))
|
|
489
|
+
// a directory that confirmed nothing cannot outrank one that confirmed
|
|
490
|
+
// something, and when nothing anywhere confirmed, every claimant is a rival.
|
|
491
|
+
const rivals = holders.filter((holder) => holder.verified === best)
|
|
492
|
+
const distinct = new Set(rivals.map((rival) => unverifiedFileKey(rival.dest)))
|
|
493
|
+
const winner = rivals[0]
|
|
494
|
+
if (!winner) return { dest: null, searched, truncated }
|
|
495
|
+
if (distinct.size > 1) {
|
|
496
|
+
throw new Error(
|
|
497
|
+
`${REMOTE_COMMAND} found this bundle's assets in more than one directory and cannot tell which build wrote them: ${rivals
|
|
498
|
+
.map((rival) => rival.dest.directory)
|
|
499
|
+
.join(
|
|
500
|
+
', ',
|
|
501
|
+
)}. Pass --assets <dir> to name the one \`react-native bundle --assets-dest\` wrote.`,
|
|
502
|
+
)
|
|
503
|
+
}
|
|
504
|
+
if (best === 0) {
|
|
505
|
+
// every descriptor declares a scale this directory does not hold, so not
|
|
506
|
+
// one of Metro's digests can be reproduced from it. that is ordinary for an
|
|
507
|
+
// asset carrying an Android rung such as @1.5x, and it is also exactly what
|
|
508
|
+
// a half-copied dest looks like, so there is nothing here to choose on.
|
|
509
|
+
throw new Error(
|
|
510
|
+
`${REMOTE_COMMAND} found images for this bundle in ${winner.dest.directory} but could not confirm a single one of them, so it will not guess that this is the build's own \`--assets-dest\`. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
return { dest: winner.dest, searched, truncated }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* an explicitly named `--assets <dir>`. the caller promised this is the dest,
|
|
518
|
+
* so it is never ranked against anything, but its bytes are still checked
|
|
519
|
+
* against Metro's hashes so a stale dest is caught rather than shipped.
|
|
520
|
+
*/
|
|
521
|
+
function explicitMetroAssetDest(
|
|
522
|
+
directory: string,
|
|
523
|
+
descriptors: readonly MetroAssetDescriptor[],
|
|
524
|
+
): MetroAssetSearch {
|
|
525
|
+
const listing = listMetroAssetDest(directory)
|
|
526
|
+
const placements = descriptors.map((descriptor) =>
|
|
527
|
+
placeMetroDescriptor(listing, descriptor),
|
|
528
|
+
)
|
|
529
|
+
return {
|
|
530
|
+
dest: { directory: listing.directory, placements },
|
|
531
|
+
searched: [listing.directory],
|
|
532
|
+
truncated: listing.truncated,
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function largestEmbeddedAssets(
|
|
537
|
+
sizes: readonly MetroArtifactAssetSize[],
|
|
538
|
+
count: number,
|
|
539
|
+
): string {
|
|
540
|
+
if (sizes.length === 0) return ''
|
|
541
|
+
const largest = sizes
|
|
542
|
+
.slice(0, count)
|
|
543
|
+
.map((entry) => `${entry.asset} ${entry.bytes} bytes`)
|
|
544
|
+
.join(', ')
|
|
545
|
+
return ` Largest embedded assets: ${largest}.`
|
|
190
546
|
}
|
|
191
547
|
|
|
192
548
|
async function inferCloudModuleIdentity(
|
|
@@ -197,9 +553,9 @@ async function inferCloudModuleIdentity(
|
|
|
197
553
|
receipt = await loadMetroFingerprintRegistry()
|
|
198
554
|
} catch (error) {
|
|
199
555
|
throw new Error(
|
|
200
|
-
|
|
556
|
+
`${REMOTE_COMMAND} could not load the Metro fingerprint registry needed to identify this bundle: ${
|
|
201
557
|
error instanceof Error ? error.message : String(error)
|
|
202
|
-
}. Build it through the published Metro plugin: withRNX(config
|
|
558
|
+
}. Build it through the published Metro plugin: withRNX(config).`,
|
|
203
559
|
)
|
|
204
560
|
}
|
|
205
561
|
try {
|
|
@@ -216,9 +572,9 @@ async function inferCloudModuleIdentity(
|
|
|
216
572
|
}
|
|
217
573
|
} catch (error) {
|
|
218
574
|
throw new Error(
|
|
219
|
-
|
|
575
|
+
`${REMOTE_COMMAND} could not infer module identity for this Metro bundle: ${
|
|
220
576
|
error instanceof Error ? error.message : String(error)
|
|
221
|
-
}. Build it through the published Metro plugin: withRNX(config
|
|
577
|
+
}. Build it through the published Metro plugin: withRNX(config).`,
|
|
222
578
|
)
|
|
223
579
|
}
|
|
224
580
|
}
|
|
@@ -230,27 +586,80 @@ async function inferCloudModuleIdentity(
|
|
|
230
586
|
* an ordinary unannotated Metro production bundle is identified through the
|
|
231
587
|
* published fingerprint registry; a withRNX footer remains the fast path.
|
|
232
588
|
*/
|
|
589
|
+
export interface CloudArtifactProduction {
|
|
590
|
+
bytes: Buffer
|
|
591
|
+
sha256: string
|
|
592
|
+
receipt: RnxCloudArtifactReceipt
|
|
593
|
+
/** the asset dest directory the producer read, or null when it read none. */
|
|
594
|
+
assetsDirectory: string | null
|
|
595
|
+
/** what the CLI prints after the receipt. */
|
|
596
|
+
warnings: string[]
|
|
597
|
+
}
|
|
598
|
+
|
|
233
599
|
export async function produceCloudArtifact(
|
|
234
600
|
bundlePath: string,
|
|
235
601
|
assetsPath?: string,
|
|
236
|
-
): Promise<
|
|
602
|
+
): Promise<CloudArtifactProduction> {
|
|
237
603
|
const source = await loadBundleSource(bundlePath)
|
|
238
|
-
const
|
|
604
|
+
const descriptors = extractAssetDescriptors(source)
|
|
605
|
+
// an https bundle has no local tree to search, and a bundle with no assets
|
|
606
|
+
// has nothing to search for.
|
|
607
|
+
const search: MetroAssetSearch = assetsPath
|
|
608
|
+
? explicitMetroAssetDest(resolve(assetsPath), descriptors)
|
|
609
|
+
: descriptors.length > 0 && !bundlePath.startsWith('https://')
|
|
610
|
+
? searchMetroAssetDest(bundlePath, descriptors)
|
|
611
|
+
: { dest: null, searched: [], truncated: false }
|
|
612
|
+
const dest = search.dest
|
|
613
|
+
// `embedMetroAssetFiles` re-parses the bundle, so its descriptors are equal
|
|
614
|
+
// to the ones the search placed but not the same objects.
|
|
615
|
+
const placements = new Map<string, DescriptorPlacement>()
|
|
616
|
+
descriptors.forEach((descriptor, index) => {
|
|
617
|
+
const placement = dest?.placements[index]
|
|
618
|
+
if (placement) placements.set(metroAssetKey(descriptor), placement)
|
|
619
|
+
})
|
|
620
|
+
const substituted: string[] = []
|
|
239
621
|
const embedded = await embedMetroAssetFiles(source, async (variant) => {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
622
|
+
const label = metroAssetKey(variant.descriptor)
|
|
623
|
+
const placement = placements.get(label)
|
|
624
|
+
if (placement?.mismatched && dest) {
|
|
625
|
+
throw new Error(
|
|
626
|
+
`${REMOTE_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.`,
|
|
627
|
+
)
|
|
628
|
+
}
|
|
629
|
+
const relative = placement?.files.get(variant.scale)
|
|
630
|
+
if (relative && dest) return readFileSync(join(dest.directory, relative))
|
|
246
631
|
// iOS copies only @1x..@3x, so a declared scale with no file is ordinary
|
|
247
|
-
// and the producer narrows the descriptor.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
632
|
+
// and the producer narrows the descriptor.
|
|
633
|
+
if (placement && placement.files.size > 0) return null
|
|
634
|
+
if (placement && placement.ambiguous.length > 0 && dest) {
|
|
635
|
+
throw new Error(
|
|
636
|
+
`${REMOTE_COMMAND} found more than one image for ${label} in ${dest.directory} and none of them is the one Metro hashed: ${placement.ambiguous.sort().join(', ')}. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
|
|
637
|
+
)
|
|
638
|
+
}
|
|
639
|
+
// a stand-in is only honest when the asset is genuinely absent from a
|
|
640
|
+
// directory we know is the right one. an explicitly named dest is a
|
|
641
|
+
// promise, not a guess, so a miss there is a typo, and a truncated walk
|
|
642
|
+
// never finished looking. both refuse instead of shipping bytes nobody
|
|
643
|
+
// chose.
|
|
644
|
+
if (assetsPath) {
|
|
645
|
+
throw new Error(
|
|
646
|
+
`${REMOTE_COMMAND} could not find ${label} under --assets ${dest?.directory ?? resolve(assetsPath)}. Point --assets at the directory \`react-native bundle --assets-dest\` wrote, or drop the flag to let ${REMOTE_COMMAND} find it.`,
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
if (search.truncated) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`${REMOTE_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. Pass --assets <dir> to name the directory \`react-native bundle --assets-dest\` wrote.`,
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
// no scale of this asset exists anywhere the search looked. an image can
|
|
655
|
+
// stand in visibly and let the app run; every declared scale gets the same
|
|
656
|
+
// stand-in, because narrowing to one would rewrite a `scales` list the
|
|
657
|
+
// descriptor may not even carry.
|
|
658
|
+
const placeholder = metroAssetPlaceholderBytes(variant.descriptor.type)
|
|
659
|
+
if (placeholder) {
|
|
660
|
+
if (!substituted.includes(label)) substituted.push(label)
|
|
661
|
+
return placeholder
|
|
662
|
+
}
|
|
254
663
|
const filename = metroAssetScaleFilename(
|
|
255
664
|
variant.descriptor.name,
|
|
256
665
|
variant.descriptor.type,
|
|
@@ -259,24 +668,37 @@ export async function produceCloudArtifact(
|
|
|
259
668
|
const classic =
|
|
260
669
|
metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ??
|
|
261
670
|
filename
|
|
262
|
-
const lookupPath = assetsPath ?? '.'
|
|
263
671
|
throw new Error(
|
|
264
|
-
|
|
672
|
+
`${REMOTE_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. Build the bundle with --assets-dest, or pass --assets <dir>.`,
|
|
265
673
|
)
|
|
266
674
|
})
|
|
267
675
|
const artifact = await buildRnxCloudArtifact(embedded.code, {
|
|
268
676
|
inferModuleIdentity: inferCloudModuleIdentity,
|
|
269
677
|
})
|
|
270
678
|
const bytes = Buffer.from(artifact.code, 'utf8')
|
|
679
|
+
const assets = artifact.receipt.assets
|
|
271
680
|
if (bytes.length > MAX_RNX_CLOUD_BUNDLE_BYTES) {
|
|
272
681
|
throw new Error(
|
|
273
|
-
|
|
682
|
+
`${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)}`,
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
const warnings: string[] = []
|
|
686
|
+
if (substituted.length > 0) {
|
|
687
|
+
warnings.push(
|
|
688
|
+
`${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.`,
|
|
689
|
+
)
|
|
690
|
+
}
|
|
691
|
+
if (bytes.length >= WARN_ARTIFACT_BYTES) {
|
|
692
|
+
warnings.push(
|
|
693
|
+
`${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)}`,
|
|
274
694
|
)
|
|
275
695
|
}
|
|
276
696
|
return {
|
|
277
697
|
bytes,
|
|
278
698
|
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
279
699
|
receipt: artifact.receipt,
|
|
700
|
+
assetsDirectory: dest?.directory ?? null,
|
|
701
|
+
warnings,
|
|
280
702
|
}
|
|
281
703
|
}
|
|
282
704
|
|
|
@@ -289,7 +711,7 @@ function readCreateResponse(
|
|
|
289
711
|
boxToken: string
|
|
290
712
|
simulatorToken: string
|
|
291
713
|
} {
|
|
292
|
-
if (!isRecord(value)) throw new Error('
|
|
714
|
+
if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
|
|
293
715
|
const artifactId = `sha256:${expectedSha256}` as const
|
|
294
716
|
const isExpectedArtifact = (artifact: unknown): artifact is { bytes: number } =>
|
|
295
717
|
isRecord(artifact) &&
|
|
@@ -321,7 +743,7 @@ function readCreateResponse(
|
|
|
321
743
|
typeof simulatorToken !== 'string' ||
|
|
322
744
|
!simulatorToken
|
|
323
745
|
) {
|
|
324
|
-
throw new Error('
|
|
746
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
325
747
|
}
|
|
326
748
|
return {
|
|
327
749
|
receipt: {
|
|
@@ -339,21 +761,17 @@ function readCreateResponse(
|
|
|
339
761
|
}
|
|
340
762
|
}
|
|
341
763
|
|
|
342
|
-
// `rnx ios --
|
|
343
|
-
// first simulator in one request, and the shell then targets that
|
|
764
|
+
// `rnx ios --remote <bundle>` creates the remote host for the artifact and its
|
|
765
|
+
// first simulator in one request, and the shell then targets that simulator
|
|
344
766
|
export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
345
767
|
receipt: RnxCloudBoxCreateReceipt
|
|
346
768
|
session: CloudSession
|
|
347
769
|
artifact: RnxCloudArtifactReceipt
|
|
770
|
+
assetsDirectory: string | null
|
|
771
|
+
warnings: string[]
|
|
348
772
|
}> {
|
|
349
773
|
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
350
|
-
const
|
|
351
|
-
const assetsPath = options.assetsPath
|
|
352
|
-
? resolve(options.assetsPath)
|
|
353
|
-
: isUrl
|
|
354
|
-
? undefined
|
|
355
|
-
: resolve(dirname(options.bundlePath))
|
|
356
|
-
const artifact = await produceCloudArtifact(options.bundlePath, assetsPath)
|
|
774
|
+
const artifact = await produceCloudArtifact(options.bundlePath, options.assetsPath)
|
|
357
775
|
let response: Response
|
|
358
776
|
try {
|
|
359
777
|
response = await fetch(`${apiOrigin}/v1/boxes`, {
|
|
@@ -365,12 +783,13 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
|
365
783
|
'x-rnx-artifact-sha256': artifact.sha256,
|
|
366
784
|
'x-rnx-platform': 'ios',
|
|
367
785
|
'x-rnx-device': options.device,
|
|
786
|
+
...(options.accountId ? { 'x-rnx-account-id': options.accountId } : {}),
|
|
368
787
|
},
|
|
369
788
|
body: new Uint8Array(artifact.bytes),
|
|
370
789
|
})
|
|
371
790
|
} catch (error) {
|
|
372
791
|
throw new Error(
|
|
373
|
-
|
|
792
|
+
`${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
|
|
374
793
|
error instanceof Error ? error.message : String(error)
|
|
375
794
|
}`,
|
|
376
795
|
)
|
|
@@ -381,7 +800,7 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
|
381
800
|
try {
|
|
382
801
|
parsed = JSON.parse(responseText)
|
|
383
802
|
} catch {
|
|
384
|
-
throw new Error('
|
|
803
|
+
throw new Error('remote simulator create returned unreadable JSON')
|
|
385
804
|
}
|
|
386
805
|
const created = readCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
|
|
387
806
|
const session = createCloudSession({
|
|
@@ -392,7 +811,13 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
|
392
811
|
claimId: created.receipt.simulator.claim.id,
|
|
393
812
|
token: created.simulatorToken,
|
|
394
813
|
})
|
|
395
|
-
return {
|
|
814
|
+
return {
|
|
815
|
+
receipt: created.receipt,
|
|
816
|
+
session,
|
|
817
|
+
artifact: artifact.receipt,
|
|
818
|
+
assetsDirectory: artifact.assetsDirectory,
|
|
819
|
+
warnings: artifact.warnings,
|
|
820
|
+
}
|
|
396
821
|
}
|
|
397
822
|
|
|
398
823
|
export function resolveCloudSessionForParsed(
|
|
@@ -402,7 +827,7 @@ export function resolveCloudSessionForParsed(
|
|
|
402
827
|
if (!session) return null
|
|
403
828
|
if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
|
|
404
829
|
throw new Error(
|
|
405
|
-
`this shell is connected to
|
|
830
|
+
`this shell is connected to remote simulator ${session.simId}; ${
|
|
406
831
|
parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
|
|
407
832
|
} is not available in this shell`,
|
|
408
833
|
)
|
|
@@ -419,7 +844,9 @@ function cloudCommand(command: {
|
|
|
419
844
|
if (key !== 'id' && key !== 'simId') clean[key] = value
|
|
420
845
|
}
|
|
421
846
|
if (isRnxCloudCommandType(command.type)) return clean
|
|
422
|
-
throw new Error(
|
|
847
|
+
throw new Error(
|
|
848
|
+
`a remote simulator does not support bridge command type ${command.type}`,
|
|
849
|
+
)
|
|
423
850
|
}
|
|
424
851
|
|
|
425
852
|
class CloudBridge implements WsBridge {
|
|
@@ -454,7 +881,7 @@ class CloudBridge implements WsBridge {
|
|
|
454
881
|
)
|
|
455
882
|
} catch (error) {
|
|
456
883
|
throw new Error(
|
|
457
|
-
`could not reach
|
|
884
|
+
`could not reach the remote simulator at ${this.session.apiOrigin}: ${
|
|
458
885
|
error instanceof Error ? error.message : String(error)
|
|
459
886
|
}`,
|
|
460
887
|
)
|
|
@@ -465,34 +892,38 @@ class CloudBridge implements WsBridge {
|
|
|
465
892
|
try {
|
|
466
893
|
parsed = JSON.parse(text)
|
|
467
894
|
} catch {
|
|
468
|
-
throw new Error('
|
|
895
|
+
throw new Error('remote simulator command returned unreadable JSON')
|
|
469
896
|
}
|
|
470
897
|
if (!isRecord(parsed) || parsed.id !== body.id) {
|
|
471
|
-
throw new Error('
|
|
898
|
+
throw new Error('remote simulator command returned an invalid command receipt')
|
|
472
899
|
}
|
|
473
900
|
if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
|
|
474
901
|
if (!Object.hasOwn(parsed, 'result')) {
|
|
475
|
-
throw new Error('
|
|
902
|
+
throw new Error('remote simulator command receipt has no result')
|
|
476
903
|
}
|
|
477
904
|
return parsed.result
|
|
478
905
|
}
|
|
479
906
|
|
|
480
907
|
listSims(): Promise<BridgeSimInfo[]> {
|
|
481
|
-
return Promise.reject(
|
|
908
|
+
return Promise.reject(
|
|
909
|
+
new Error('a remote simulator does not expose a local simulator list'),
|
|
910
|
+
)
|
|
482
911
|
}
|
|
483
912
|
|
|
484
913
|
focusSim(): Promise<never> {
|
|
485
|
-
return Promise.reject(
|
|
486
|
-
new Error('RNX Cloud simulators have no browser window to focus'),
|
|
487
|
-
)
|
|
914
|
+
return Promise.reject(new Error('remote simulators have no browser window to focus'))
|
|
488
915
|
}
|
|
489
916
|
|
|
490
917
|
closeSim(): Promise<never> {
|
|
491
|
-
return Promise.reject(
|
|
918
|
+
return Promise.reject(
|
|
919
|
+
new Error('remote simulator close uses the simulator DELETE route'),
|
|
920
|
+
)
|
|
492
921
|
}
|
|
493
922
|
|
|
494
923
|
claim(): Promise<BridgeClaimResult> {
|
|
495
|
-
return Promise.reject(
|
|
924
|
+
return Promise.reject(
|
|
925
|
+
new Error('remote simulator claim uses the simulator claim route'),
|
|
926
|
+
)
|
|
496
927
|
}
|
|
497
928
|
|
|
498
929
|
close(): void {}
|
|
@@ -511,7 +942,7 @@ function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
|
|
|
511
942
|
!claim.id ||
|
|
512
943
|
typeof claim.expiresAt !== 'number'
|
|
513
944
|
) {
|
|
514
|
-
throw new Error('
|
|
945
|
+
throw new Error('remote simulator claim returned an invalid receipt')
|
|
515
946
|
}
|
|
516
947
|
return { id: claim.id, expiresAt: claim.expiresAt }
|
|
517
948
|
}
|
|
@@ -542,7 +973,7 @@ export async function claimCloudSimulator(
|
|
|
542
973
|
try {
|
|
543
974
|
parsed = JSON.parse(text)
|
|
544
975
|
} catch {
|
|
545
|
-
throw new Error('
|
|
976
|
+
throw new Error('remote simulator claim returned unreadable JSON')
|
|
546
977
|
}
|
|
547
978
|
const claim = readClaimResponse(parsed)
|
|
548
979
|
return {
|
|
@@ -554,7 +985,7 @@ export async function claimCloudSimulator(
|
|
|
554
985
|
export async function confirmCloudSimulator(session: CloudSession): Promise<void> {
|
|
555
986
|
const confirmed = await claimCloudSimulator(session)
|
|
556
987
|
if (confirmed.session.claimId !== session.claimId) {
|
|
557
|
-
throw new Error('
|
|
988
|
+
throw new Error('remote simulator confirmation replaced the initial claim')
|
|
558
989
|
}
|
|
559
990
|
}
|
|
560
991
|
|
|
@@ -576,6 +1007,7 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
|
|
|
576
1007
|
export interface AddCloudSimulatorOptions {
|
|
577
1008
|
device: string
|
|
578
1009
|
authorization: string
|
|
1010
|
+
accountId: string | null
|
|
579
1011
|
}
|
|
580
1012
|
|
|
581
1013
|
export async function addCloudSimulator(
|
|
@@ -595,13 +1027,14 @@ export async function addCloudSimulator(
|
|
|
595
1027
|
authorization: `Bearer ${session.boxToken}`,
|
|
596
1028
|
'x-rnx-account-authorization': options.authorization,
|
|
597
1029
|
'content-type': 'application/json',
|
|
1030
|
+
...(options.accountId ? { 'x-rnx-account-id': options.accountId } : {}),
|
|
598
1031
|
},
|
|
599
1032
|
body: JSON.stringify({ device: options.device }),
|
|
600
1033
|
},
|
|
601
1034
|
)
|
|
602
1035
|
} catch (error) {
|
|
603
1036
|
throw new Error(
|
|
604
|
-
`could not reach
|
|
1037
|
+
`could not reach the remote simulator at ${session.apiOrigin}: ${
|
|
605
1038
|
error instanceof Error ? error.message : String(error)
|
|
606
1039
|
}`,
|
|
607
1040
|
)
|
|
@@ -617,16 +1050,19 @@ export async function addCloudSimulator(
|
|
|
617
1050
|
(isRecord(parsed.error) && parsed.error.code === 'unknown_artifact'))
|
|
618
1051
|
) {
|
|
619
1052
|
throw new Error(
|
|
620
|
-
'this
|
|
1053
|
+
'this remote simulator does not hold a new bundle; run `rnx close` and create again',
|
|
621
1054
|
)
|
|
622
1055
|
}
|
|
623
1056
|
} catch (err) {
|
|
624
|
-
if (
|
|
1057
|
+
if (
|
|
1058
|
+
err instanceof Error &&
|
|
1059
|
+
err.message.startsWith('this remote simulator does not hold')
|
|
1060
|
+
) {
|
|
625
1061
|
throw err
|
|
626
1062
|
}
|
|
627
1063
|
}
|
|
628
1064
|
throw new Error(
|
|
629
|
-
'this
|
|
1065
|
+
'this remote simulator does not hold a new bundle; run `rnx close` and create again',
|
|
630
1066
|
)
|
|
631
1067
|
}
|
|
632
1068
|
throw responseError(response.status, responseText)
|
|
@@ -635,10 +1071,10 @@ export async function addCloudSimulator(
|
|
|
635
1071
|
try {
|
|
636
1072
|
parsed = JSON.parse(responseText)
|
|
637
1073
|
} catch {
|
|
638
|
-
throw new Error('
|
|
1074
|
+
throw new Error('remote simulator create returned unreadable JSON')
|
|
639
1075
|
}
|
|
640
1076
|
if (!isRecord(parsed) || !isRecord(parsed.simulator)) {
|
|
641
|
-
throw new Error('
|
|
1077
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
642
1078
|
}
|
|
643
1079
|
const simulator = parsed.simulator
|
|
644
1080
|
const simId = simulator.simId
|
|
@@ -658,7 +1094,7 @@ export async function addCloudSimulator(
|
|
|
658
1094
|
typeof artifact.id !== 'string' ||
|
|
659
1095
|
typeof artifact.bytes !== 'number'
|
|
660
1096
|
) {
|
|
661
|
-
throw new Error('
|
|
1097
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
662
1098
|
}
|
|
663
1099
|
const receipt: RnxCloudCreateReceipt = {
|
|
664
1100
|
simId,
|