import { buildLoopbackProbeBaseUrl, isLoopbackHost, LOOPBACK_PROBE_HOSTNAMES, } from './backend-origin' import { alignLoopbackBundleUrlWithProbe, METRO_BUNDLE_PATH, normalizeNativeDevBundleUrl, } from './native-dev-bundle-url' export const DEFAULT_TEST_DRIVE_PORT = 8081 export interface ResolvedDevBundle { bundleUrl: string port: number framework: string projectName?: string } function buildUnresolvedTargetError(targetLabel: string): Error { return new Error( `could not resolve a native bundle for ${targetLabel}. pass an explicit bundle URL or open Connect and choose the app there.`, ) } export function inferManifestFramework( launchUrl: string | undefined, sdkVersion: unknown, ): string { if (launchUrl?.includes('/one/metro-entry.bundle')) return 'one' if (typeof sdkVersion === 'string' && sdkVersion) return 'expo' return 'unknown' } function buildBaseUrl(protocol: string, host: string, port: number) { return `${protocol}//${host}:${port}` } function shouldProxyLoopbackProbe(targetUrl: string) { if (typeof window === 'undefined') return false try { const parsed = new URL(targetUrl) return ( isLoopbackHost(parsed.hostname) && isLoopbackHost(window.location.hostname) && parsed.origin !== window.location.origin ) } catch { return false } } async function fetchDevProbe(targetUrl: string, init?: RequestInit): Promise { const requestInit: RequestInit = { ...init, cache: init?.cache ?? 'no-store', } if (!shouldProxyLoopbackProbe(targetUrl)) { return fetch(targetUrl, requestInit) } return fetch(`/__fetch-proxy?url=${encodeURIComponent(targetUrl)}`, requestInit) } function getDefaultPortForProtocol(protocol: string) { return protocol === 'https:' ? 443 : 80 } function isBaseServerUrl(url: URL) { const path = url.pathname || '/' return (path === '/' || path === '') && !url.search && !url.hash } // resolve a loopback dev-server port through the SAME server-side scanner the // dev-server list uses (`/__server-scan`), so an explicit port (`/rn/`, a // typed port) gets the same manifest path and query the list resolves. loopback // hosts stay aligned to the address that answered discovery. // // why this exists: in a browser/worker the direct manifest probe below is // cross-origin (shell on :5173, dev server on :), and a dev server // answers a cross-origin `GET /` with its SSR HTML, not the expo manifest. so // the manifest branch silently fails (`.json()` throws on HTML) and the probe // falls back to a generic `/index.bundle` — a DIFFERENT metro entry point than // the manifest's `metro-entry.bundle`, which makes metro build the same app // twice. the scanner runs server-side (same-origin node fetch, sends the // expo-platform header) and reads `launchAsset.url` correctly. // // env-agnostic by design: it just attempts `/__server-scan` and lets the result // decide. in the tenant worker (which has no `location`) the patched fetch // resolves the relative path to the shell origin and returns the scan json. in a // node/bun CLI a relative-URL fetch with no base throws → null → the direct probe // runs (correct there, since node can read the cross-origin manifest). in prod // the path SPA-falls-back to HTML → the json content-type guard returns null → // direct probe. so there is exactly one resolver per environment, no env sniff. async function resolvePortViaServerScan(port: number): Promise { try { const res = await fetch('/__server-scan', { cache: 'no-store' }) if (!res.ok) return null if (!(res.headers.get('content-type') || '').toLowerCase().includes('json')) return null const servers: unknown = await res.json() if (!Array.isArray(servers)) return null const match = servers.find( ( s, ): s is { port: number bundleUrl: string framework?: string projectName?: string bundleUrlProvisional?: boolean } => !!s && typeof s === 'object' && (s as { port?: unknown }).port === port && typeof (s as { bundleUrl?: unknown }).bundleUrl === 'string', ) if (!match) return null // the scanner marks a bundle URL provisional when its own fast manifest // probe got no answer and it fell back to the generic `/index.bundle`. that // is enough to LIST the server and not enough to open it, so fall through // to the direct probe below, which asks the manifest again with a budget // that fits a real dev server. same rule the CLI applies in // `resolveConnectionInputForCli`; a provisional URL must never reach a sim. if (match.bundleUrlProvisional) return null return { // normalize exactly like the manifest-probe branch below — the scan // returns metro's manifest launchAsset url (lazy=true for code-split // one/vxrn apps), but sootsim loads ONE self-contained bundle into the tenant // worker, so a deferred React.lazy chunk throws "Requiring unknown module // ". normalizeNativeDevBundleUrl forces lazy=false + strips bytecode. bundleUrl: normalizeNativeDevBundleUrl(match.bundleUrl), port, framework: typeof match.framework === 'string' ? match.framework : 'unknown', projectName: typeof match.projectName === 'string' ? match.projectName : undefined, } } catch { return null } } // how a dev server answered `GET /`, which is the one request that tells the // two kinds of dev server apart. an Expo or One server answers with its // manifest. a vanilla Metro server answers with Metro's own HTML landing page // and has no manifest to serve. NO answer is neither: a One server that is // still starting leaves `/` hanging until its manifest middleware is up, while // `/status` already reports packager-status:running. reading that silence as // "this server has no manifest" opens the app on `/index.bundle` — a // second entry point, with its own module graph, that the app's manifest never // named — so Metro builds the whole app twice and the connection is labelled // `metro` instead of its real framework. type ManifestProbe = | { state: 'resolved'; bundle: ResolvedDevBundle } | { state: 'no-manifest' } | { state: 'no-answer' } // a `/` that has not answered in this long is a server still coming up, not a // server telling us what it is. without it an unresponsive dev server hangs // the caller for as long as it feels like. const MANIFEST_PROBE_TIMEOUT_MS = 5_000 // how long to keep asking a confirmed-running packager for its manifest. const MANIFEST_BOOT_BUDGET_MS = 30_000 const MANIFEST_BOOT_RETRY_MS = 1_000 async function probeExpoManifest( normalizedBaseUrl: string, port: number, ): Promise { let body: string // the deadline means STOP WAITING, not KILL THE REQUEST. `AbortSignal.timeout` // does the second: it tears down a dev server that is still assembling its // manifest, which fires "Cannot pipe to a closed or destroyed stream" into // One's manifest middleware and can exit the user's dev server. so nothing // here aborts. we race the probe against the budget, answer `no-answer` on // time, and let the request run itself out in the background, discarded. const pending = fetchDevProbe(`${normalizedBaseUrl}/`, { headers: { 'expo-platform': 'ios' }, }).then( async (res) => (res.ok ? { ok: true as const, body: await res.text() } : null), () => undefined, ) let probeDeadline: ReturnType | undefined const answered = await Promise.race([ pending, new Promise<'timeout'>((resolve) => { probeDeadline = setTimeout(() => resolve('timeout'), MANIFEST_PROBE_TIMEOUT_MS) }), ]) // the race is settled, so drop the deadline rather than leaving it pending. clearTimeout(probeDeadline) if (answered === 'timeout' || answered === undefined) return { state: 'no-answer' } if (answered === null) return { state: 'no-manifest' } body = answered.body if (!body.trim()) return { state: 'no-answer' } let manifest: any try { manifest = JSON.parse(body) } catch { // a page rather than a manifest — Metro's landing page, or an app's own // SSR html. that is a real answer: this server serves no manifest. return { state: 'no-manifest' } } const client = manifest?.extra?.expoClient || manifest?.extra || {} const rawLaunchUrl = typeof manifest?.launchAsset?.url === 'string' ? manifest.launchAsset.url : undefined const launchUrl = rawLaunchUrl ? alignLoopbackBundleUrlWithProbe(rawLaunchUrl, normalizedBaseUrl) : undefined if (!launchUrl && !client.name) return { state: 'no-manifest' } return { state: 'resolved', bundle: { bundleUrl: normalizeNativeDevBundleUrl( launchUrl || `${normalizedBaseUrl}${METRO_BUNDLE_PATH}`, ), port, framework: inferManifestFramework(launchUrl, client.sdkVersion), projectName: client.name, }, } } async function isPackagerRunning(normalizedBaseUrl: string): Promise { try { const res = await fetchDevProbe(`${normalizedBaseUrl}/status`) if (!res.ok) return false return (await res.text()).includes('packager-status:running') } catch { return false } } async function probeBaseUrlBundleDirect( baseUrl: string, port: number, ): Promise { const normalizedBaseUrl = baseUrl.replace(/\/+$/, '') let manifest = await probeExpoManifest(normalizedBaseUrl, port) let packagerConfirmed = false // nothing answered `/`. that is either a closed address — the other loopback // family, a dead port — or a packager whose manifest middleware has not come // up yet. `/status` separates them, and only the second is worth waiting on. if (manifest.state === 'no-answer') { packagerConfirmed = await isPackagerRunning(normalizedBaseUrl) if (!packagerConfirmed) return null const deadline = Date.now() + MANIFEST_BOOT_BUDGET_MS while (manifest.state === 'no-answer' && Date.now() < deadline) { await new Promise((resolve) => setTimeout(resolve, MANIFEST_BOOT_RETRY_MS)) manifest = await probeExpoManifest(normalizedBaseUrl, port) } } if (manifest.state === 'resolved') return manifest.bundle // a server that never says what it is gets no guessed entry point. if (manifest.state === 'no-answer') return null // vanilla Metro (or any non-Expo dev server) — packager-status:running on // /status is the canonical signature. we previously also did `HEAD // /node_modules/one/metro-entry.bundle` as a framework discriminator, but // Metro lazy-resolves modules at request time, so HEAD on *any* `.bundle` // path can return 200 even when the underlying file doesn't exist. that // caused false-positive One framework detection on non-One apps that had // `node_modules/one` anywhere on disk (or that returned 200 HEAD + 404 // GET for missing bundles), surfacing as // "failed to fetch bundle (404|502): .../node_modules/one/metro-entry.bundle…" // the Expo manifest probe above already returns the correct launchAsset.url // for One apps (which serve the Expo manifest format via vxrn), so we // drop the bespoke One probe entirely. if (!packagerConfirmed && !(await isPackagerRunning(normalizedBaseUrl))) return null return { bundleUrl: `${normalizedBaseUrl}${METRO_BUNDLE_PATH}`, port, framework: 'metro', } } async function probeBaseUrlBundle( baseUrl: string, port: number, ): Promise { // browser/worker loopback targets resolve through the server-side scanner so // they match the dev-server list exactly — see resolvePortViaServerScan. try { if (isLoopbackHost(new URL(baseUrl).hostname)) { const viaScan = await resolvePortViaServerScan(port) if (viaScan) return viaScan } } catch {} return probeBaseUrlBundleDirect(baseUrl, port) } export async function probePortBundle(port: number): Promise { const viaScan = await resolvePortViaServerScan(port) if (viaScan) return viaScan const candidates = await Promise.all( LOOPBACK_PROBE_HOSTNAMES.map((hostname) => probeBaseUrlBundleDirect(buildLoopbackProbeBaseUrl('http:', hostname, port), port), ), ) return candidates.find((candidate) => candidate !== null) ?? null } export async function resolveConnectionInput(input: string): Promise { const trimmed = input.trim() if (/^\d+$/.test(trimmed)) { const port = parseInt(trimmed, 10) const resolved = await probePortBundle(port) if (resolved) return resolved throw buildUnresolvedTargetError(`localhost:${port}`) } const bundleUrl = trimmed.startsWith('http') ? trimmed : `http://${trimmed}` let parsed: URL try { parsed = new URL(bundleUrl) } catch { throw new Error( `could not parse "${input}". pass a dev-server port, a dev-server base URL, or a full bundle URL.`, ) } const protocol = parsed.protocol || 'http:' const port = parsed.port ? parseInt(parsed.port, 10) : getDefaultPortForProtocol(protocol) const baseUrl = buildBaseUrl(protocol, parsed.hostname, port) // a bare base URL (just an origin, no path) is unambiguously a dev server // to probe — regardless of host. probe its live manifest rather than // guessing that the origin itself is the bundle URL. loopback vs. remote // only affects how the probe fetch is routed (see shouldProxyLoopbackProbe). if (isBaseServerUrl(parsed)) { const resolved = await probeBaseUrlBundle(baseUrl, port) if (resolved) return resolved throw buildUnresolvedTargetError(baseUrl) } return { bundleUrl: parsed.toString(), port, framework: 'unknown' } }