Post 14 ended on a promise: “versioned remotes on the CDN, a version map, a per-launch resolver, and an old binary that never downloads code it cannot run.” This post builds that, then uses it. An app sits installed with its dev servers off, showing version 1.1.0 of the Pokédex list. You ship it 1.2.0 with one directory and one edited line of JSON, watch the change arrive at its next launch, and roll it back the same way.
The operation needs three pieces. A content delivery network (CDN) keeps every published version of each remote, so a new version never replaces files an installed app is loading. A map for each released app version tells that binary which of those versions to load. A resolver in the host turns every chunk request into a versioned, signature-verified URL. Together they deliver what post 1 said federation gives you: “A bug in one remote is a re-upload of that remote, not a store submission.”
One rule shapes all of it: an old binary must keep working for as long as anyone has it installed. The CDN keeps old remote versions the way a backend keeps old endpoints, and each binary is handed only the versions its own map names. Nothing on the device checks that those versions work with the binary: choosing versions it has been tested with is the operator’s job, and the map is where that choice is written down.
Start from post 14’s finished state, the tag post-14-production-build; this post finishes at the tag post-15-cdn-flip. You type the configuration changes and the smaller edits yourself. The host’s new launch code and a handful of other files change in more places than a post can usefully retype, so those come from the end tag, and the copy commands name each one.
The map is fetched before anything federated is imported, and every request after it carries the version the map named.
Versions on the shelf
A version has to reach three places in a remote’s build, and they have to agree: the directory the artefacts are written to, the directory the chunks are written to, and the string the running screen prints about itself. One constant feeds all three. In apps/list/rspack.config.mjs, above defineRspackConfig:
// --- Which version of this remote the build produces. It decides two things at once: the
// directory the artefacts are written to, and the string the running code reports about itself.
// Both come from one variable, so a build cannot write 1.2.0's files and claim to be 1.1.0:
// MF_REMOTE_VERSION=1.2.0 npm run bundle:ios:prod
// A version directory is written once and never edited afterwards. Rebuilding a version that
// installed apps are already loading replaces code those apps treat as fixed, which is the one
// move this layout exists to make unnecessary: ship a new version instead. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';
Post 14’s two production paths each gain the segment. In output:
// A production build writes the tree the CDN serves, laid out as the URL path it is served
// at: cdn/<platform>/listApp/<version>/. The version segment is what lets one CDN hold
// several releases of this remote at once, each at its own URL. A development build keeps
// writing to build/, where the dev server reads it from, and carries no version: there is
// only ever one build there, and it is whatever was saved last.
path: isProd
? `${__dirname}/cdn/[platform]/listApp/${REMOTE_VERSION}`
: `${__dirname}/build/[platform]`,
and in the RepackPlugin’s extraChunks entry:
// The chunks land beside the container and the manifest, inside the same version
// directory, because the host will ask for them at URLs relative to the manifest it
// loaded. This entry copies them there: Rspack has already written them under
// output.path, so a missing version segment here breaks nothing at runtime, and
// instead leaves a second, unversioned copy of every chunk beside the versions.
outputPath: isProd
? `cdn/${platform}/listApp/${REMOTE_VERSION}`
: `build/${platform}/remote`,
The third place is a literal compiled into the bundle. Add DefinePlugin to the @rspack/core import beside the minimiser, and a plugin entry before ModuleFederationPluginV2:
import { DefinePlugin, SwcJsMinimizerRspackPlugin } from '@rspack/core';
// The version, compiled into the bundle as a literal so the running screen can print the
// build it came from. In production it is read from the same constant the output path uses,
// so the chip on screen and the directory on the CDN can never disagree. A development
// build says 'dev' instead: it was never published anywhere, and a number on it would be a
// version claim about a file that is rebuilt on every save.
new DefinePlugin({
__REMOTE_VERSION__: JSON.stringify(isProd ? REMOTE_VERSION : 'dev'),
}),
Mirror the two paths in apps/party/rspack.config.mjs with partyApp. Its constant carries a comment of its own, because the party has no chip and so needs no DefinePlugin:
// --- Which version of this remote the build produces, and the directory it is written to. Same
// variable, same rule as listApp's: a version directory is written once and never edited after
// installed apps have started loading it. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';
Back in the list app, TypeScript needs a declaration for __REMOTE_VERSION__, because the name has no module behind it. A new apps/list/src/globals.d.ts provides one:
// --- Names the bundler replaces with literals at build time, declared for the compiler. They
// are not imports and there is no module behind them: rspack.config.mjs substitutes each one
// during the build, so the shipped bundle contains the value and never the name. ---
/** The version of this remote the build produced, and the CDN directory it was written to. */
declare const __REMOTE_VERSION__: string;
The chip is what makes a deploy visible, because two builds of the same remote are otherwise the same screen. In apps/list/src/PokedexScreen.tsx, above EMPTY_TYPES:
// --- The version this bundle was built at, compiled in by DefinePlugin. Showing it is what
// makes a deploy visible: two builds of this remote are otherwise the same screen, so without
// it there is no way to tell from the app which one is running. The fallback covers Jest, where
// no bundler runs and the name is never substituted. ---
const REMOTE_VERSION = typeof __REMOTE_VERSION__ === 'string' ? __REMOTE_VERSION__ : 'dev';
The header row becomes the chip on the left and the party counter on the right. The chip is its own accessible element, so a screen reader can reach it without hearing the party count first. The live region and its label move off the row onto a group of their own around post 12’s label and pill, so the row no longer swallows the chip:
ListHeaderComponent={
<Box className="flex-row items-center justify-between px-1.5 py-2.5">
{/* Which build of this remote is on screen. Its own element rather than part of the
counter's group, so a screen reader can reach it without it being read out every
time the party count changes. */}
<Box
className="rounded-full bg-offGrey px-2 py-0.5 dark:bg-white/10"
accessible
accessibilityLabel={`Pokédex remote, version ${REMOTE_VERSION}`}>
<Text size="xs" className="font-semi text-darkGrey dark:text-lightGrey">
listApp {REMOTE_VERSION}
</Text>
</Box>
{/* The count changes when the user adds a member from a screen away, without focus
moving here. A sighted user sees the number tick; a screen-reader user is told
nothing unless this is a live region (SC 4.1.3). The label spells the ratio out,
because "3/6" is read as "three slash six" or as a date, depending on the reader. */}
<Box
className="flex-row items-center gap-2"
accessible
accessibilityLiveRegion="polite"
accessibilityLabel={`My Party, ${partyCount} of ${MAX_PARTY}`}>
<Text size="sm" className="font-semi text-darkGrey dark:text-lightGrey">
My Party
</Text>
<Box className="rounded-full bg-lightGreen px-2.5 py-0.5 dark:bg-white/10">
{/* darkGrey, not darkGreen: darkGreen is #A6D3A0, the grass fill, and on lightGreen
it measures 1.53:1. darkGrey is the colour the label beside it already uses. */}
<Text size="xs" className="font-head text-darkGrey dark:text-pokemonGreen">
{partyCount}/{MAX_PARTY}
</Text>
</Box>
</Box>
</Box>
}
Post 14’s tools/build-cdn.mjs built one version of each remote into a flat tree. Replace it with one that starts from two lists. REMOTE_VERSIONS says which versions of each remote the CDN holds. APP_VERSION_MAPS says which of those versions each released app version loads. A version can sit in the first list with no map pointing at it:
// --- Assembles the directory a CDN would serve.
//
// The layout is the URL layout, and it now has a version in it:
//
// cdn-root/<platform>/<remote>/<version>/ the container, its chunks and mf-manifest.json
// cdn-root/<platform>/maps/<appVersion>/ version-map.json, one per released app version
//
// A file at cdn-root/ios/listApp/1.2.0/mf-manifest.json is served at
// <base>/ios/listApp/1.2.0/mf-manifest.json, which is the URL the host builds at launch out of
// the version the map gave it.
//
// Two lists below, and the difference between them is the whole idea. REMOTE_VERSIONS is what the
// CDN holds: every version ever published, kept until nobody is running it. APP_VERSION_MAPS is
// what each released binary is allowed to load out of that. Shipping a remote to installed apps
// is a new entry in the first list and one edited line in the second.
//
// Usage: node tools/build-cdn.mjs [ios|android] (no argument builds both)
//
// Then serve it and point the host at it (an Android emulator reaches this machine at 10.0.2.2,
// so the Android build gets that address instead of localhost):
// npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
// ( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm start )
import { execSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const REMOTE_APPS = { listApp: 'list', partyApp: 'party' };
const ALL_PLATFORMS = ['ios', 'android'];
// --- Every version of each remote the CDN holds. A version directory is written once and then
// left alone: installed apps are loading those exact files, so a rebuild of a published version
// is a silent change to code somebody is already running. New work gets a new number.
//
// listApp has two: 1.0.0 and 1.1.0 are the same screen with different stamps on it, which is
// what the two-binaries demo needs. The flip to 1.2.0 adds the third. ---
const REMOTE_VERSIONS = {
listApp: ['1.0.0', '1.1.0'],
partyApp: ['1.0.0'],
};
// --- What each released app version may load. The host asks for its own entry by name at every
// launch, so an old binary keeps being handed the versions it was built against, however far the
// newest release has moved on. An entry is retired when nobody is left on that app version, the
// same way an old API endpoint is.
//
// Editing a line here rebuilds the whole tree, which is the wrong tool for shipping a version:
// the operation the post performs is an edit to the map file already sitting in cdn-root,
// because that file is what a running app reads. This is the seeding of a CDN, not an operation
// against one.
//
// There is nothing else in the map. No signature over it, no counter, nothing that would let the
// app tell a map written here from one written by anybody else who can reach the bucket. That is
// a real hole and it is left open on purpose: it is the subject of the last post in the series. ---
const APP_VERSION_MAPS = {
'1.0.0': { listApp: '1.0.0', partyApp: '1.0.0' },
'2.0.0': { listApp: '1.1.0', partyApp: '1.0.0' },
};
// --- Never published: source maps are four fifths of the built tree, they are a debugging
// artefact for a crash reporter rather than something a client downloads, and on a public bucket
// they hand the whole readable source to anyone who asks for it. mf-stats.json is build analysis
// in the same position. The remote's own index.bundle stays, because mf-manifest.json names it
// among the shared assets and nothing here has established that no path fetches it. ---
const NEVER_PUBLISHED = /\.map$|^mf-stats\.json$/;
const [platformArg] = process.argv.slice(2);
if (platformArg && !ALL_PLATFORMS.includes(platformArg)) {
console.error(`Unknown platform "${platformArg}". Use one of: ${ALL_PLATFORMS.join(', ')}`);
process.exit(1);
}
const platforms = platformArg ? [platformArg] : ALL_PLATFORMS;
// --- A map naming a version the CDN does not hold is the failure the post demonstrates by hand,
// and it is worth catching here rather than at a user's launch. Checked before anything is built,
// so a typo costs a second instead of two bundle runs. ---
const unknownRemotes = Object.keys(REMOTE_VERSIONS).filter(remote => !REMOTE_APPS[remote]);
if (unknownRemotes.length > 0) {
console.error(
`\nNo app to build for: ${unknownRemotes.join(', ')}. Add it to REMOTE_APPS, or remove it from REMOTE_VERSIONS.\n`,
);
process.exit(1);
}
const missing = Object.entries(APP_VERSION_MAPS).flatMap(([appVersion, versions]) =>
Object.entries(versions)
.filter(([remote, version]) => !REMOTE_VERSIONS[remote]?.includes(version))
.map(([remote, version]) => ` app ${appVersion} asks for ${remote} ${version}`),
);
if (missing.length > 0) {
console.error('\nThese versions are mapped but not published:');
console.error(missing.join('\n'));
console.error('\nAdd them to REMOTE_VERSIONS, or point the map at a version that exists.\n');
process.exit(1);
}
for (const platform of platforms) {
// Wipe this platform only, so building one does not delete the other's tree.
rmSync(join(repoRoot, 'cdn-root', platform), { recursive: true, force: true });
mkdirSync(join(repoRoot, 'cdn-root', platform), { recursive: true });
for (const [remote, versions] of Object.entries(REMOTE_VERSIONS)) {
const appDir = join(repoRoot, 'apps', REMOTE_APPS[remote]);
for (const version of versions) {
console.log(`\n=== building ${remote} ${version} (${platform}) ===`);
// Named once: the directory that is cleared, written and then read is one place, so no
// later edit can move the build's output out from under the copy that follows it.
const built = join(appDir, 'cdn', platform, remote, version);
// Cleared first, because Rspack writes into a directory rather than replacing it, so a file
// an earlier build emitted and this one does not would survive and be published beside the
// real ones, for no reason anyone could work out from the source.
rmSync(built, { recursive: true, force: true });
// MF_REMOTE_VERSION decides both what the bundle says about itself and where it is written,
// so one variable cannot produce a build that is stamped one version and filed under another.
execSync(`npm run bundle:${platform}:prod`, {
cwd: appDir,
stdio: 'inherit',
env: { ...process.env, MF_REMOTE_VERSION: version },
});
// The build reported success, so a missing directory here means its output path and this
// path have drifted apart, which is worth saying in one line rather than as a stack trace.
if (!existsSync(built)) {
console.error(`\n${remote} built but wrote nothing to ${built}.`);
console.error("Check the output path in that app's rspack.config.mjs.\n");
process.exit(1);
}
cpSync(built, join(repoRoot, 'cdn-root', platform, remote, version), {
recursive: true,
filter: source => !NEVER_PUBLISHED.test(source.split('/').pop()),
});
console.log(`published -> cdn-root/${platform}/${remote}/${version}`);
}
}
for (const [appVersion, versions] of Object.entries(APP_VERSION_MAPS)) {
const dir = join(repoRoot, 'cdn-root', platform, 'maps', appVersion);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'version-map.json'), `${JSON.stringify(versions, null, 2)}\n`);
console.log(`wrote -> cdn-root/${platform}/maps/${appVersion}/version-map.json`);
}
}
const HOST_ADDRESS = { ios: 'http://localhost:8000', android: 'http://10.0.2.2:8000' };
const appVersions = Object.keys(APP_VERSION_MAPS);
console.log(`\nCDN assembled at ${join(repoRoot, 'cdn-root')}`);
console.log('Serve it: npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors');
for (const platform of platforms) {
console.log(
`Point the host at it: ( cd apps/host && MF_CDN_BASE=${HOST_ADDRESS[platform]} MF_APP_VERSION=${appVersions.at(-1)} npm start ) # ${platform}`,
);
}
console.log(`App versions with a map: ${appVersions.join(', ')}`);
Source maps stop being published: they are four fifths of a built remote (16 MB of the 20 MB listApp writes on iOS), a crash reporter’s artefact rather than a client’s download, and on a public bucket they hand the readable source to anyone who asks. Build the tree:
node tools/build-cdn.mjs ios
cdn-root/ios/listApp/1.0.0/ cdn-root/ios/maps/1.0.0/version-map.json
cdn-root/ios/listApp/1.1.0/ cdn-root/ios/maps/2.0.0/version-map.json
cdn-root/ios/partyApp/1.0.0/
Each map is the whole of what a binary is told:
{
"listApp": "1.1.0",
"partyApp": "1.0.0"
}
Two lines and nothing else. The chunks it points at are signed; the file that chooses between them is not. Post 17 closes that; this post leaves it open and says so each time it matters.
Ask before you load
The host needs two facts at build time: where the CDN is, and which app version this binary is. In apps/host/rspack.config.mjs, post 14’s comment and const CDN_BASE = process.env.MF_CDN_BASE; become:
// --- Where the remotes are served from. Set MF_CDN_BASE and the host looks to the content
// delivery network instead of the dev servers; leave it unset and nothing changes. The value is
// read at BUILD time and baked into the bundle, so a build that forgot it ships the dev URLs:
// MF_CDN_BASE=http://localhost:8000 npm start (the local CDN, on a development build)
// MF_CDN_BASE=https://cdn.example.com npm run … (a real one, on a release build)
//
// What changed in this post is who uses the value. It still shapes the remotes map below, but
// that map is now a placeholder: the URLs it holds carry no version segment, so against a
// versioned CDN tree they resolve to nothing. The value that matters is the one handed to the
// running code through DefinePlugin, where src/shell/scriptManager.ts reads it, asks the CDN
// which versions this binary may run, and re-registers every remote at a versioned URL before
// the first import fires.
// Trailing slashes are trimmed, because every URL built from this value adds its own separator
// and a base written with one produces a double slash in the middle of every path. Most servers
// forgive that; a downloaded script is cached against the URL that fetched it, so it is not worth
// finding out which ones do not.
const CDN_BASE = (process.env.MF_CDN_BASE || '').replace(/\/+$/, '');
// --- This binary's own version, the question it asks the CDN at launch. The CDN answers with the
// remote versions this binary is allowed to run, which is how a two-year-old install keeps
// working: it keeps being handed the versions it shipped against. A real app reads this from the
// version it was released under; here it is a variable, so one checkout can produce two binaries
// that ask different questions:
// MF_APP_VERSION=1.0.0 npm run ios -- --mode Release
const APP_VERSION = process.env.MF_APP_VERSION || '1.0.0';
Both facts reach the running code as literals: add DefinePlugin to the host’s @rspack/core import, and this entry to plugins before ModuleFederationPluginV2:
// The two build-time facts the operational layer needs as literals in the bundle: where the
// CDN is, and which version this binary is. An empty base is the signal that no CDN was
// configured, which is what keeps a plain development build on the dev servers.
new DefinePlugin({
__MF_CDN_BASE__: JSON.stringify(CDN_BASE),
__APP_VERSION__: JSON.stringify(APP_VERSION),
}),
Post 14’s remoteUrl function and the remotes map stay, and their job changes. Module Federation wants a name and an entry for every remote declared at build time, so the map stays; its URLs carry no version, so against a versioned tree they resolve to nothing. The comment above the function says so:
// The build-time remotes map, in one function: dev server or CDN, same manifest filename either
// way. In CDN mode what it produces is a placeholder and nothing loads from it: the versioned
// URL the app really uses is decided at launch. It is left pointing somewhere plausible rather
// than removed, because Module Federation wants a name and an entry for every remote declared
// at build time, and because in dev mode this is still the whole story.
const remoteUrl = name =>
CDN_BASE
? `${name}@${CDN_BASE}/${platform}/${name}/mf-manifest.json`
: `${name}@${DEV_REMOTES[name]}/${platform}/mf-manifest.json`;
In CDN mode the build-time map is a placeholder. The URL the app really uses is decided at launch, by host code you copy from the end tag rather than type. Fetch the tag once; the copy commands take that code with its tests and Jest stand-ins, plus the other files later sections use:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-15-cdn-flip /tmp/pokedex-ref-15
cp /tmp/pokedex-ref-15/apps/host/src/shell/{remoteLocator,scriptManager,federationErrors}.ts /tmp/pokedex-ref-15/apps/host/src/shell/FederationBanner.tsx apps/host/src/shell/
cp /tmp/pokedex-ref-15/apps/host/__mocks__/{repack-client,module-federation-runtime}.js apps/host/__mocks__/
cp /tmp/pokedex-ref-15/apps/host/__tests__/{remoteLocator,scriptManager,federationErrors}.test.ts /tmp/pokedex-ref-15/apps/host/__tests__/{App,RemoteBoundary}.test.tsx apps/host/__tests__/
cp /tmp/pokedex-ref-15/apps/host/App.tsx apps/host/
cp /tmp/pokedex-ref-15/scripts/federation-smoke.sh scripts/
cp /tmp/pokedex-ref-15/packages/ui/src/tokens/__tests__/contrast.accessibility.ts packages/ui/src/tokens/__tests__/
cp /tmp/pokedex-ref-15/tools/gen-signing-keys.mjs /tmp/pokedex-ref-15/tools/gen-signing-keys.test.mjs tools/
Read them in the order they run. src/shell/scriptManager.ts is the launch, and its first job is fetching the map. Every federated import waits for that answer, so the request needs a bounded wait: without one, a slow CDN would hold the app at its splash screen for as long as the network let it. The wait is a second and a half:
const PROBE_TIMEOUT_MS = 1500;
An AbortController and a timer enforce it, wired by hand because React Native offers no shortcut. AbortSignal.timeout() does not exist there: 0.85 installs AbortController and AbortSignal from the abort-controller package, which has no timeout. The request has no timeout of its own either: React Native builds Android’s HTTP client with every timeout at zero, and on iOS passes the request’s timeout through, which defaults to zero:
// --- Fetch and read the version map for this app version. Returns null for every kind of
// failure, because the caller treats them all the same way: an unreachable CDN, a 404 for an app
// version nobody published a map for, a timeout, and a map that does not parse all end with this
// binary running no remotes. ---
async function fetchVersionMap(): Promise<Record<string, string> | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const response = await fetch(versionMapUrl(CDN_BASE, Platform.OS, APP_VERSION), {
signal: controller.signal,
// The map is the one file on the CDN that must never be served from a cache: it is the
// record of what is current, and a stale copy is a silently undone deploy. Every other file
// the app fetches carries its version in the URL and can be cached forever.
headers: { 'cache-control': 'no-cache' },
});
if (!response.ok) {
console.warn(`[federation] version map returned ${response.status}`);
return null;
}
const versions = parseVersionMap((await response.json()) as unknown, REMOTE_NAMES);
if (!versions) {
// The one failure that is nobody's network and nobody's outage: the file was served and
// could not be believed. Said out loud, because the alternative is an app that launches,
// loads nothing and offers no reason.
console.warn('[federation] version map was served but could not be read');
}
return versions;
} catch (error) {
console.warn('[federation] version map could not be fetched', error);
return null;
} finally {
clearTimeout(timer);
}
}
With the versions in hand, every remote is pointed at the manifest inside its version directory:
// --- Point every remote at the versioned manifest this launch resolved.
//
// force is not optional. Each remote is already registered under its name from the build-time
// remotes map, and registerRemotes leaves an already-registered name alone unless it is told
// otherwise: without the flag this call returns quietly, changes nothing, and the app loads the
// unversioned placeholder URLs instead. With it, Module Federation logs a warning about
// re-registering a remote on every launch, which is the expected cost of doing this. ---
function registerCdnRemotes(versions: Record<string, string>): void {
registerRemotes(
REMOTE_NAMES.filter(name => versions[name]).map(name => ({
name,
entry: remoteManifestUrl(CDN_BASE, Platform.OS, name, versions[name]),
})),
{ force: true },
);
}
registerRemoteswithoutforceis a silent no-op for a name that is already registered. In Module Federation's runtime core at 2.9.0, the branch for an existing name does nothing at all whenforceis absent: no error, no warning, no change. The build-time map registered both remotes long before the probe ran, so every launch re-registers with{ force: true }, and the runtime prints[ Federation Runtime ]: The remote "listApp" is already registered. Please note that overriding it may cause unexpected errors.once per remote in the development console. That line is the arrangement working. A launch that does not print it loaded the placeholders.
The launch is one function, held as a promise so that a second caller arriving mid-probe waits for the same answer instead of reading the state from before it started:
export function initializeFederation(): Promise<FederationStatus> {
initialization ??= resolveFederation();
return initialization;
}
async function resolveFederation(): Promise<FederationStatus> {
if (!CDN_CONFIGURED) {
status = __DEV__
? { mode: 'dev', source: 'dev servers', versions: {} }
: { mode: 'unresolved', source: 'no CDN configured', versions: {} };
return status;
}
const versions = await fetchVersionMap();
if (!versions) {
// Worded to cover every way this fails, because the banner is a claim the app makes about
// itself: a map that was served and refused is not an unreachable one, and the log line
// beside it already says which of the two happened.
status = { mode: 'unresolved', source: 'no usable version map', versions: {} };
return status;
}
// The status is set before the registration because the resolver reads it, and then rolled back
// if the registration throws. Claiming CDN mode after a failed re-registration would put the
// versions on the banner while every load went to the build-time placeholder URL: an app that
// says it is running 1.2.0 and is running nothing.
status = { mode: 'cdn', source: CDN_BASE, versions };
try {
registerCdnRemotes(versions);
} catch (error) {
console.warn('[federation] remotes could not be re-registered', error);
status = { mode: 'unresolved', source: 'remotes could not be registered', versions: {} };
}
return status;
}
Three modes come out of it: dev is post 14’s world, cdn is the one this post exists for, and unresolved is a failure with a name: no usable map, so no remote at all, refused rather than loaded unverified. FederationBanner.tsx paints the mode and the resolved versions as a pill above the tab bar, so a demo can be photographed rather than believed.
The gate is in App.tsx. The navigator’s tabs are React.lazy federated imports, so mounting the navigator starts the first download, and that must wait for the re-registration. The state and the effect sit in App above SafeAreaProvider, because that provider renders no children until it has measured the insets: below it, the gate would wait on that measurement, and under Jest, where nothing measures, it would never open:
const [federationReady, setFederationReady] = useState(false);
useEffect(() => {
let live = true;
initializeFederation()
.catch(err => console.warn('federation initialisation failed', err))
.then(() => {
if (live) {
setFederationReady(true);
}
});
return () => {
live = false;
};
}, []);
{federationReady ? (
<>
<Shell navTheme={navTheme} mode={mode} onReady={() => setNavReady(true)} />
<FederationBanner />
</>
) : null}
Post 8’s boot import of partyApp/partySlice moves behind the same flag, because it is a federated load like any other. After that move, nothing federated loads before the launch has its answer.
A failed probe does not keep the app at its splash screen. The gate opens either way, at most one probe timeout later, and the mode records the difference: after a failed probe the banner shows unresolved, the resolver refuses every remote, and each tab shows its error state.
The host’s tests need two more entries in apps/host/jest.config.js, because ScriptManager.shared reaches for a bundler runtime the moment it is touched, and a Jest process has none. The map and the comment above it become:
// Reanimated drives animations through the JSI (the JavaScript Interface React Native uses to
// call native code), which a Jest process has no runtime for, so it is replaced by the stand-in
// in __mocks__. One entry covers the whole federation: @pokedex/ui and @pokedex/detail import
// the same module specifier, so their animated components resolve to the same mock. The
// federated state module has no resolvable source under Jest either; its mock repeats the real
// module's side effect (reducer injection) so boot readiness is testable.
// The Re.Pack client and Module Federation runtime entries exist because Re.Pack's ScriptManager
// and Module Federation's registerRemotes both reach into a bundler runtime that a Jest process
// does not have, and the host touches both at module scope, so the resolver is in place before
// any federated import can fire. Their stand-ins record what was asked of them.
moduleNameMapper: {
'^react-native-reanimated$': '<rootDir>/__mocks__/react-native-reanimated.js',
'^partyApp/partySlice$': '<rootDir>/__mocks__/partyApp-partySlice.js',
'^partyApp/styles$': '<rootDir>/__mocks__/partyApp-styles.js',
'^@callstack/repack/client$': '<rootDir>/__mocks__/repack-client.js',
'^@module-federation/runtime$': '<rootDir>/__mocks__/module-federation-runtime.js',
},
One resolver, every chunk
The map names a version per remote. Something still has to turn every script the federation loads into a URL inside that version’s directory: the container when the remote is first imported, then each chunk the container asks for. Re.Pack’s ScriptManager asks its resolvers in priority order, and the first to return a locator wins. This post adds one. Its decisions live in src/shell/remoteLocator.ts as plain functions, testable without a device, and each script gets one of three answers:
/** The resolver's decision for one script. */
export type Resolution =
| { kind: 'defer' }
| { kind: 'locate'; locator: RemoteLocator }
| { kind: 'refuse'; reason: string };
Which one depends on the mode, the remote and the map:
// --- Which remote a script belongs to. A container announces itself: its script id IS the remote
// name. A chunk does not, so the caller is the only thing that says where it came from, and it is
// why the resolver takes both arguments rather than pattern-matching the id. ---
function remoteFor(
scriptId: string,
caller: string | undefined,
remoteNames: readonly string[],
): string | undefined {
if (remoteNames.includes(scriptId)) {
return scriptId;
}
if (caller && remoteNames.includes(caller)) {
return caller;
}
return undefined;
}
// --- Decide one script: locate it inside its version's directory, refuse it, or defer it to
// Re.Pack's own resolution.
//
// Deferring hands the script to the next resolver, and for one of this host's remotes the next
// one is Re.Pack's per-remote resolver: it answers with a URL built from whichever manifest was
// registered last and no signature check at all. That is the right answer in development, where
// the dev servers own everything, and for any script that is not one of this host's remotes.
// Outside development it is never the right answer for a remote: with no version map, or a map
// that named no version for this remote, deferring would load code from an unversioned URL,
// unverified. So those loads are refused, and the refusal surfaces as the tab's error state. ---
export function resolveRemoteLocator(input: ResolveInput): Resolution {
if (input.mode === 'dev') {
return { kind: 'defer' };
}
const remoteName = remoteFor(input.scriptId, input.caller, input.remoteNames);
if (!remoteName) {
return { kind: 'defer' };
}
const version = input.mode === 'cdn' ? input.versions[remoteName] : undefined;
if (!version) {
return {
kind: 'refuse',
reason:
input.mode === 'cdn'
? `the version map named no version for ${remoteName}`
: `no version map was read at launch, so ${remoteName} has no version to load`,
};
}
const filename =
input.scriptId === remoteName
? `${remoteName}.container.js.bundle`
: `${input.scriptId}.chunk.bundle`;
return {
kind: 'locate',
locator: {
url: `${input.cdnBase}/${input.platform}/${remoteName}/${version}/${filename}`,
// Caching is per URL, and a URL here carries its version, so a cached file can only ever be
// served for the version it was fetched for. A new version is a new URL and a fresh download.
cache: true,
verifyScriptSignature: input.verify,
},
};
}
remoteLocator.test.ts walks this tree, including a chunk resolved by its caller rather than its id, the case that proves the resolver reads its second argument at all.
scriptManager.ts registers it at module scope, so it is in place before anything federated can be imported, whatever order the launch runs in: once webpack and the federation runtime have loaded a container or a chunk they never ask for it again, so a resolver added after a script’s first load never sees that script.
ScriptManager.shared.addResolver(
async (scriptId: string, caller?: string) => {
const resolution = resolveRemoteLocator({
scriptId,
caller,
remoteNames: REMOTE_NAMES,
mode: status.mode,
versions: status.versions,
platform: Platform.OS,
cdnBase: CDN_BASE,
verify: VERIFY,
});
if (resolution.kind === 'refuse') {
// Thrown, not returned. Returning nothing passes the script to the next resolver, which for a
// remote is Re.Pack's own and would load it unverified. Re.Pack's resolveScript stops at the
// first resolver that throws, so the load fails and the tab shows its error state.
throw new Error(`[federation] refused ${scriptId}: ${resolution.reason}`);
}
return resolution.kind === 'locate' ? resolution.locator : undefined;
},
{ key: '__signed_resolver__', priority: 100 },
);
The priority is the line that decides whether any of this runs. Re.Pack’s ResolverPlugin registers a resolver of its own for each remote as it is registered, with a key and no priority, so it takes ScriptManager’s default, which is 2 in 5.2.5’s source; resolvers run highest first. Once the launch re-registers the remotes, that built-in resolver is rebuilt from the versioned manifest URL, but its locator carries no verification setting, so a custom resolver below 2 loses to it, and loses quietly: the right versions load, and every chunk loads unverified. 100 is far above 2, and scriptManager.test.ts asserts it against the stand-in.
The same built-in resolver is why returning nothing is unsafe when a remote has no version. Returning nothing hands the load to it, and it would fetch the script from the unversioned build-time URL with no signature check. Throwing ends the search instead, because Re.Pack’s resolveScript stops at the first resolver that throws.
The map arrives over the network, so parseVersionMap reads it as a stranger’s JSON. A version becomes a path segment in a URL the app downloads code from, so each one is checked against /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/ first: a slash would climb out of the version directory, and the leading character rules out ... One bad version refuses the whole map, because a half-read map would launch the app on versions nobody published; remote names this binary has never heard of are ignored, since one CDN can serve several apps. remoteLocator.test.ts covers the shapes the parser refuses and the names it ignores.
Turn the key
Post 14 signed every production chunk and read none of the signatures: “a signature nobody verifies is a stamp, not a lock.” The lock is one field on the locator:
// --- Signature verification is only meaningful where there is a public key to verify against:
// the key is embedded in the iOS Info.plist and in Android's strings.xml, and nowhere else. On
// those two platforms it is strict, which means a chunk whose signature does not match the key,
// or which carries no signature at all, is rejected before it executes. ---
const SIGNED_PLATFORMS = ['ios', 'android'];
const VERIFY: VerifyMode = SIGNED_PLATFORMS.includes(Platform.OS) ? 'strict' : 'off';
Re.Pack accepts three values. lax verifies only when a token is present and lets an unsigned chunk through; off verifies nothing; strict refuses a mismatched signature and a missing one alike, which is what makes the stamp a lock.
Each platform reads the key by name: iOS from Info.plist under RepackPublicKey, Android from res/values/strings.xml under the same name. Both files are committed and compiled into the binary, so the generator you copied from the tag now writes the key into them.
The two platforms take it in different shapes. iOS parses PEM, the base64 text between BEGIN and END lines, so it gets the file as saved. Android uses only the base64 body, stripping those lines and the line breaks before it decodes, so it gets that body alone, on one line. Before building either shape, the generator turns Windows line endings (CRLF, a carriage return and a line feed) into Unix ones (LF), so a key saved by any editor embeds the same value. Its check against the private key compares DER, the binary form both shapes encode, so line endings cannot break it either:
// --- Put the public half where the app reads it. The two platforms take the key in two shapes:
// iOS parses PEM, so it gets the file verbatim; Android strips the header, the footer and the line
// breaks before it decodes, so it is given the base64 body alone, on one line. ---
// Line endings are normalised before either shape is built, so a public key that has been through
// a tool or an editor that writes CRLF embeds exactly the values one saved with LF does. It still
// matches its private key either way: the check above compares DER bytes, not text.
const publicPem = readFileSync(publicPath, 'utf8').replace(/\r\n/g, '\n').trim();
const publicBase64 = publicPem
.split('\n')
.filter(line => !line.includes('PUBLIC KEY'))
.join('');
The rest of the generator’s change replaces the value of an existing entry in each file. It treats a missing entry as an error rather than a warning, because strict verification fails closed: a key that never reached the app surfaces later as every remote refusing to load, with a message that says nothing about the generator. So add both entries now, empty, for the generator to fill. In apps/host/ios/Host/Info.plist, inside the top-level <dict> and above RCTNewArchEnabled:
<!-- The public half of the chunk-signing keypair. Re.Pack's native verifier reads it from
this key by name, and strict verification fails closed without it. Written here by
tools/gen-signing-keys.mjs; left empty in the repository because the key is generated per
checkout, and a public key is safe to commit but a stale one is not useful. -->
<key>RepackPublicKey</key>
<string></string>
and in apps/host/android/app/src/main/res/values/strings.xml, after app_name:
<!-- The public half of the chunk-signing keypair, read by name by Re.Pack's native verifier.
The base64 body alone, on one line: the verifier strips any PEM header, footer and line
breaks before it decodes. Written by tools/gen-signing-keys.mjs; empty in the repository,
because the key is generated per checkout. -->
<string name="RepackPublicKey"></string>
Run the generator. Post 14’s private key is kept, and the public half lands in both files; the first three lines of its output say so:
node tools/gen-signing-keys.mjs
chunk-signing keypair already present, kept
embedded the public key -> apps/host/ios/Host/Info.plist
embedded the public key -> apps/host/android/app/src/main/res/values/strings.xml
Verification happens at download time. On both platforms the check runs inside the download-and-cache path, and a script already on disk is executed without being verified again. In this build that window is one session: the locator cache lives in memory (no
setStorage), so every launch downloads and verifies every chunk afresh, and the server logs in this post show it. A build that persists that cache extends the window to the life of the cached file.
Two binaries, one CDN
The map is per app version, rather than one file for everyone, because compatibility cannot be negotiated on the device. The host’s copy of each shared singleton is the one copy in the runtime, loaded before any remote, which is post 3’s contract; a remote built against a different version cannot bring its own. So the choice is made on the server, per binary. Two builds from one checkout show it. Serve the tree, then build the Release scheme twice, once per app version, onto two simulators:
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release --udid <simulator A> )
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=1.0.0 npm run ios -- --mode Release --udid <simulator B> )
Both boot on the Pokédex tab with no dev server running. The 2.0.0 build’s chip reads listApp 1.1.0 and its banner cdn · listApp 1.1.0 · partyApp 1.0.0; the 1.0.0 build’s chip reads listApp 1.0.0. The server’s terminal says why (trimmed for length: the vendor chunks, the party’s chunks and the 1.0.0 launch’s lines after its container are cut):
[2026-09-21T15:51:21.029Z] "GET /ios/maps/2.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.069Z] "GET /ios/listApp/1.1.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.076Z] "GET /ios/partyApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.083Z] "GET /ios/listApp/1.1.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.095Z] "GET /ios/partyApp/1.0.0/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.152Z] "GET /ios/listApp/1.1.0/__federation_expose_ListStack.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.057Z] "GET /ios/maps/1.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.091Z] "GET /ios/listApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.116Z] "GET /ios/listApp/1.0.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
The map first, then the manifests, then every chunk inside the directory the map named. Same CDN, two binaries, two answers. The skews this arrangement handles:
| Situation | What changes on the CDN | What each installed binary loads |
|---|---|---|
| A fix to a remote, for everyone | A new version directory, then the line in every map that should take it | Its own map’s version, at its next launch |
| A remote that needs a newer shared singleton | A new version directory, and a line in the new app version’s map only | Old binaries keep their old line; the new binary gets the new version once it ships |
| A host release with no remote change | A new map, copied from the last one | The same remote versions the last app version ran |
| A bad version already shipped | The map line put back | The previous version, at the next launch, with nothing rebuilt |
The detail screen has no row because it is not a remote of its own. Since post 5 it has been a package compiled into whichever remote installs it, so a detail fix ships as a new version of the list and of the party. The unit of a flip is the remote, and everything installed into it moves with it.
Android needs one more declaration: MF_APP_VERSION joins MF_CDN_BASE as an input of the bundle task, for the reason post 14 gave, and the block post 14 added to apps/host/android/app/build.gradle becomes:
// --- MF_CDN_BASE and MF_APP_VERSION are read by rspack.config.mjs when the bundle task runs, and
// Gradle cannot see that: an environment variable is not one of the task's declared inputs, so a
// build where only the value changed leaves createBundleReleaseJsAndAssets UP-TO-DATE and ships
// the previous bundle, dev-server URLs and all. Declaring them as inputs makes a changed value
// rebuild the bundle and an unchanged one keep the cache.
//
// MF_APP_VERSION matters here for the same reason, and more: the two-binaries demo, run on
// Android, builds the same source twice with nothing different but that variable, so without this
// line the second build silently ships the first one's bundle and asks the CDN the first one's
// question. ---
tasks.configureEach {
if (name.startsWith("createBundle") && name.endsWith("JsAndAssets")) {
inputs.property("MF_CDN_BASE", System.getenv("MF_CDN_BASE") ?: "")
inputs.property("MF_APP_VERSION", System.getenv("MF_APP_VERSION") ?: "")
}
}
node tools/build-cdn.mjs android && ( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 MF_APP_VERSION=2.0.0 npm run android -- --mode release )
Ship 1.2.0
The installed 2.0.0 app shows list 1.1.0. Ship it a change a user would see: when the party reaches six members, the counter in the Pokédex header changes its label from “My Party” to “Party full”, and its pill deepens. In PokedexScreen.tsx, after partyCount:
const partyFull = partyCount >= MAX_PARTY;
The counter group reads it three times: for the visible label, for the label a screen reader speaks, and for the pill’s fill. Its comments say why the full state is marked by the word and by the colour:
{/* The count changes when the user adds a member from a screen away, without focus
moving here. A sighted user sees the number tick; a screen-reader user is told
nothing unless this is a live region (SC 4.1.3). The label spells the ratio out,
because "3/6" is read as "three slash six" or as a date, depending on the reader.
At six it reads the full state too, which is the word a colour alone cannot say. */}
<Box
className="flex-row items-center gap-2"
accessible
accessibilityLiveRegion="polite"
accessibilityLabel={`${partyFull ? 'Party full' : 'My Party'}, ${partyCount} of ${MAX_PARTY}`}>
<Text size="sm" className="font-semi text-darkGrey dark:text-lightGrey">
{partyFull ? 'Party full' : 'My Party'}
</Text>
{/* Full is the party's one state worth marking, and it is marked twice: the label
beside this pill changes, and the pill deepens. The label carries the state for
everyone, including anyone who cannot use colour (SC 1.4.1); the deeper pill
makes it visible at a glance, in both themes.
The dark half deepens by alpha rather than by hue, because the pill over navy is
translucent white in the first place and a green fill there would be a different
component wearing the same shape.
darkGrey, not darkGreen: darkGreen is #A6D3A0, the grass fill, and on lightGreen
it measures 1.53:1. darkGrey is the colour the label beside it already uses, and
it clears the bar on both greens. All four pairs are in the contrast matrix. */}
<Box
className={`rounded-full px-2.5 py-0.5 ${
partyFull ? 'bg-pokemonGreen dark:bg-white/20' : 'bg-lightGreen dark:bg-white/10'
}`}>
<Text size="xs" className="font-head text-darkGrey dark:text-pokemonGreen">
{partyCount}/{MAX_PARTY}
</Text>
</Box>
</Box>
Every pair this post paints is measured in the contrast matrix post 12 built; the four new rows are in the design-system test you copied. The list’s own accessibility tests check the full state’s label and its pill, in both themes, and the chip beside them; copy them from the tag now:
cp /tmp/pokedex-ref-15/apps/list/__tests__/ListStack.accessibility.tsx apps/list/__tests__/
Now build the change as a version and put its directory on the CDN. Not through build-cdn, which seeds a tree from its lists and would rewrite everything the server is serving: the operation is two commands against the tree already there.
( cd apps/list && MF_REMOTE_VERSION=1.2.0 npm run bundle:ios:prod ) && rsync -a --exclude '*.map' --exclude mf-stats.json apps/list/cdn/ios/listApp/1.2.0/ cdn-root/ios/listApp/1.2.0/
Nothing has changed for anybody. The directory is there and no map points at it. Then the second step, one line of cdn-root/ios/maps/2.0.0/version-map.json:
{
"listApp": "1.2.0",
"partyApp": "1.0.0"
}
Relaunch the installed 2.0.0 app. Nothing was rebuilt and nothing was reinstalled. A phone takes the change the same way, at its next launch, and a user who already has the app open keeps the code they loaded until then. The server’s log, with the list’s vendor chunk lines cut for length:
[2026-09-21T15:52:00.957Z] "GET /ios/maps/2.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.977Z] "GET /ios/listApp/1.2.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.981Z] "GET /ios/partyApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.987Z] "GET /ios/listApp/1.2.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.995Z] "GET /ios/partyApp/1.0.0/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.038Z] "GET /ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.185Z] "GET /ios/partyApp/1.0.0/__federation_expose_styles.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.185Z] "GET /ios/partyApp/1.0.0/__federation_expose_partySlice.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
The chip reads listApp 1.2.0, the banner agrees, and with six Pokémon added the header says Party full on a deeper pill. The 1.0.0 binary on the other simulator, launched after the flip, still loaded listApp 1.0.0: its own map never changed.
Upload the version directory first. Flip the map last. The map is the commit. Nothing in this build gives a binary anywhere else to load a remote from, so a map that points at a directory not yet on the CDN is a 404 for every user who launches in between, and what they get is a tab that will not open. The missing-version break-it in Now break it, three times is what the other order looks like.
Record the release in the tool too, so its lists match what the CDN now holds. In tools/build-cdn.mjs the two lists and their comments become:
// --- Every version of each remote the CDN holds. A version directory is written once and then
// left alone: installed apps are loading those exact files, so a rebuild of a published version
// is a silent change to code somebody is already running. New work gets a new number.
//
// listApp has three because this post ships two releases of it. 1.0.0 and 1.1.0 are the same
// screen with different stamps on it, which is what the two-binaries demo needs; 1.2.0 is the
// build that added the party counter's full state, and it is the one the flip ships. This tool
// builds every version from the source in front of it, so rebuilt from the finished tree all three
// carry that state: the post builds 1.0.0 and 1.1.0 before making the change, which is what keeps
// them without it. ---
const REMOTE_VERSIONS = {
listApp: ['1.0.0', '1.1.0', '1.2.0'],
partyApp: ['1.0.0'],
};
// --- What each released app version may load. The host asks for its own entry by name at every
// launch, so an old binary keeps being handed the versions it was built against, however far the
// newest release has moved on. An entry is retired when nobody is left on that app version, the
// same way an old API endpoint is.
//
// 2.0.0 pointed at listApp 1.1.0 until the flip; the line below is what changed, and changing it
// back is the rollback. Editing it here rebuilds the whole tree, which is the wrong tool for that
// job: the operation the post performs is an edit to the map file already sitting in cdn-root,
// because that file is what a running app reads. This is the seeding of a CDN, not an operation
// against one.
//
// There is nothing else in the map. No signature over it, no counter, nothing that would let the
// app tell a map written here from one written by anybody else who can reach the bucket. That is
// a real hole and it is left open on purpose: it is the subject of the last post in the series. ---
const APP_VERSION_MAPS = {
'1.0.0': { listApp: '1.0.0', partyApp: '1.0.0' },
'2.0.0': { listApp: '1.2.0', partyApp: '1.0.0' },
};
That edit is bookkeeping; the deploy already happened. It also means a fresh seeding from the finished tree builds all three versions from one source, so 1.0.0 and 1.1.0 would carry the full-party state too: the order you just followed is what keeps them without it.
Two caching rules make the operation safe to repeat. Each chunk URL includes its version, so the CDN, a proxy or the device can keep that file forever without confusing it with a later release. The version map changes with every deploy, so it gets the opposite rule: the probe sends cache-control: no-cache, and locally -c-1 makes http-server send cache-control: no-cache, no-store, must-revalidate. A production CDN gets the same two rules as configuration.
This host still downloads the party’s unchanged 1.0.0 chunks after a relaunch, as the log shows, because it keeps the locator cache in memory only. Persisting that cache would save those downloads, and would also extend how long a file on disk runs without another signature check, for the reason in the “Verification happens at download time” callout.
Roll it back
The version that shipped is wrong. Put the line back:
{
"listApp": "1.1.0",
"partyApp": "1.0.0"
}
Relaunch, and the log moves back to 1.1.0 with nothing rebuilt, because the old directory was never removed. The two lists in build-cdn make this possible: the CDN keeps 1.1.0 while nothing points at it, so a rollback is an edit and never a build.
The same plain file that makes rollback a one-line edit lets anyone who can write to the bucket steer every installed app to whichever version they choose, including an old one with a known fault. The chunks are guarded; the map is naked. Post 17 is about that file.
Now break it, three times
A refused chunk and a missing version look the same on the screen and mean opposite things. A release whose module throws while it initialises looks no different. Run all three on the Release build, with the map pointing at 1.2.0 again.
First, the system working. Change one byte inside the exposed chunk on the CDN and relaunch:
printf 'X' | dd of=cdn-root/ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle bs=1 seek=2000 conv=notrunc
The server serves the chunk, the device downloads it, and verification refuses it before a line of it runs. A Release build has no Metro console, so read the simulator’s own log:
xcrun simctl spawn <simulator A> log show --last 1m --predicate 'process == "Host"' --style compact | grep -A17 'Failed to load script'
The match and the lines that matter in the seventeen after it, with the log’s own prefix cut:
'[ScriptManager] Failed to load script:', '[ScriptDownloadFailure]', { scriptId: '__federation_expose_ListStack',
caller: 'listApp',
url: 'http://localhost:8000/ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle',
{ [Error: The bundle verification failed because the bundle hash is invalid.]
code: 'ScriptDownloadFailure',
The Pokédex tab shows the design system’s error state, “This tab could not load” with a Try again button; the banner still reads cdn · listApp 1.2.0 · partyApp 1.0.0; the Party tab opens and works. The line under the title follows the launch’s mode: from the CDN it says the remote could not be downloaded, verified or started and suggests a relaunch, where post 11’s wording pointed at a dev server this build does not have.
Append bytes after the end of the file instead and the logged error becomes no token for the bundle was found: the verifier reads the file’s last 1,280 bytes and expects them to begin with the signature’s marker, and the appended bytes moved that window past it. Strict mode refuses that too. Restore the chunk from the built copy before going on.
Second, the operational failure. Point the 2.0.0 map at a version whose directory does not exist, 1.3.0, and relaunch:
[2026-09-21T15:54:25.541Z] "GET /ios/listApp/1.3.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:54:25.542Z] "GET /ios/listApp/1.3.0/mf-manifest.json" Error (404): "Not found"
The same error state on the same tab, and the banner reports listApp 1.3.0, the version it was told to load. Nothing was tampered with; the map was written before the directory it names, which is the wrong order the “map is the commit” callout warns about, seen from the user’s side. Copy any built version into cdn-root/ios/listApp/1.3.0/ and the next launch loads it. Put the map back to 1.2.0.
Third, a release with a bug in it. Add one line below the imports of apps/list/src/PokedexScreen.tsx:
throw new Error('PokedexScreen failed to initialise');
Ship it the way 1.2.0 shipped, as a version of its own, then point the 2.0.0 map at 1.4.0:
( cd apps/list && MF_REMOTE_VERSION=1.4.0 npm run bundle:ios:prod ) && rsync -a --exclude '*.map' --exclude mf-stats.json apps/list/cdn/ios/listApp/1.4.0/ cdn-root/ios/listApp/1.4.0/
The requested JavaScript bundles download and pass signature verification, but the tab shows the same error state again, with the banner reporting listApp 1.4.0. Nothing failed to load this time: the list’s code arrived intact and threw while its module initialised. Delete the line and put the map back to 1.2.0.
None of the three error states appeared the first time its failure ran on an iOS Release build: the app died before any of them could be drawn. Android had already shown the same death in post 14, where a release build’s refused manifest request ended the boot.
For the first two, the cause is the order in which the failure is reported. When a remote’s module fails to load, webpack’s remote runtime records the error, appends while loading "./ListStack" from … to its message, and replaces the module’s factory with one that throws. Re.Pack replaces webpack’s require with a guarded version in every bundle it builds: when a require throws, the guard catches the error, reports it to React Native’s global error handler as fatal, and returns nothing. That report comes first, before React has tried to render the tab.
In a development build the fatal report is a red box over a working app. In a Release build the handler passes it to React Native’s native error handling, where the fatal path ends the process: on iOS the exceptions module calls RCTFatal, which throws an exception nothing catches, and on Android it throws a JavascriptException that the default host rethrows.
If the process survives that report, the tab still reaches its error state. The guarded require’s empty return lets the tab’s import settle without a component, React refuses to render it (Element type is invalid), and RemoteBoundary, which post 11 put around each tab, catches that render error and shows the error state. The boundary never sees the original loading error; it handles the failure that follows from it.
src/shell/federationErrors.ts lets the process survive. It wraps React Native’s global handler and, for the first two breaks, drops the report whose message ends with the suffix the remote runtime appended, which the runtime writes at exactly one place and always as the message’s last line:
const HANDLED_BY_REMOTE_RUNTIME = /\nwhile loading "[^"\n]+" from \S+$/;
export function isHandledRemoteLoadError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
const { message } = error as { message?: unknown };
return typeof message === 'string' && HANDLED_BY_REMOTE_RUNTIME.test(message);
}
Two earlier versions of that matcher were written, measured and removed, and both are now test cases. The first matched ChunkLoadError by name and also required the container half of the suffix to name the remote. In a development build that half reads webpack/container/reference/listApp; in a Release build that module is minified to its numeric id, 77469, so the check passed in development and failed exactly where it mattered. The second dropped the container check but still matched ChunkLoadError by name, which is what a bad signature arrives as; a version the CDN does not hold arrives as [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003 with the same suffix, and it still killed the app. For a failed load, the guard matches the suffix alone.
The suffix covers the first two breaks and not the third. The list’s code downloaded and verified, then threw while its module was being evaluated. The remote’s container is a bundle Re.Pack built too, so the outermost require inside it is guarded as well, and it reports the throw as fatal before anything returns. The remote runtime saw a successful load, so nothing appended a suffix, and with the matcher alone the Release build died at launch:
*** Terminating app due to uncaught exception 'RCTFatalException: Unhandled JS Exception: Error: PokedexScreen failed to initialise'
The guard can recognise the third break’s report by its timing instead: it arrives while a remote module is being evaluated. When the host imports a remote module, the remote runtime asks Module Federation for the module’s factory unexecuted, with loadFactory: false, and Module Federation passes that factory to every runtime plugin’s onLoad hook before anything calls it. A function returned from the hook replaces the factory. src/shell/scriptManager.ts installs a plugin that returns one running the real factory inside evaluateRemoteModule, so every remote module the host imports, for the tabs and at boot, is evaluated there:
const evaluationWindow: ModuleFederationRuntimePlugin = {
name: 'evaluation-window',
onLoad({ exposeModuleFactory }) {
if (typeof exposeModuleFactory !== 'function') {
return undefined;
}
return () => evaluateRemoteModule(exposeModuleFactory);
},
};
registerPlugins([evaluationWindow]);
evaluateRemoteModule, in federationErrors.ts, opens a window around the factory. Any fatal report raised while the window is open comes from evaluating that module, so the guard holds it instead of passing it on. Once the factory returns, evaluateRemoteModule throws the held error:
export function evaluateRemoteModule<T>(factory: () => T): T {
const globals = store();
const outer = globals[EVALUATING];
const evaluation: Evaluation = {};
globals[EVALUATING] = evaluation;
try {
let exports: T;
try {
exports = factory();
} catch (error) {
throw remember(error);
}
if (evaluation.failure) {
throw remember(evaluation.failure.error);
}
return exports;
} finally {
globals[EVALUATING] = outer;
}
}
The window is exact because evaluation is synchronous: nothing else runs between opening it and closing it. Only fatal reports are held, because only a fatal report ends the process.
The error thrown from the window then reaches the host’s own guarded require, which reports it as fatal a second time, outside the window. remember keeps every error thrown from the window, and the guard drops that second report the way it drops one that ends with the suffix. The import settles without the module, and the tab reaches the same error state as the other two breaks.
The installed guard puts the two paths together. It holds a fatal report raised inside a window, drops a report that ends with the suffix or repeats an error thrown from a window, and passes everything else to the handler that was there before:
// --- Where the guard keeps the handler it wraps. Fast Refresh can evaluate this module again in a
// running app, and each evaluation starts with fresh module state, so a flag in this file cannot
// tell a second installation from a first. The wrapper carries the handler underneath it instead,
// on a property every evaluation of the module knows by name. ---
const WRAPPED = '__federationGuardWrapped';
type GuardHandler = GlobalErrorHandler & { [WRAPPED]?: GlobalErrorHandler };
export function guardHandledRemoteLoadErrors(): void {
const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsShape }).ErrorUtils;
if (!errorUtils) {
return;
}
const current: GuardHandler = errorUtils.getGlobalHandler();
const previous = current[WRAPPED] ?? current;
const guard: GuardHandler = (error, isFatal) => {
const evaluation = store()[EVALUATING] as Evaluation | undefined;
if (evaluation && isFatal) {
evaluation.failure ??= { error };
console.warn(
'[federation] a remote module threw while it was evaluated; the import that asked for it settles without it',
error,
);
return;
}
if (isHandledRemoteLoadError(error) || isThrownFromEvaluation(error)) {
// Logged, not swallowed: the reason behind a tab's error state belongs in the console of
// whoever is looking at it.
console.warn(
'[federation] a remote failed to load; its tab will show the error state',
error,
);
return;
}
previous(error, isFatal);
};
guard[WRAPPED] = previous;
errorUtils.setGlobalHandler(guard);
}
Installing the guard again replaces the one it finds rather than wrapping it. Fast Refresh can run the module again during development, and a second wrapper would leave the first one’s checks running underneath; federationErrors.test.ts re-evaluates the module to hold it to one guard. The window and the record remember keeps are stored on the global object for the same reason, so every evaluation of the module shares them.
None of this gives the binary anywhere else to load from: a dead tab is honest, and it is not a working app.
What the stores permit
Everything in this post downloads code into an installed app, so the platform rules apply, and both vendors have reworded these clauses before. Every quotation in this section comes from the current pages.
| Platform | Governing text | What it says | Condition |
|---|---|---|---|
| Apple, review | App Review Guideline 2.5.2 | Apps may not “download, install, or execute code which introduces or changes features or functionality of the app, including other apps” | The reviewed binary is what the app does; downloaded code may not add to or change that |
| Apple, licence | Developer Program License Agreement 3.3.1(B) | “Interpreted code may be downloaded to an Application but only so long as” three conditions hold | It keeps the app’s intended and advertised purpose; it does not bypass the operating system’s signing, sandbox or other security features; in an App Store app, it creates no store or storefront for other apps |
| Google Play | Device and Network Abuse policy | An app “may not download executable code (such as dex, JAR, .so files) from a source other than Google Play” | The restriction “does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs” |
Apple’s two documents draw different lines, and both apply. The licence agreement’s clause 3.3.1(B) permits downloaded interpreted code only so long as it “does not change the primary purpose of the Application by providing features or functionality that are inconsistent with the intended and advertised purpose of the Application”, “does not bypass signing, sandbox, or other security features of the OS”, and, “for Applications distributed on the App Store, does not create a store or storefront for other Applications”. Guideline 2.5.2 is stricter: the licence’s first condition protects the app’s purpose, while the guideline rules out downloaded code that introduces or changes the app’s features or functionality. So meeting the licence’s conditions does not establish compliance with review. The reading over-the-air services operate on is to keep what ships this way to fixes and adjustments within the functionality Apple already reviewed, accepting that the wording leaves Apple room to disagree.
Google’s rule names an exception rather than a condition: the restriction “does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs (such as JavaScript in a webview or browser)”. The policy does not name React Native or Hermes (the JavaScript engine React Native runs by default), so whether Hermes fits is an inference. It is a sound one: JavaScript under Hermes reaches Android APIs only through the natively compiled modules the binary already contains, which is indirect access in the clause’s own terms. The chunks on this CDN are plain JavaScript rather than Hermes bytecode, because no remote in the series compiles to bytecode; the policy’s line is about how code runs, not the form it ships in. The same page adds that interpreted code “loaded at run time (for example, not packaged with the app) must not allow potential violations of Google Play policies”, so what a remote does is held to the same rules as the app it runs in.
For React Native in particular, the closest guidance comes from the vendors who ship over-the-air updates for it. Microsoft’s CodePush served that market for years and was retired on 31 March 2025. EAS Update, the over-the-air service in Expo Application Services, still runs, and its documentation holds updates to the stores’ rules: “you need to follow the rules of the platforms and app stores you are building for”, updates “need to follow the App Store and Play Store guidelines, including the content of the updates and how you use them”, and “This usually means changes to your app’s behavior need to be reviewed”. Its table of when to use an update marks “Change to native code or native dependencies” and “Anything that requires a new app binary version” as cases for a new binary instead.
The practical rule: ship fixes and improvements to features the store already reviewed, never a new primary purpose, and keep the reviewed binary able to work on its own. The flip in this post is the kind of change the first half describes: it changes how an existing feature, the party counter, shows a state, and adds nothing new. Whether a particular change stays inside what was reviewed is the store’s judgement, and no build can establish it. The second half needs the copy in the binary that post 16 adds, because today a binary that cannot reach the CDN has nothing to show.
Run it
Keys, tree, server:
node tools/gen-signing-keys.mjs && node tools/build-cdn.mjs ios
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
The Release build, told where the CDN is and which binary it is:
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release )
A development build works the same way, with the two variables on the dev server’s command instead:
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm start )
( cd apps/host && npm run ios )
Android, with the emulator’s address for the machine:
node tools/build-cdn.mjs android && ( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 MF_APP_VERSION=2.0.0 npm run android -- --mode release )
The suites, the smoke test and the generator’s tests:
( for d in apps/host apps/list apps/party packages/ui; do ( cd "$d" && npx jest --silent ) || exit 1; done ) && sh scripts/federation-smoke.sh && node --test tools/gen-signing-keys.test.mjs
The smoke test now builds both remotes at 9.9.9, a version no config defaults to, and follows the manifest’s own chunk list into the version directory, so a version segment missing from output.path fails at build time rather than at a user’s launch.
Run from the finished tree, build-cdn builds all three list versions from the same source and seeds the 2.0.0 map with 1.2.0, so the three versions differ only in the chip. To watch a flip here, edit cdn-root/ios/maps/2.0.0/version-map.json to name listApp 1.1.0 and relaunch, then put 1.2.0 back and relaunch again. The full-party state arriving with the flip belongs to the build-along, the only route that builds 1.0.0 and 1.1.0 before the change.
What you built, and what’s next
An installed binary now asks the CDN for its own map at launch, loads the signed versions that map names, and refuses any remote the map gives no version. Shipping a remote is one directory and one line, and a rollback is the line put back; each binary takes either at its next launch. When a remote fails to load, or its module throws while it initialises, the Release build keeps running with one dead tab: measured on iOS with a refused chunk, a missing version and a module that throws as it initialises, and on Android with a missing version.
Two limits remain. The map is unsigned, so anyone who can write to the bucket can steer every install; post 17 signs it, adds a counter that makes yesterday’s map worthless, and has the app roll a failing version back on its own. The nearer limit is reach: a binary that cannot reach the CDN, or cannot read its map, launches in unresolved mode with nothing to show in either tab.
Next: the day the CDN is unreachable. An offline fallback baked into the binary, and an in-session net for the remote that fails mid-use.
Sources
- Module Federation: runtime hooks —
onLoad, the hook that receives a remote module’s factory before anything calls it when that factory was requested withloadFactory: false - Module Federation: runtime API —
registerRemotesand itsforceoption: an already-registered module is overwritten and its cache deleted, and a warning says the operation is risky - Module Federation source: runtime-core remote/index.ts at 2.9.0 — the branch for an already-registered name, which does nothing without
forceand warns with it, andloadRemote, which passes theonLoadhook a factory nothing has run whenloadFactoryis false, and returns a function the hook hands back in its place - Module Federation source: webpack-bundler-runtime remotes.ts at 2.9.0 — the error handler that appends
while loading "…" from …and replaces the failed module with one that throws, and the request for each remote module’s factory withloadFactory: false - Module Federation source: runtime-core module/index.ts at 2.9.0 —
get, which runs a module’s factory before returning unlessloadFactoryis false - Re.Pack: ScriptManager —
addResolverwith itspriorityandkeyoptions, the default priority of 2, andverifyScriptSignature - Re.Pack: CodeSigningPlugin — the plugin post 14 configured and this post verifies against
- Re.Pack source: ScriptManager.ts at 5.2.5 —
DEFAULT_RESOLVER_PRIORITY = 2, resolvers sorted highest first,resolveScriptasking the next resolver whenever one returns nothing and stopping when one throws, and the in-memory locator cache - Re.Pack source: ResolverPlugin.ts at 5.2.5 — the per-remote resolver registered with a key and no priority
- Re.Pack source: OutputPlugin.ts at 5.2.5 — remote chunks copied to
extraChunks.outputPathfrom where Rspack wrote them, not moved - Re.Pack source: guardedRequire.ts at 5.2.5 — the
requireRe.Pack builds into every bundle, which catches a module that throws, reports the error toErrorUtilsas fatal and returns nothing - Re.Pack source: ScriptManager types at 5.2.5 —
verifyScriptSignatureas'strict' | 'lax' | 'off', and whatcachecompares - Re.Pack source: ScriptManager.mm at 5.2.5 — verification inside the iOS download path, for
strictalways and forlaxonly when a token is present - Re.Pack source: RemoteScriptLoader.kt at 5.2.5 — the same check on Android, and a cached file executed without it
- Re.Pack source: CodeSigningErrors.swift, CodeSigningUtils.swift and CodeSigningUtils.kt at 5.2.5 — the verification failures quoted in this post, the token read from the last 1,280 bytes, and
RepackPublicKeyread fromInfo.plistand fromstrings.xml - React Native source: setUpXHR.js at 0.85.3 —
AbortControllerandAbortSignalinstalled from theabort-controllerpackage, which has notimeout - React Native source: error-guard.js at 0.85.3 —
ErrorUtils.reportFatalError, which calls the global handler with its fatal flag set, andsetGlobalHandlerandgetGlobalHandler - React Native source: ExceptionsManager.js at 0.85.3 — LogBox in development; otherwise the error handed to the native error handler, which the new architecture installs
- React Native source: RCTInstance.mm and RCTExceptionsManager.mm at 0.85.3 — an error no host delegate claims forwarded to the exceptions module, whose fatal path calls
RCTFatal - React Native source: RCTAssert.m at 0.85.3 —
RCTFatalthrowing an exception that only a debug build catches - React Native source: ExceptionsManagerModule.kt at 0.85.3 — a fatal report thrown as a
JavascriptException - React Native source: ReactInstance.kt and DefaultReactHost.kt at 0.85.3 — that throw routed to the host’s exception handler, which rethrows by default
- App Store Review Guidelines, 2.5.2 — the self-contained-apps constraint, quoted from the current text
- Apple Developer Program License Agreement, 3.3.1(B) — the interpreted-code permission and its three conditions, quoted from the public copy
- Google Play: Device and Network Abuse policy — the executable-code restriction, its interpreter exception and its rule for interpreted code loaded at run time, quoted from the current text
- Expo: EAS Update introduction — the answer on store guidelines and the table of when to use an update, quoted from the current page
- microsoft/react-native-code-push — the archived repository, with its notice that CodePush was retired on 31 March 2025
- http-server — the
-coption, where-c-1disables caching - react-native-module-federation — the companion repo, the build at the tag
post-15-cdn-flip