import { fstatSync, writeSync } from 'node:fs' import { isLoopbackHost } from '../src/backend-origin.ts' const CLOUD_SESSION_VERSION = 3 as const const SHELL_UPDATE_FD_ENV = 'RNX_SHELL_UPDATE_FD' const MAX_ENCODED_SESSION_BYTES = 32 * 1024 export const RNX_CLOUD_SESSION_ENV = 'RNX_CLOUD_SESSION' export const RNX_SHELL_UPDATE_PREFIX = '__RNX_SHELL_UPDATE_V1__' export const RNX_SHELL_UPDATE_SET_PREFIX = `${RNX_SHELL_UPDATE_PREFIX}set:` export const RNX_SHELL_UPDATE_UNSET = `${RNX_SHELL_UPDATE_PREFIX}unset` // the shell's remote target. two services answer rnx commands and they are // addressed differently, so the session names which one it is rather than // leaving a reader to infer it from which fields happen to be filled in. export type CloudSession = CloudSimSession | CloudBoxSession export type RemotePlatform = 'ios' | 'android' export interface CloudSessionArtifactIdentity { sha256: string bytes?: number } /** * one remote simulator and the claim ordinary commands drive it with. the * watch URL the create returned rides along so a reattach reopens the same * simulator. a nano-box simulator keeps the box that hosts it; an instance * API simulator has no box. */ export interface CloudSimSession { version: 3 service: 'sim' simId: string apiOrigin: string claimId: string token: string streamUrl?: string | null platform?: RemotePlatform device?: string artifact?: CloudSessionArtifactIdentity /** nano-box simulators only: the box that hosts them. absent on the instance API. */ boxId?: string boxToken?: string } /** * a micro box, whose simulator lives in its own box page rather than in the * service. there is nothing to claim and no separate simulator token: the box * token addresses the box, and the box relays each command to the page that * is hosting it. */ export interface CloudBoxSession { version: 3 service: 'box' boxId: string boxToken: string apiOrigin: string platform?: RemotePlatform device?: string artifact?: CloudSessionArtifactIdentity } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } export function normalizeCloudOrigin(source: string): string { let parsed: URL try { parsed = new URL(source) } catch { throw new Error(`invalid remote simulator origin: ${source}`) } if ( (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') || parsed.username || parsed.password || parsed.pathname !== '/' || parsed.search || parsed.hash ) { throw new Error(`remote simulator origin must be an HTTP(S) origin: ${source}`) } if (parsed.protocol === 'http:' && !isLoopbackHost(parsed.hostname)) { throw new Error(`remote simulator origin requires HTTPS outside loopback: ${source}`) } return parsed.origin } export function createCloudSession(input: { simId: string apiOrigin: string claimId: string token: string streamUrl?: string | null platform?: RemotePlatform device?: string artifact?: CloudSessionArtifactIdentity boxId?: string boxToken?: string }): CloudSimSession { const boxId = input.boxId?.trim() ?? '' const boxToken = input.boxToken ?? '' if ((boxId === '') !== (boxToken === '')) { throw new Error('cloud session names a box with both its id and its token') } const session: CloudSimSession = { version: CLOUD_SESSION_VERSION, service: 'sim', simId: input.simId.trim(), apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()), claimId: input.claimId.trim(), token: input.token, ...(input.streamUrl ? { streamUrl: input.streamUrl } : {}), ...(input.platform ? { platform: input.platform } : {}), ...(input.device?.trim() ? { device: input.device.trim() } : {}), ...(input.artifact ? { artifact: input.artifact } : {}), ...(boxId && boxToken ? { boxId, boxToken } : {}), } if (!session.simId || !session.claimId || !session.token) { throw new Error('cloud session requires a simulator, an origin, a claim, and a token') } return session } export function createCloudBoxSession(input: { boxId: string boxToken: string apiOrigin: string platform?: RemotePlatform device?: string artifact?: CloudSessionArtifactIdentity }): CloudBoxSession { const session: CloudBoxSession = { version: CLOUD_SESSION_VERSION, service: 'box', boxId: input.boxId.trim(), boxToken: input.boxToken, apiOrigin: normalizeCloudOrigin(input.apiOrigin.trim()), ...(input.platform ? { platform: input.platform } : {}), ...(input.device?.trim() ? { device: input.device.trim() } : {}), ...(input.artifact ? { artifact: input.artifact } : {}), } if (!session.boxId || !session.boxToken) { throw new Error('cloud box session requires a box, an origin, and a box token') } return session } export function serializeCloudSession(session: CloudSession): string { const validated = session.service === 'box' ? createCloudBoxSession(session) : createCloudSession(session) return Buffer.from(JSON.stringify(validated), 'utf8').toString('base64url') } export function parseCloudSession(encoded: string): CloudSession { const value = encoded.trim() if ( !value || value.length > MAX_ENCODED_SESSION_BYTES || !/^[A-Za-z0-9_-]+$/.test(value) ) { throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`) } let parsed: unknown try { const bytes = Buffer.from(value, 'base64url') if (bytes.toString('base64url') !== value) { throw new Error('non-canonical cloud session') } parsed = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes)) } catch { throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`) } if (!isRecord(parsed) || parsed.version !== CLOUD_SESSION_VERSION) { throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`) } const apiOrigin = parsed.apiOrigin if (typeof apiOrigin !== 'string') { throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`) } const platform = parsed.platform === 'ios' || parsed.platform === 'android' ? parsed.platform : undefined const device = typeof parsed.device === 'string' ? parsed.device : undefined const artifact = isRecord(parsed.artifact) && typeof parsed.artifact.sha256 === 'string' ? { sha256: parsed.artifact.sha256, bytes: typeof parsed.artifact.bytes === 'number' ? parsed.artifact.bytes : undefined, } : undefined const streamUrl = typeof parsed.streamUrl === 'string' ? parsed.streamUrl : undefined const boxId = typeof parsed.boxId === 'string' ? parsed.boxId : undefined const boxToken = typeof parsed.boxToken === 'string' ? parsed.boxToken : undefined try { if (parsed.service === 'box') { if (!boxId || !boxToken) throw new Error('unreadable cloud box session') return createCloudBoxSession({ boxId, boxToken, apiOrigin, platform, device, artifact, }) } const simId = parsed.simId const claimId = parsed.claimId const token = parsed.token if ( parsed.service !== 'sim' || typeof simId !== 'string' || typeof claimId !== 'string' || typeof token !== 'string' ) { throw new Error('unreadable cloud session') } return createCloudSession({ simId, apiOrigin, claimId, token, streamUrl, platform, device, artifact, boxId, boxToken, }) } catch { throw new Error(`${RNX_CLOUD_SESSION_ENV} is invalid`) } } export function readCloudSession( environment: Readonly> = process.env, ): CloudSession | null { const encoded = environment[RNX_CLOUD_SESSION_ENV] return encoded?.trim() ? parseCloudSession(encoded) : null } function shellUpdateFd( environment: Readonly> = process.env, ): number | null { const source = environment[SHELL_UPDATE_FD_ENV] if (!source) return null const descriptor = Number(source) if (!Number.isSafeInteger(descriptor) || descriptor < 3) { throw new Error('RNX shell integration provided an invalid update channel') } try { fstatSync(descriptor) } catch { throw new Error('RNX shell integration update channel is unavailable') } return descriptor } export function requireCloudSessionShellUpdate(): number { const descriptor = shellUpdateFd() if (descriptor === null) { throw new Error( 'RNX shell integration is required so this remote simulator stays connected to the current shell; restart the shell after installing RNX', ) } return descriptor } export function publishCloudSessionToShell( session: CloudSession | null, descriptor = requireCloudSessionShellUpdate(), ): void { const update = session ? `${RNX_SHELL_UPDATE_SET_PREFIX}${serializeCloudSession(session)}` : RNX_SHELL_UPDATE_UNSET writeSync(descriptor, `${update}\n`) }