import { spawn, type ChildProcess } from 'node:child_process' import { createHash } from 'node:crypto' import fs from 'node:fs' import http from 'node:http' import path from 'node:path' import { fileURLToPath } from 'node:url' export type RnxDesktopOpenOption = boolean | { appName: string } const scheduledMetroOpens = new Set() export function resolveRnxDesktopAppName( open: RnxDesktopOpenOption | undefined, projectRoot: string, ): string | null { if (!open) return null if (typeof open === 'object') { const appName = open.appName.trim() if (!appName) throw new Error('rnx: open.appName must not be empty') return appName } const packageJsonPath = path.join(projectRoot, 'package.json') const packageJson: unknown = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) const packageName = packageJson && typeof packageJson === 'object' ? Reflect.get(packageJson, 'name') : undefined if (typeof packageName !== 'string' || !packageName.trim()) { throw new Error(`rnx: ${packageJsonPath} must declare a package name`) } return packageName.trim() } export function tagRnxBundleForPlugin( bundleUrl: string, replacementOrigin: string, replacementToken: string, projectRoot: string, appName?: string | null, ): string { const absolute = /^[a-z][a-z\d+.-]*:\/\//i.test(bundleUrl) const url = new URL(bundleUrl, absolute ? undefined : 'http://rnx.local') if (appName) { url.searchParams.set('rnxAppName', appName) url.searchParams.set( 'rnxProjectId', createHash('sha256') .update(fs.realpathSync(projectRoot)) .digest('hex') .slice(0, 32), ) } url.searchParams.set('rnxReplacementOrigin', replacementOrigin) url.searchParams.set('rnxReplacementToken', replacementToken) return absolute ? url.toString() : `${url.pathname}${url.search}${url.hash}` } export function resolveRnxDesktopLaunchTarget(port: number, bundleUrl: string): string { return new URL(bundleUrl, `http://localhost:${port}`).toString() } export function buildRnxDesktopOpenArgs(bundleUrl: string): string[] { return ['open', bundleUrl, '--new', '--driver', 'electron', '--no-describe'] } export function observeRnxDesktopLaunch(child: ChildProcess, appName: string): void { let stderr = '' let spawnFailed = false child.stderr?.setEncoding('utf8') child.stderr?.on('data', (chunk: string) => { stderr = `${stderr}${chunk}`.slice(-16_384) }) child.once('error', (error) => { spawnFailed = true console.error(`[rnx] could not open ${appName}: ${error.message}`) }) child.once('close', (code, signal) => { if (spawnFailed) return if (code === 0) { console.log(`[rnx] opened ${appName} in the desktop simulator`) return } const detail = stderr.trim() || (signal ? `signal ${signal}` : `exit ${code}`) console.error(`[rnx] could not open ${appName}: ${detail}`) }) } export function launchRnxDesktop(bundleUrl: string, appName: string): void { const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const binPath = path.join(packageRoot, 'dist-cli', 'bin.js') console.log(`[rnx] opening ${appName} in the desktop simulator`) const child = spawn( process.execPath, [binPath, ...buildRnxDesktopOpenArgs(bundleUrl)], { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true, }, ) observeRnxDesktopLaunch(child, appName) } interface MetroDesktopOpenOptions { port: number bundleUrl: string appName: string projectRoot: string serverId: string intervalMs?: number timeoutMs?: number launch?: (bundleUrl: string, appName: string) => void } export function openRnxDesktopWhenMetroIsReady({ port, bundleUrl, appName, projectRoot, serverId, intervalMs = 100, timeoutMs = 30_000, launch = launchRnxDesktop, }: MetroDesktopOpenOptions): Promise { const key = `${projectRoot}\0${port}` if (scheduledMetroOpens.has(key)) return Promise.resolve(false) scheduledMetroOpens.add(key) const deadline = Date.now() + timeoutMs return new Promise((resolve) => { let finished = false let retryTimer: ReturnType | undefined const finish = (opened: boolean) => { if (finished) return finished = true clearTimeout(retryTimer) scheduledMetroOpens.delete(key) resolve(opened) } const retry = () => { if (finished) return if (Date.now() >= deadline) { console.error( `[rnx] could not open ${appName}: Metro did not become ready on port ${port}`, ) finish(false) return } retryTimer = setTimeout(check, intervalMs) } const check = () => { const request = http.get( `http://127.0.0.1:${port}/__server-scan`, { headers: { Accept: 'application/json' } }, (response) => { let body = '' response.setEncoding('utf8') response.on('data', (chunk: string) => { body = `${body}${chunk}`.slice(0, 65_536) }) response.once('end', () => { try { const scan: unknown = JSON.parse(body) const matches = response.statusCode === 200 && Array.isArray(scan) && scan.some((entry: unknown) => { if (!entry || typeof entry !== 'object') return false const scannedPort = Reflect.get(entry, 'port') const projectName = Reflect.get(entry, 'projectName') const scannedServerId = Reflect.get(entry, 'rnxServerId') const scannedBundle = Reflect.get(entry, 'bundleUrl') if ( scannedPort !== port || projectName !== appName || scannedServerId !== serverId || typeof scannedBundle !== 'string' ) { return false } return new URL(scannedBundle).searchParams.get('rnxAppName') === appName }) if (matches) { launch(bundleUrl, appName) finish(true) return } } catch {} retry() }) }, ) request.setTimeout(Math.min(1_000, timeoutMs), () => request.destroy()) request.once('error', retry) } check() }) }