El post 15 cerró la sección «Ahora rómpelo, tres veces» con su propio límite: «Nada de esto le da al binario otro sitio desde el que cargar: la pestaña muerta muestra el fallo sin ocultarlo, pero no es una app que funcione». Este post le da al binario ese otro sitio. Cada build de release lleva ahora una copia de cada remote, y la app funciona desde esas copias cuando no se puede llegar a la red de distribución de contenidos (CDN), o cuando un remote no llega a cargarse mientras el otro se sigue cargando desde la CDN.
El post 1 dijo quién construye eso: «Module Federation no resuelve por sí solo nada del offline: incrustar una copia de cada remote en el binario, para que la app revisada funcione sola y sin red, es una arquitectura que te toca montar a ti». También fijó el requisito para un remote que no se carga: «la app tiene que degradarse a algo seguro en vez de mostrar una pantalla en blanco». La build del post 15 cumple el requisito para un remote que no se carga, y se queda ahí. Con la CDN fuera de alcance arranca en unresolved, con un estado de error en cada pestaña. Cuando un remote falla, su pestaña muestra el estado de error, y Try again nunca la recupera.
La primera mitad construye la copia: un paso de build que la prepara, una fase en cada plataforma que la mete en el binario, y un modo de arranque nuevo, bundled, que ejecuta todos los remotes desde ella. La segunda mitad construye dos redes de seguridad para un arranque en modo CDN. Una atrapa un manifest que la CDN no puede servir y pasa ese único remote a su copia; la otra rodea cada pestaña y atrapa una carga que falla más tarde. Con la segunda red de seguridad llega un Try again que de verdad reintenta.
Una regla da forma a la copia: es la versión que ya se sabe que funciona en este binario, congelada el día en que se construyó el binario. Es el mínimo garantizado: las versiones nuevas siguen llegando a las apps instaladas a través de la CDN, y el host usa la copia solo cuando la CDN no puede entregar una versión que se cargue.
Empieza desde el estado final del post 15, el tag post-15-cdn-flip; este post termina en post-16-fallbacks. Como antes, los cambios de configuración y las ediciones pequeñas los escribes tú, y el resto lo copias del tag final: la herramienta de build, el código del host y sus tests, los archivos nativos y un test del design system.
El arranque decide el modo una sola vez, antes de que se cargue nada federado. Usar las versiones de la CDN significa que llegó un mapa utilizable y sus versiones se registraron; cuando no se pueden usar, el arranque registra las copias en su lugar. Termina en unresolved solo cuando eso también falla. Las dos redes de seguridad actúan dentro de un arranque en modo CDN, remote a remote.
Mete una copia de cada remote en el binario
La copia la elige el mismo mapa que escoge las versiones de la CDN. La versión que lleva un binario es la que nombra el mapa de su propia versión de la app, porque esa es la versión que se sabe que funciona en ese binario: para la build 2.0.0 de aquí, list 1.2.0 y party 1.0.0.
La copia contiene los bundles firmados de esa versión y su manifest, byte a byte como los sirve la CDN. Los bytes se quedan como están porque cada bundle se sigue verificando contra la clave de firma cuando se carga, y un cambio en su código no pasa la comprobación.
tools/build-cdn.mjs ya construye todas las versiones en cdn-root/, y ahora prepara también las copias. Descarga el tag final una vez, porque todos los comandos de copia de este post toman sus archivos de él, y empieza por la herramienta:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-16-fallbacks /tmp/pokedex-ref-16
cp /tmp/pokedex-ref-16/tools/build-cdn.mjs tools/
La nueva etapa se ejecuta después de construir el árbol de la CDN. Para cada plataforma toma todas las versiones que nombra el mapa y copia sus bundles y su manifest en embed-root/<platform>/<remote>/<version>/:
for (const [remote, version] of Object.entries(embeddedVersions)) {
const published = join(cdnDir, remote, version);
const embedded = join(embedDir, remote, version);
mkdirSync(embedded, { recursive: true });
// Todos los scripts de la versión están en la raíz de su directorio junto a su manifest, y la
// copia son todos ellos, byte a byte: el container, sus chunks, index.bundle, que el manifest
// nombra entre los assets de los módulos compartidos, y mf-manifest.json, que el runtime de la
// federación lee desde el disco igual que lo lee desde la CDN. La carpeta assets/ de al lado
// contiene imágenes que ya llevan las copias de esas librerías que el host comparte.
for (const file of readdirSync(published)) {
if (file.endsWith('.bundle') || file === 'mf-manifest.json') {
copyFileSync(join(published, file), join(embedded, file));
}
}
bundledVersions[platform][remote] = version;
Las copias conservan los directorios <remote>/<version>/ en lugar de compartir una sola carpeta, porque dos remotes pueden llevar chunks de vendor con el mismo nombre de archivo. En una sola carpeta, el segundo chunk sobrescribiría al primero.
La herramienta de build también anota las versiones copiadas en apps/host/src/shell/embedded-versions.ts, un archivo generado que el host incorpora al compilar, para que el host sepa qué copias lleva antes de leer nada del disco.
embed-root/ es salida de build, como cdn-root/, así que añádelo a .gitignore:
# The copies build-cdn stages for the native embed phases.
embed-root/
Ejecuta primero el generador de claves y después la herramienta para las dos plataformas. El generador conserva las claves que ya tenga el checkout, y las crea en un checkout nuevo del tag, que no tiene ninguna porque nunca entran en Git. Después del árbol de la CDN, la herramienta informa de las copias y del archivo generado:
node tools/gen-signing-keys.mjs && node tools/build-cdn.mjs
embedded -> embed-root/ios/listApp/1.2.0
embedded -> embed-root/ios/partyApp/1.0.0
embedded -> embed-root/android/listApp/1.2.0
embedded -> embed-root/android/partyApp/1.0.0
wrote -> apps/host/src/shell/embedded-versions.ts
El archivo generado anota la versión de cada copia, por plataforma:
export const BUNDLED_VERSIONS: Record<string, Record<string, string>> = {
ios: { listApp: '1.2.0', partyApp: '1.0.0' },
android: { listApp: '1.2.0', partyApp: '1.0.0' },
};
La herramienta de build prepara la copia para la versión de la app que indica MF_APP_VERSION, o para la versión de la app más nueva que tenga mapa cuando esa variable no está definida, así que, para una build de otra versión de la app, la herramienta se ejecuta antes con la misma variable.
iOS: la última fase de build
En iOS la copia va dentro del .app, junto a main.jsbundle. Un script hace la copia, y una fase Run Script al final del target Host lo ejecuta. Copia el script del tag final:
mkdir -p apps/host/scripts && cp /tmp/pokedex-ref-16/apps/host/scripts/embed-remotes-ios.sh apps/host/scripts/
#!/usr/bin/env bash
# --- La copia en el binario, la mitad de iOS. La última fase Run Script del target Host copia la
# versión incrustada de cada remote desde embed-root/ios a la app, en cdn/ios/<remote>/<version>/
# junto a main.jsbundle, donde el host la lee mediante URLs file:// absolutas: los bundles firmados
# y el mf-manifest.json de la versión.
#
# Los directorios <remote>/<version>/ se conservan en lugar de aplanarse: dos remotes pueden llevar
# chunks de vendor con el mismo nombre de archivo, y una copia plana dejaría que los chunks de un
# remote sobrescribieran los del otro.
#
# Se ejecuta en todas las configuraciones. Una build Debug carga el bundle del propio host por http,
# así que no puede leer la copia, y ahí la copia solo cuesta el tiempo que tarda. Sus remotes vienen
# de los servidores de desarrollo cuando no hay ninguna CDN configurada, o de la CDN cuando la hay.
#
# Si falta embed-root, elimina cualquier copia que dejara una build anterior, imprime un aviso y
# sale con 0, y la build termina bien sin nada incrustado. Esa es la trampa: ejecuta
# `node tools/build-cdn.mjs` antes de una build de release. ---
set -euo pipefail
REPO_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
SOURCE="$REPO_DIR/embed-root/ios"
: "${CONFIGURATION_BUILD_DIR:?run this from an Xcode build phase}"
: "${UNLOCALIZED_RESOURCES_FOLDER_PATH:?run this from an Xcode build phase}"
DEST="$CONFIGURATION_BUILD_DIR/$UNLOCALIZED_RESOURCES_FOLDER_PATH/cdn/ios"
if [ ! -d "$SOURCE" ]; then
rm -rf "$DEST"
rmdir "$(dirname "$DEST")" 2>/dev/null || true
echo "warning: $SOURCE does not exist, so no remotes are embedded. Run 'node tools/build-cdn.mjs' before a release build."
exit 0
fi
mkdir -p "$DEST"
# --delete deja la app exactamente con lo que contiene embed-root, así que una versión que incrustó
# una build anterior no se queda. Los bytes se copian tal cual: la firma de cada bundle los cubre.
rsync -a --delete --include='*/' --include='*.bundle' --include='mf-manifest.json' --exclude='*' "$SOURCE/" "$DEST/"
echo "embedded $(find "$DEST" -name '*.bundle' | wc -l | tr -d ' ') remote bundles and $(find "$DEST" -name 'mf-manifest.json' | wc -l | tr -d ' ') manifests into $DEST"
La fase es un solo cambio en el archivo del proyecto. En Xcode es una fase Run Script nueva en el target Host, llamada «Embed federated remotes (offline fallback)», que ejecuta "$SRCROOT/../scripts/embed-remotes-ios.sh", colocada después de todas las demás fases, con «Based on dependency analysis» desmarcado. Va la última para que el .app en el que escribe ya esté montado. Con la casilla desmarcada se ejecuta en todas las builds, cosa que una fase que no declara salidas hace de todos modos: Xcode solo se salta una fase de script cuando tiene salidas que comprobar. La alternativa es declarar como salidas los archivos copiados bajo cdn/ios del .app, con los archivos de embed-root/ como entradas. El archivo de proyecto del tag final lleva la fase con la casilla desmarcada, así que copiarlo hace el mismo cambio:
cp /tmp/pokedex-ref-16/apps/host/ios/Host.xcodeproj/project.pbxproj apps/host/ios/Host.xcodeproj/
Android: una tarea de Gradle y un módulo nativo
En Android la copia va a los assets del APK, el paquete de Android desde el que se instala una app. Al final de apps/host/android/app/build.gradle, después de los cambios del post 15, añade:
// --- La copia en el binario, la mitad de Android. build-cdn prepara la versión incrustada de cada
// remote bajo embed-root/android, los bundles firmados y el mf-manifest.json de la versión, y esta
// tarea replica ese árbol en los assets del APK, en cdn/android, de donde EmbeddedRemotesModule lo
// extrae en el arranque. Sync en lugar de Copy, para que un archivo que incrustó una build anterior
// y esta ya no incrusta se elimine en lugar de empaquetarse.
//
// Si falta embed-root, esta tarea no incrusta nada, warnIfNothingToEmbed imprime un aviso y la
// build termina bien igualmente. Esa es la trampa: una build de release que termina bien y no lleva
// ninguna copia. Ejecuta build-cdn antes de una build de release. ---
def embedSource = file("$rootDir/../../../embed-root/android")
def embeddedAssetsDir = layout.buildDirectory.dir("generated/embedded-remotes").get().asFile
def embedRemotes = tasks.register('embedRemotes', Sync) {
from(embedSource) { include '**/*.bundle', '**/mf-manifest.json' }
into(file("$embeddedAssetsDir/cdn/android"))
}
// --- Un Sync sin origen se salta sus acciones, así que un aviso dentro de él no se imprimiría
// nunca. Gradle sigue limpiando lo que la tarea incrustó antes cuando desaparece su origen, así que
// no se empaqueta ninguna copia obsoleta. Esta tarea no tiene entradas ni salidas, así que se
// ejecuta en todas las builds, e imprime el aviso cuando falta embed-root/android. ---
def warnIfNothingToEmbed = tasks.register('warnIfNothingToEmbed') {
doLast {
if (!embedSource.exists()) {
logger.warn("warning: ${embedSource} does not exist, so no remotes are embedded. Run 'node tools/build-cdn.mjs' before a release build.")
}
}
}
android {
sourceSets {
main {
assets.srcDirs += embeddedAssetsDir
}
}
}
tasks.named('preBuild').configure { dependsOn embedRemotes, warnIfNothingToEmbed }
Un asset no es un archivo en disco, y Re.Pack solo llega al directorio <remote>/<version>/ de una copia a través de una ruta de archivo real. Así que un TurboModule pequeño extrae assets/cdn al directorio de archivos de la app y resuelve con el directorio que ha usado. Su spec es tan corta que se puede leer entera:
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
// --- La mitad de Android de la copia en el binario. Una tarea de Gradle empaqueta la versión
// incrustada de cada remote en los assets del APK, y los assets no son archivos en disco: el loader
// file:// de Re.Pack necesita una ruta real. prepare() los extrae al directorio de archivos de la
// app una vez por build instalada, conservando la estructura <remote>/<version>/, y resuelve con el
// directorio sobre el que el host construye las URLs file://.
//
// No hay implementación para iOS, porque iOS lee las copias directamente del .app. Por eso esto usa
// TurboModuleRegistry.get, que devuelve null donde el módulo no existe, en lugar de getEnforcing,
// que lanzaría una excepción en iOS en cuanto se cargara este archivo. ---
export interface Spec extends TurboModule {
/** Extrae los remotes incrustados, una vez por build instalada, y devuelve el directorio. */
prepare(appVersion: string): Promise<string>;
}
export default TurboModuleRegistry.get<Spec>('EmbeddedRemotesModule');
El lado Kotlin copia el árbol una vez por instalación. Un archivo marcador guarda la versión de la app y la hora en que se instaló o actualizó el APK, así que volver a abrir la app reutiliza el árbol extraído, y cualquier instalación nueva lo sustituye, incluido un APK reconstruido que conservó su número de versión. El módulo copia los bytes tal cual, porque la firma de cada bundle los cubre. Toma del tag final el módulo, su spec y el HostNativePackage actualizado, que lo registra junto al módulo de navegación del post 13:
cp /tmp/pokedex-ref-16/apps/host/specs/NativeEmbeddedRemotesModule.ts apps/host/specs/
cp /tmp/pokedex-ref-16/apps/host/android/app/src/main/java/com/host/{EmbeddedRemotesModule,HostNativePackage}.kt apps/host/android/app/src/main/java/com/host/
| iOS | Android | |
|---|---|---|
| Dónde va la copia | el .app, en cdn/ios/<remote>/<version>/ | los assets del APK, en cdn/android/<remote>/<version>/, extraídos al directorio de archivos en el arranque |
| Qué la pone ahí | la última fase Run Script, embed-remotes-ios.sh | la tarea Sync embedRemotes, antes de preBuild |
| Cómo la encuentra el host | el directorio de main.jsbundle, leído del módulo SourceCode de React Native | el directorio con el que resuelve EmbeddedRemotesModule.prepare() |
Sin embed-root/ | un aviso, una build que termina bien, ninguna copia | un aviso, una build que termina bien, ninguna copia |
Carga una copia desde el disco
El host cambia en cinco archivos y sus tests. Tómalos del tag final. mf-modules.d.ts desaparece, porque ya nada carga un remote con import() (la sección del Try again dice por qué), y el design system incorpora una comprobación de contraste para el nuevo color del banner:
cp /tmp/pokedex-ref-16/apps/host/src/shell/{remoteLocator,scriptManager,federationErrors}.ts /tmp/pokedex-ref-16/apps/host/src/shell/FederationBanner.tsx apps/host/src/shell/
cp /tmp/pokedex-ref-16/apps/host/App.tsx /tmp/pokedex-ref-16/apps/host/jest.config.js apps/host/
cp /tmp/pokedex-ref-16/apps/host/__mocks__/{module-federation-runtime,partyApp-partySlice}.js apps/host/__mocks__/
cp /tmp/pokedex-ref-16/apps/host/__tests__/{remoteLocator,scriptManager}.test.ts /tmp/pokedex-ref-16/apps/host/__tests__/{App,RemoteBoundary,FederationBanner}.test.tsx apps/host/__tests__/
cp /tmp/pokedex-ref-16/packages/ui/src/tokens/__tests__/contrast.accessibility.ts packages/ui/src/tokens/__tests__/
rm apps/host/mf-modules.d.ts
Lo primero que tiene que saber el host es dónde están las copias en el dispositivo. En iOS la respuesta sale del propio módulo SourceCode de React Native: una build de release carga file:///…/Host.app/main.jsbundle, y el directorio que contiene ese archivo es el .app. Una build de desarrollo carga su bundle desde el servidor de desarrollo por http, así que no hay directorio que derivar ni copia que usar. En iOS, todo lo que funciona desde una copia necesita una build de release. En Android el directorio es aquel con el que haya resuelto prepare():
const sourceCode = NativeModules.SourceCode as
| { scriptURL?: string; getConstants?: () => { scriptURL?: string } }
| undefined;
const SCRIPT_URL = sourceCode?.scriptURL ?? sourceCode?.getConstants?.().scriptURL;
const APP_PATH = SCRIPT_URL?.startsWith('file://')
? SCRIPT_URL.replace(/^file:\/\//, '').replace(/\/[^/]+$/, '')
: undefined;
let embeddedRoot: string | undefined = Platform.OS === 'ios' ? APP_PATH : undefined;
Todos los scripts de un remote siguen pasando por el resolver del post 15. El resolver incorpora una rama nueva: un remote que funciona desde su copia, en un arranque que no pudo usar las versiones de la CDN, o después de que ese remote fallara al cargarse desde la CDN, resuelve a un archivo dentro de su copia:
return {
kind: 'locate',
locator: {
url: `file://${input.embeddedRoot}/cdn/${input.platform}/${remoteName}/${version}/${filename}`,
cache: true,
absolute: true,
verifyScriptSignature: input.verify,
},
};
absolute: true es el detalle fácil de pasar por alto. Sin él, Re.Pack se queda solo con el nombre de archivo del script y lo busca en el nivel superior de la app: iOS lo busca entre los recursos del bundle de la app con URLForResource:withExtension:, y Android abre un asset con ese nombre. Ninguno de los dos llega a un directorio <remote>/<version>/. Con él, Re.Pack lee el archivo en la ruta tal como se le da.
La verificación sigue activa. El loader de archivos de Re.Pack ejecuta en las dos plataformas la misma comprobación de firma que al descargar un script, antes de que el código se ejecute, en cada carga. Eso hace que la copia sea más estricta que una descarga cacheada, que el post 15 señaló que se ejecuta desde el disco sin una segunda comprobación. También es por lo que la copia nunca se reescribe de camino a la app: cambia un byte del código de una copia instalada y la pestaña la rechaza, con el mismo fallo de la comprobación de hash que una descarga manipulada. En iOS el loader de archivos informa de la descripción completa del error, así que el motivo aparece después del prefijo genérico del sistema:
[Error: The operation couldn’t be completed. The bundle verification failed because the bundle hash is invalid.]
En Android el mensaje se lee igual que con una descarga manipulada, sin prefijo.
El manifest no necesita ningún trato especial. La copia lleva mf-manifest.json junto a sus bundles, y el runtime de la federación lo lee desde una URL file:// igual que lee uno desde la CDN, porque el fetch de React Native abre archivos locales: a través de su handler de peticiones de archivo en iOS, y en Android a través del handler del módulo de blobs, que acepta cualquier URL que no sea http ni https cuando el tipo de respuesta es blob, el tipo que pide el fetch de React Native.
Modo bundled: el día que la CDN no responde
El arranque prepara ahora las copias a la vez que la sonda, porque ninguna de las dos tareas necesita a la otra, y un arranque sin un mapa utilizable funciona desde las copias en lugar de rendirse:
const [versions] = await Promise.all([
CDN_CONFIGURED ? fetchVersionMap() : Promise.resolve(null),
prepareEmbeddedCopies(),
]);
if (!versions) {
return runFromCopies(CDN_CONFIGURED ? 'no usable version map' : 'no CDN configured');
}
// --- El arranque que no pudo usar la CDN: sin mapa utilizable, sin CDN configurada, o con un
// registro que falló. Con copias en el binario funciona desde ellas, con cada remote registrado
// apuntando al manifest de su copia, que el runtime lee desde el disco. Sin ninguna, o cuando
// registrarlas también falla, no hay nada que ejecutar. ---
function runFromCopies(reason: string): FederationStatus {
const embedded = REMOTE_NAMES.filter(hasEmbeddedCopy);
if (embedded.length === 0) {
setStatus({ mode: 'unresolved', source: reason, versions: {}, embedded: [] });
return status;
}
const versions = Object.fromEntries(embedded.map(name => [name, bundledVersions[name]]));
setStatus({ mode: 'bundled', source: 'the copy in the binary', versions, embedded });
try {
registerRemotes(
embedded.map(name => ({ name, entry: embeddedManifestUrlFor(name) })),
{ force: true },
);
} catch (error) {
console.warn('[federation] the copies could not be registered', error);
setStatus({ mode: 'unresolved', source: 'remotes could not be registered', versions: {}, embedded: [] });
}
return status;
}
Registrar cada remote apuntando al manifest de su copia, con el force del post 15, manda al runtime al disco para todo lo que tenga que ver con ese remote. A partir de ahí, lo demás no tiene nada de especial: el runtime obtiene el manifest, el resolver manda cada script a la copia, y cada uno se verifica al cargarse. unresolved queda para un arranque cuyo binario no lleva anotada ninguna copia con un directorio desde el que cargar, o en el que registrar las copias falla.
Pruébalo quitando la CDN. Arranca el servidor como hizo el post 15, y después haz la build Release:
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 )
El banner dice cdn · listApp 1.2.0 · partyApp 1.0.0. Detén el servidor y comprueba que el mapa ya no está:
curl -s -o /dev/null http://localhost:8000/ios/maps/2.0.0/version-map.json; echo $?
7 significa que curl no ha podido conectar. Cierra la app y vuelve a abrirla. La sonda falla dentro de su segundo y medio, el banner se vuelve morado y dice bundled · listApp 1.2.0 · partyApp 1.0.0, y las dos pestañas se abren desde las copias, sin pedir nada a la red salvo los Pokémon a PokéAPI y sus ilustraciones a GitHub.
La copia está congelada en tiempo de build, y esa es la contrapartida. Un binario construido hoy y abierto sin la CDN el año que viene ejecuta las versiones de hoy, haya publicado lo que haya publicado la CDN desde entonces. Esa es la parte del requisito del post 1 que una copia puede cumplir: el código revisado funciona sin la CDN. Los Pokémon siguen viniendo de PokéAPI, así que sin ninguna red la petición de la list falla y la pantalla muestra su propio error, «Couldn’t reach PokéAPI».
Del fallo tampoco se recuerda nada. El siguiente arranque vuelve a preguntar a la CDN, y si llega un mapa utilizable y sus versiones se registran, la app vuelve a las versiones que nombra el mapa.
Un remote que falla en un arranque en modo CDN
El modo bundled cubre un arranque que no pudo usar las versiones de la CDN. El otro fallo es más acotado: el arranque usa las versiones de la CDN, y después falla un remote. La causa puede ser una versión retirada de la CDN mientras un mapa todavía la nombra, un chunk cortado a medias, o una release que lanza un error mientras su módulo se inicializa, que fue el tercer caso del post 15. En la build del post 15 cada uno de esos casos deja una pestaña muerta. Dos redes de seguridad los convierten en una pestaña que funciona desde su copia, mientras el otro remote sigue en la CDN.
La red de seguridad del manifest
Module Federation descarga el mf-manifest.json de un remote antes de que se cargue nada de su código. El boundary de una pestaña se enteraría de que esa descarga falla, pero solo cuando el propio fetch del runtime se rinde, y el runtime no le pone ningún límite de tiempo. Y no todas las cargas se ejecutan dentro de un boundary: los módulos de estado y de estilos de la party piden su manifest en el arranque, desde un efecto fuera de cualquier pestaña. Así que la primera red de seguridad se coloca donde el runtime pide todos los manifests: el hook fetch de un plugin de runtime, que el runtime llama antes de su propio fetch, y que puede responder con una Response o dejar la petición en manos del runtime al no devolver nada:
const embeddedFallback: ModuleFederationRuntimePlugin = {
name: 'embedded-fallback',
fetch(url: string) {
if (status.mode !== 'cdn') {
return undefined;
}
const remote = manifestRemote(url, REMOTE_NAMES);
if (!remote || !hasEmbeddedCopy(remote)) {
return undefined;
}
if (fallbackRemotes.has(remote)) {
return fetch(embeddedManifestUrlFor(remote));
}
return cdnManifestOrEmbedded(url, remote);
},
};
registerPlugins([embeddedFallback]);
El manifest de un remote en la CDN se descarga de la CDN, dentro del límite de un segundo y medio de la sonda. Cualquier petición fallida (un código de error como un 404, un timeout o una conexión cortada) pasa ese único remote a su copia y responde con el manifest de la copia:
async function cdnManifestOrEmbedded(url: string, remote: string): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const response = await fetch(url, { signal: controller.signal });
if (response.ok) {
return response;
}
console.warn(`[federation] ${remote} manifest returned ${response.status}`);
} catch (error) {
console.warn(`[federation] ${remote} manifest could not be fetched`, error);
} finally {
clearTimeout(timer);
}
fallBack(remote);
return fetch(embeddedManifestUrlFor(remote));
}
Un manifest que llega con un código de éxito vuelve al runtime tal cual, aunque no se pueda leer. El runtime falla con él después, y en la carga de una pestaña la red de seguridad del boundary atrapa ese fallo.
fallBack añade el remote a un conjunto y muestra ese remote en el banner, como listApp 1.2.0 embedded. El resolver lee ese mismo conjunto para cada script, así que el siguiente script de ese remote ya resuelve a su copia, mientras todos los demás remotes conservan su URL de la CDN. El conjunto vive en memoria a propósito: un remote pasa a su copia solo hasta el siguiente arranque, que vuelve a preguntar a la CDN, así que un fallo que fue solo de conexión no mantiene el remote en su copia. Recordar los fallos entre arranques, y revertir una versión mala de forma permanente, es cosa del post 17.
Otras herramientas ya ofrecen partes de esto, y cada una funcionaría en su propia capa. El hook errorLoadRemote de Module Federation puede devolver un fallback cuando falla una carga, el manifest incluido. Su plugin oficial de reintentos reintenta una carga fallida y puede rotar entre dominios de respaldo, y el locator de scripts de Re.Pack acepta retry y retryDelay. El hook fetch era el que encajaba aquí por dos motivos. errorLoadRemote se entera de un fallo solo cuando el fetch del runtime se rinde, como el boundary, mientras que el hook fetch hace la petición él mismo y puede ponerle el límite de tiempo de la sonda. Y reintentar es otra política: gasta tiempo en volver a preguntar a la misma CDN, mientras que esta red de seguridad responde al momento desde una copia que el binario ya tiene y deja el siguiente intento para el siguiente arranque. Los reintentos podrían ir delante de la red de seguridad, a cambio de una espera más larga antes de la copia.
Prueba la red de seguridad con una versión que la CDN ya no tiene. Arranca el servidor otra vez, saca de él la versión de la list y arranca la app en frío:
mv cdn-root/ios/listApp/1.2.0 /tmp/listApp-1.2.0
El manifest de la list vuelve con un 404, la list funciona desde su copia, y la party se sigue cargando desde la CDN, como muestra el log del servidor:
[2026-09-29T14:40:27.102Z] "GET /ios/listApp/1.2.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-29T14:40:27.103Z] "GET /ios/listApp/1.2.0/mf-manifest.json" Error (404): "Not found"
[2026-09-29T14:40:27.120Z] "GET /ios/partyApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-29T14:40:27.159Z] "GET /ios/partyApp/1.0.0/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
Devuelve el directorio a su sitio antes de seguir:
mv /tmp/listApp-1.2.0 cdn-root/ios/listApp/1.2.0
Si todos los remotes pasan a su copia, comprueba que la app llega al servidor. Un banner que marca los dos remotes como embedded, o que dice
bundled, con el servidor en marcha suele significar que la app no ha podido llegar a él: el App Transport Security de iOS y las reglas de cleartext de Android rechazan el http en claro si la build no lo permite. Este host permite la red local en iOS y, en Android, cleartext solo hacialocalhost,127.0.0.1y10.0.2.2, con sus subdominios.
La red de seguridad del boundary
Algunos fallos vienen después de un manifest que llegó bien: un container o un chunk que no llega o no supera la verificación, o un módulo que lanza un error mientras se evalúa. Hacen fallar la carga de la pestaña, y la carga de la pestaña se ejecuta dentro del RemoteBoundary que el post 11 puso alrededor de cada pestaña. El boundary es la segunda red de seguridad, y sigue siendo un componente de clase, porque React todavía no tiene forma de escribir un error boundary como componente de función. Ahora hace una pregunta antes de mostrar el estado de error: ¿falló la carga, de modo que el remote nunca produjo un componente, y hay una copia a la que este remote todavía no ha pasado?
componentDidCatch(error: unknown) {
console.warn(`${this.props.remote} failed`, error);
if (this.dropsToCopy()) {
fallBackAndReload(this.props.remote);
this.nextAttempt();
}
}
// Un fallo al que la copia en el binario puede responder: la carga falló, así que el remote nunca
// produjo un componente, y este remote tiene una copia a la que todavía no ha pasado.
dropsToCopy() {
return !this.loaded() && canFallBack(this.props.remote);
}
Cuando puede, el boundary pasa el remote a su copia y vuelve a cargar la pestaña, mostrando mientras tanto el estado de carga, así que el estado de error no llega a aparecer. canFallBack lo permite una vez por remote, solo en un arranque en modo CDN, y solo para un remote que tiene copia. Cuando no puede, porque la copia también falló, o no hay ninguna, la pestaña muestra el estado de error como antes.
Un remote cuya carga devolvió un componente conserva su estado de error cuando ese componente lanza un error. Es una decisión, y se apoya en si el remote produjo un componente. Antes de que lo haga, la pestaña sigue mostrando su estado de carga, así que un cambio a la copia es invisible. Una vez que la carga ha devuelto un componente, el boundary se queda con esa versión, incluso cuando el componente lanza un error en su primer renderizado. El boundary anota el intento cuya carga llegó con un componente, no si la pantalla llegó a mostrarse, así que un fallo en ese intento cuenta como lanzado por el propio código del remote, que ya se ha ejecutado en esta sesión. Hasta que el mapa nombre otra versión, la copia es de todos modos esa misma versión. Así que el boundary muestra «This tab stopped working» y ofrece Try again, que vuelve a renderizar el remote y recupera la pestaña cuando el error no se repite. Un error que se repite deja la pestaña en su estado de error durante el resto de la sesión, y tras volver a abrir la app mientras el mapa nombre esa versión, aunque haya una copia que funcione en el binario.
La red de seguridad del boundary no cubre los dos módulos de arranque de la party: se cargan desde un efecto fuera de cualquier pestaña, así que el host solo registra en el log un fallo al que la red de seguridad del manifest no responde. Cuando falla el módulo de estado, añadir a la party queda desactivado hasta que se carga la pestaña Party, porque esa pestaña inyecta el estado ella misma; un módulo de estilos fallido no afecta a la acción de añadir Pokémon.
El tercer caso del post 15 es ahora una carga fallida a la que la copia puede responder. Su release lanza un error mientras su módulo se inicializa, y la ventana de evaluación del post 15 convierte ese error en una carga fallida en lugar de en una notificación fatal. Vuelve a construir esa release. Añade la misma línea debajo de los imports de apps/list/src/PokedexScreen.tsx:
throw new Error('PokedexScreen failed to initialise');
Después publica esa release como 1.4.0, como hizo el post 15:
( 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/
Borra la línea, apunta el mapa 2.0.0 a 1.4.0 y vuelve a abrir la app. La pestaña pasa a la copia 1.2.0: el chip dice 1.2.0, el banner marca la list como embedded, y el log del simulador no tiene ningún RCTFatal. Devuelve el mapa a 1.2.0.
La red de seguridad del boundary también atrapa una CDN que desaparece a mitad de sesión. Arranca con el servidor en marcha y deja la pestaña Party sin abrir. Añade un Pokémon desde la Pokédex, detén el servidor y abre Party por primera vez. En el arranque solo se cargaron los módulos de estado y de estilos de la party, así que sus pantallas aún no están en el dispositivo: la descarga falla, y la pestaña muestra un spinner un momento y después la party desde su copia, con el Pokémon que añadiste, y el banner dice partyApp 1.0.0 embedded. Quitar el Pokémon ahí también funciona, porque las pantallas de la copia y el módulo de estado que se cargó desde la CDN comparten el único store del host.
Un Try again que reintenta
En la build del post 15, Try again nunca recupera una pestaña cuya carga ha fallado. Tres registros de la carga fallida se interponen entre Try again y un intento nuevo.
El primero es el de React. Un componente lazy llama a su función de carga una vez: la documentación de React dice «Both the returned Promise and the Promise’s resolved value will be cached, so React will not call load more than once», y un rechazo va al error boundary más cercano. El boundary ya se ocupaba de eso, montando un componente lazy nuevo bajo una key nueva en cada intento.
El segundo es el del bundle del host, y es el que mantuvo muertas las pestañas del post 15. Un import('listApp/ListStack') se compila en un módulo del propio bundle del host, y el bundle conserva cada módulo que ha evaluado durante el resto de la sesión. Tras una carga fallida conserva lo que dejó el fallo, un módulo sin nada dentro, y cada import() posterior se resuelve con eso, aunque el reintento descargue el remote bien. Medido en una build Release de iOS: el reintento descargó el manifest y los dos chunks, y el import siguió resolviéndose sin componente. Así que las pestañas, y los dos módulos que la party carga en el arranque, ya no usan import(). Piden el módulo directamente al runtime de la federación, en cada carga:
export async function loadRemoteModule<T>(id: string): Promise<T | undefined> {
const factory = await loadRemote<() => T>(id, { loadFactory: false, from: 'runtime' });
return factory ? factory() : undefined;
}
Esta función pide la factory del módulo en lugar de sus exports, con loadFactory: false, que es como la ventana de evaluación del post 15 llega a envolver la factory: el runtime devuelve el envoltorio de la ventana, y llamar a ese envoltorio aquí evalúa el módulo dentro de la ventana.
El tercero es el del runtime de la federación. En @module-federation/runtime-core 2.9.0, la versión que instala esta serie, su registro de un remote sobrevive a una carga fallida: conserva la entrada del remote, el manifest que leyó, el container que cargó, y una carga de container que falló, que entrega a cada petición posterior. El registro de chunks del remote, un array global con el nombre del uniqueName del remote, también conserva todos los chunks que descargó su último container, y un container nuevo los instala todos antes de descargar nada. Try again vacía el registro del runtime y el registro de chunks antes de volver a cargar:
function reloadRemote(remote: RemoteName, entry: string): void {
try {
registerRemotes([{ name: remote, entry }], { force: true });
} catch (error) {
console.warn(`[federation] ${remote} could not be registered again`, error);
}
delete (globalThis as Record<string, unknown>)[CHUNK_REGISTRY[remote]];
}
Volver a registrar el remote con force elimina el registro del runtime, el global del container incluido, y apunta la siguiente carga a entry. Try again lee entry del runtime en lugar de construirla, lo que funciona en todos los modos, desarrollo incluido, donde solo la build conoce la URL del servidor de desarrollo. Un remote que pasó a su copia sigue reintentando su copia, porque la red de seguridad del manifest y el resolver mandan a su copia cualquier remote del conjunto.
La caché de scripts de Re.Pack no necesita vaciarse para una carga fallida: una vez que la carga de un script resuelto termina, Re.Pack descarta su promesa, y una descarga que no pasa la verificación nunca se escribe en su caché, en ninguna de las dos plataformas. La excepción es un rechazo del resolver. Llega antes de esa limpieza, así que Re.Pack conserva el rechazo y lo entrega a cada petición posterior del mismo script. El resolver de este host rechaza solo un remote para el que no tiene versión ni copia.
Queda un hueco abierto: Try again no puede cancelar una descarga ya en curso. Un chunk que el container fallido todavía estaba descargando va a parar al registro que exista cuando llega, y cuando un script todavía se está cargando, Re.Pack responde a una petición nueva de ese mismo script con la promesa que ya está pendiente. Cuando la copia es la versión que servía la CDN, el caso por defecto, son los mismos bytes en cualquier caso; cuando el mapa nombra otra versión, un chunk tardío de la CDN puede quedar junto al container de la copia.
Un error de renderizado muestra cómo Try again recupera una pestaña cuyo código ya se ha cargado. La demo a mitad de sesión de la red de seguridad del boundary detuvo el servidor, así que arráncalo otra vez y déjalo en marcha:
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
Después haz que la pantalla de la list lance un error mientras se renderiza, durante sus primeros ocho segundos. Debajo de los imports de apps/list/src/PokedexScreen.tsx añade:
const LOADED_AT = Date.now();
y como primeras líneas de PokedexScreen():
if (Date.now() - LOADED_AT < 8000) {
throw new Error('PokedexScreen render failed');
}
El error tiene que durar: React reintenta un renderizado que falla, así que si el error deja de producirse demasiado pronto, la pestaña se recupera antes de que el estado de error del boundary llegue a mostrarse. Publica la list como una versión propia, como se publicó la 1.4.0, y después borra las líneas y apunta el mapa 2.0.0 a 1.5.0:
( cd apps/list && MF_REMOTE_VERSION=1.5.0 npm run bundle:ios:prod ) && rsync -a --exclude '*.map' --exclude mf-stats.json apps/list/cdn/ios/listApp/1.5.0/ cdn-root/ios/listApp/1.5.0/
Vuelve a abrir la app y la pestaña muestra «This tab stopped working». Espera a que pasen los ocho segundos y pulsa Try again: la list se renderiza en la 1.5.0, y el log del servidor no muestra ninguna petición nueva, porque el código ya se había cargado y solo necesitaba renderizarse otra vez. Devuelve el mapa a 1.2.0 después.
Los fallos y lo que cuesta cada uno, uno al lado del otro:
| Fallo | Qué lo atrapa | Qué ve el usuario |
|---|---|---|
| No se llega a la CDN en el arranque | el arranque, en modo bundled | todas las pestañas desde su copia, y un banner morado |
| Falla el manifest de un remote: un 404, un timeout, una conexión cortada | la red de seguridad del manifest | la pestaña desde su copia, marcada como embedded en el banner |
| El código del remote de una pestaña no llega, no supera la verificación, o lanza un error mientras su módulo se inicializa | la red de seguridad del boundary | un momento de carga, y después la pestaña desde su copia |
| El módulo de estado de la party falla en el arranque, una vez llegado su manifest | nada: el host lo registra en el log | añadir a la party queda desactivado hasta que se carga la pestaña Party |
| El módulo de estilos de la party falla en el arranque, una vez llegado su manifest | nada: el host lo registra en el log | añadir sigue funcionando |
| La copia también falla, o no hay copia | el boundary | «This tab could not load», con Try again |
| El componente del remote lanza un error al renderizarse | el boundary | «This tab stopped working», con Try again |
Ahora rómpelo
Los dos pasos que incrustan terminan bien sin embed-root/, y esa es la trampa. Sácalo fuera y reconstruye la build Release, esta vez con --verbose:
mv embed-root /tmp/embed-root && ( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release --verbose )
La build termina bien, y el aviso de la fase es la única señal de que falta algo. Sin --verbose, npm run ios muestra un spinner en lugar de la salida de la build (o pasa la salida por xcbeautify o xcpretty, cuando hay uno instalado), después imprime success Successfully built the app y abre la app. Con --verbose, el aviso queda entre las líneas de la propia build:
debug warning: …/embed-root/ios does not exist, so no remotes are embedded. Run 'node tools/build-cdn.mjs' before a release build.
debug ** BUILD SUCCEEDED ** [11.716 sec]
success Successfully built the app
Detén el servidor y arranca la app en frío. El banner sigue diciendo bundled · listApp 1.2.0 · partyApp 1.0.0, porque el host lleva compiladas las versiones que le dijeron que llevaba, y las dos pestañas dicen «The app’s own copy of this remote could not be loaded».
Construye el árbol de la CDN y después la build de release, siempre. Las fases que incrustan copian lo que
embed-root/contenga cuando se ejecutan, y cuando falta dejan que la build termine bien sin nada más que un aviso en la salida de la build. Una build de release hecha antes denode tools/build-cdn.mjs, o después de limpiarembed-root/, se construye bien sin ninguna copia dentro, y nada falla hasta que la app necesita una copia: cuando el arranque no puede usar las versiones de la CDN, o cuando falla un remote.
Devuélvelo a su sitio y reconstruye antes de seguir:
mv /tmp/embed-root embed-root && ( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release )
Ejecútalo
Las copias y el árbol de la CDN, y después el servidor:
node tools/gen-signing-keys.mjs && node tools/build-cdn.mjs
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
La build Release en iOS, y después Android con la dirección con la que el emulador llega a la máquina:
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release )
( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 MF_APP_VERSION=2.0.0 npm run android -- --mode release )
Con cualquiera de las dos en marcha, detén el servidor y arranca en frío para el modo bundled, o saca una versión de cdn-root para la red de seguridad del manifest. En Android las copias extraídas están en el directorio de archivos de la app, bajo files/cdn/android/, con el marcador .installed al lado en files/cdn/.
Las suites, el smoke test y los tests del generador de claves:
( 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
Lo que has construido, y lo que viene
Las demos de este post se ejecutaron en una build Release de iOS, y las copias, su extracción y el modo bundled también en una build de release de Android.
Un remote que pasó a su copia se olvida en el siguiente arranque, así que una versión que sigue fallando vuelve a fallar en cada arranque antes de que su copia tome el relevo. Recordar los fallos va con el otro problema abierto: el mapa en el que confía cada binario sigue siendo un archivo de texto plano que puede cambiar cualquiera que pueda escribir en el bucket.
Lo siguiente: firmar ese mapa, un contador que hace que la app rechace un mapa más antiguo, y una app que revierte por sí sola una versión que falla.
Fuentes
- React: lazy — la función de carga llamada una vez, su promesa y su valor resuelto cacheados, y un rechazo lanzado al error boundary más cercano
- React source: ReactLazy at 19.2.3 — una carga rechazada guardada en el componente lazy y lanzada otra vez en cada renderizado posterior
- React: Component, catching rendering errors with an error boundary — «There is currently no way to write an Error Boundary as a function component»
- Module Federation: runtime hooks — el hook
fetchpara las peticiones de manifest, que devuelvePromise<Response> | void | false, yerrorLoadRemote - Module Federation source: runtime-core SnapshotHandler at 2.9.0 — un manifest pedido primero al hook
fetch, después alfetchglobal, y después aerrorLoadRemotesi falla - Module Federation source: runtime-core remote/index.ts y utils/load.ts at 2.9.0 —
registerRemotesconforcellamando aremoveRemote, que borra la entrada del remote, el manifest, el global del container y la promesa de carga del container; la promesa de una carga de container fallida conservada y devuelta a las cargas posteriores hasta entonces - Module Federation: retry plugin — reintentos y dominios de respaldo para cargas fallidas
- Re.Pack source: ScriptManager types at 5.2.5 —
absolute,retryyretryDelaydel locator - Re.Pack source: ScriptManager.ts at 5.2.5 — la promesa de un script resuelto borrada una vez que su carga termina, fallida o no; el rechazo de un resolver llega antes de esa limpieza y se conserva
- Re.Pack source: ScriptManager.mm at 5.2.5 — un script file:// leído en su ruta absoluta, o por nombre a través de
URLForResource:withExtension:, y verificado antes de ejecutarse; una descarga escrita en la caché solo después de verificarse - Re.Pack source: FileSystemScriptLoader.kt y RemoteScriptLoader.kt at 5.2.5 — lo mismo en Android, con un asset abierto por su nombre de archivo cuando la ruta no es absoluta, y una descarga verificada antes de escribirse
- Apple: URLForResource:withExtension: — un recurso buscado por nombre en el bundle, junto a la variante que acepta un subdirectorio
- swift-build source: ShellScriptTaskProducer.swift at swift-6.2.1-RELEASE — una fase Run Script que no declara salidas, o que está configurada para ejecutarse siempre, se ejecuta en todas las builds
- React Native CLI source: buildProject.ts at 20.2.0 — sin
--verbose, la salida de la build pasada porxcbeautifyoxcprettycuando hay uno instalado, y un spinner en otro caso - React Native source: RCTSourceCode.mm at 0.85.3 —
scriptURL, la URL desde la que se cargó el bundle de JavaScript - React Native source: RCTFileRequestHandler.mm y RCTAppSetupUtils.mm at 0.85.3 — las peticiones file:// respondidas en iOS, y el handler registrado en la capa de red de la app
- React Native source: BlobModule.kt at 0.85.3 — cualquier URL que no sea http ni https, leída en Android cuando el tipo de respuesta es
blob - Android: AssetManager — assets abiertos y listados desde el APK, no como archivos
- Gradle: Sync — una copia que además elimina los archivos que el origen ya no tiene
- react-native-module-federation — el repo de acompañamiento, la build en el tag
post-16-fallbacks