rnxsim 0.1.432 → 0.1.434
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 +577 -99
- 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 +270 -26
- 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 +32 -5
- package/cli/parse-args.ts +17 -0
- package/cli/send-to-box.ts +112 -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 +5324 -1371
- 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 +298 -91
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +369 -241
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +7 -1
- package/dist-lib/menu.mjs +7 -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 +327 -299
- 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 +4849 -1199
- package/dist-lib/vite.cjs +298 -91
- package/package.json +3 -2
- package/src/auth/shared-session.ts +31 -0
- package/src/bridge-contract.ts +6 -0
- package/src/cloud.ts +83 -113
- package/src/host/bridge-host.ts +79 -0
- package/src/host/replacement-module-handler.ts +20 -96
- package/src/menu.ts +8 -0
- 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,
|
|
@@ -20,6 +24,7 @@ import {
|
|
|
20
24
|
} from '../../contrast-bundler/src/metroCloudArtifact'
|
|
21
25
|
import { inferMetroModuleIdentity } from '../../sootsim-engine/src/metro-fingerprint'
|
|
22
26
|
import { loadMetroFingerprintRegistry } from '../../sootsim-engine/src/metro-fingerprint-registry-client'
|
|
27
|
+
import { isLoopbackHost } from '../src/backend-origin'
|
|
23
28
|
import {
|
|
24
29
|
isRnxCloudCommandType,
|
|
25
30
|
RNX_CLOUD_MAX_ARTIFACT_BYTES,
|
|
@@ -31,12 +36,15 @@ import {
|
|
|
31
36
|
type RNXMetroModuleIdentityMetadata,
|
|
32
37
|
} from '../src/metro-production-bundle'
|
|
33
38
|
import { rnxPublicBrand } from '../src/public-brand'
|
|
39
|
+
import { CHOOSE_ACCOUNT_HINT } from './auth'
|
|
34
40
|
import {
|
|
35
41
|
createCloudSession,
|
|
36
42
|
normalizeCloudOrigin,
|
|
37
43
|
readCloudSession,
|
|
38
44
|
type CloudSession,
|
|
39
45
|
} from './cloud-session'
|
|
46
|
+
import { detectProject, findProjectRoot } from './commands/detect'
|
|
47
|
+
import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract'
|
|
40
48
|
import type {
|
|
41
49
|
BridgeClaimResult,
|
|
42
50
|
BridgeSimInfo,
|
|
@@ -47,13 +55,35 @@ import type {
|
|
|
47
55
|
export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin
|
|
48
56
|
export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES
|
|
49
57
|
|
|
58
|
+
// every message this module writes names the command through this constant.
|
|
59
|
+
const REMOTE_COMMAND = 'rnx ios --remote'
|
|
60
|
+
|
|
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
|
+
// a Metro asset dest is a shallow tree of images. a walk that runs past this
|
|
66
|
+
// is pointed at something else entirely, and its extra entries cannot resolve
|
|
67
|
+
// an asset anyway.
|
|
68
|
+
const MAX_ASSET_DEST_FILES = 25_000
|
|
69
|
+
|
|
50
70
|
interface CreateCloudBoxOptions {
|
|
51
71
|
bundlePath: string
|
|
52
72
|
device: string
|
|
53
73
|
authorization: string
|
|
74
|
+
/** the account to bill when the authorization is a session token; an api key names its own */
|
|
75
|
+
accountId: string | null
|
|
54
76
|
/** the `--assets-dest` directory Metro wrote beside the bundle. */
|
|
55
77
|
assetsPath?: string
|
|
56
78
|
apiOrigin?: string
|
|
79
|
+
/** the name this box carries in the account's box list. */
|
|
80
|
+
boxName?: string | null
|
|
81
|
+
/**
|
|
82
|
+
* the storage and route the first simulator boots with, captured from a
|
|
83
|
+
* running app. a snapshot reaches a megabyte, so a create that carries one
|
|
84
|
+
* is multipart rather than a bare artifact body.
|
|
85
|
+
*/
|
|
86
|
+
storage?: StorageSnapshot | null
|
|
57
87
|
}
|
|
58
88
|
|
|
59
89
|
interface CloudCommandEnvelope {
|
|
@@ -66,18 +96,43 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
66
96
|
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
67
97
|
}
|
|
68
98
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
99
|
+
// three services answer this client and each states a failure its own way:
|
|
100
|
+
// the box service says { error: '<sentence>' }, the site's public api says
|
|
101
|
+
// { error: '<code>', message: '<sentence>' }, and its service-to-service api
|
|
102
|
+
// says { error: { code, message } }. all three carry one sentence meant for
|
|
103
|
+
// the person at the terminal, so read whichever field holds it. without this
|
|
104
|
+
// the sentence never reaches them and they get the raw json body instead.
|
|
105
|
+
function failureSentence(text: string): string | null {
|
|
106
|
+
let parsed: unknown
|
|
107
|
+
try {
|
|
108
|
+
parsed = JSON.parse(text)
|
|
109
|
+
} catch {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
if (!isRecord(parsed)) return null
|
|
113
|
+
if (isRecord(parsed.error) && typeof parsed.error.message === 'string') {
|
|
114
|
+
return parsed.error.message
|
|
78
115
|
}
|
|
116
|
+
if (typeof parsed.message === 'string') return parsed.message
|
|
117
|
+
if (typeof parsed.error === 'string') return parsed.error
|
|
118
|
+
return null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// the account service says what it needs in its own words; the CLI says what
|
|
122
|
+
// the person can do about it, which is choose the account at `rnx login`.
|
|
123
|
+
const ACCOUNT_CHOICE_REFUSALS = new Set([
|
|
124
|
+
'accountId is required',
|
|
125
|
+
'you do not have access to this account',
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
function responseError(status: number, text: string): Error {
|
|
129
|
+
const parsed = text ? failureSentence(text) : null
|
|
130
|
+
const sentence =
|
|
131
|
+
parsed && ACCOUNT_CHOICE_REFUSALS.has(parsed) ? CHOOSE_ACCOUNT_HINT : parsed
|
|
79
132
|
return new Error(
|
|
80
|
-
`
|
|
133
|
+
`remote simulator request failed (${status})${
|
|
134
|
+
sentence ? `: ${sentence}` : text.trim() ? `: ${text.trim()}` : ''
|
|
135
|
+
}`,
|
|
81
136
|
)
|
|
82
137
|
}
|
|
83
138
|
|
|
@@ -90,16 +145,16 @@ function resolveCloudOrigin(value?: string): string {
|
|
|
90
145
|
function readImmutableBundleSource(bundlePath: string): string {
|
|
91
146
|
const filepath = resolve(bundlePath)
|
|
92
147
|
if (extname(filepath) !== '.js') {
|
|
93
|
-
throw new Error(
|
|
148
|
+
throw new Error(`${REMOTE_COMMAND} requires one local Metro .js bundle`)
|
|
94
149
|
}
|
|
95
150
|
const descriptor = openSync(filepath, 'r')
|
|
96
151
|
try {
|
|
97
152
|
const before = fstatSync(descriptor, { bigint: true })
|
|
98
153
|
if (!before.isFile()) {
|
|
99
|
-
throw new Error(
|
|
154
|
+
throw new Error(`${REMOTE_COMMAND} bundle must be a regular local file`)
|
|
100
155
|
}
|
|
101
156
|
if (before.size <= 0n) {
|
|
102
|
-
throw new Error(
|
|
157
|
+
throw new Error(`${REMOTE_COMMAND} bundle is empty`)
|
|
103
158
|
}
|
|
104
159
|
const bytes = readFileSync(descriptor)
|
|
105
160
|
const after = fstatSync(descriptor, { bigint: true })
|
|
@@ -110,16 +165,16 @@ function readImmutableBundleSource(bundlePath: string): string {
|
|
|
110
165
|
before.mtimeNs !== after.mtimeNs ||
|
|
111
166
|
bytes.length !== Number(before.size)
|
|
112
167
|
) {
|
|
113
|
-
throw new Error(
|
|
168
|
+
throw new Error(`${REMOTE_COMMAND} bundle changed while it was being read`)
|
|
114
169
|
}
|
|
115
170
|
let source: string
|
|
116
171
|
try {
|
|
117
172
|
source = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
|
|
118
173
|
} catch {
|
|
119
|
-
throw new Error(
|
|
174
|
+
throw new Error(`${REMOTE_COMMAND} bundle must be valid UTF-8 JavaScript`)
|
|
120
175
|
}
|
|
121
176
|
if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
|
|
122
|
-
throw new Error(
|
|
177
|
+
throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
|
|
123
178
|
}
|
|
124
179
|
return source
|
|
125
180
|
} finally {
|
|
@@ -133,33 +188,49 @@ async function fetchBundleSource(bundleUrl: string): Promise<string> {
|
|
|
133
188
|
response = await fetch(bundleUrl)
|
|
134
189
|
} catch (error) {
|
|
135
190
|
throw new Error(
|
|
136
|
-
|
|
191
|
+
`${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: ${
|
|
137
192
|
error instanceof Error ? error.message : String(error)
|
|
138
193
|
}`,
|
|
139
194
|
)
|
|
140
195
|
}
|
|
141
196
|
if (!response.ok) {
|
|
142
197
|
throw new Error(
|
|
143
|
-
|
|
198
|
+
`${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${
|
|
144
199
|
response.statusText ? ` ${response.statusText}` : ''
|
|
145
200
|
}`,
|
|
146
201
|
)
|
|
147
202
|
}
|
|
148
203
|
const text = await response.text()
|
|
149
204
|
if (text.length === 0) {
|
|
150
|
-
throw new Error(
|
|
205
|
+
throw new Error(`${REMOTE_COMMAND} bundle is empty`)
|
|
151
206
|
}
|
|
152
207
|
if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) {
|
|
153
|
-
throw new Error(
|
|
208
|
+
throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`)
|
|
154
209
|
}
|
|
155
210
|
return text
|
|
156
211
|
}
|
|
157
212
|
|
|
213
|
+
// a bundle served over plain http is only trustworthy when it comes from this
|
|
214
|
+
// machine, which is what "send to box" reads: the Metro dev server the app is
|
|
215
|
+
// already running on.
|
|
216
|
+
function isLoopbackBundleUrl(bundleInput: string): boolean {
|
|
217
|
+
if (!bundleInput.startsWith('http://')) return false
|
|
218
|
+
try {
|
|
219
|
+
return isLoopbackHost(new URL(bundleInput).hostname)
|
|
220
|
+
} catch {
|
|
221
|
+
return false
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isRemoteBundleInput(bundleInput: string): boolean {
|
|
226
|
+
return bundleInput.startsWith('https://') || bundleInput.startsWith('http://')
|
|
227
|
+
}
|
|
228
|
+
|
|
158
229
|
async function loadBundleSource(bundleInput: string): Promise<string> {
|
|
159
|
-
if (bundleInput.startsWith('http://')) {
|
|
160
|
-
throw new Error(
|
|
230
|
+
if (bundleInput.startsWith('http://') && !isLoopbackBundleUrl(bundleInput)) {
|
|
231
|
+
throw new Error(`${REMOTE_COMMAND} bundle URL must use HTTPS`)
|
|
161
232
|
}
|
|
162
|
-
if (bundleInput
|
|
233
|
+
if (isRemoteBundleInput(bundleInput)) {
|
|
163
234
|
return fetchBundleSource(bundleInput)
|
|
164
235
|
}
|
|
165
236
|
return readImmutableBundleSource(bundleInput)
|
|
@@ -168,10 +239,36 @@ async function loadBundleSource(bundleInput: string): Promise<string> {
|
|
|
168
239
|
// `react-native bundle --assets-dest` dest paths are not always the JS
|
|
169
240
|
// descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does
|
|
170
241
|
// that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the
|
|
171
|
-
// project-root-relative app path.
|
|
172
|
-
|
|
173
|
-
|
|
242
|
+
// project-root-relative app path. resolveMetroIosAssetDestFiles matches both.
|
|
243
|
+
interface MetroAssetDestListing {
|
|
244
|
+
directory: string
|
|
245
|
+
files: string[]
|
|
246
|
+
/** the walk stopped at the cap, so "not in this listing" proves nothing. */
|
|
247
|
+
truncated: boolean
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// a Metro asset dest never holds these, but a project root does, and a
|
|
251
|
+
// candidate can be either. `node_modules` is not in the list on purpose: a real
|
|
252
|
+
// dest DOES carry one, because that is where `react-native bundle` copies the
|
|
253
|
+
// assets that live inside packages. it is skipped only under a project root,
|
|
254
|
+
// which is what a package.json at the candidate marks.
|
|
255
|
+
const PROJECT_ONLY_DIRECTORIES = new Set([
|
|
256
|
+
'node_modules',
|
|
257
|
+
'Pods',
|
|
258
|
+
'DerivedData',
|
|
259
|
+
'.expo',
|
|
260
|
+
'.next',
|
|
261
|
+
'.turbo',
|
|
262
|
+
'.cache',
|
|
263
|
+
'.gradle',
|
|
264
|
+
])
|
|
265
|
+
|
|
266
|
+
function listMetroAssetDest(assetsPath: string): MetroAssetDestListing {
|
|
267
|
+
const directory = resolve(assetsPath)
|
|
268
|
+
if (!existsSync(directory)) return { directory, files: [], truncated: false }
|
|
269
|
+
const skipNested = existsSync(join(directory, 'package.json'))
|
|
174
270
|
const files: string[] = []
|
|
271
|
+
let truncated = false
|
|
175
272
|
const walk = (dir: string, prefix: string) => {
|
|
176
273
|
let entries
|
|
177
274
|
try {
|
|
@@ -180,13 +277,298 @@ function listMetroAssetDestFiles(assetsPath?: string): string[] {
|
|
|
180
277
|
return
|
|
181
278
|
}
|
|
182
279
|
for (const entry of entries) {
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
280
|
+
if (files.length >= MAX_ASSET_DEST_FILES) {
|
|
281
|
+
truncated = true
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
if (entry.isDirectory()) {
|
|
285
|
+
if (entry.name === '.git') continue
|
|
286
|
+
if (skipNested && PROJECT_ONLY_DIRECTORIES.has(entry.name)) continue
|
|
287
|
+
walk(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name)
|
|
288
|
+
} else if (entry.isFile()) {
|
|
289
|
+
files.push(prefix ? `${prefix}/${entry.name}` : entry.name)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
walk(directory, '')
|
|
294
|
+
return { directory, files, truncated }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Metro's own identity for an asset. `getAbsoluteAssetInfo` in metro/src/Assets
|
|
299
|
+
* md5s every scale file's bytes into ONE digest, in the ascending scale order
|
|
300
|
+
* `buildAssetMap` keeps `scales` and `files` parallel in, and that digest is
|
|
301
|
+
* the descriptor's `hash`. so a directory can be asked whether it holds THIS
|
|
302
|
+
* asset instead of merely a file with the same name.
|
|
303
|
+
*/
|
|
304
|
+
function metroDescriptorHash(scaleBytes: readonly Buffer[]): string {
|
|
305
|
+
const hasher = createHash('md5')
|
|
306
|
+
for (const bytes of scaleBytes) hasher.update(bytes)
|
|
307
|
+
return hasher.digest('hex')
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function metroAssetKey(descriptor: MetroAssetDescriptor): string {
|
|
311
|
+
return `${descriptor.httpServerLocation}/${descriptor.name}.${descriptor.type}`
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** where one descriptor's scales sit inside one candidate directory. */
|
|
315
|
+
interface DescriptorPlacement {
|
|
316
|
+
/** the dest-relative file chosen for each declared scale that is present. */
|
|
317
|
+
files: Map<number, string>
|
|
318
|
+
/** files a scale matched that hold different images, so none was chosen. */
|
|
319
|
+
ambiguous: string[]
|
|
320
|
+
/** every declared scale is present and their bytes are Metro's `hash`. */
|
|
321
|
+
verified: boolean
|
|
322
|
+
/** every declared scale is present and their bytes are something else. */
|
|
323
|
+
mismatched: boolean
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// an ambiguous scale is rare and its file count is small, so trying every
|
|
327
|
+
// combination against the descriptor hash is cheap. the cap only stops a
|
|
328
|
+
// pathological dest from turning identity into a search.
|
|
329
|
+
const MAX_PLACEMENT_COMBINATIONS = 64
|
|
330
|
+
|
|
331
|
+
function placementCombinations(perScale: readonly string[][]): string[][] {
|
|
332
|
+
let total = 1
|
|
333
|
+
for (const options of perScale) total *= options.length
|
|
334
|
+
if (total > MAX_PLACEMENT_COMBINATIONS) return []
|
|
335
|
+
let combinations: string[][] = [[]]
|
|
336
|
+
for (const options of perScale) {
|
|
337
|
+
combinations = combinations.flatMap((prefix) =>
|
|
338
|
+
options.map((option) => [...prefix, option]),
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
return combinations
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* one file per distinct image, out of the files a scale matched.
|
|
346
|
+
*
|
|
347
|
+
* two paths holding byte-identical images are not a choice to make, and the
|
|
348
|
+
* descriptor's hash could not separate them if they were.
|
|
349
|
+
*/
|
|
350
|
+
function distinctByContent(directory: string, options: readonly string[]): string[] {
|
|
351
|
+
if (options.length < 2) return [...options]
|
|
352
|
+
const byContent = new Map<string, string>()
|
|
353
|
+
for (const relative of options) {
|
|
354
|
+
let bytes: Buffer
|
|
355
|
+
try {
|
|
356
|
+
bytes = readFileSync(join(directory, relative))
|
|
357
|
+
} catch {
|
|
358
|
+
continue
|
|
186
359
|
}
|
|
360
|
+
const digest = createHash('md5').update(bytes).digest('hex')
|
|
361
|
+
if (!byContent.has(digest)) byContent.set(digest, relative)
|
|
187
362
|
}
|
|
188
|
-
|
|
189
|
-
|
|
363
|
+
return [...byContent.values()]
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* decides what a candidate directory holds for one descriptor, by bytes.
|
|
368
|
+
*
|
|
369
|
+
* Metro hashes the asset's SOURCE files, and `react-native bundle` copies only
|
|
370
|
+
* @1x..@3x to an iOS dest while leaving `scales` unfiltered. so a descriptor
|
|
371
|
+
* that declares an Android rung such as @1.5x records a digest no iOS dest can
|
|
372
|
+
* reproduce, however complete that dest is. those come back neither verified
|
|
373
|
+
* nor mismatched, and the caller refuses when a whole bundle is like that
|
|
374
|
+
* rather than picking a directory it cannot check.
|
|
375
|
+
*/
|
|
376
|
+
function placeMetroDescriptor(
|
|
377
|
+
listing: MetroAssetDestListing,
|
|
378
|
+
descriptor: MetroAssetDescriptor,
|
|
379
|
+
): DescriptorPlacement {
|
|
380
|
+
const perScale = descriptor.scales.map((scale) =>
|
|
381
|
+
distinctByContent(
|
|
382
|
+
listing.directory,
|
|
383
|
+
resolveMetroIosAssetDestFiles(listing.files, descriptor, scale),
|
|
384
|
+
),
|
|
385
|
+
)
|
|
386
|
+
// a scale that resolved to one image names its file whatever the verdict
|
|
387
|
+
// below is. one that resolved to several is left for the hash to settle.
|
|
388
|
+
const files = new Map<number, string>()
|
|
389
|
+
const ambiguous: string[] = []
|
|
390
|
+
descriptor.scales.forEach((scale, index) => {
|
|
391
|
+
const options = perScale[index] ?? []
|
|
392
|
+
if (options.length === 1 && options[0]) files.set(scale, options[0])
|
|
393
|
+
else if (options.length > 1) ambiguous.push(...options)
|
|
394
|
+
})
|
|
395
|
+
if (perScale.length === 0 || perScale.some((options) => options.length === 0)) {
|
|
396
|
+
return { files, ambiguous, verified: false, mismatched: false }
|
|
397
|
+
}
|
|
398
|
+
for (const combination of placementCombinations(perScale)) {
|
|
399
|
+
let bytes: Buffer[]
|
|
400
|
+
try {
|
|
401
|
+
bytes = combination.map((relative) =>
|
|
402
|
+
readFileSync(join(listing.directory, relative)),
|
|
403
|
+
)
|
|
404
|
+
} catch {
|
|
405
|
+
continue
|
|
406
|
+
}
|
|
407
|
+
if (metroDescriptorHash(bytes) !== descriptor.hash) continue
|
|
408
|
+
const chosen = new Map<number, string>()
|
|
409
|
+
descriptor.scales.forEach((scale, index) => {
|
|
410
|
+
const relative = combination[index]
|
|
411
|
+
if (relative) chosen.set(scale, relative)
|
|
412
|
+
})
|
|
413
|
+
return { files: chosen, ambiguous: [], verified: true, mismatched: false }
|
|
414
|
+
}
|
|
415
|
+
// every declared scale is here and no arrangement of them is this asset, so
|
|
416
|
+
// the directory holds different images under these names.
|
|
417
|
+
return { files, ambiguous, verified: false, mismatched: true }
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
interface MetroAssetDest {
|
|
421
|
+
directory: string
|
|
422
|
+
/** the placement of each descriptor, in the order the bundle declares them. */
|
|
423
|
+
placements: DescriptorPlacement[]
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
interface MetroAssetSearch {
|
|
427
|
+
/** the directory the producer reads asset bytes from, or null if none held any. */
|
|
428
|
+
dest: MetroAssetDest | null
|
|
429
|
+
/** every directory the search considered, in order, for the warning text. */
|
|
430
|
+
searched: string[]
|
|
431
|
+
/** a walk stopped at the cap, so no directory can be called fully looked at. */
|
|
432
|
+
truncated: boolean
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* what two candidates could still disagree about.
|
|
437
|
+
*
|
|
438
|
+
* a verified placement's bytes reproduce Metro's own digest, so it is the right
|
|
439
|
+
* content wherever it was read from and two directories that both verified it
|
|
440
|
+
* are not in conflict. only the unverified files are a real fork, and they are
|
|
441
|
+
* keyed by absolute path because `<dir>/assets` and `<dir>` are both candidates
|
|
442
|
+
* and list the same files under different dest-relative names.
|
|
443
|
+
*/
|
|
444
|
+
function unverifiedFileKey(dest: MetroAssetDest): string {
|
|
445
|
+
return dest.placements
|
|
446
|
+
.filter((placement) => !placement.verified)
|
|
447
|
+
.flatMap((placement) =>
|
|
448
|
+
[...placement.files.values()].map((relative) => join(dest.directory, relative)),
|
|
449
|
+
)
|
|
450
|
+
.sort()
|
|
451
|
+
.join('\n')
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* finds the `--assets-dest` directory by RESOLVING the bundle's own asset
|
|
456
|
+
* descriptors against a short ordered list of the places Metro writes one, and
|
|
457
|
+
* CONFIRMING the bytes it finds against the md5 Metro recorded in each
|
|
458
|
+
* descriptor. name and layout alone are a popularity contest an unrelated build
|
|
459
|
+
* output can win; the hash makes it an identity check.
|
|
460
|
+
*/
|
|
461
|
+
function searchMetroAssetDest(
|
|
462
|
+
bundlePath: string,
|
|
463
|
+
descriptors: readonly MetroAssetDescriptor[],
|
|
464
|
+
): MetroAssetSearch {
|
|
465
|
+
const bundleDirectory = dirname(resolve(bundlePath))
|
|
466
|
+
const candidates = [
|
|
467
|
+
join(bundleDirectory, 'assets'),
|
|
468
|
+
bundleDirectory,
|
|
469
|
+
resolve(bundleDirectory, '..', 'assets'),
|
|
470
|
+
]
|
|
471
|
+
const cwd = process.cwd()
|
|
472
|
+
if (detectProject(cwd)?.hasMetroConfig) {
|
|
473
|
+
const root = findProjectRoot(cwd)
|
|
474
|
+
candidates.push(join(root, 'dist'), join(root, 'build'))
|
|
475
|
+
}
|
|
476
|
+
const searched: string[] = []
|
|
477
|
+
const holders: { dest: MetroAssetDest; verified: number }[] = []
|
|
478
|
+
const stale: string[] = []
|
|
479
|
+
let truncated = false
|
|
480
|
+
for (const candidate of candidates) {
|
|
481
|
+
const directory = resolve(candidate)
|
|
482
|
+
if (searched.includes(directory)) continue
|
|
483
|
+
searched.push(directory)
|
|
484
|
+
const listing = listMetroAssetDest(directory)
|
|
485
|
+
// a cut-short walk is remembered even when the candidate resolves nothing,
|
|
486
|
+
// because the reason it resolved nothing may be that it never finished.
|
|
487
|
+
if (listing.truncated) truncated = true
|
|
488
|
+
if (listing.files.length === 0) continue
|
|
489
|
+
const placements = descriptors.map((descriptor) =>
|
|
490
|
+
placeMetroDescriptor(listing, descriptor),
|
|
491
|
+
)
|
|
492
|
+
// one wrong asset is enough: a directory that provably holds different
|
|
493
|
+
// bytes under these names is a different build, not a partial dest.
|
|
494
|
+
if (placements.some((placement) => placement.mismatched)) {
|
|
495
|
+
stale.push(directory)
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
const verified = placements.filter((placement) => placement.verified).length
|
|
499
|
+
if (verified === 0 && placements.every((placement) => placement.files.size === 0))
|
|
500
|
+
continue
|
|
501
|
+
holders.push({ dest: { directory, placements }, verified })
|
|
502
|
+
}
|
|
503
|
+
if (holders.length === 0) {
|
|
504
|
+
// every scale is on disk and none of it is this bundle's. saying the
|
|
505
|
+
// assets are missing would be false, and a stand-in over a stale image is
|
|
506
|
+
// the wrong repair for a dest that only needs rebuilding.
|
|
507
|
+
if (stale.length > 0) {
|
|
508
|
+
throw new Error(
|
|
509
|
+
`${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.`,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
return { dest: null, searched, truncated }
|
|
513
|
+
}
|
|
514
|
+
const best = Math.max(...holders.map((holder) => holder.verified))
|
|
515
|
+
// a directory that confirmed nothing cannot outrank one that confirmed
|
|
516
|
+
// something, and when nothing anywhere confirmed, every claimant is a rival.
|
|
517
|
+
const rivals = holders.filter((holder) => holder.verified === best)
|
|
518
|
+
const distinct = new Set(rivals.map((rival) => unverifiedFileKey(rival.dest)))
|
|
519
|
+
const winner = rivals[0]
|
|
520
|
+
if (!winner) return { dest: null, searched, truncated }
|
|
521
|
+
if (distinct.size > 1) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`${REMOTE_COMMAND} found this bundle's assets in more than one directory and cannot tell which build wrote them: ${rivals
|
|
524
|
+
.map((rival) => rival.dest.directory)
|
|
525
|
+
.join(
|
|
526
|
+
', ',
|
|
527
|
+
)}. Pass --assets <dir> to name the one \`react-native bundle --assets-dest\` wrote.`,
|
|
528
|
+
)
|
|
529
|
+
}
|
|
530
|
+
if (best === 0) {
|
|
531
|
+
// every descriptor declares a scale this directory does not hold, so not
|
|
532
|
+
// one of Metro's digests can be reproduced from it. that is ordinary for an
|
|
533
|
+
// asset carrying an Android rung such as @1.5x, and it is also exactly what
|
|
534
|
+
// a half-copied dest looks like, so there is nothing here to choose on.
|
|
535
|
+
throw new Error(
|
|
536
|
+
`${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.`,
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
return { dest: winner.dest, searched, truncated }
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* an explicitly named `--assets <dir>`. the caller promised this is the dest,
|
|
544
|
+
* so it is never ranked against anything, but its bytes are still checked
|
|
545
|
+
* against Metro's hashes so a stale dest is caught rather than shipped.
|
|
546
|
+
*/
|
|
547
|
+
function explicitMetroAssetDest(
|
|
548
|
+
directory: string,
|
|
549
|
+
descriptors: readonly MetroAssetDescriptor[],
|
|
550
|
+
): MetroAssetSearch {
|
|
551
|
+
const listing = listMetroAssetDest(directory)
|
|
552
|
+
const placements = descriptors.map((descriptor) =>
|
|
553
|
+
placeMetroDescriptor(listing, descriptor),
|
|
554
|
+
)
|
|
555
|
+
return {
|
|
556
|
+
dest: { directory: listing.directory, placements },
|
|
557
|
+
searched: [listing.directory],
|
|
558
|
+
truncated: listing.truncated,
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function largestEmbeddedAssets(
|
|
563
|
+
sizes: readonly MetroArtifactAssetSize[],
|
|
564
|
+
count: number,
|
|
565
|
+
): string {
|
|
566
|
+
if (sizes.length === 0) return ''
|
|
567
|
+
const largest = sizes
|
|
568
|
+
.slice(0, count)
|
|
569
|
+
.map((entry) => `${entry.asset} ${entry.bytes} bytes`)
|
|
570
|
+
.join(', ')
|
|
571
|
+
return ` Largest embedded assets: ${largest}.`
|
|
190
572
|
}
|
|
191
573
|
|
|
192
574
|
async function inferCloudModuleIdentity(
|
|
@@ -197,9 +579,9 @@ async function inferCloudModuleIdentity(
|
|
|
197
579
|
receipt = await loadMetroFingerprintRegistry()
|
|
198
580
|
} catch (error) {
|
|
199
581
|
throw new Error(
|
|
200
|
-
|
|
582
|
+
`${REMOTE_COMMAND} could not load the Metro fingerprint registry needed to identify this bundle: ${
|
|
201
583
|
error instanceof Error ? error.message : String(error)
|
|
202
|
-
}. Build it through the published Metro plugin: withRNX(config
|
|
584
|
+
}. Build it through the published Metro plugin: withRNX(config).`,
|
|
203
585
|
)
|
|
204
586
|
}
|
|
205
587
|
try {
|
|
@@ -216,9 +598,9 @@ async function inferCloudModuleIdentity(
|
|
|
216
598
|
}
|
|
217
599
|
} catch (error) {
|
|
218
600
|
throw new Error(
|
|
219
|
-
|
|
601
|
+
`${REMOTE_COMMAND} could not infer module identity for this Metro bundle: ${
|
|
220
602
|
error instanceof Error ? error.message : String(error)
|
|
221
|
-
}. Build it through the published Metro plugin: withRNX(config
|
|
603
|
+
}. Build it through the published Metro plugin: withRNX(config).`,
|
|
222
604
|
)
|
|
223
605
|
}
|
|
224
606
|
}
|
|
@@ -230,27 +612,80 @@ async function inferCloudModuleIdentity(
|
|
|
230
612
|
* an ordinary unannotated Metro production bundle is identified through the
|
|
231
613
|
* published fingerprint registry; a withRNX footer remains the fast path.
|
|
232
614
|
*/
|
|
615
|
+
export interface CloudArtifactProduction {
|
|
616
|
+
bytes: Buffer
|
|
617
|
+
sha256: string
|
|
618
|
+
receipt: RnxCloudArtifactReceipt
|
|
619
|
+
/** the asset dest directory the producer read, or null when it read none. */
|
|
620
|
+
assetsDirectory: string | null
|
|
621
|
+
/** what the CLI prints after the receipt. */
|
|
622
|
+
warnings: string[]
|
|
623
|
+
}
|
|
624
|
+
|
|
233
625
|
export async function produceCloudArtifact(
|
|
234
626
|
bundlePath: string,
|
|
235
627
|
assetsPath?: string,
|
|
236
|
-
): Promise<
|
|
628
|
+
): Promise<CloudArtifactProduction> {
|
|
237
629
|
const source = await loadBundleSource(bundlePath)
|
|
238
|
-
const
|
|
630
|
+
const descriptors = extractAssetDescriptors(source)
|
|
631
|
+
// an https bundle has no local tree to search, and a bundle with no assets
|
|
632
|
+
// has nothing to search for.
|
|
633
|
+
const search: MetroAssetSearch = assetsPath
|
|
634
|
+
? explicitMetroAssetDest(resolve(assetsPath), descriptors)
|
|
635
|
+
: descriptors.length > 0 && !isRemoteBundleInput(bundlePath)
|
|
636
|
+
? searchMetroAssetDest(bundlePath, descriptors)
|
|
637
|
+
: { dest: null, searched: [], truncated: false }
|
|
638
|
+
const dest = search.dest
|
|
639
|
+
// `embedMetroAssetFiles` re-parses the bundle, so its descriptors are equal
|
|
640
|
+
// to the ones the search placed but not the same objects.
|
|
641
|
+
const placements = new Map<string, DescriptorPlacement>()
|
|
642
|
+
descriptors.forEach((descriptor, index) => {
|
|
643
|
+
const placement = dest?.placements[index]
|
|
644
|
+
if (placement) placements.set(metroAssetKey(descriptor), placement)
|
|
645
|
+
})
|
|
646
|
+
const substituted: string[] = []
|
|
239
647
|
const embedded = await embedMetroAssetFiles(source, async (variant) => {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
648
|
+
const label = metroAssetKey(variant.descriptor)
|
|
649
|
+
const placement = placements.get(label)
|
|
650
|
+
if (placement?.mismatched && dest) {
|
|
651
|
+
throw new Error(
|
|
652
|
+
`${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.`,
|
|
653
|
+
)
|
|
654
|
+
}
|
|
655
|
+
const relative = placement?.files.get(variant.scale)
|
|
656
|
+
if (relative && dest) return readFileSync(join(dest.directory, relative))
|
|
246
657
|
// 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
|
-
|
|
658
|
+
// and the producer narrows the descriptor.
|
|
659
|
+
if (placement && placement.files.size > 0) return null
|
|
660
|
+
if (placement && placement.ambiguous.length > 0 && dest) {
|
|
661
|
+
throw new Error(
|
|
662
|
+
`${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.`,
|
|
663
|
+
)
|
|
664
|
+
}
|
|
665
|
+
// a stand-in is only honest when the asset is genuinely absent from a
|
|
666
|
+
// directory we know is the right one. an explicitly named dest is a
|
|
667
|
+
// promise, not a guess, so a miss there is a typo, and a truncated walk
|
|
668
|
+
// never finished looking. both refuse instead of shipping bytes nobody
|
|
669
|
+
// chose.
|
|
670
|
+
if (assetsPath) {
|
|
671
|
+
throw new Error(
|
|
672
|
+
`${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.`,
|
|
673
|
+
)
|
|
674
|
+
}
|
|
675
|
+
if (search.truncated) {
|
|
676
|
+
throw new Error(
|
|
677
|
+
`${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.`,
|
|
678
|
+
)
|
|
679
|
+
}
|
|
680
|
+
// no scale of this asset exists anywhere the search looked. an image can
|
|
681
|
+
// stand in visibly and let the app run; every declared scale gets the same
|
|
682
|
+
// stand-in, because narrowing to one would rewrite a `scales` list the
|
|
683
|
+
// descriptor may not even carry.
|
|
684
|
+
const placeholder = metroAssetPlaceholderBytes(variant.descriptor.type)
|
|
685
|
+
if (placeholder) {
|
|
686
|
+
if (!substituted.includes(label)) substituted.push(label)
|
|
687
|
+
return placeholder
|
|
688
|
+
}
|
|
254
689
|
const filename = metroAssetScaleFilename(
|
|
255
690
|
variant.descriptor.name,
|
|
256
691
|
variant.descriptor.type,
|
|
@@ -259,24 +694,37 @@ export async function produceCloudArtifact(
|
|
|
259
694
|
const classic =
|
|
260
695
|
metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ??
|
|
261
696
|
filename
|
|
262
|
-
const lookupPath = assetsPath ?? '.'
|
|
263
697
|
throw new Error(
|
|
264
|
-
|
|
698
|
+
`${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
699
|
)
|
|
266
700
|
})
|
|
267
701
|
const artifact = await buildRnxCloudArtifact(embedded.code, {
|
|
268
702
|
inferModuleIdentity: inferCloudModuleIdentity,
|
|
269
703
|
})
|
|
270
704
|
const bytes = Buffer.from(artifact.code, 'utf8')
|
|
705
|
+
const assets = artifact.receipt.assets
|
|
271
706
|
if (bytes.length > MAX_RNX_CLOUD_BUNDLE_BYTES) {
|
|
272
707
|
throw new Error(
|
|
273
|
-
|
|
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)}`,
|
|
709
|
+
)
|
|
710
|
+
}
|
|
711
|
+
const warnings: string[] = []
|
|
712
|
+
if (substituted.length > 0) {
|
|
713
|
+
warnings.push(
|
|
714
|
+
`${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
|
+
)
|
|
716
|
+
}
|
|
717
|
+
if (bytes.length >= WARN_ARTIFACT_BYTES) {
|
|
718
|
+
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)}`,
|
|
274
720
|
)
|
|
275
721
|
}
|
|
276
722
|
return {
|
|
277
723
|
bytes,
|
|
278
724
|
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
279
725
|
receipt: artifact.receipt,
|
|
726
|
+
assetsDirectory: dest?.directory ?? null,
|
|
727
|
+
warnings,
|
|
280
728
|
}
|
|
281
729
|
}
|
|
282
730
|
|
|
@@ -289,7 +737,7 @@ function readCreateResponse(
|
|
|
289
737
|
boxToken: string
|
|
290
738
|
simulatorToken: string
|
|
291
739
|
} {
|
|
292
|
-
if (!isRecord(value)) throw new Error('
|
|
740
|
+
if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON')
|
|
293
741
|
const artifactId = `sha256:${expectedSha256}` as const
|
|
294
742
|
const isExpectedArtifact = (artifact: unknown): artifact is { bytes: number } =>
|
|
295
743
|
isRecord(artifact) &&
|
|
@@ -321,7 +769,7 @@ function readCreateResponse(
|
|
|
321
769
|
typeof simulatorToken !== 'string' ||
|
|
322
770
|
!simulatorToken
|
|
323
771
|
) {
|
|
324
|
-
throw new Error('
|
|
772
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
325
773
|
}
|
|
326
774
|
return {
|
|
327
775
|
receipt: {
|
|
@@ -339,38 +787,51 @@ function readCreateResponse(
|
|
|
339
787
|
}
|
|
340
788
|
}
|
|
341
789
|
|
|
342
|
-
// `rnx ios --
|
|
343
|
-
// first simulator in one request, and the shell then targets that
|
|
790
|
+
// `rnx ios --remote <bundle>` creates the remote host for the artifact and its
|
|
791
|
+
// first simulator in one request, and the shell then targets that simulator
|
|
344
792
|
export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
345
793
|
receipt: RnxCloudBoxCreateReceipt
|
|
346
794
|
session: CloudSession
|
|
347
795
|
artifact: RnxCloudArtifactReceipt
|
|
796
|
+
assetsDirectory: string | null
|
|
797
|
+
warnings: string[]
|
|
348
798
|
}> {
|
|
349
799
|
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
800
|
+
const artifact = await produceCloudArtifact(options.bundlePath, options.assetsPath)
|
|
801
|
+
const boxName = options.boxName?.trim()
|
|
802
|
+
const headers: Record<string, string> = {
|
|
803
|
+
authorization: options.authorization,
|
|
804
|
+
'x-rnx-box-size': 'nano',
|
|
805
|
+
'x-rnx-artifact-sha256': artifact.sha256,
|
|
806
|
+
'x-rnx-platform': 'ios',
|
|
807
|
+
'x-rnx-device': options.device,
|
|
808
|
+
...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null),
|
|
809
|
+
...(boxName ? { 'x-rnx-box-name': boxName } : null),
|
|
810
|
+
}
|
|
811
|
+
let body: BodyInit
|
|
812
|
+
if (options.storage) {
|
|
813
|
+
const form = new FormData()
|
|
814
|
+
form.append(
|
|
815
|
+
'artifact',
|
|
816
|
+
new Blob([new Uint8Array(artifact.bytes)], { type: 'application/javascript' }),
|
|
817
|
+
'bundle.js',
|
|
818
|
+
)
|
|
819
|
+
form.append('storage', JSON.stringify(options.storage))
|
|
820
|
+
body = form
|
|
821
|
+
} else {
|
|
822
|
+
headers['content-type'] = 'application/javascript'
|
|
823
|
+
body = new Uint8Array(artifact.bytes)
|
|
824
|
+
}
|
|
357
825
|
let response: Response
|
|
358
826
|
try {
|
|
359
827
|
response = await fetch(`${apiOrigin}/v1/boxes`, {
|
|
360
828
|
method: 'POST',
|
|
361
|
-
headers
|
|
362
|
-
|
|
363
|
-
'content-type': 'application/javascript',
|
|
364
|
-
'x-rnx-box-size': 'nano',
|
|
365
|
-
'x-rnx-artifact-sha256': artifact.sha256,
|
|
366
|
-
'x-rnx-platform': 'ios',
|
|
367
|
-
'x-rnx-device': options.device,
|
|
368
|
-
},
|
|
369
|
-
body: new Uint8Array(artifact.bytes),
|
|
829
|
+
headers,
|
|
830
|
+
body,
|
|
370
831
|
})
|
|
371
832
|
} catch (error) {
|
|
372
833
|
throw new Error(
|
|
373
|
-
|
|
834
|
+
`${REMOTE_COMMAND} could not reach ${apiOrigin}: ${
|
|
374
835
|
error instanceof Error ? error.message : String(error)
|
|
375
836
|
}`,
|
|
376
837
|
)
|
|
@@ -381,7 +842,7 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
|
381
842
|
try {
|
|
382
843
|
parsed = JSON.parse(responseText)
|
|
383
844
|
} catch {
|
|
384
|
-
throw new Error('
|
|
845
|
+
throw new Error('remote simulator create returned unreadable JSON')
|
|
385
846
|
}
|
|
386
847
|
const created = readCreateResponse(parsed, artifact.bytes.length, artifact.sha256)
|
|
387
848
|
const session = createCloudSession({
|
|
@@ -392,7 +853,13 @@ export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{
|
|
|
392
853
|
claimId: created.receipt.simulator.claim.id,
|
|
393
854
|
token: created.simulatorToken,
|
|
394
855
|
})
|
|
395
|
-
return {
|
|
856
|
+
return {
|
|
857
|
+
receipt: created.receipt,
|
|
858
|
+
session,
|
|
859
|
+
artifact: artifact.receipt,
|
|
860
|
+
assetsDirectory: artifact.assetsDirectory,
|
|
861
|
+
warnings: artifact.warnings,
|
|
862
|
+
}
|
|
396
863
|
}
|
|
397
864
|
|
|
398
865
|
export function resolveCloudSessionForParsed(
|
|
@@ -402,7 +869,7 @@ export function resolveCloudSessionForParsed(
|
|
|
402
869
|
if (!session) return null
|
|
403
870
|
if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) {
|
|
404
871
|
throw new Error(
|
|
405
|
-
`this shell is connected to
|
|
872
|
+
`this shell is connected to remote simulator ${session.simId}; ${
|
|
406
873
|
parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator'
|
|
407
874
|
} is not available in this shell`,
|
|
408
875
|
)
|
|
@@ -419,7 +886,9 @@ function cloudCommand(command: {
|
|
|
419
886
|
if (key !== 'id' && key !== 'simId') clean[key] = value
|
|
420
887
|
}
|
|
421
888
|
if (isRnxCloudCommandType(command.type)) return clean
|
|
422
|
-
throw new Error(
|
|
889
|
+
throw new Error(
|
|
890
|
+
`a remote simulator does not support bridge command type ${command.type}`,
|
|
891
|
+
)
|
|
423
892
|
}
|
|
424
893
|
|
|
425
894
|
class CloudBridge implements WsBridge {
|
|
@@ -454,7 +923,7 @@ class CloudBridge implements WsBridge {
|
|
|
454
923
|
)
|
|
455
924
|
} catch (error) {
|
|
456
925
|
throw new Error(
|
|
457
|
-
`could not reach
|
|
926
|
+
`could not reach the remote simulator at ${this.session.apiOrigin}: ${
|
|
458
927
|
error instanceof Error ? error.message : String(error)
|
|
459
928
|
}`,
|
|
460
929
|
)
|
|
@@ -465,34 +934,38 @@ class CloudBridge implements WsBridge {
|
|
|
465
934
|
try {
|
|
466
935
|
parsed = JSON.parse(text)
|
|
467
936
|
} catch {
|
|
468
|
-
throw new Error('
|
|
937
|
+
throw new Error('remote simulator command returned unreadable JSON')
|
|
469
938
|
}
|
|
470
939
|
if (!isRecord(parsed) || parsed.id !== body.id) {
|
|
471
|
-
throw new Error('
|
|
940
|
+
throw new Error('remote simulator command returned an invalid command receipt')
|
|
472
941
|
}
|
|
473
942
|
if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error)
|
|
474
943
|
if (!Object.hasOwn(parsed, 'result')) {
|
|
475
|
-
throw new Error('
|
|
944
|
+
throw new Error('remote simulator command receipt has no result')
|
|
476
945
|
}
|
|
477
946
|
return parsed.result
|
|
478
947
|
}
|
|
479
948
|
|
|
480
949
|
listSims(): Promise<BridgeSimInfo[]> {
|
|
481
|
-
return Promise.reject(
|
|
950
|
+
return Promise.reject(
|
|
951
|
+
new Error('a remote simulator does not expose a local simulator list'),
|
|
952
|
+
)
|
|
482
953
|
}
|
|
483
954
|
|
|
484
955
|
focusSim(): Promise<never> {
|
|
485
|
-
return Promise.reject(
|
|
486
|
-
new Error('RNX Cloud simulators have no browser window to focus'),
|
|
487
|
-
)
|
|
956
|
+
return Promise.reject(new Error('remote simulators have no browser window to focus'))
|
|
488
957
|
}
|
|
489
958
|
|
|
490
959
|
closeSim(): Promise<never> {
|
|
491
|
-
return Promise.reject(
|
|
960
|
+
return Promise.reject(
|
|
961
|
+
new Error('remote simulator close uses the simulator DELETE route'),
|
|
962
|
+
)
|
|
492
963
|
}
|
|
493
964
|
|
|
494
965
|
claim(): Promise<BridgeClaimResult> {
|
|
495
|
-
return Promise.reject(
|
|
966
|
+
return Promise.reject(
|
|
967
|
+
new Error('remote simulator claim uses the simulator claim route'),
|
|
968
|
+
)
|
|
496
969
|
}
|
|
497
970
|
|
|
498
971
|
close(): void {}
|
|
@@ -511,7 +984,7 @@ function readClaimResponse(value: unknown): { id: string; expiresAt: number } {
|
|
|
511
984
|
!claim.id ||
|
|
512
985
|
typeof claim.expiresAt !== 'number'
|
|
513
986
|
) {
|
|
514
|
-
throw new Error('
|
|
987
|
+
throw new Error('remote simulator claim returned an invalid receipt')
|
|
515
988
|
}
|
|
516
989
|
return { id: claim.id, expiresAt: claim.expiresAt }
|
|
517
990
|
}
|
|
@@ -542,7 +1015,7 @@ export async function claimCloudSimulator(
|
|
|
542
1015
|
try {
|
|
543
1016
|
parsed = JSON.parse(text)
|
|
544
1017
|
} catch {
|
|
545
|
-
throw new Error('
|
|
1018
|
+
throw new Error('remote simulator claim returned unreadable JSON')
|
|
546
1019
|
}
|
|
547
1020
|
const claim = readClaimResponse(parsed)
|
|
548
1021
|
return {
|
|
@@ -554,7 +1027,7 @@ export async function claimCloudSimulator(
|
|
|
554
1027
|
export async function confirmCloudSimulator(session: CloudSession): Promise<void> {
|
|
555
1028
|
const confirmed = await claimCloudSimulator(session)
|
|
556
1029
|
if (confirmed.session.claimId !== session.claimId) {
|
|
557
|
-
throw new Error('
|
|
1030
|
+
throw new Error('remote simulator confirmation replaced the initial claim')
|
|
558
1031
|
}
|
|
559
1032
|
}
|
|
560
1033
|
|
|
@@ -576,6 +1049,7 @@ export async function closeCloudBox(session: CloudSession): Promise<void> {
|
|
|
576
1049
|
export interface AddCloudSimulatorOptions {
|
|
577
1050
|
device: string
|
|
578
1051
|
authorization: string
|
|
1052
|
+
accountId: string | null
|
|
579
1053
|
}
|
|
580
1054
|
|
|
581
1055
|
export async function addCloudSimulator(
|
|
@@ -595,13 +1069,14 @@ export async function addCloudSimulator(
|
|
|
595
1069
|
authorization: `Bearer ${session.boxToken}`,
|
|
596
1070
|
'x-rnx-account-authorization': options.authorization,
|
|
597
1071
|
'content-type': 'application/json',
|
|
1072
|
+
...(options.accountId ? { 'x-rnx-account-id': options.accountId } : {}),
|
|
598
1073
|
},
|
|
599
1074
|
body: JSON.stringify({ device: options.device }),
|
|
600
1075
|
},
|
|
601
1076
|
)
|
|
602
1077
|
} catch (error) {
|
|
603
1078
|
throw new Error(
|
|
604
|
-
`could not reach
|
|
1079
|
+
`could not reach the remote simulator at ${session.apiOrigin}: ${
|
|
605
1080
|
error instanceof Error ? error.message : String(error)
|
|
606
1081
|
}`,
|
|
607
1082
|
)
|
|
@@ -617,16 +1092,19 @@ export async function addCloudSimulator(
|
|
|
617
1092
|
(isRecord(parsed.error) && parsed.error.code === 'unknown_artifact'))
|
|
618
1093
|
) {
|
|
619
1094
|
throw new Error(
|
|
620
|
-
'this
|
|
1095
|
+
'this remote simulator does not hold a new bundle; run `rnx close` and create again',
|
|
621
1096
|
)
|
|
622
1097
|
}
|
|
623
1098
|
} catch (err) {
|
|
624
|
-
if (
|
|
1099
|
+
if (
|
|
1100
|
+
err instanceof Error &&
|
|
1101
|
+
err.message.startsWith('this remote simulator does not hold')
|
|
1102
|
+
) {
|
|
625
1103
|
throw err
|
|
626
1104
|
}
|
|
627
1105
|
}
|
|
628
1106
|
throw new Error(
|
|
629
|
-
'this
|
|
1107
|
+
'this remote simulator does not hold a new bundle; run `rnx close` and create again',
|
|
630
1108
|
)
|
|
631
1109
|
}
|
|
632
1110
|
throw responseError(response.status, responseText)
|
|
@@ -635,10 +1113,10 @@ export async function addCloudSimulator(
|
|
|
635
1113
|
try {
|
|
636
1114
|
parsed = JSON.parse(responseText)
|
|
637
1115
|
} catch {
|
|
638
|
-
throw new Error('
|
|
1116
|
+
throw new Error('remote simulator create returned unreadable JSON')
|
|
639
1117
|
}
|
|
640
1118
|
if (!isRecord(parsed) || !isRecord(parsed.simulator)) {
|
|
641
|
-
throw new Error('
|
|
1119
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
642
1120
|
}
|
|
643
1121
|
const simulator = parsed.simulator
|
|
644
1122
|
const simId = simulator.simId
|
|
@@ -658,7 +1136,7 @@ export async function addCloudSimulator(
|
|
|
658
1136
|
typeof artifact.id !== 'string' ||
|
|
659
1137
|
typeof artifact.bytes !== 'number'
|
|
660
1138
|
) {
|
|
661
|
-
throw new Error('
|
|
1139
|
+
throw new Error('remote simulator create returned an invalid receipt')
|
|
662
1140
|
}
|
|
663
1141
|
const receipt: RnxCloudCreateReceipt = {
|
|
664
1142
|
simId,
|