// rnx record upload — capture the currently-loaded bundle + device spec from // a running rnx sim and POST it to the Contrast upload endpoint. returns a // /preview/ url that anyone can open to load this bundle in a frame. // // uploads are unlisted-public (unguessable id) but require a signed-in // desktop session so the share is associated with the user's account. import { createHash } from 'crypto' import { readFileSync } from 'fs' import path from 'path' import { gzipSync, gunzipSync } from 'zlib' import { shouldCompress } from 'sootsim-engine/preview/compress' import { brotliBundle } from 'sootsim-engine/preview/compress-node' import { runPresignedUpload, type InitRequestBody, } from 'sootsim-engine/preview/presigned-upload' import { TRACE_CAPTURE_POSTROLL_MS, TRACE_CAPTURE_PREROLL_MS, isTraceTimelineEvent, parseTraceTimelineJsonl, } from 'sootsim-engine/preview/trace' import { rnxPublicBrand } from '../../src/public-brand' import { fetchAppFontFiles } from '../app-fonts' import { fetchAppSplashImages } from '../app-splash' import { authHeaderValue, githubUploadIdentity, resolveCliAuth } from '../auth' import { openUrl } from '../open-url' import { resolveRunProvenance } from '../run-registry' import { rnxExit } from '../run-rnx' import { callInBridge, createBridgeFromParsed, evalInBridge, parseBridgeCliArgs, } from '../ws-bridge' import type { SootSimTimelineEvent } from '@rnx/globals' interface UploadOptions { port?: number verbose?: boolean } export type UploadResult = { previewUrl: string shareId: string } // pull the server's human-readable `message` out of a JSON error body so the // CLI can print it directly instead of dumping the raw `{"error":...}` blob. function parseServerMessage(responseText?: string): string | null { if (!responseText) return null try { const body = JSON.parse(responseText) as { message?: unknown } return typeof body.message === 'string' && body.message ? body.message : null } catch { return null } } type BundleSnapshot = { bundleUrl: string | null entry: string | null deviceSpec: InitRequestBody['deviceSpec'] runtimeVersion: string | null pageHref: string transformedBundle: { url: string byteLength: number timestamp: number } | null } type AttachedInteractionSnapshot = { meta: NonNullable[number] & { id: string } bytes: Uint8Array } function readSnapshotManifest(manifestPath: string): AttachedInteractionSnapshot[] { const parsed: unknown = JSON.parse(readFileSync(manifestPath, 'utf8')) if (!Array.isArray(parsed)) throw new Error('snapshot manifest must be an array') const seen = new Set() return parsed.map((entry, index) => { if (entry === null || typeof entry !== 'object') { throw new Error(`snapshot manifest entry ${index} must be an object`) } const id = Reflect.get(entry, 'id') const label = Reflect.get(entry, 'label') const filePath = Reflect.get(entry, 'path') const t = Reflect.get(entry, 't') if (typeof id !== 'string' || !id || seen.has(id)) { throw new Error(`snapshot manifest entry ${index} has an invalid or duplicate id`) } if (typeof filePath !== 'string' || !filePath) { throw new Error(`snapshot manifest entry ${index} has no PNG path`) } if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) { throw new Error(`snapshot manifest entry ${index} has an invalid capture time`) } seen.add(id) const bytes = readFileSync(filePath) return { meta: { id, label: typeof label === 'string' && label ? label : id, kind: 'interaction', t: Math.round(t), contentType: 'image/png', sizeBytes: bytes.byteLength, }, bytes, } }) } const REMOTE_DEFAULT_ORIGIN = process.env.RNX_UPLOAD_ORIGIN || 'https://contrast.dev' // the canonical public preview link. Uploads go to the contrast backend, but // reviewers/users open rnx preview pages on rnxsim.com. const REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN = process.env.RNX_PREVIEW_ORIGIN || rnxPublicBrand.origin export async function resolveDefaultUploadOrigin( explicitOrigin?: string, ): Promise { if (explicitOrigin) return explicitOrigin if (process.env.RNX_UPLOAD_ORIGIN) return process.env.RNX_UPLOAD_ORIGIN return REMOTE_DEFAULT_ORIGIN } function normalizeOrigin(origin: string): string { return origin.replace(/\/$/, '') } function isLoopbackOrigin(origin: string): boolean { try { const parsed = new URL(origin) const hostname = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase() return ( hostname === 'localhost' || hostname.endsWith('.localhost') || hostname === '0.0.0.0' || hostname === '::1' || /^127(?:\.\d{1,3}){3}$/.test(hostname) ) } catch { return false } } function isContrastOrigin(origin: string): boolean { try { const hostname = new URL(origin).hostname.toLowerCase() return hostname === 'contrast.dev' || hostname.endsWith('.contrast.dev') } catch { return false } } export function resolvePublicPreviewOrigin( uploadOrigin: string, explicitOrigin?: string, ): string { if (explicitOrigin) return normalizeOrigin(explicitOrigin) if (process.env.RNX_PREVIEW_ORIGIN) { return normalizeOrigin(process.env.RNX_PREVIEW_ORIGIN) } if (isLoopbackOrigin(uploadOrigin)) return normalizeOrigin(uploadOrigin) if (isContrastOrigin(uploadOrigin)) { return normalizeOrigin(REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN) } return normalizeOrigin(uploadOrigin) } function printHelp() { console.log(` rnx record upload — publish the current recorded bundle as a /preview/ link usage: rnx record upload [--origin ] [--public-origin ] (--events | --video ) [--screenshot ] [--owner --repo ] [--sim ] [--open] [--assets-only] options: --origin upload target (default: ${REMOTE_DEFAULT_ORIGIN}) override with RNX_UPLOAD_ORIGIN env var --public-origin public /preview link origin (default: ${REMOTE_DEFAULT_PUBLIC_PREVIEW_ORIGIN} for prod uploads, upload origin for localhost/custom origins). override with RNX_PREVIEW_ORIGIN env var --events path to a gzipped events .jsonl.gz file to attach required unless --video is present; previews must have replay or recording playback data --screenshot path to a PNG thumbnail to attach to the share --snapshot-manifest JSON manifest of existing interaction PNGs to attach --video path to a mp4/gif flow recording. embedded inline in the pr sticky comment served at /api/preview/flow-video?id= --video-duration-ms duration hint for attached flow video --timeline-events extra trace timeline rows, JSONL or JSONL gz --trace-frame-stats frame stats JSON returned by the recorder bridge --failure-bundle per-failure debug bundle dir written by the flow runner (error.json, describe.json, a11y.txt, tree.txt, console.json, screenshot.png). uploaded with the share so failed CI runs are debuggable from the dashboard --owner associate a session upload with a linked org repo --repo repo name for --owner; the signed-in user must belong to the local/org team that owns the link --billing-kind test_run meter this upload as a natural-language test run --sim target a specific sim (see: rnx list) --open open the resulting /preview/ url in the browser --assets-only drop API/JSON/HTML records before upload; keep images, fonts, css, js, and binary blobs. live-data demos hit the real network at replay time instead of serving recorded API snapshots -h, --help examples: rnx record upload --events ./my-session.jsonl.gz rnx record upload --origin https://contrast.localhost:3000 --events ./my-session.jsonl.gz --open rnx record upload --video /tmp/rnx-flow.mp4 --video-duration-ms 12000 `) } function extractStringFlag(args: string[], name: string): string | undefined { const idx = args.findIndex((a) => a === name) if (idx < 0) return undefined const value = args[idx + 1] args.splice(idx, 2) return value } function extractBoolFlag(args: string[], name: string): boolean { const idx = args.findIndex((a) => a === name) if (idx < 0) return false args.splice(idx, 1) return true } function parsePositiveMs(raw: string | undefined, name: string): number | undefined { if (!raw) return undefined const value = Number(raw) if (!Number.isFinite(value) || value <= 0) { console.error(` invalid ${name}: ${raw}`) rnxExit(1) } return Math.round(value) } // mirrors fetch-recorder.ts#isAssetContentType — kept in sync manually. // applied when --assets-only is set so the client-side filter matches what // the recorder would have captured in assets-only mode. function isAssetContentType(rawContentType: string): boolean { const ct = (rawContentType || '').toLowerCase().split(';')[0].trim() if (!ct) return false if ( ct.startsWith('image/') || ct.startsWith('font/') || ct.startsWith('video/') || ct.startsWith('audio/') || ct.startsWith('model/') ) return true if (ct === 'text/css') return true if (ct === 'application/javascript' || ct === 'text/javascript') return true if (ct === 'application/wasm') return true if (ct === 'application/font-woff' || ct === 'application/font-woff2') return true if (ct === 'application/octet-stream') return true return false } async function fetchBytes(url: string): Promise { const res = await fetch(url) if (!res.ok) { throw new Error(`fetch ${url} -> ${res.status} ${res.statusText}`) } return new Uint8Array(await res.arrayBuffer()) } function guessBundleContentType(url: string): string { if (/\.bundle($|\?)/.test(url)) return 'application/javascript' if (/\.js($|\?)/.test(url)) return 'application/javascript' if (/\.zip($|\?)/.test(url)) return 'application/zip' return 'application/javascript' } function appFontsWireFromPageHref(pageHref: string): string { try { return new URL(pageHref).searchParams.get('appFonts') || '' } catch { return '' } } function appSplashWireFromPageHref(pageHref: string): string { try { return new URL(pageHref).searchParams.get('appSplash') || '' } catch { return '' } } // metro serves a self-contained bundle when lazy=false. rewrite the // bundle URL to force that so the captured JS has every chunk inlined. function forceInlineBundle(bundleUrl: string): string { try { const u = new URL(bundleUrl) if (u.searchParams.get('lazy') === 'true') { u.searchParams.set('lazy', 'false') return u.toString() } return bundleUrl } catch { return bundleUrl } } // pull the retained transformed bundle over the bridge in slices. a single // message carrying tens of MB hits the ws framing cap, so this pages the // bytes in. the page holds the bundle as a Blob, so the slices are byte // ranges: collect the bytes and decode once at the end, because decoding // each slice on its own would split multi-byte characters at the seams. async function pullTransformedBundle( bridge: ReturnType, byteLength: number, ): Promise { const CHUNK = 1_000_000 // ~1.33 MB of base64 per call const pieces: Uint8Array[] = [] let total = 0 for (let offset = 0; offset < byteLength; offset += CHUNK) { const end = Math.min(offset + CHUNK, byteLength) const chunk = await callInBridge( bridge, 'SootSim.bridges.readTransformedBundleSlice', offset, end, ) if (typeof chunk !== 'string') return null const bytes = Uint8Array.from(atob(chunk), (c) => c.charCodeAt(0)) pieces.push(bytes) total += bytes.length } const joined = new Uint8Array(total) let at = 0 for (const piece of pieces) { joined.set(piece, at) at += piece.length } return new TextDecoder().decode(joined) } type RecordedEntry = { url: string urlhash: string method?: string bodyHash?: string requestVaryHash?: string status: number contentType: string responseHeaders?: Array<{ name: string; value: string }> size: number } type RecordedFetch = RecordedEntry & { bodyBase64: string } function traceTimelineQuery(args: { recordedDurationMs?: number recordingStartedAtMs?: number }): { since?: number; until?: number; limit: number } { if (args.recordedDurationMs && args.recordingStartedAtMs) { return { since: args.recordingStartedAtMs - TRACE_CAPTURE_PREROLL_MS, until: args.recordingStartedAtMs + args.recordedDurationMs + TRACE_CAPTURE_POSTROLL_MS, limit: 2000, } } return { limit: 2000 } } function readTraceTimelineEventsFile(filePath: string): SootSimTimelineEvent[] { const bytes = readFileSync(filePath) const isGzip = bytes[0] === 0x1f && bytes[1] === 0x8b const text = (isGzip ? gunzipSync(bytes) : bytes).toString('utf8') return parseTraceTimelineJsonl(text) } function readTraceFrameStatsFile(filePath: string): unknown { return JSON.parse(readFileSync(filePath, 'utf8')) } function frameRowsFromStats(frameStats: unknown): SootSimTimelineEvent[] { if (frameStats === null || typeof frameStats !== 'object') return [] const record = frameStats as { startedAtEpochMs?: unknown; samples?: unknown } const startedAt = typeof record.startedAtEpochMs === 'number' && record.startedAtEpochMs > 0 ? record.startedAtEpochMs : null if (startedAt === null || !Array.isArray(record.samples)) return [] const rows: SootSimTimelineEvent[] = [] for (const sample of record.samples) { if (!Array.isArray(sample) || typeof sample[0] !== 'number') continue const num = (value: unknown) => typeof value === 'number' && Number.isFinite(value) ? Math.round(value * 100) / 100 : 0 rows.push({ schemaVersion: 1, t: startedAt + Math.round(sample[0]), seq: rows.length + 1, context: 'tenant', kind: 'frame', data: { totalMs: num(sample[1]), layoutMs: num(sample[2]), renderMs: num(sample[3]), copyMs: num(sample[4]), }, }) } return rows } function frameStatsMeta(frameStats: unknown): unknown { if (frameStats === null || typeof frameStats !== 'object') return frameStats const { samples: _samples, ...aggregates } = frameStats as { samples?: unknown [key: string]: unknown } return aggregates } function mergeRecordedEntries( entries: T[], ): T[] { const merged = new Map() for (const entry of entries) { merged.set(entry.urlhash || entry.url, entry) } return Array.from(merged.values()) } export async function runUpload( args: string[], _opts: UploadOptions, ): Promise { if (args.includes('--help') || args.includes('-h')) { printHelp() rnxExit(0) } const mutableArgs = [...args] const origin = await resolveDefaultUploadOrigin( extractStringFlag(mutableArgs, '--origin'), ) const publicOrigin = resolvePublicPreviewOrigin( origin, extractStringFlag(mutableArgs, '--public-origin'), ) const eventsPath = extractStringFlag(mutableArgs, '--events') const screenshotPath = extractStringFlag(mutableArgs, '--screenshot') const snapshotManifestPath = extractStringFlag(mutableArgs, '--snapshot-manifest') const videoPath = extractStringFlag(mutableArgs, '--video') const timelineEventsPath = extractStringFlag(mutableArgs, '--timeline-events') const traceFrameStatsPath = extractStringFlag(mutableArgs, '--trace-frame-stats') const failureBundleDir = extractStringFlag(mutableArgs, '--failure-bundle') const videoDurationMsRaw = extractStringFlag(mutableArgs, '--video-duration-ms') const recordedDurationMs = parsePositiveMs( extractStringFlag(mutableArgs, '--recorded-duration-ms'), '--recorded-duration-ms', ) const recordingStartedAtMs = parsePositiveMs( extractStringFlag(mutableArgs, '--recording-started-at-ms'), '--recording-started-at-ms', ) const owner = extractStringFlag(mutableArgs, '--owner') const repo = extractStringFlag(mutableArgs, '--repo') const billingKind = extractStringFlag(mutableArgs, '--billing-kind') const runScope = extractStringFlag(mutableArgs, '--run-scope') const openResult = extractBoolFlag(mutableArgs, '--open') if ((owner && !repo) || (!owner && repo)) { console.error(' --owner and --repo must be provided together') rnxExit(1) } if (billingKind && billingKind !== 'test_run') { console.error(` invalid --billing-kind: ${billingKind}`) rnxExit(1) } if (!eventsPath && !videoPath) { console.error( ' preview uploads require playback data: pass --events or --video.\n' + ' for a normal local preview, use `rnx record --mode combined --open`\n' + ' or `rnx maestro test --preview` instead of uploading a bundle-only snapshot.', ) rnxExit(1) } // assets-only filters captured cross-origin responses to just image/font/ // css/js/binary records before upload — API responses (JSON, HTML) hit the // live network at replay time. used by the home demo-app capture so // demos stay fresh (token prices, feeds) instead of serving snapshots. const assetsOnly = extractBoolFlag(mutableArgs, '--assets-only') const parsed = parseBridgeCliArgs(mutableArgs, { stripBooleanFlags: [], stripValueFlags: [], }) const bridge = createBridgeFromParsed(parsed) let snapshot: BundleSnapshot // bundle-origin manifest (no bodies) — CLI re-fetches these over http so // they can be large without blowing the ws frame cap. sibling bundle // chunks and static assets from the dev server. let manifest: RecordedEntry[] = [] // cross-origin fetches with bodies already captured in-memory (clerk, // supabase, the app's api worker, etc.). the dev bundle's auth and // cookies were on *its* session; re-fetching those urls from a fresh // node process would fail or get different payloads. use the recorded // response so replay is deterministic and prod-viable. let recordedFetches: RecordedFetch[] = [] let traceTimelineEvents: SootSimTimelineEvent[] = timelineEventsPath ? readTraceTimelineEventsFile(timelineEventsPath) : [] const traceFrameStats = traceFrameStatsPath ? readTraceFrameStatsFile(traceFrameStatsPath) : null traceTimelineEvents.push(...frameRowsFromStats(traceFrameStats)) const localTraceTimelineCount = traceTimelineEvents.length let transformedBundleText: string | null = null try { snapshot = await evalInBridge( bridge, '(typeof window.__sootsimCaptureBundle === "function") ? window.__sootsimCaptureBundle() : null', ) if (snapshot?.bundleUrl) { const bundleOrigin = new URL(snapshot.bundleUrl).origin // manifest stays the body-less re-fetch path for bundle-origin static // assets (chunks, fonts, wasm). bundle-origin api endpoints would also // land here after the recorder's /__app-api unwrap, but re-fetching // them from the dev server fails (no session cookies) — they get // pulled with bodies via the recordedFetches dump below instead. const [mainManifest, workerManifest] = await Promise.all([ evalInBridge( bridge, `(async () => { const rec = window.__sootsimPreviewRecorder await rec?.flush?.() return rec?.list?.(${JSON.stringify(bundleOrigin)}) || [] })()`, ), evalInBridge( bridge, `(async () => { const list = window.__sootsimListWorkerFetchRecorder return typeof list === 'function' ? await list(${JSON.stringify(bundleOrigin)}) : [] })()`, ), ]) manifest = mergeRecordedEntries([...mainManifest, ...workerManifest]).filter( (entry) => { try { return !new URL(entry.url).pathname.startsWith('/api/') } catch { return true } }, ) // dump everything recorded, filter to cross-origin. these carry // response bodies (base64), which can exceed the ws frame cap for // large responses — gate on count + size before pulling. const recorderLists = await evalInBridge<{ main: RecordedEntry[] worker: RecordedEntry[] } | null>( bridge, `(async () => { const rec = window.__sootsimPreviewRecorder const workerList = window.__sootsimListWorkerFetchRecorder await rec?.flush?.() return { main: rec?.list ? rec.list() : [], worker: typeof workerList === 'function' ? await workerList() : [], } })()`, ) const allRecordedEntries = recorderLists ? mergeRecordedEntries([...recorderLists.main, ...recorderLists.worker]) : [] const crossOriginEntries = allRecordedEntries.filter((entry) => { try { return new URL(entry.url).origin !== bundleOrigin } catch { return false } }) const crossOriginStats = crossOriginEntries.length > 0 ? { count: crossOriginEntries.length, totalBytes: crossOriginEntries.reduce((n, e) => n + (e.size || 0), 0), } : null if (crossOriginStats && crossOriginStats.count > 0) { console.log( ` ${crossOriginStats.count} recorded cross-origin responses ` + `(${(crossOriginStats.totalBytes / 1024).toFixed(1)} KiB)`, ) // fetch the full records (with bodies). limit to ~20 MiB total — // if we're over, warn and truncate. most dev sessions are well // under this. // also pull bundle-origin-keyed api responses with bodies so the // upload can use them instead of re-fetching from the dev server // (which fails with 401 because the cli has no auth context). main // bundle + static assets stay on the manifest re-fetch path. const dumped = await evalInBridge<{ main: RecordedFetch[] worker: RecordedFetch[] }>( bridge, `(async () => { const rec = window.__sootsimPreviewRecorder const workerDump = window.__sootsimDumpWorkerFetchRecorder const bundleUrl = ${JSON.stringify(snapshot.bundleUrl)} await rec?.flush?.() const keep = (r) => { try { const u = new URL(r.url) if (u.origin !== ${JSON.stringify(bundleOrigin)}) return true // bundle-origin api responses came from the rewritten // /__app-api proxy at record time (re-keyed by the // recorder's unwrap step). they need bodies attached the // same way as cross-origin records — direct re-fetch from // the dev server lacks the bundle's session cookies. if (r.url !== bundleUrl && u.pathname.startsWith('/api/')) return true return false } catch { return false } } return { main: rec?.dump ? rec.dump().filter(keep) : [], worker: typeof workerDump === 'function' ? (await workerDump()).filter(keep) : [], } })()`, ) recordedFetches = mergeRecordedEntries([...dumped.main, ...dumped.worker]) if (assetsOnly) { const before = recordedFetches.length recordedFetches = recordedFetches.filter((r) => isAssetContentType(r.contentType), ) const droppedBytes = crossOriginStats.totalBytes - recordedFetches.reduce((n, r) => n + (r.size || 0), 0) console.log( ` --assets-only: kept ${recordedFetches.length}/${before} records ` + `(dropped ${(droppedBytes / 1024).toFixed(1)} KiB of API responses)`, ) } } } if (snapshot?.transformedBundle) { transformedBundleText = await pullTransformedBundle( bridge, snapshot.transformedBundle.byteLength, ) } const timelineResult = await evalInBridge<{ events?: unknown[] } | null>( bridge, `(() => { const bridge = window.SootSim?.bridges?.timeline ?? window.__sootsimTimeline if (!bridge || typeof bridge.recent !== 'function') return null return bridge.recent(${JSON.stringify( traceTimelineQuery({ recordedDurationMs, recordingStartedAtMs }), )}) })()`, ).catch((err: unknown) => { console.warn( ` warning: failed to capture trace timeline: ${ err instanceof Error ? err.message : String(err) }`, ) return null }) const timelineEvents = Array.isArray(timelineResult?.events) ? timelineResult.events : [] traceTimelineEvents = [ ...traceTimelineEvents, ...timelineEvents.filter(isTraceTimelineEvent), ].sort((a, b) => a.t - b.t || a.seq - b.seq) } finally { bridge.close() } if (!snapshot) { console.error( ' could not read bundle snapshot — is rnx running and is the bundle loaded?', ) rnxExit(2) } if (!snapshot.bundleUrl) { console.error( ' no ?bundle= URL on the current rnx tab.\n' + ' open the app you want to share first (e.g. rnx open 8082), then run upload.', ) rnxExit(2) } // prefer the post-transform bundle captured from the running tab. this // is what actually evaluated — preview replay evaluates the same JS and // does not have to re-run sootsim's transforms. fall back to re-fetching // the raw metro URL + forcing lazy=false when the transform hasn't // published yet (e.g., tab was opened but bundle not finished loading). let bundleBytes: Uint8Array let isTransformed = false if (transformedBundleText !== null) { bundleBytes = new TextEncoder().encode(transformedBundleText) isTransformed = true console.log( ` using post-transform bundle: ${(bundleBytes.byteLength / 1024).toFixed(1)} KiB`, ) } else { const fetchBundleUrl = forceInlineBundle(snapshot.bundleUrl) if (fetchBundleUrl !== snapshot.bundleUrl) { console.log(` forcing lazy=false for self-contained bundle`) } console.log(` capturing: ${fetchBundleUrl}`) bundleBytes = await fetchBytes(fetchBundleUrl) console.log(` main bundle: ${(bundleBytes.byteLength / 1024).toFixed(1)} KiB`) } // everything else captured becomes a side file served from R2 via the // preview page's service worker. skip the main bundle (already sent). let extraEntries = manifest.filter((r) => r.url !== snapshot.bundleUrl) if (assetsOnly) { extraEntries = extraEntries.filter((r) => isAssetContentType(r.contentType)) } console.log(` fetching ${extraEntries.length} extra files…`) // fetch all extras in parallel (all localhost or proxy-passthrough). // each is small-to-medium; keep it simple, no concurrency cap needed // for typical v1 payloads. const extras = await Promise.all( extraEntries.map(async (entry) => { try { const bytes = await fetchBytes(entry.url) return { ...entry, bytes } } catch (err) { console.error( ` warning: failed to re-fetch ${entry.url}: ${err instanceof Error ? err.message : err}`, ) return null } }), ) const extrasOk = extras.filter((x): x is NonNullable => !!x) const totalExtrasBytes = extrasOk.reduce((n, r) => n + r.bytes.byteLength, 0) console.log( ` ${extrasOk.length} extra files: ${(totalExtrasBytes / 1024).toFixed(1)} KiB`, ) let eventsGz: Uint8Array | undefined if (eventsPath) { // the CLI accepts a pre-gzipped .jsonl.gz (that's what the docstring // promises), so we pass the bytes straight through. if someone hands // us a plain jsonl, the server will still accept it but the preview // page's event replayer decodes via DecompressionStream — pre-gzip // stays the contract. eventsGz = readFileSync(eventsPath) } traceTimelineEvents.sort((a, b) => a.t - b.t || a.seq - b.seq) const traceTimelineJsonl = traceTimelineEvents .map((event) => JSON.stringify(event)) .join('\n') const timelineGz = gzipSync(traceTimelineJsonl ? `${traceTimelineJsonl}\n` : '') console.log( ` trace timeline: ${traceTimelineEvents.length} events${ localTraceTimelineCount ? ` (${localTraceTimelineCount} from runner sidecars)` : '' }`, ) let screenshotBytes: Uint8Array | undefined if (screenshotPath) { screenshotBytes = readFileSync(screenshotPath) console.log( ` attaching screenshot: ${(screenshotBytes.byteLength / 1024).toFixed(1)} KiB`, ) } const interactionSnapshots = snapshotManifestPath ? readSnapshotManifest(snapshotManifestPath) : [] if (interactionSnapshots.length > 0) { console.log(` attaching ${interactionSnapshots.length} interaction snapshots`) } // the flow runner's per-failure debug bundle: text artifacts fold into one // gzipped JSON envelope stored at the share's failure slot; screenshot.png // rides the snapshots channel as a 'failure' frame so the dashboard's run // rows can show the failing screen without fetching the whole envelope. let failureBundleGz: Uint8Array | undefined let failureScreenshotBytes: Uint8Array | undefined if (failureBundleDir) { const readText = (name: string): string | null => { try { return readFileSync(path.join(failureBundleDir, name), 'utf8') } catch { return null } } const readJson = (name: string): unknown => { const text = readText(name) if (text === null) return null try { return JSON.parse(text) } catch { return text } } const envelope = { error: readJson('error.json'), describe: readJson('describe.json'), console: readJson('console.json'), a11y: readText('a11y.txt'), tree: readText('tree.txt'), } if (Object.values(envelope).some((v) => v !== null)) { failureBundleGz = gzipSync(JSON.stringify(envelope)) console.log( ` attaching failure bundle: ${(failureBundleGz.byteLength / 1024).toFixed(1)} KiB gz`, ) } try { failureScreenshotBytes = readFileSync(path.join(failureBundleDir, 'screenshot.png')) } catch { // screenshot capture itself failed at the time — envelope still helps } } let videoBytes: Uint8Array | undefined let videoContentType: string | undefined let videoDurationMs: number | undefined if (videoPath) { videoBytes = readFileSync(videoPath) videoContentType = videoPath.endsWith('.mp4') ? 'video/mp4' : videoPath.endsWith('.gif') ? 'image/gif' : undefined if (!videoContentType) { console.error(' preview flow videos must be mp4 or gif') rnxExit(1) } if (videoDurationMsRaw) { const parsed = Number(videoDurationMsRaw) if (!Number.isFinite(parsed) || parsed <= 0) { console.error(` invalid --video-duration-ms: ${videoDurationMsRaw}`) rnxExit(1) } videoDurationMs = Math.round(parsed) } console.log( ` attaching flow video: ${(videoBytes.byteLength / 1024).toFixed(1)} KiB (${videoContentType})`, ) } // priority: RNX_API_KEY env → github runner token → saved credential // file → session token. github runner auth is what keeps installed PR // previews from requiring a new Contrast secret. const cliAuth = resolveCliAuth() const gitHubIdentity = githubUploadIdentity(cliAuth) const provenance = resolveRunProvenance() if (!cliAuth) { console.error(' preview uploads need auth.') console.error( ' set RNX_API_KEY=sk_rnx_..., run `rnx login`, or use the Contrast github runner.', ) rnxExit(1) } const resolvedAuthHeader = authHeaderValue(cliAuth) const bundleOrigin = (() => { try { return new URL(snapshot.bundleUrl).origin } catch { return null } })() // compress the bundle (brotli, below) + compressible files (gzip). // bundle-origin extras + cross-origin recorded responses share one list — // the preview SW decides how to match each at replay time. type PreparedFile = { url: string urlhash: string method: string bodyHash: string requestVaryHash?: string contentType: string responseHeaders?: Array<{ name: string; value: string }> bytes: Uint8Array encoding: 'gzip' | undefined } const prepareFile = ( raw: Uint8Array, url: string, urlhash: string, method: string, bodyHash: string, contentType: string, responseHeaders?: Array<{ name: string; value: string }>, requestVaryHash?: string, ): PreparedFile => { const compress = shouldCompress(contentType) const bytes = compress ? gzipSync(raw) : raw return { url, urlhash, method, bodyHash, requestVaryHash, contentType, responseHeaders, bytes, encoding: compress ? 'gzip' : undefined, } } // config-plugin app fonts (expo-font plugin etc.) are NOT in the metro // asset-registry and are fetched once at engine boot — before the recorder // starts — so neither the manifest re-fetch nor recordedFetches captures // them and the /preview replay 404s. backfill them here via the shared // fetcher (same code path as the build's `rnx app-fonts stage`) so both // pipelines store identical bytes keyed by sha256(url). const appFontFiles: PreparedFile[] = [] const appFontsWire = appFontsWireFromPageHref(snapshot.pageHref) || '' if (appFontsWire) { const fonts = await fetchAppFontFiles(appFontsWire, { onStaged: ({ url, byteLength }) => console.log(` staged app font: ${url} (${(byteLength / 1024).toFixed(1)} KiB)`), onError: (url, err) => console.error( ` warning: failed to fetch app font ${url}: ${err instanceof Error ? err.message : err}`, ), }) for (const font of fonts) { appFontFiles.push( prepareFile( Buffer.from(font.bytes), font.url, font.urlhash, 'GET', '-', font.contentType, ), ) } } // stage the app's configured splash image(s) the same way: the metro asset // they reference is gone in a preview share, so re-fetch the bytes off the // still-live metro and persist them keyed by sha256(url). the engine's // resolves them through the share fetch proxy in preview mode. const appSplashFiles: PreparedFile[] = [] const appSplashWire = appSplashWireFromPageHref(snapshot.pageHref) || '' if (appSplashWire) { const images = await fetchAppSplashImages(appSplashWire, { onStaged: ({ url, byteLength }) => console.log( ` staged splash image: ${url} (${(byteLength / 1024).toFixed(1)} KiB)`, ), onError: (url, err) => console.error( ` warning: failed to fetch splash image ${url}: ${err instanceof Error ? err.message : err}`, ), }) for (const image of images) { appSplashFiles.push( prepareFile( Buffer.from(image.bytes), image.url, image.urlhash, 'GET', '-', image.contentType, ), ) } } const prepared: PreparedFile[] = [ ...extrasOk.map((r) => prepareFile( r.bytes, r.url, r.urlhash, 'GET', '-', r.contentType, r.responseHeaders, r.requestVaryHash, ), ), ...recordedFetches.map((r) => prepareFile( Buffer.from(r.bodyBase64, 'base64'), r.url, r.urlhash, r.method || 'GET', r.bodyHash || '-', r.contentType, r.responseHeaders, r.requestVaryHash, ), ), ...appFontFiles, ...appSplashFiles, ] // brotli source bytes for the upload. finalize verifies the source hash, // minifies in the node build container, and stores one canonical brotli // bundle. assets stay gzip below. const bundleBr = brotliBundle(bundleBytes) const bundleSha256 = createHash('sha256').update(bundleBytes).digest('hex') console.log( ` bundle raw=${(bundleBytes.byteLength / 1024).toFixed(1)} KiB br=${(bundleBr.byteLength / 1024).toFixed(1)} KiB`, ) const initBody: InitRequestBody = { contentHash: bundleSha256, bundleSizeBytes: bundleBytes.byteLength, bundleContentType: guessBundleContentType(snapshot.bundleUrl), bundleEncoding: 'br', bundleMinified: false, bundleOrigin, runtimeVersion: snapshot.runtimeVersion ?? undefined, // config-only fonts resolved by `rnx open` and carried on the engine // URL. persist them onto the share meta so /preview/ re-applies // `?appFonts=` and registers the same typefaces. appFonts: appFontsWire || undefined, // configured splash (image + bg) resolved by `rnx open`. persist it so // /preview/ re-applies `?appSplash=` and paints the same splash; the // image bytes are staged above so the engine's resolves in preview. appSplash: appSplashWire || undefined, entry: snapshot.entry, isTransformed, deviceSpec: snapshot.deviceSpec, installationId: gitHubIdentity?.installationId ?? undefined, repoId: gitHubIdentity?.repoId, owner: owner ?? gitHubIdentity?.owner ?? provenance.owner ?? undefined, repo: repo ?? gitHubIdentity?.repo ?? provenance.repo ?? undefined, branch: provenance.branch ?? undefined, commitSha: provenance.commitSha ?? undefined, githubUsername: provenance.githubUsername ?? undefined, pullRequestNumber: provenance.pullRequestNumber ?? undefined, pullRequestTitle: provenance.pullRequestTitle ?? undefined, billingKind: billingKind === 'test_run' ? 'test_run' : undefined, runScope: runScope || undefined, files: prepared.map((f) => ({ url: f.url, urlhash: f.urlhash, method: f.method, bodyHash: f.bodyHash, contentType: f.contentType, responseHeaders: f.responseHeaders, requestVaryHash: f.requestVaryHash, encoding: f.encoding, sizeBytes: f.bytes.byteLength, })), events: eventsGz ? { sizeBytes: eventsGz.byteLength } : undefined, timeline: { sizeBytes: timelineGz.byteLength, encoding: 'gzip' }, traceFrameStats: frameStatsMeta(traceFrameStats), recordedDurationMs, recordingStartedAtMs, failureBundle: failureBundleGz ? { sizeBytes: failureBundleGz.byteLength, encoding: 'gzip' } : undefined, snapshots: (() => { const entries: NonNullable = [] if (screenshotBytes) { entries.push({ id: 'landing', label: 'Landing', kind: 'landing', t: 0, contentType: 'image/png', sizeBytes: screenshotBytes.byteLength, }) } if (failureScreenshotBytes) { entries.push({ id: 'failure', label: 'Failure', kind: 'failure', contentType: 'image/png', sizeBytes: failureScreenshotBytes.byteLength, }) } for (const snapshot of interactionSnapshots) entries.push(snapshot.meta) return entries.length > 0 ? entries : undefined })(), flowVideo: videoBytes && videoContentType ? { sizeBytes: videoBytes.byteLength, contentType: videoContentType, durationMs: videoDurationMs, } : undefined, } const filesByHash = new Map() for (const f of prepared) filesByHash.set(f.urlhash, f.bytes) const snapshotBytes = new Map() if (screenshotBytes) snapshotBytes.set('landing', screenshotBytes) if (failureScreenshotBytes) snapshotBytes.set('failure', failureScreenshotBytes) for (const snapshot of interactionSnapshots) { snapshotBytes.set(snapshot.meta.id, snapshot.bytes) } const putT0 = Date.now() let putBytes = bundleBr.byteLength for (const f of prepared) putBytes += f.bytes.byteLength if (eventsGz) putBytes += eventsGz.byteLength putBytes += timelineGz.byteLength if (screenshotBytes) putBytes += screenshotBytes.byteLength if (failureScreenshotBytes) putBytes += failureScreenshotBytes.byteLength for (const snapshot of interactionSnapshots) putBytes += snapshot.bytes.byteLength if (failureBundleGz) putBytes += failureBundleGz.byteLength if (videoBytes) putBytes += videoBytes.byteLength const initUrl = `${normalizeOrigin(origin)}/api/preview/upload/init` console.log(` init: ${initUrl}`) let finalize try { const result = await runPresignedUpload({ originBase: origin, initBody, bundleBytes: bundleBr, filesByHash, extras: { eventsBytes: eventsGz, timelineBytes: timelineGz, videoBytes, failureBundleBytes: failureBundleGz, snapshotBytes, }, authHeader: resolvedAuthHeader, concurrency: 16, }) finalize = result.finalize const totalObjects = 1 + result.init.files.length + (result.init.events ? 1 : 0) + (result.init.timeline ? 1 : 0) + result.init.snapshots.length + (result.init.flowVideo ? 1 : 0) + (result.init.failureBundle ? 1 : 0) console.log( ` PUT ${totalObjects} objects in ${Date.now() - putT0}ms (${(putBytes / 1024).toFixed(1)} KiB)`, ) } catch (err: unknown) { const e = err as { phase?: 'init' | 'finalize' status?: number message?: string responseText?: string } if (e.phase === 'init' && e.status === 401) { console.error(' preview upload requires a valid login.') console.error(' run `rnx login` and retry.') rnxExit(1) } if (e.status === 402) { // billing gate — surface the server's own `message`, not raw JSON. console.error( ` ${parseServerMessage(e.responseText) ?? 'preview uploads require Personal, Team, or an active trial — upgrade from the billing dialog.'}`, ) rnxExit(1) } // PresignedUploadError.message already carries the " failed:" // prefix — don't re-prefix it (that produced "init failed: init failed:"). console.error(` ${e.message ?? `${e.phase ?? 'upload'} failed: ${String(err)}`}`) rnxExit(1) } const previewUrl = `${publicOrigin}${finalize.url}` console.log(`\n stored ${finalize.filesStored ?? 0} extra files`) console.log(` preview: ${previewUrl}`) if (openResult) { await openUrl(previewUrl) } return { previewUrl, shareId: finalize.id } }