// rnx test --cloud live-dev-stack publisher (tunnel CLI half). // // when --app names a live packager (http loopback) instead of a prebuilt // bundle, the submitter publishes that stack through a token-gated // cloudflared quick tunnel and submits the tunnel origin plus the mode-2 // fields (hostHeader, jobToken, sidecarPorts). the recorder projects the // origin back to loopback (scripts/runner/sootsim-recorder.ts), so flows // keep their unmodified localhost authorities. // // the handshake mirrors packages/contrast-runner/src/hosted.ts, which this // published package cannot import: Bearer-gated proxy between cloudflared // and the dev server, --http-host-header on the dev authority, forwarded // headers stripped so Metro emits loopback authorities the recorder // mirrors. header names must match hostedPorts.ts and loopback-proxy.mjs. import { spawn, spawnSync, type ChildProcess } from 'node:child_process' import { randomBytes } from 'node:crypto' import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs' import { createServer, request as httpRequest } from 'node:http' import { tmpdir } from 'node:os' import { join } from 'node:path' // must match packages/contrast-runner/src/hostedPorts.ts. const UPSTREAM_PORT_HEADER = 'x-contrast-upstream-port' const APPLICATION_AUTH_HEADER = 'x-contrast-application-authorization' const MAX_SIDECAR_PORTS = 8 export type CloudTestSidecarResult = | { ok: true; ports: number[] } | { ok: false; message: string } // same grammar as the jobs API (parseHostedSidecarPorts): comma-separated // ports 1-65535, at most 8, deduped, primary excluded. export function parseCloudTestSidecarPorts( value: string | null | undefined, primaryPort?: number, ): CloudTestSidecarResult { if (!value?.trim()) return { ok: true, ports: [] } const values = value.split(',').map((part) => part.trim()) if (values.length > MAX_SIDECAR_PORTS) { return { ok: false, message: `sidecar-ports accepts at most ${MAX_SIDECAR_PORTS} ports`, } } const ports: number[] = [] for (const raw of values) { if (!/^[1-9][0-9]{0,4}$/.test(raw)) { return { ok: false, message: 'sidecar-ports must be a comma-separated list of ports from 1 to 65535', } } const port = Number(raw) if (port > 65_535) { return { ok: false, message: 'sidecar-ports must be a comma-separated list of ports from 1 to 65535', } } if (port !== primaryPort && !ports.includes(port)) ports.push(port) } return { ok: true, ports } } function isLoopbackHostname(hostname: string): boolean { return ( hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1' ) } // a live dev stack is an http(s) loopback URL: the packager the recorder // cannot reach directly. https loopback is accepted for tunnels that // terminate TLS locally; everything else (files, https remotes, share ids) // stays on the prebuilt path. export function parseLiveDevStackApp(app: string): { port: number } | null { if (!app.startsWith('http://') && !app.startsWith('https://')) return null let url: URL try { url = new URL(app) } catch { return null } if (!isLoopbackHostname(url.hostname)) return null const port = Number(url.port) if (!Number.isInteger(port) || port < 1 || port > 65_535) return null return { port } } function resolveUpstreamPort( value: string | string[] | undefined, primaryPort: number, sidecarPorts: readonly number[], ): number | null { if (value === undefined) return primaryPort if (Array.isArray(value) || !/^[1-9][0-9]{0,4}$/.test(value)) return null const port = Number(value) return sidecarPorts.includes(port) ? port : null } // Bearer-gating reverse proxy between cloudflared and the dev server. // rejects without the per-job token; strips cloudflared forwarding headers // so Metro builds absolute URLs on the dev authority the recorder mirrors. export function startCloudTestTokenProxy( listenPort: number, upstreamPort: number, sidecarPorts: readonly number[], token: string, ): Promise<{ close: () => void } | null> { const server = createServer((req, res) => { const auth = req.headers.authorization || '' if (auth !== `Bearer ${token}`) { res.writeHead(401, { 'content-type': 'text/plain' }) res.end('unauthorized') return } const headers = { ...req.headers } const applicationAuthorization = headers[APPLICATION_AUTH_HEADER] delete headers[APPLICATION_AUTH_HEADER] delete headers.authorization if (typeof applicationAuthorization === 'string') { headers.authorization = applicationAuthorization } const selectedPort = resolveUpstreamPort( headers[UPSTREAM_PORT_HEADER], upstreamPort, sidecarPorts, ) delete headers[UPSTREAM_PORT_HEADER] if (selectedPort === null) { res.writeHead(403, { 'content-type': 'text/plain' }) res.end('upstream port is not enabled for this run') return } if (selectedPort !== upstreamPort) headers.host = `127.0.0.1:${selectedPort}` for (const key of Object.keys(headers)) { const lower = key.toLowerCase() if (lower.startsWith('x-forwarded-') || lower.startsWith('cf-')) { delete headers[key] } } const up = httpRequest( { host: '127.0.0.1', port: selectedPort, path: req.url, method: req.method, headers, }, (upRes) => { res.writeHead(upRes.statusCode || 502, upRes.headers) upRes.pipe(res) }, ) up.on('error', (err) => { res.writeHead(502, { 'content-type': 'text/plain' }) res.end(`upstream error: ${err.message}`) }) req.pipe(up) }) return new Promise((resolve) => { server.on('error', () => resolve(null)) server.listen(listenPort, '127.0.0.1', () => { resolve({ close: () => server.close() }) }) }) } function cloudflaredAvailable(): boolean { const probe = spawnSync('cloudflared', ['--version'], { stdio: 'ignore' }) return probe.status === 0 } async function ensureCloudflared(): Promise { if (cloudflaredAvailable()) return true const archMap: Record = { x64: 'amd64', arm64: 'arm64' } const arch = archMap[process.arch] const osMap: Record = { linux: 'linux', darwin: 'darwin' } const os = osMap[process.platform] if (!arch || !os) return false const dest = join(tmpdir(), 'rnx-cloudflared') const binary = join(dest, 'cloudflared') if (existsSync(binary)) { process.env.PATH = `${dest}:${process.env.PATH ?? ''}` return cloudflaredAvailable() } // linux publishes a bare binary; darwin publishes a tarball. const asset = os === 'darwin' ? `cloudflared-${os}-${arch}.tgz` : `cloudflared-${os}-${arch}` try { const res = await fetch( `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`, ) if (!res.ok) return false mkdirSync(dest, { recursive: true }) if (os === 'darwin') { const tgz = join(dest, asset) writeFileSync(tgz, Buffer.from(await res.arrayBuffer())) const untar = spawnSync('tar', ['-xzf', tgz, '-C', dest]) try { unlinkSync(tgz) } catch {} if (untar.status !== 0 || !existsSync(binary)) return false } else { writeFileSync(binary, Buffer.from(await res.arrayBuffer())) } } catch { return false } chmodSync(binary, 0o755) process.env.PATH = `${dest}:${process.env.PATH ?? ''}` return cloudflaredAvailable() } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } function childAlive(child: ChildProcess): boolean { return child.exitCode === null && child.signalCode === null } async function launchQuickTunnel( originUrl: string, hostHeader: string, logFile: string, ): Promise<{ url: string; child: ChildProcess } | null> { writeFileSync(logFile, '') const child = spawn( 'cloudflared', [ 'tunnel', '--url', originUrl, '--http-host-header', hostHeader, '--protocol', 'http2', '--no-autoupdate', ], { stdio: ['ignore', 'pipe', 'pipe'] }, ) // cloudflared logs the tunnel URL to stderr; mirror both streams into the // log file and poll it for the trycloudflare origin. const lines: string[] = [] child.stderr?.on('data', (chunk) => { lines.push(String(chunk)) writeFileSync(logFile, lines.join('')) }) child.stdout?.on('data', (chunk) => { lines.push(String(chunk)) writeFileSync(logFile, lines.join('')) }) for (let i = 0; i < 60; i++) { let text = '' try { text = readFileSync(logFile, 'utf8') } catch { text = '' } const match = text.match(/https:\/\/[-a-z0-9]+\.trycloudflare\.com/g) const url = match?.[match.length - 1] if (url) return { url, child } if (!childAlive(child)) return null await sleep(1000) } child.kill() return null } export interface PublishedLiveDevStack { targetUrl: string hostHeader: string jobToken: string sidecarPorts: number[] close: () => void } // publish a live dev stack for one hosted test run. the caller keeps the // returned tunnel open until the job reaches a terminal state, then closes // it. throws on any setup failure with a message the CLI prints. export async function publishLiveDevStack(args: { devPort: number sidecarPorts: readonly number[] }): Promise { const { devPort, sidecarPorts } = args const jobToken = randomBytes(16).toString('hex') // free loopback port for the token proxy; the advertised authority stays // the dev server's own port so Metro URLs resolve on the recorder. const { Server } = await import('node:net') const proxyPort = await new Promise((resolve, reject) => { const probe = new Server() probe.once('error', reject) probe.listen(0, '127.0.0.1', () => { const address = probe.address() const port = address && typeof address === 'object' ? address.port : 0 probe.close(() => resolve(port)) }) }) if (!proxyPort) throw new Error('could not allocate a tunnel proxy port') const proxy = await startCloudTestTokenProxy(proxyPort, devPort, sidecarPorts, jobToken) if (!proxy) { throw new Error(`could not start the tunnel token proxy on 127.0.0.1:${proxyPort}`) } const closeProxy = proxy.close const hostHeader = `127.0.0.1:${devPort}` if (!(await ensureCloudflared())) { closeProxy() throw new Error( 'cloudflared is required for live dev stacks (rnx test --cloud with http://localhost:). install it or check your network.', ) } const logFile = join(tmpdir(), `rnx-test-tunnel-${Date.now()}-${proxyPort}.log`) const tunnel = await launchQuickTunnel( `http://127.0.0.1:${proxyPort}`, hostHeader, logFile, ) if (!tunnel) { closeProxy() let tail = '' try { tail = readFileSync(logFile, 'utf8').slice(-500) } catch { tail = '' } throw new Error( `could not establish the cloudflared tunnel for the live dev stack${tail ? `: ${tail.trim().split('\n').pop()}` : ''}`, ) } let closed = false return { targetUrl: tunnel.url, hostHeader, jobToken, sidecarPorts: [...sidecarPorts], close: () => { if (closed) return closed = true tunnel.child.kill() closeProxy() }, } }