import Constants from 'expo-constants'; import * as Updates from 'expo-updates'; /** What the update server needs to answer this device, all of it already on hand. */ export type SurfConfig = { /** Where /branch_lists lives: the update URL with its last segment dropped. */ baseUrl: string; appId: string; channel: string; runtimeVersion: string; /** * The request headers baked at build time. setUpdateRequestHeadersOverride * REPLACES the whole set rather than merging into it, and expo-updates only * accepts keys that were declared at build time — so every override has to be * rebuilt from this, or the poll loses its channel and its app id. */ requestHeaders: Record; }; export const BRANCH_HEADER = 'xprem-branch'; /** State the manifest already carries, so nothing has to be stored on the side. */ export type LoadedState = { /** The branch actually served. Not necessarily the one that was asked for. */ branch: string | null; /** Set when the server refused a branch because its update crashed here. */ refusedBranch: string | null; }; function extra(): Record { return ((Updates.manifest as { extra?: Record } | undefined)?.extra ?? {}) as Record; } export function readLoadedState(): LoadedState { const manifestExtra = extra(); return { branch: typeof manifestExtra.branch === 'string' ? manifestExtra.branch : null, refusedBranch: typeof manifestExtra.branchSurfingRefused === 'string' && manifestExtra.branchSurfingRefused.length > 0 ? manifestExtra.branchSurfingRefused : null, }; } /** * The keys the override cannot be built without. expo-updates only accepts an * override whose keys were declared at build time, and the override replaces the * whole header set — so a build missing any of these can be sent into a state * where every poll is answered 400 and no update can reach it to fix that. */ const REQUIRED_BUILD_HEADERS = ['expo-app-id', 'expo-channel-name', BRANCH_HEADER]; function declared(requestHeaders: Record, key: string): boolean { return typeof requestHeaders[key] === 'string'; } /** * Returns null when this build cannot surf at all: no update config, a runtime too * old for the header override, or a build-time header set the override could not * be rebuilt from. The component renders nothing in that case rather than offering * a switch that would strand the device. */ export function readConfig(): SurfConfig | null { const updates = Constants.expoConfig?.updates as | { url?: string; requestHeaders?: Record } | undefined; if (!updates?.url || typeof Updates.setUpdateRequestHeadersOverride !== 'function') { return null; } const requestHeaders = updates.requestHeaders ?? {}; // A key whose value came from an unset env var is dropped from the config // entirely, so this checks presence rather than truthiness — the empty string // is a declaration, undefined is not. const missing = REQUIRED_BUILD_HEADERS.filter(key => !declared(requestHeaders, key)); if (missing.length > 0) { // Loud, because the symptom is a panel that never appears and the cause is // three lines away in app config. console.warn( `[xprem] Branch surfing is unavailable: ${missing.join(', ')} ${ missing.length === 1 ? 'is' : 'are' } missing from updates.requestHeaders. A header can only be overridden at ` + `runtime if it was declared at build time.` ); return null; } const appId = requestHeaders['expo-app-id']; const channel = Updates.channel; const runtimeVersion = Updates.runtimeVersion; if (!appId || !channel || !runtimeVersion) { return null; } let baseUrl: string; try { // Sibling of the manifest route, not root of the host: a self-hosted server // at https://host/ota/manifest serves https://host/ota/branch_lists. Using // the origin alone sent every request to the wrong path, and the 404 that // came back was indistinguishable from "this channel does not allow it". const parsed = new URL(updates.url); // Trailing slashes go first: on ".../manifest/" the segment removal would // otherwise eat the empty part after the slash and leave "manifest" in the // path, sending every request one level too deep. baseUrl = parsed.origin + parsed.pathname.replace(/\/+$/, '').replace(/\/[^/]*$/, ''); } catch { return null; } return { baseUrl, appId, channel, runtimeVersion, requestHeaders }; }