apps/control-center/src/surf.ts
Rama main · réplica de lectura del repositorio original.
import * as Updates from 'expo-updates';
import { Platform } from 'react-native';
import { BRANCH_HEADER, SurfConfig } from './config';
export type SurfableBranch = {
name: string;
lastUpdateAt: string;
};
/** A page of branches, plus how many matched in all, so the panel can offer the rest. */
/** Set by the server on a 404 it decided, so a proxy's 404 is not mistaken for one. */
const SURFING_DISABLED_HEADER = 'xprem-branch-surfing';
export type BranchPage = {
branches: SurfableBranch[];
total: number;
};
/**
* Asks the server which branches this build may be served. Returns null when the
* channel does not allow surfing at all — distinct from an empty page, which
* means it does but has nothing else built for this runtime version. The panel
* hides itself on null and shows an empty state on [].
*
* The default answer is the newest 50; pass all to ask for the rest, which the
* panel only does when a tester taps for it.
*/
export async function listBranches(
config: SurfConfig,
signal?: AbortSignal,
all = false
): Promise<BranchPage | null> {
const response = await fetch(`${config.baseUrl}/branch_lists${all ? '?all=1' : ''}`, {
method: 'GET',
headers: {
'expo-app-id': config.appId,
'expo-channel-name': config.channel,
'expo-runtime-version': config.runtimeVersion,
// The list is per platform, like the manifest: a branch whose only update
// is for the other one cannot be served here, so it must not be offered.
'expo-platform': Platform.OS,
},
signal,
});
if (response.status === 404) {
// Turning surfing off for a channel has to reach the devices already pinned
// to a branch, and this is the only place it can: the panel hides itself on
// this answer, so after it does there is no interface left to unpin from.
// Left pinned, a device would silently resume surfing the moment surfing was
// switched back on, onto a branch nobody chose in that session.
//
// Gated on the server saying so, not on the status alone: any 404 used to
// trigger this, so an old server, a proxy, or a path-based deployment wiped
// the tester's branch at every launch with nothing said anywhere.
if (response.headers.get(SURFING_DISABLED_HEADER) !== null) {
pin(config, null);
}
return null;
}
if (!response.ok) {
throw new Error(`Could not reach the update server (${response.status}).`);
}
const page = (await response.json()) as BranchPage;
// The build carrying this code outlives the server it was written against, so
// a body of another shape hides the panel rather than crashing the host app.
if (!page || !Array.isArray(page.branches) || typeof page.total !== 'number') {
return null;
}
return page;
}
/**
* Rebuilds the WHOLE header set with one value changed: the override replaces
* the native set and persists across relaunches.
*
* The channel and app id are pinned from the running values rather than trusted
* from Constants.expoConfig: that copy is evaluated when the JS bundle is
* exported, and a config spelled `process.env.RELEASE_CHANNEL` evaluates to
* undefined in any bundle exported without the variable. Spreading it then
* strips the channel out of every future poll — observed live as /manifest
* answering 400 "No channel name provided" until the override is cleared.
*/
/**
* What this session last pinned, so a surf that fails partway can put it back.
* undefined means "not set by us": the override may still hold something from an
* earlier session, which nothing on the client can read back — expo-updates
* exposes no getter — so the only honest restore in that case is to clear it.
*/
let pinnedBranch: string | null | undefined;
function pin(config: SurfConfig, branch: string | null) {
if (branch === null) {
// Not "override with an empty value" — no override at all. The native side
// reverts to the headers baked at build time, the one state that cannot be
// wrong.
Updates.setUpdateRequestHeadersOverride(null);
} else {
applyBranchHeader(config, branch);
}
pinnedBranch = branch;
}
function applyBranchHeader(config: SurfConfig, branch: string) {
const headers: Record<string, string> = {};
for (const [key, value] of Object.entries(config.requestHeaders)) {
// Empty values are dropped, and that is load-bearing rather than tidy.
// expo-updates applies this header set LAST, after the server-defined
// headers it has stored, and each entry REPLACES rather than adds — so an
// empty xprem-surf-blocked declared in app config would wipe the refusal
// verdicts on every poll and let a crashing update be served again.
if (typeof value === 'string' && value !== '') {
headers[key] = value;
}
}
headers['expo-channel-name'] = config.channel;
headers['expo-app-id'] = config.appId;
headers[BRANCH_HEADER] = branch;
Updates.setUpdateRequestHeadersOverride(headers);
}
export type SurfOutcome = 'reloading' | 'nothing-to-load';
/**
* Points this device at a branch and reloads onto it. Returns without reloading
* when the server has nothing new: the caller says so rather than leaving the
* tester looking at an unchanged screen.
*/
export async function surfTo(config: SurfConfig, branch: string | null): Promise<SurfOutcome> {
// The override has to be in place before checkForUpdateAsync, since that is
// the request it governs — so a failure after this line leaves the device
// pinned to a branch it never loaded, persistently and across relaunches,
// while the panel still shows the old one. Whatever happens below, the device
// must not be left pointing somewhere the tester was not told about.
const previous = pinnedBranch;
pin(config, branch);
try {
return await runSurf(config);
} catch (cause) {
pin(config, previous ?? null);
throw cause;
}
}
async function runSurf(_config: SurfConfig): Promise<SurfOutcome> {
const { isAvailable } = await Updates.checkForUpdateAsync();
if (!isAvailable) {
return 'nothing-to-load';
}
await Updates.fetchUpdateAsync();
await Updates.reloadAsync();
return 'reloading';
}