import { isLoopbackHost } from './backend-origin.ts' // canonical entry path for a plain Metro packager that serves no Expo // manifest. only used when `/status` confirms `packager-status:running` and // the server reports nothing else — never as a guess for a manifest-capable // dev server, whose `launchAsset.url` is always the source of truth. export const METRO_BUNDLE_PATH = '/index.bundle?platform=ios&dev=true&hot=true&minify=false' function isAbsoluteHttpUrl(url: string): boolean { return /^https?:\/\//i.test(url) } function isNativeDevBundlePath(pathname: string): boolean { return pathname.endsWith('.bundle') } // expo can advertise an IPv4 loopback launch URL even when its --localhost // listener resolved to IPv6 only. keep the manifest's path and query, but bind // its loopback host to the base URL that answered the discovery request. export function alignLoopbackBundleUrlWithProbe( bundleUrl: string, probeBaseUrl: string, ): string { try { const bundle = new URL(bundleUrl) const probe = new URL(probeBaseUrl) if (!isLoopbackHost(bundle.hostname) || !isLoopbackHost(probe.hostname)) { return bundleUrl } bundle.hostname = probe.hostname return bundle.toString() } catch { return bundleUrl } } // opt-in (contrastbot preview-recording context only): serve a production // minified bundle instead of the default dev=true unminified one. a // preview run just records the app — it never needs fast-refresh or dev // warnings — and the ~28MB unminified dev bundle cold-(re)boots // unreliably on small CI runners (tens of seconds when metro is warm, // minutes cold, sometimes past any wait-ready budget). a minified bundle // parses/executes far faster and reboots reliably. gated on // SOOTSIM_PREVIEW_PROD_BUNDLE so normal interactive `rnx open` keeps // dev=true. app-agnostic: rewrites whatever metro/launchAsset advertised. function applyPreviewProdBundle(parsed: URL): void { if (!process.env.SOOTSIM_PREVIEW_PROD_BUNDLE) return parsed.searchParams.set('dev', 'false') parsed.searchParams.set('minify', 'true') // prod bundles have no HMR; keep metro's cache key consistent. if (parsed.searchParams.has('hot')) parsed.searchParams.set('hot', 'false') } export function normalizeNativeDevBundleUrl(bundleUrl: string): string { try { const isAbsolute = isAbsoluteHttpUrl(bundleUrl) const parsed = new URL(bundleUrl, 'http://sootsim.local') parsed.pathname = parsed.pathname.replace(/\.\.bundle$/, '.bundle') if (!isNativeDevBundlePath(parsed.pathname)) return bundleUrl // do NOT force dev=true or minify=false — metro's dev server already // defaults to dev mode + unminified, and iOS sim's launchAsset URL // omits both params. forcing them gives metro a different cache key // for the same bundle. only the prod-bundle override path // (demo-upload-specs) sets dev=false&minify=true on purpose. // do NOT touch `hot` either. real RN iOS clients load launchAsset.url // verbatim — expo's createBundleUrlSearchParams hardcodes hot=false // (with a `// TODO: Is this still needed?` comment) for every manifest // it emits, and HMR still works on iOS because RN's HMRClient.js // polyfill connects to /hot independently. sootsim's HmrClient is the // same — connection happens via /hot regardless of this query param — // so matching the iOS sim's URL exactly lets metro share its bundle // cache between the two clients (otherwise the 28MB+ bundle rebuilds // twice on first cold metro request). // strip transform.bytecode — metro would return hermes bytecode and our // bundle-loader can only execute plain JS. parsed.searchParams.delete('transform.bytecode') // force lazy=false. sootsim loads ONE self-contained bundle into the tenant // worker. metro's lazy=true defers code-split modules to async chunks; vxrn/ // one's emitted entry can synchronously `require` a deferred module before // its chunk has loaded, throwing "Requiring unknown module " and crashing // the route (observed with one's React.lazy GameScene3D split). lazy=false // ships every module in the served bundle — import()/React.lazy RUNTIME // semantics are unchanged (the chunk is just inlined), so this is faithful, // not a behavior change. the engine's __loadBundleAsync still handles any // genuinely-async chunks an app fetches at runtime. if (parsed.searchParams.get('lazy') === 'true') { parsed.searchParams.set('lazy', 'false') } applyPreviewProdBundle(parsed) if (isAbsolute) return parsed.toString() return `${parsed.pathname}${parsed.search}${parsed.hash}` } catch { return bundleUrl } } // explicit override used by `rnx open` to flip metro's `hot` query when // the caller knows their context: interactive opens want `hot=true` so metro // includes its HMR/react-refresh runtime in the bundle prelude and pushes // edits as `update` events; `--driver playwright` / agent-test runs want // `hot=false` for deterministic e2e (no in-bundle refresh wiring, no // background ws). normalizeNativeDevBundleUrl deliberately preserves // whatever value the launchAsset URL carries so metro's bundle cache is // shared with real iOS sims; this helper is the one place that breaks that // rule on purpose. non-`.bundle` paths pass through unchanged. export function applyHotMode(bundleUrl: string, hot: boolean): string { try { const isAbsolute = isAbsoluteHttpUrl(bundleUrl) const parsed = new URL(bundleUrl, 'http://sootsim.local') if (!isNativeDevBundlePath(parsed.pathname)) return bundleUrl parsed.searchParams.set('hot', hot ? 'true' : 'false') if (isAbsolute) return parsed.toString() return `${parsed.pathname}${parsed.search}${parsed.hash}` } catch { return bundleUrl } }