import { createHash, randomUUID } from 'node:crypto' import { closeSync, existsSync, fstatSync, openSync, readdirSync, readFileSync, } from 'node:fs' import { dirname, extname, join, resolve } from 'node:path' import { embedMetroAssetFiles, extractAssetDescriptors, isEmbeddedAssetDescriptor, metroAssetPlaceholderBytes, metroAssetScaleFilename, metroIosAssetDestRelatives, resolveMetroIosAssetDestFiles, type MetroArtifactAssetSize, type MetroAssetDescriptor, } from '../../contrast-bundler/src/metroAssetArtifact.ts' import { buildRnxCloudArtifact, type RnxCloudArtifactReceipt, } from '../../contrast-bundler/src/metroCloudArtifact.ts' import { loadMetroFingerprintRegistry } from '../../sootsim-engine/src/metro-fingerprint-registry-client.ts' import { inferMetroModuleIdentity } from '../../sootsim-engine/src/metro-fingerprint.ts' import { isLoopbackHost } from '../src/backend-origin.ts' import { isRnxCloudCommandType, isRnxCloudSimStatus, RNX_CLOUD_MAX_ARTIFACT_BYTES, RNX_CLOUD_TOKEN_SCOPES, rnxCloudCommandScope, rnxCloudScopesAllowCommand, type RnxCloudBoxCreateReceipt, type RnxCloudClaim, type RnxCloudSimCreateResponse, type RnxCloudSimInstance, type RnxCloudSimView, } from '../src/cloud-contract.ts' import { RNX_METRO_MODULE_IDENTITY_VERSION, type RNXMetroModuleIdentityMetadata, } from '../src/metro-production-bundle.ts' import { rnxPublicBrand } from '../src/public-brand.ts' import { CHOOSE_ACCOUNT_HINT, resolveCliAuth } from './auth.ts' import { createCloudSession, normalizeCloudOrigin, readCloudSession, type CloudBoxSession, type CloudSession, type CloudSimSession, type RemotePlatform, } from './cloud-session.ts' import { detectProject } from './commands/detect.ts' import type { StorageSnapshot } from '../../sootsim-engine/src/preview/storage-snapshot-contract.ts' import type { BridgeClaimResult, BridgeSimInfo, ParsedBridgeCliArgs, WsBridge, } from './ws-bridge.ts' export const DEFAULT_RNX_CLOUD_ORIGIN = rnxPublicBrand.origin export const MAX_RNX_CLOUD_BUNDLE_BYTES = RNX_CLOUD_MAX_ARTIFACT_BYTES // every message this module writes names the command through this constant. const REMOTE_COMMAND = 'rnx remote' const PREVIEW_COMMAND = 'rnx preview' export class UnsupportedRemoteHostError extends Error { readonly code = 'unsupported_host' as const readonly platform: string readonly host: string constructor(platform: string, host: string, message?: string) { super( message ?? `rnx remote ${platform} does not support --${host}: rnxsim/cloud only creates iOS simulators`, ) this.name = 'UnsupportedRemoteHostError' this.platform = platform this.host = host } } // a Metro asset dest is a shallow tree of images. a walk that runs past this // is pointed at something else entirely, and its extra entries cannot resolve // an asset anyway. const MAX_ASSET_DEST_FILES = 25_000 interface CreateCloudBoxOptions { bundlePath: string device: string authorization: string /** the account to bill when the authorization is a session token; an api key names its own */ accountId: string | null platform?: RemotePlatform apiOrigin?: string /** the name this box carries in the account's box list. */ boxName?: string | null /** * the storage and route the first simulator boots with, captured from a * running app. a snapshot reaches a megabyte, so a create that carries one * is multipart rather than a bare artifact body. */ storage?: StorageSnapshot | null } interface CloudCommandEnvelope { id: string claimId: string command: Record } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } // three services answer this client and each states a failure its own way: // the box service says { error: '' }, the site's public api says // { error: '', message: '' }, and its service-to-service api // says { error: { code, message } }. all three carry one sentence meant for // the person at the terminal, so read whichever field holds it. without this // the sentence never reaches them and they get the raw json body instead. function failureSentence(text: string): string | null { let parsed: unknown try { parsed = JSON.parse(text) } catch { return null } if (!isRecord(parsed)) return null if (isRecord(parsed.error) && typeof parsed.error.message === 'string') { // the code is the contract a script matches on; the message is for people. const code = typeof parsed.error.code === 'string' && parsed.error.code ? ` [${parsed.error.code}]` : '' return `${parsed.error.message}${code}` } if (typeof parsed.message === 'string') return parsed.message if (typeof parsed.error === 'string') return parsed.error return null } // the account service says what it needs in its own words; the CLI says what // the person can do about it, which is choose the account at `rnx login`. const ACCOUNT_CHOICE_REFUSALS = new Set([ 'accountId is required', 'you do not have access to this account', ]) function responseError(status: number, text: string): Error { const parsed = text ? failureSentence(text) : null const sentence = parsed && ACCOUNT_CHOICE_REFUSALS.has(parsed) ? CHOOSE_ACCOUNT_HINT : parsed return new Error( `remote simulator request failed (${status})${ sentence ? `: ${sentence}` : text.trim() ? `: ${text.trim()}` : '' }`, ) } function resolveCloudOrigin(value?: string): string { const configured = value?.trim() || process.env.RNX_CLOUD_ORIGIN?.trim() const source = configured || DEFAULT_RNX_CLOUD_ORIGIN return normalizeCloudOrigin(source) } function readImmutableBundleSource(bundlePath: string): string { const filepath = resolve(bundlePath) if (extname(filepath) !== '.js') { throw new Error(`${REMOTE_COMMAND} requires one local Metro .js bundle`) } const descriptor = openSync(filepath, 'r') try { const before = fstatSync(descriptor, { bigint: true }) if (!before.isFile()) { throw new Error(`${REMOTE_COMMAND} bundle must be a regular local file`) } if (before.size <= 0n) { throw new Error(`${REMOTE_COMMAND} bundle is empty`) } const bytes = readFileSync(descriptor) const after = fstatSync(descriptor, { bigint: true }) if ( before.dev !== after.dev || before.ino !== after.ino || before.size !== after.size || before.mtimeNs !== after.mtimeNs || bytes.length !== Number(before.size) ) { throw new Error(`${REMOTE_COMMAND} bundle changed while it was being read`) } let source: string try { source = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw new Error(`${REMOTE_COMMAND} bundle must be valid UTF-8 JavaScript`) } if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) { throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`) } return source } finally { closeSync(descriptor) } } async function fetchBundleSource(bundleUrl: string): Promise { let response: Response try { response = await fetch(bundleUrl) } catch (error) { throw new Error( `${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: ${ error instanceof Error ? error.message : String(error) }`, ) } if (!response.ok) { throw new Error( `${REMOTE_COMMAND} could not fetch bundle from ${bundleUrl}: HTTP ${response.status}${ response.statusText ? ` ${response.statusText}` : '' }`, ) } const text = await response.text() if (text.length === 0) { throw new Error(`${REMOTE_COMMAND} bundle is empty`) } if (text.includes('\0') || !text.includes('__d(') || !text.includes('__r(')) { throw new Error(`${REMOTE_COMMAND} requires a Metro JavaScript bundle`) } return text } // a bundle served over plain http is only trustworthy when it comes from this // machine, which is what "send to box" reads: the Metro dev server the app is // already running on. function isLoopbackBundleUrl(bundleInput: string): boolean { if (!bundleInput.startsWith('http://')) return false try { return isLoopbackHost(new URL(bundleInput).hostname) } catch { return false } } function isRemoteBundleInput(bundleInput: string): boolean { return bundleInput.startsWith('https://') || bundleInput.startsWith('http://') } async function loadBundleSource(bundleInput: string): Promise { if (bundleInput.startsWith('http://') && !isLoopbackBundleUrl(bundleInput)) { throw new Error(`${REMOTE_COMMAND} bundle URL must use HTTPS`) } if (isRemoteBundleInput(bundleInput)) { return fetchBundleSource(bundleInput) } return readImmutableBundleSource(bundleInput) } // `react-native bundle --assets-dest` dest paths are not always the JS // descriptor's httpServerLocation with `../` replaced by `_`. Metro 0.83 does // that (`__node_modules`); Metro 0.84 often dests `node_modules` and keeps the // project-root-relative app path. resolveMetroIosAssetDestFiles matches both. interface MetroAssetDestListing { directory: string files: string[] /** the walk stopped at the cap, so "not in this listing" proves nothing. */ truncated: boolean } // a Metro asset dest never holds these, but a project root does, and a // candidate can be either. `node_modules` is not in the list on purpose: a real // dest DOES carry one, because that is where `react-native bundle` copies the // assets that live inside packages. it is skipped only under a project root, // which is what a package.json at the candidate marks. const PROJECT_ONLY_DIRECTORIES = new Set([ 'node_modules', 'Pods', 'DerivedData', '.expo', '.next', '.turbo', '.cache', '.gradle', ]) function listMetroAssetDest(assetsPath: string): MetroAssetDestListing { const directory = resolve(assetsPath) if (!existsSync(directory)) return { directory, files: [], truncated: false } const skipNested = existsSync(join(directory, 'package.json')) const files: string[] = [] let truncated = false const walk = (dir: string, prefix: string) => { let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return } for (const entry of entries) { if (files.length >= MAX_ASSET_DEST_FILES) { truncated = true return } if (entry.isDirectory()) { if (entry.name === '.git') continue if (skipNested && PROJECT_ONLY_DIRECTORIES.has(entry.name)) continue walk(join(dir, entry.name), prefix ? `${prefix}/${entry.name}` : entry.name) } else if (entry.isFile()) { files.push(prefix ? `${prefix}/${entry.name}` : entry.name) } } } walk(directory, '') return { directory, files, truncated } } /** * Metro's own identity for an asset. `getAbsoluteAssetInfo` in metro/src/Assets * md5s every scale file's bytes into ONE digest, in the ascending scale order * `buildAssetMap` keeps `scales` and `files` parallel in, and that digest is * the descriptor's `hash`. so a directory can be asked whether it holds THIS * asset instead of merely a file with the same name. */ function metroDescriptorHash(scaleBytes: readonly Buffer[]): string { const hasher = createHash('md5') for (const bytes of scaleBytes) hasher.update(bytes) return hasher.digest('hex') } function metroAssetKey(descriptor: MetroAssetDescriptor): string { return `${descriptor.httpServerLocation}/${descriptor.name}.${descriptor.type}` } /** where one descriptor's scales sit inside one candidate directory. */ interface DescriptorPlacement { /** the dest-relative file chosen for each declared scale that is present. */ files: Map /** files a scale matched that hold different images, so none was chosen. */ ambiguous: string[] /** every declared scale is present and their bytes are Metro's `hash`. */ verified: boolean /** every declared scale is present and their bytes are something else. */ mismatched: boolean } // an ambiguous scale is rare and its file count is small, so trying every // combination against the descriptor hash is cheap. the cap only stops a // pathological dest from turning identity into a search. const MAX_PLACEMENT_COMBINATIONS = 64 function placementCombinations(perScale: readonly string[][]): string[][] { let total = 1 for (const options of perScale) total *= options.length if (total > MAX_PLACEMENT_COMBINATIONS) return [] let combinations: string[][] = [[]] for (const options of perScale) { combinations = combinations.flatMap((prefix) => options.map((option) => [...prefix, option]), ) } return combinations } /** * one file per distinct image, out of the files a scale matched. * * two paths holding byte-identical images are not a choice to make, and the * descriptor's hash could not separate them if they were. */ function distinctByContent(directory: string, options: readonly string[]): string[] { if (options.length < 2) return [...options] const byContent = new Map() for (const relative of options) { let bytes: Buffer try { bytes = readFileSync(join(directory, relative)) } catch { continue } const digest = createHash('md5').update(bytes).digest('hex') if (!byContent.has(digest)) byContent.set(digest, relative) } return [...byContent.values()] } /** * decides what a candidate directory holds for one descriptor, by bytes. * * Metro hashes the asset's SOURCE files, and `react-native bundle` copies only * @1x..@3x to an iOS dest while leaving `scales` unfiltered. so a descriptor * that declares an Android rung such as @1.5x records a digest no iOS dest can * reproduce, however complete that dest is. those come back neither verified * nor mismatched, and the caller refuses when a whole bundle is like that * rather than picking a directory it cannot check. * * a source tree holding both `icon.png` and `icon@1x.png` declares scale 1 * twice, and Metro hashes both files while a dest keeps one file per scale. the * expo asset plugin records one md5 per declared file in `fileHashes`, and * that is the identity check for such a descriptor: the file chosen for a * scale must be one of the files Metro hashed at that scale. */ function placeMetroDescriptor( listing: MetroAssetDestListing, descriptor: MetroAssetDescriptor, ): DescriptorPlacement { const scales = [...new Set(descriptor.scales)] const perScale = scales.map((scale) => distinctByContent( listing.directory, resolveMetroIosAssetDestFiles(listing.files, descriptor, scale), ), ) // a scale that resolved to one image names its file whatever the verdict // below is. one that resolved to several is left for the hash to settle. const files = new Map() const ambiguous: string[] = [] scales.forEach((scale, index) => { const options = perScale[index] ?? [] if (options.length === 1 && options[0]) files.set(scale, options[0]) else if (options.length > 1) ambiguous.push(...options) }) if (perScale.length === 0 || perScale.some((options) => options.length === 0)) { return { files, ambiguous, verified: false, mismatched: false } } // a descriptor that is already embedded carries the embedder's sha256s in // `fileHashes`; only Metro's own md5 per source file is an identity here. const fileHashes = !descriptor.fileUris && descriptor.fileHashes?.length === descriptor.scales.length ? descriptor.fileHashes : null for (const combination of placementCombinations(perScale)) { let bytes: Buffer[] try { bytes = combination.map((relative) => readFileSync(join(listing.directory, relative)), ) } catch { continue } const identical = fileHashes ? bytes.every((file, index) => { const digest = createHash('md5').update(file).digest('hex') return descriptor.scales.some( (scale, declared) => scale === scales[index] && fileHashes[declared] === digest, ) }) : scales.length === descriptor.scales.length && metroDescriptorHash(bytes) === descriptor.hash if (!identical) continue const chosen = new Map() scales.forEach((scale, index) => { const relative = combination[index] if (relative) chosen.set(scale, relative) }) return { files: chosen, ambiguous: [], verified: true, mismatched: false } } // a duplicated scale with no per-file md5 leaves Metro's digest covering a // file this dest does not hold, so nothing here can be called wrong. if (!fileHashes && scales.length !== descriptor.scales.length) { return { files, ambiguous, verified: false, mismatched: false } } // every declared scale is here and no arrangement of them is this asset, so // the directory holds different images under these names. return { files, ambiguous, verified: false, mismatched: true } } interface MetroAssetDest { directory: string /** the placement of each descriptor, in the order the bundle declares them. */ placements: DescriptorPlacement[] } interface MetroAssetSearch { /** the directory the producer reads asset bytes from, or null if none held any. */ dest: MetroAssetDest | null /** every directory the search considered, in order, for the warning text. */ searched: string[] /** a walk stopped at the cap, so no directory can be called fully looked at. */ truncated: boolean /** * the dest holds every filename but not one of Metro's digests could be * reproduced from it, so it was taken on layout alone. ordinary when every * asset in the bundle carries an Android rung such as @1.5x that an iOS dest * never receives, and indistinguishable from a previous build's images left * in place, so the producer says so rather than staying quiet. */ unconfirmed: boolean } /** * what two candidates could still disagree about. * * a verified placement's bytes reproduce Metro's own digest, so it is the right * content wherever it was read from and two directories that both verified it * are not in conflict. only the unverified files are a real fork, and they are * keyed by absolute path because `/assets` and `` are both candidates * and list the same files under different dest-relative names. */ function unverifiedFileKey(dest: MetroAssetDest): string { return dest.placements .filter((placement) => !placement.verified) .flatMap((placement) => [...placement.files.values()].map((relative) => join(dest.directory, relative)), ) .sort() .join('\n') } /** * finds the `--assets-dest` directory by RESOLVING the bundle's own asset * descriptors against a short ordered list of the places Metro writes one, and * CONFIRMING the bytes it finds against the md5 Metro recorded in each * descriptor. name and layout alone are a popularity contest an unrelated build * output can win; the hash makes it an identity check. */ function searchMetroAssetDest( bundlePath: string, descriptors: readonly MetroAssetDescriptor[], ): MetroAssetSearch { const bundleDirectory = dirname(resolve(bundlePath)) const candidates = [ join(bundleDirectory, 'assets'), bundleDirectory, resolve(bundleDirectory, '..', 'assets'), ] const cwd = process.cwd() let projectDirectory: string | null = null for (const start of [bundleDirectory, cwd]) { let directory = resolve(start) while (true) { if (detectProject(directory)?.hasMetroConfig) { projectDirectory = directory break } const parent = dirname(directory) if (parent === directory) break directory = parent } if (projectDirectory) break } if (projectDirectory) { candidates.push( join(projectDirectory, 'assets'), join(projectDirectory, 'dist'), join(projectDirectory, 'dist', 'assets'), join(projectDirectory, 'build'), join(projectDirectory, 'build', 'assets'), ) } const searched: string[] = [] const holders: { dest: MetroAssetDest; verified: number }[] = [] const stale: string[] = [] let truncated = false for (const candidate of candidates) { const directory = resolve(candidate) if (searched.includes(directory)) continue searched.push(directory) const listing = listMetroAssetDest(directory) // a cut-short walk is remembered even when the candidate resolves nothing, // because the reason it resolved nothing may be that it never finished. if (listing.truncated) truncated = true if (listing.files.length === 0) continue const placements = descriptors.map((descriptor) => placeMetroDescriptor(listing, descriptor), ) // one wrong asset is enough: a directory that provably holds different // bytes under these names is a different build, not a partial dest. if (placements.some((placement) => placement.mismatched)) { stale.push(directory) continue } const verified = placements.filter((placement) => placement.verified).length if (verified === 0 && placements.every((placement) => placement.files.size === 0)) continue holders.push({ dest: { directory, placements }, verified }) } if (holders.length === 0) { // every scale is on disk and none of it is this bundle's. saying the // assets are missing would be false, and a stand-in over a stale image is // the wrong repair for a dest that only needs rebuilding. if (stale.length > 0) { throw new Error( `${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.`, ) } return { dest: null, searched, truncated, unconfirmed: false } } const best = Math.max(...holders.map((holder) => holder.verified)) // a directory that confirmed nothing cannot outrank one that confirmed // something, and when nothing anywhere confirmed, every claimant is a rival. const rivals = holders.filter((holder) => holder.verified === best) const distinct = new Set(rivals.map((rival) => unverifiedFileKey(rival.dest))) const winner = rivals[0] if (!winner) return { dest: null, searched, truncated, unconfirmed: false } if (distinct.size > 1) { throw new Error( `${REMOTE_COMMAND} found this bundle's assets in more than one directory and cannot tell which build wrote them: ${rivals .map((rival) => rival.dest.directory) .join( ', ', )}. Delete the stale one, or rebuild with \`react-native bundle --assets-dest\` into a directory beside the bundle.`, ) } // `best === 0` means no digest could be reproduced, which is ordinary for an // asset carrying an Android rung such as @1.5x. with one claimant there is // nothing to choose between, and a genuinely half-copied dest still fails // per asset below, naming the file it could not read. return { dest: winner.dest, searched, truncated, unconfirmed: best === 0 } } function largestEmbeddedAssets( sizes: readonly MetroArtifactAssetSize[], count: number, ): string { if (sizes.length === 0) return '' const largest = sizes .slice(0, count) .map((entry) => `${entry.asset} ${entry.bytes} bytes`) .join(', ') return ` Largest embedded assets: ${largest}.` } async function inferCloudModuleIdentity( source: string, ): Promise { let receipt try { receipt = await loadMetroFingerprintRegistry() } catch (error) { throw new Error( `${REMOTE_COMMAND} could not load the Metro fingerprint registry needed to identify this bundle: ${ error instanceof Error ? error.message : String(error) }. Build it through the published Metro plugin: withRNX(config).`, ) } try { const inferred = await inferMetroModuleIdentity({ source, registry: receipt.registry, registryIntegrity: receipt.integrity, }) return { version: RNX_METRO_MODULE_IDENTITY_VERSION, identitySource: 'contrast-bundler', modulePaths: inferred.modulePaths, logicalSpecifiers: {}, } } catch (error) { throw new Error( `${REMOTE_COMMAND} could not infer module identity for this Metro bundle: ${ error instanceof Error ? error.message : String(error) }. Build it through the published Metro plugin: withRNX(config).`, ) } } /** * turns the Metro production bundle a caller built into the immutable artifact * the runner executes: every asset scale is read from the asset graph and * embedded, module identity is normalized, and the JavaScript is minified. * an ordinary unannotated Metro production bundle is identified through the * published fingerprint registry; a withRNX footer remains the fast path. */ export interface CloudArtifactProduction { bytes: Buffer sha256: string receipt: RnxCloudArtifactReceipt /** the asset dest directory the producer read, or null when it read none. */ assetsDirectory: string | null /** what the CLI prints after the receipt. */ warnings: string[] } export async function produceCloudArtifact( bundlePath: string, /** * the largest artifact the consumer accepts. the nano simulator's limit is * the default; a box that accepts a larger artifact passes its own. */ maxBytes: number = MAX_RNX_CLOUD_BUNDLE_BYTES, ): Promise { return produceArtifact(bundlePath, maxBytes, REMOTE_COMMAND, 'allow-placeholders') } /** prepares a standalone preview whose bytes must represent every Metro asset. */ export async function producePreviewArtifact( bundlePath: string, ): Promise { return produceArtifact( bundlePath, Number.POSITIVE_INFINITY, PREVIEW_COMMAND, 'require-assets', ) } export interface CloudBoxArtifactProduction { bytes: Buffer assetsDirectory: string | null warnings: string[] } /** prepares a prebuilt bundle for a Box by embedding any beside-the-bundle Metro assets. */ export async function produceCloudBoxArtifact( bundlePath: string, ): Promise { const prepared = await embedBundleAssets( bundlePath, 'rnx box create', 'allow-placeholders', ) const warnings: string[] = [] if (prepared.search.unconfirmed && prepared.dest) { warnings.push( `rnx box create read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`, ) } if (prepared.substituted.length > 0) { warnings.push( `rnx box create embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`, ) } return { bytes: Buffer.from(prepared.code, 'utf8'), assetsDirectory: prepared.dest?.directory ?? null, warnings, } } async function embedBundleAssets( bundlePath: string, command: string, assetPolicy: 'allow-placeholders' | 'require-assets', ): Promise<{ source: string code: string search: MetroAssetSearch dest: MetroAssetDest | null substituted: string[] }> { const source = await loadBundleSource(bundlePath) const descriptors = extractAssetDescriptors(source).filter( (descriptor) => !isEmbeddedAssetDescriptor(descriptor), ) if (descriptors.length === 0) { return { source, code: source, search: { dest: null, searched: [], truncated: false, unconfirmed: false }, dest: null, substituted: [], } } // an https bundle has no local tree to search, and a bundle with no assets // has nothing to search for. const search: MetroAssetSearch = !isRemoteBundleInput(bundlePath) ? searchMetroAssetDest(bundlePath, descriptors) : { dest: null, searched: [], truncated: false, unconfirmed: false } const dest = search.dest // `embedMetroAssetFiles` re-parses the bundle, so its descriptors are equal // to the ones the search placed but not the same objects. const placements = new Map() descriptors.forEach((descriptor, index) => { const placement = dest?.placements[index] if (placement) placements.set(metroAssetKey(descriptor), placement) }) const substituted: string[] = [] const embedded = await embedMetroAssetFiles(source, async (variant) => { const label = metroAssetKey(variant.descriptor) const placement = placements.get(label) if (placement?.mismatched && dest) { throw new Error( `${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.`, ) } const relative = placement?.files.get(variant.scale) if (relative && dest) return readFileSync(join(dest.directory, relative)) // iOS copies only @1x..@3x, so a declared scale with no file is ordinary // and the producer narrows the descriptor. if (placement && placement.files.size > 0) return null if (placement && placement.ambiguous.length > 0 && dest) { throw new Error( `${command} found more than one image for ${label} in ${dest.directory} and none of them is the one Metro hashed: ${placement.ambiguous.sort().join(', ')}. Rebuild with \`react-native bundle --assets-dest\` into a directory that holds only this build's assets.`, ) } // a stand-in is only honest when the asset is genuinely absent from a // directory we know is the right one. a truncated walk never finished // looking, so it refuses instead of shipping bytes nobody chose. if (search.truncated) { throw new Error( `${command} stopped listing ${search.searched.join(', ')} after ${MAX_ASSET_DEST_FILES} files and never reached ${label}, so it cannot tell a missing asset from an unread one. Rebuild with \`react-native bundle --assets-dest\` into a directory beside the bundle.`, ) } if (assetPolicy === 'require-assets') { throw new Error( `${command} could not find ${label}. Build the bundle with \`react-native bundle --assets-dest \` so its assets land beside it.`, ) } // no scale of this asset exists anywhere the search looked. an image can // stand in visibly and let the app run; every declared scale gets the same // stand-in, because narrowing to one would rewrite a `scales` list the // descriptor may not even carry. const placeholder = metroAssetPlaceholderBytes(variant.descriptor.type) if (placeholder) { if (!substituted.includes(label)) substituted.push(label) return placeholder } const filename = metroAssetScaleFilename( variant.descriptor.name, variant.descriptor.type, variant.scale, ) const classic = metroIosAssetDestRelatives(variant.descriptor.httpServerLocation, filename)[0] ?? filename throw new Error( `${command} could not read ${join(dest?.directory ?? '.', classic)} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}, and has no stand-in for a .${variant.descriptor.type} asset. Rebuild with \`react-native bundle --assets-dest\`.`, ) }) return { source, code: embedded.code, search, dest, substituted, } } async function produceArtifact( bundlePath: string, maxBytes: number, command: string, assetPolicy: 'allow-placeholders' | 'require-assets', ): Promise { const prepared = await embedBundleAssets(bundlePath, command, assetPolicy) const artifact = await buildRnxCloudArtifact(prepared.code, { inferModuleIdentity: inferCloudModuleIdentity, }) const bytes = Buffer.from(artifact.code, 'utf8') const assets = artifact.receipt.assets if (bytes.length > maxBytes) { throw new Error( `${REMOTE_COMMAND} artifact is ${bytes.length} bytes and exceeds the ${maxBytes}-byte limit. Embedded assets weigh ${assets.bytes} bytes on disk, and base64 embedding costs about a third on top of that, so the practical asset budget is roughly ${Math.round((maxBytes * 0.75) / 1e6)} MB.${largestEmbeddedAssets(assets.sizes, 10)}`, ) } const warnings: string[] = [] if (prepared.search.unconfirmed && prepared.dest) { warnings.push( `${command} read this bundle's assets from ${prepared.dest.directory} on layout alone: every asset declares a scale that directory does not hold, so not one of Metro's hashes could be reproduced. Confirm these are the images this bundle was built against.`, ) } if (prepared.substituted.length > 0) { warnings.push( `${command} embedded a placeholder image for ${prepared.substituted.length} asset(s) no scale of which is in ${prepared.search.searched.join(', ') || 'any asset dest directory'}: ${prepared.substituted.join(', ')}. Rebuild with \`react-native bundle --assets-dest\` to ship the real images.`, ) } // an artifact this close to the ceiling is one asset away from being refused, // and the producer knows which assets it would be. if (bytes.length >= Math.floor(maxBytes * 0.8)) { warnings.push( `${command} artifact is ${bytes.length} bytes, ${Math.round((bytes.length / maxBytes) * 100)}% of the ${maxBytes}-byte limit.${largestEmbeddedAssets(assets.sizes, 5)}`, ) } return { bytes, sha256: createHash('sha256').update(bytes).digest('hex'), receipt: artifact.receipt, assetsDirectory: prepared.dest?.directory ?? null, warnings, } } function readCreateResponse( value: unknown, expectedBytes: number, expectedSha256: string, ): { receipt: RnxCloudBoxCreateReceipt boxToken: string simulatorToken: string } { if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON') const artifactId = `sha256:${expectedSha256}` as const const isExpectedArtifact = (artifact: unknown): artifact is { bytes: number } => isRecord(artifact) && artifact.id === artifactId && typeof artifact.bytes === 'number' && Number.isInteger(artifact.bytes) && artifact.bytes === expectedBytes const boxId = value.boxId const boxToken = value.token const simulator = value.simulator const simId = isRecord(simulator) ? simulator.simId : null const claim = isRecord(simulator) ? simulator.claim : null const simulatorToken = isRecord(simulator) ? simulator.token : null if ( typeof boxId !== 'string' || !boxId || value.size !== 'nano' || typeof boxToken !== 'string' || !boxToken || !isExpectedArtifact(value.artifact) || !isRecord(simulator) || typeof simId !== 'string' || !simId || !isExpectedArtifact(simulator.artifact) || !isRecord(claim) || typeof claim.id !== 'string' || !claim.id || typeof claim.expiresAt !== 'number' || typeof simulatorToken !== 'string' || !simulatorToken ) { throw new Error('remote simulator create returned an invalid receipt') } return { receipt: { boxId, size: 'nano', artifact: { id: artifactId, bytes: expectedBytes }, simulator: { simId, artifact: { id: artifactId, bytes: expectedBytes }, claim: { id: claim.id, expiresAt: claim.expiresAt }, }, }, boxToken, simulatorToken, } } // "send to box" wraps the running app in a nano box with its first simulator // in one request, and the shell then targets that simulator export async function createCloudBox(options: CreateCloudBoxOptions): Promise<{ receipt: RnxCloudBoxCreateReceipt session: CloudSession artifact: RnxCloudArtifactReceipt assetsDirectory: string | null warnings: string[] }> { const platform = options.platform ?? 'ios' const apiOrigin = resolveCloudOrigin(options.apiOrigin) const artifact = await produceCloudArtifact(options.bundlePath) const boxName = options.boxName?.trim() const headers: Record = { authorization: options.authorization, 'x-rnx-box-size': 'nano', 'x-rnx-artifact-sha256': artifact.sha256, 'x-rnx-platform': platform, 'x-rnx-device': options.device, ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null), ...(boxName ? { 'x-rnx-box-name': boxName } : null), } let body: BodyInit if (options.storage) { const form = new FormData() form.append( 'artifact', new Blob([new Uint8Array(artifact.bytes)], { type: 'application/javascript' }), 'bundle.js', ) form.append('storage', JSON.stringify(options.storage)) body = form } else { headers['content-type'] = 'application/javascript' body = new Uint8Array(artifact.bytes) } let response: Response try { response = await fetch(`${apiOrigin}/v1/boxes`, { method: 'POST', headers, body, }) } catch (error) { throw new Error( `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const responseText = await response.text() if (!response.ok) throw responseError(response.status, responseText) let parsed: unknown try { parsed = JSON.parse(responseText) } catch { throw new Error('remote simulator create returned unreadable JSON') } const created = readCreateResponse(parsed, artifact.bytes.length, artifact.sha256) const session = createCloudSession({ boxId: created.receipt.boxId, boxToken: created.boxToken, simId: created.receipt.simulator.simId, apiOrigin, claimId: created.receipt.simulator.claim.id, token: created.simulatorToken, platform, device: options.device, artifact: { sha256: artifact.sha256, bytes: artifact.bytes.length, }, }) return { receipt: created.receipt, session, artifact: artifact.receipt, assetsDirectory: artifact.assetsDirectory, warnings: artifact.warnings, } } // one customer plane: the sim-service instance API. every `rnx remote` // simulator is created, listed, read, and deleted here; the box stays the // host behind it and is never the addressed object. export interface CreateRemoteSimOptions { bundlePath: string device: string authorization: string /** the account to bill when the authorization is a session token; an api key names its own */ accountId: string | null platform: RemotePlatform apiOrigin?: string /** match an existing simulator for the same artifact and labels instead of creating */ reuse?: boolean } function readStringMap(value: unknown): Record { if (!isRecord(value)) return {} const labels: Record = {} for (const [key, label] of Object.entries(value)) { if (typeof label === 'string') labels[key] = label } return labels } function readSimCreateResponse( value: unknown, expectedBytes: number, expectedSha256: string, ): RnxCloudSimCreateResponse { if (!isRecord(value)) throw new Error('remote simulator create returned invalid JSON') const artifactId = `sha256:${expectedSha256}` as const const artifact = isRecord(value.artifact) ? value.artifact : null const claim = isRecord(value.claim) ? value.claim : null if ( typeof value.simId !== 'string' || !value.simId || !artifact || artifact.id !== artifactId || artifact.bytes !== expectedBytes || !claim || typeof claim.id !== 'string' || !claim.id || typeof claim.expiresAt !== 'number' || typeof value.token !== 'string' || !value.token || typeof value.status !== 'string' || !isRnxCloudSimStatus(value.status) || (value.streamUrl !== null && typeof value.streamUrl !== 'string') ) { throw new Error('remote simulator create returned an invalid receipt') } return { simId: value.simId, artifact: { id: artifactId, bytes: expectedBytes }, claim: { id: claim.id, expiresAt: claim.expiresAt }, token: value.token, status: value.status, labels: readStringMap(value.labels), streamUrl: value.streamUrl, } } // `rnx remote ios|android ` creates one simulator through the // instance API and waits for its app to answer ready. both platforms run // this same typed path: an android create is refused by the service with a // typed 400, never by a cli-side branch. export async function createRemoteSim(options: CreateRemoteSimOptions): Promise<{ receipt: RnxCloudSimCreateResponse session: CloudSimSession artifact: RnxCloudArtifactReceipt assetsDirectory: string | null warnings: string[] reused: boolean }> { const apiOrigin = resolveCloudOrigin(options.apiOrigin) const artifact = await produceCloudArtifact(options.bundlePath) const query = new URLSearchParams({ labels: `rnx.remote=1,rnx.platform=${options.platform},rnx.bundle=${artifact.sha256.slice(0, 12)}`, wait: 'true', }) if (options.reuse) query.set('reuseIfExists', 'true') const headers: Record = { authorization: options.authorization, 'content-type': 'application/javascript', 'x-rnx-artifact-sha256': artifact.sha256, 'x-rnx-platform': options.platform, 'x-rnx-device': options.device, ...(options.accountId ? { 'x-rnx-account-id': options.accountId } : null), } let response: Response try { response = await fetch(`${apiOrigin}/v1/sims?${query}`, { method: 'POST', headers, body: new Uint8Array(artifact.bytes), }) } catch (error) { throw new Error( `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const responseText = await response.text() if (!response.ok) throw responseError(response.status, responseText) let parsed: unknown try { parsed = JSON.parse(responseText) } catch { throw new Error('remote simulator create returned unreadable JSON') } const receipt = readSimCreateResponse(parsed, artifact.bytes.length, artifact.sha256) const session = createCloudSession({ simId: receipt.simId, apiOrigin, claimId: receipt.claim.id, token: receipt.token, streamUrl: receipt.streamUrl, platform: options.platform, device: options.device, artifact: { sha256: artifact.sha256, bytes: artifact.bytes.length, }, }) return { receipt, session, artifact: artifact.receipt, assetsDirectory: artifact.assetsDirectory, warnings: artifact.warnings, reused: response.status === 200, } } function readSimView(value: unknown): RnxCloudSimView { if (!isRecord(value) || !isRecord(value.labels)) { throw new Error('remote simulator list returned an invalid simulator') } const labels: Record = {} for (const [key, label] of Object.entries(value.labels)) { if (typeof label !== 'string') { throw new Error('remote simulator list returned an invalid simulator') } labels[key] = label } const artifact = isRecord(value.artifact) ? value.artifact : null const claimValue = isRecord(value.claim) ? value.claim : null let claim: RnxCloudClaim | null = null if (claimValue !== null) { if ( typeof claimValue.id !== 'string' || !claimValue.id || typeof claimValue.expiresAt !== 'number' ) { throw new Error('remote simulator list returned an invalid simulator') } claim = { id: claimValue.id, expiresAt: claimValue.expiresAt } } const inactivityTimeoutMs = value.inactivityTimeoutMs const hardTimeoutAt = value.hardTimeoutAt if ( typeof value.simId !== 'string' || !value.simId || typeof value.status !== 'string' || !isRnxCloudSimStatus(value.status) || !artifact || typeof artifact.sha256 !== 'string' || typeof artifact.bytes !== 'number' || typeof value.createdAt !== 'number' || typeof value.lastActiveAt !== 'number' || (typeof inactivityTimeoutMs !== 'number' && inactivityTimeoutMs !== null) || (typeof hardTimeoutAt !== 'number' && hardTimeoutAt !== null) ) { throw new Error('remote simulator list returned an invalid simulator') } return { simId: value.simId, status: value.status, labels, artifact: { sha256: artifact.sha256, bytes: artifact.bytes }, claim, createdAt: value.createdAt, lastActiveAt: value.lastActiveAt, inactivityTimeoutMs, hardTimeoutAt, } } // `rnx remote list`: the simulators this account runs that `rnx remote` made. export async function listRemoteSims(options: { apiOrigin?: string authorization: string accountId?: string | null }): Promise { const apiOrigin = resolveCloudOrigin(options.apiOrigin) const selector = new URLSearchParams({ labelSelector: 'rnx.remote=1' }) const headers: Record = { authorization: options.authorization } if (options.accountId) headers['x-rnx-account-id'] = options.accountId let response: Response try { response = await fetch(`${apiOrigin}/v1/sims?${selector}`, { headers }) } catch (error) { throw new Error( `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const responseText = await response.text() if (!response.ok) throw responseError(response.status, responseText) let parsed: unknown try { parsed = JSON.parse(responseText) } catch { throw new Error('remote simulator list returned unreadable JSON') } if (!isRecord(parsed) || !Array.isArray(parsed.sims)) { throw new Error('remote simulator list returned invalid JSON') } return parsed.sims.map(readSimView) } // one simulator reread by id: the reattach liveness probe, and the fresh // watch url an api-key caller mints with it. a simulator this account no // longer runs reads back as missing rather than forbidden, so 404 is null // and anything else refused throws. export async function getRemoteSim(options: { apiOrigin?: string simId: string authorization: string }): Promise { const apiOrigin = resolveCloudOrigin(options.apiOrigin) let response: Response try { response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, { headers: { authorization: options.authorization }, }) } catch (error) { throw new Error( `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const responseText = await response.text() if (response.status === 404) return null if (!response.ok) throw responseError(response.status, responseText) let parsed: unknown try { parsed = JSON.parse(responseText) } catch { throw new Error('remote simulator read returned unreadable JSON') } if (!isRecord(parsed) || !isRecord(parsed.sim)) { throw new Error('remote simulator read returned invalid JSON') } const view = readSimView(parsed.sim) const streamUrl = parsed.sim.streamUrl if (streamUrl !== null && streamUrl !== undefined && typeof streamUrl !== 'string') { throw new Error('remote simulator read returned an invalid watch url') } return { ...view, streamUrl: typeof streamUrl === 'string' ? streamUrl : null, } } // `rnx remote stop `: delete one simulator by id. missing is null so the // caller can name what was not there; anything else refused throws. export async function deleteRemoteSim(options: { apiOrigin?: string simId: string authorization: string accountId?: string | null }): Promise<{ simId: string; status: string } | null> { const apiOrigin = resolveCloudOrigin(options.apiOrigin) const headers: Record = { authorization: options.authorization } if (options.accountId) headers['x-rnx-account-id'] = options.accountId let response: Response try { response = await fetch(`${apiOrigin}/v1/sims/${encodeURIComponent(options.simId)}`, { method: 'DELETE', headers, }) } catch (error) { throw new Error( `${REMOTE_COMMAND} could not reach ${apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const responseText = await response.text() if (response.status === 404) return null if (!response.ok) throw responseError(response.status, responseText) let parsed: unknown try { parsed = JSON.parse(responseText) } catch { throw new Error('remote simulator delete returned unreadable JSON') } if ( !isRecord(parsed) || typeof parsed.simId !== 'string' || typeof parsed.status !== 'string' ) { throw new Error('remote simulator delete returned invalid JSON') } return { simId: parsed.simId, status: parsed.status } } export function resolveCloudSessionForParsed( parsed: Pick, ): CloudSession | null { const session = readCloudSession() if (!session) return null // a box holds one simulator and names it by the box, so --sim names a box // rather than a sim there and this shell already knows which box it is on. if (session.service === 'box') { if (parsed.simIdSource === 'flag' && parsed.simId !== session.boxId) { throw new Error( `this shell is connected to box ${session.boxId}; ${ parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator' } is not available in this shell`, ) } return session } if (parsed.simIdSource === 'flag' && parsed.simId !== session.simId) { throw new Error( `this shell is connected to remote simulator ${session.simId}; ${ parsed.simId ? `--sim ${parsed.simId}` : 'the requested simulator' } is not available in this shell`, ) } return session } function cloudCommand(command: { type: string [key: string]: unknown }): Record { const clean: Record = {} for (const [key, value] of Object.entries(command)) { if (key !== 'id' && key !== 'simId') clean[key] = value } if (!isRnxCloudCommandType(command.type)) { throw new Error( `a remote simulator does not support bridge command type ${command.type}`, ) } if (!rnxCloudScopesAllowCommand(RNX_CLOUD_TOKEN_SCOPES, command.type)) { throw new Error( `a remote simulator command ${command.type} requires the ${rnxCloudCommandScope(command.type)} scope`, ) } return clean } class CloudBridge implements WsBridge { readonly plane = 'cloud' as const constructor(private readonly session: CloudSimSession) {} async send( command: { type: string; [key: string]: unknown }, options?: { timeoutMs?: number }, ): Promise { const body: CloudCommandEnvelope = { id: randomUUID(), claimId: this.session.claimId, command: cloudCommand(command), } let response: Response try { response = await fetch( `${this.session.apiOrigin}/v1/sims/${encodeURIComponent(this.session.simId)}/commands`, { method: 'POST', headers: { authorization: `Bearer ${this.session.token}`, 'content-type': 'application/json', }, body: JSON.stringify(body), ...(options?.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}), }, ) } catch (error) { throw new Error( `could not reach the remote simulator at ${this.session.apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const text = await response.text() if (!response.ok) throw responseError(response.status, text) let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('remote simulator command returned unreadable JSON') } if (!isRecord(parsed) || parsed.id !== body.id) { throw new Error('remote simulator command returned an invalid command receipt') } if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error) if (!Object.hasOwn(parsed, 'result')) { throw new Error('remote simulator command receipt has no result') } return parsed.result } listSims(): Promise { return Promise.reject( new Error('a remote simulator does not expose a local simulator list'), ) } focusSim(): Promise { return Promise.reject(new Error('remote simulators have no browser window to focus')) } resolveReloadedSim(): Promise { // cloud sims carry no local browser host, so there is no lineage to follow return Promise.resolve(null) } closeSim(): Promise { return Promise.reject( new Error('remote simulator close uses the simulator DELETE route'), ) } claim(): Promise { return Promise.reject( new Error('remote simulator claim uses the simulator claim route'), ) } close(): void {} } class CloudBoxBridge implements WsBridge { readonly plane = 'cloud' as const constructor(private readonly session: CloudBoxSession) {} async send( command: { type: string; [key: string]: unknown }, options?: { timeoutMs?: number }, ): Promise { const id = randomUUID() // the wire id is this request's, so the command carries none. `simId` names // a local sim among several and a box has exactly one. const payload: Record = {} for (const [key, value] of Object.entries(command)) { if (key !== 'id' && key !== 'simId') payload[key] = value } let response: Response try { response = await fetch( `${this.session.apiOrigin}/v1/boxes/${encodeURIComponent(this.session.boxId)}/commands`, { method: 'POST', headers: { authorization: `Bearer ${this.session.boxToken}`, 'content-type': 'application/json', }, body: JSON.stringify({ id, command: payload }), ...(options?.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}), }, ) } catch (error) { throw new Error( `could not reach box ${this.session.boxId} at ${this.session.apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const text = await response.text() if (!response.ok) throw responseError(response.status, text) let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('box command returned unreadable JSON') } if (!isRecord(parsed) || parsed.id !== id) { throw new Error('box command returned an invalid command receipt') } if (typeof parsed.error === 'string' && parsed.error) throw new Error(parsed.error) if (!Object.hasOwn(parsed, 'result')) { throw new Error('box command receipt has no result') } return parsed.result } listSims(): Promise { return Promise.reject(new Error('a box does not expose a local simulator list')) } focusSim(): Promise { return Promise.reject(new Error('a box simulator has no browser window to focus')) } resolveReloadedSim(): Promise { // cloud sims carry no local browser host, so there is no lineage to follow return Promise.resolve(null) } closeSim(): Promise { return Promise.reject(new Error('close a box simulator with rnx box stop')) } claim(): Promise { return Promise.reject( new Error('a box simulator is held by its Box page and has nothing to claim'), ) } close(): void {} } export function createCloudBridgeForParsed( parsed: Pick, ): WsBridge | null { const session = resolveCloudSessionForParsed(parsed) if (!session) return null return session.service === 'box' ? new CloudBoxBridge(session) : new CloudBridge(session) } // stops the box, which ends the simulator it holds. an account api key stops // with its own authority; a session login stops with the login, falling back // to the box token the session already holds. export async function closeCloudBox(session: CloudSession): Promise { if (session.service !== 'box') { if (!session.boxId || !session.boxToken) return const response = await fetch( `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${session.boxToken}` }, }, ) const text = await response.text() if (!response.ok && response.status !== 404) { throw responseError(response.status, text) } return } const auth = resolveCliAuth() let authHeader = `Bearer ${session.boxToken}` if (auth?.kind === 'api-key') { authHeader = `Bearer ${auth.secret}` } else if (auth?.kind === 'session') { authHeader = `Bearer ${auth.token}` } let response: Response try { response = await fetch( `${session.apiOrigin}/v1/boxes/${encodeURIComponent(session.boxId)}/stop`, { method: 'POST', headers: { authorization: authHeader }, }, ) } catch (error) { throw new Error( `could not reach box ${session.boxId} at ${session.apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } const text = await response.text() if (!response.ok && response.status !== 404) { throw responseError(response.status, text) } } export interface BoxDisplayGrant { url: string authToken: string expectedPeer: unknown } // how long `rnx remote` waits for a fresh box's page to attach its simulator // before giving up on the watch url. the page boots the bundle after the // browser is up, and a large bundle takes a while to parse. const BOX_DISPLAY_GRANT_WAIT_MS = 180_000 const BOX_DISPLAY_GRANT_RETRY_MS = 2_000 function readBoxDisplayGrant(value: unknown): BoxDisplayGrant { if (!isRecord(value) || !isRecord(value.grant)) { throw new Error('box display-grant returned an invalid grant') } const grant = value.grant if ( typeof grant.url !== 'string' || !grant.url || typeof grant.authToken !== 'string' || !grant.authToken || !isRecord(grant.expectedPeer) ) { throw new Error('box display-grant returned an invalid grant') } return { url: grant.url, authToken: grant.authToken, expectedPeer: grant.expectedPeer } } // mints a watch-only display grant from the box and builds the bare shell // watch url for it: the same /sootsim/#remoteEngine fragment the nano service // mints, pointed at the box's display socket instead. the box token stays in // this process; only the grant travels in the fragment. export async function requestBoxWatchUrl(options: { apiOrigin: string boxId: string boxToken: string }): Promise { const started = Date.now() for (;;) { let response: Response try { response = await fetch( `${options.apiOrigin}/v1/boxes/${encodeURIComponent(options.boxId)}/display-grant`, { method: 'POST', headers: { authorization: `Bearer ${options.boxToken}`, 'content-type': 'application/json', }, body: '{}', }, ) } catch (error) { throw new Error( `could not reach box ${options.boxId} at ${options.apiOrigin}: ${ error instanceof Error ? error.message : String(error) }`, ) } if (response.status === 409) { // the page has not attached its simulator yet. this is the normal state // right after a create, so wait rather than refusing. if (Date.now() - started >= BOX_DISPLAY_GRANT_WAIT_MS) { throw new Error( `box ${options.boxId} has no simulator hosting it yet; try again once its page has booted`, ) } await new Promise((resolve) => setTimeout(resolve, BOX_DISPLAY_GRANT_RETRY_MS)) continue } const text = await response.text() if (!response.ok) throw responseError(response.status, text) let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('box display-grant returned unreadable JSON') } const grant = readBoxDisplayGrant(parsed) const watch = new URL('/sootsim/', rnxPublicBrand.origin) // the shell boots its stage at its own default device, while the box page // hosts its simulator at the grant's device profile. without ?device= the // tenant refuses the host hello on dimensions and paints nothing — the // same param SimPage and BoxHostPage already set on their stage urls. if ( isRecord(grant.expectedPeer) && typeof grant.expectedPeer.deviceProfile === 'string' && grant.expectedPeer.deviceProfile ) { watch.searchParams.set('device', grant.expectedPeer.deviceProfile) } watch.hash = `remoteEngine=${encodeURIComponent( JSON.stringify({ url: grant.url, authToken: grant.authToken, expectedPeer: grant.expectedPeer, }), )}` return watch.toString() } } function readClaimResponse(value: unknown): { id: string; expiresAt: number } { const claim = isRecord(value) ? value.claim : null if ( !isRecord(claim) || typeof claim.id !== 'string' || !claim.id || typeof claim.expiresAt !== 'number' ) { throw new Error('remote simulator claim returned an invalid receipt') } return { id: claim.id, expiresAt: claim.expiresAt } } export async function claimCloudSimulator( session: CloudSimSession, options: { force?: boolean } = {}, ): Promise<{ claim: { id: string; expiresAt: number } session: CloudSimSession }> { const response = await fetch( `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}/claim`, { method: 'POST', headers: { authorization: `Bearer ${session.token}`, 'content-type': 'application/json', }, body: JSON.stringify( options.force ? { force: true } : { claimId: session.claimId }, ), }, ) const text = await response.text() if (!response.ok) throw responseError(response.status, text) let parsed: unknown try { parsed = JSON.parse(text) } catch { throw new Error('remote simulator claim returned unreadable JSON') } const claim = readClaimResponse(parsed) return { claim, session: createCloudSession({ ...session, claimId: claim.id }), } } export async function confirmCloudSimulator(session: CloudSimSession): Promise { const confirmed = await claimCloudSimulator(session) if (confirmed.session.claimId !== session.claimId) { throw new Error('remote simulator confirmation replaced the initial claim') } } export async function closeCloudSession(session: CloudSession): Promise { const boxId = session.boxId const boxToken = session.boxToken if (boxId && boxToken) { const response = await fetch( `${session.apiOrigin}/v1/boxes/${encodeURIComponent(boxId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${boxToken}` }, }, ) const text = await response.text() if (!response.ok && response.status !== 404) { throw responseError(response.status, text) } return } // box sessions close through closeCloudBox; only sim sessions reach this leg. if (session.service !== 'sim') return const response = await fetch( `${session.apiOrigin}/v1/sims/${encodeURIComponent(session.simId)}`, { method: 'DELETE', headers: { authorization: `Bearer ${session.token}` }, }, ) const text = await response.text() if (!response.ok && response.status !== 404) { throw responseError(response.status, text) } }