Una app que funciona des de les còpies que porta a dins mentre falla una de les seves línies de subministrament

Fallback per a remotes federats: la còpia al binari i la xarxa de seguretat a la sessió

El post 15 va tancar la secció «Ara trenca-ho, tres vegades» amb el límit del post mateix: «Res de tot això no dona al binari cap altre lloc des d’on carregar: la pestanya morta mostra l’errada sense amagar-la, però no és una app que funcioni». Aquest post dona al binari aquest altre lloc. Cada build de release porta ara una còpia de cada remote, i l’app funciona des d’aquestes còpies quan no es pot arribar a la xarxa de distribució de continguts (CDN), o quan un remote no es carrega mentre l’altre continua carregant-se des d’ella.

El post 1 va dir qui ho construeix: «Module Federation no et resol per si sol res de la part offline: encastar una còpia de cada remote al binari, perquè l’app revisada funcioni sola i sense xarxa, és una arquitectura que et toca muntar a tu». També va fixar el requisit per a un remote que no es carrega: «l’app s’ha de degradar a alguna cosa segura en lloc de mostrar una pantalla en blanc». La build del post 15 compleix el requisit per a un remote que no es carrega, i s’atura aquí. Amb la CDN fora d’abast arrenca en unresolved, amb un estat d’error a cada pestanya. Quan un remote falla, la seva pestanya mostra l’estat d’error, i Try again no la recupera mai.

La primera meitat construeix la còpia: un pas de build que la prepara, una fase a cada plataforma que la posa al binari, i un mode d’arrencada nou, bundled, que executa tots els remotes des d’ella. La segona meitat construeix dues xarxes de seguretat per a una arrencada en mode CDN. Una atrapa un manifest que la CDN no pot servir i passa només aquell remote a la seva còpia; l’altra envolta cada pestanya i atrapa una càrrega que falla més tard. Amb la segona xarxa de seguretat arriba un Try again que reintenta de debò.

Una regla dona forma a la còpia: és la versió que ja se sap que funciona en aquest binari, congelada el dia que es va construir el binari. És el mínim garantit: les versions noves continuen arribant a les apps instal·lades a través de la CDN, i el host fa servir la còpia només quan la CDN no pot lliurar una versió que es carregui.

Comença des de l’estat final del post 15, el tag post-15-cdn-flip; aquest post acaba a post-16-fallbacks. Com abans, els canvis de configuració i les edicions petites els escrius tu, i la resta la copies del tag final: l’eina de build, el codi del host i els seus tests, els fitxers natius i un test del design system.

L’arrencada decideix el mode un sol cop, abans que es carregui res federat. Fer servir les versions de la CDN vol dir que ha arribat un mapa utilitzable i les seves versions s’han registrat; quan no es poden fer servir, l’arrencada registra les còpies en lloc seu. Acaba en unresolved només quan això també falla. Les dues xarxes de seguretat actuen dins d’una arrencada en mode CDN, remote per remote.

Posa una còpia de cada remote al binari

La còpia la tria el mateix mapa que escull les versions de la CDN. La versió que porta un binari és la que anomena el mapa de la seva versió de l’app, perquè és la versió que se sap que funciona en aquell binari: per a la build 2.0.0 d’aquí, list 1.2.0 i party 1.0.0.

La còpia conté els bundles signats d’aquella versió i el seu manifest, byte a byte tal com els serveix la CDN. Els bytes es queden com són perquè cada bundle es continua verificant contra la clau de signatura quan es carrega, i un canvi al seu codi no passa la comprovació.

tools/build-cdn.mjs ja construeix totes les versions a cdn-root/, i ara prepara també les còpies. Descarrega el tag final un sol cop, perquè totes les ordres de còpia d’aquest post en treuen els fitxers, i comença per l’eina:

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/

L’etapa nova s’executa després de construir l’arbre de la CDN. Per a cada plataforma pren totes les versions que anomena el mapa i en copia els bundles i el manifest a 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 });
  // Tots els scripts de la versió són a l'arrel del seu directori al costat del manifest, i la còpia
  // són tots ells, byte a byte: el container, els seus chunks, index.bundle, que el manifest anomena
  // entre els assets dels mòduls compartits, i mf-manifest.json, que el runtime de la federació
  // llegeix des del disc tal com el llegeix des de la CDN. La carpeta assets/ del costat conté
  // imatges que les còpies d'aquestes biblioteques que el host comparteix ja porten.
  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;

Les còpies conserven els directoris <remote>/<version>/ en lloc de compartir una sola carpeta, perquè dos remotes poden portar chunks de vendor amb el mateix nom de fitxer. En una sola carpeta, el segon chunk sobreescriuria el primer.

L’eina de build també anota les versions copiades a apps/host/src/shell/embedded-versions.ts, un fitxer generat que es compila dins del host, perquè el host sàpiga quines còpies porta abans de llegir res del disc.

embed-root/ és sortida de build, com cdn-root/, així que afegeix-lo a .gitignore:

# The copies build-cdn stages for the native embed phases.
embed-root/

Executa primer el generador de claus i després l’eina per a les dues plataformes. El generador conserva les claus que el checkout ja tingui, i les crea en un checkout nou del tag, que no en té cap perquè mai no entren a Git. Després de l’arbre de la CDN, l’eina informa de les còpies i del fitxer generat:

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 fitxer generat anota la versió de cada còpia, per 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' },
};

L’eina de build prepara la còpia per a la versió de l’app que indica MF_APP_VERSION, o per a la versió de l’app més nova que tingui mapa quan aquesta variable no està definida, així que una build per a una altra versió de l’app executa abans l’eina amb la mateixa variable.

iOS: l’última fase de build

A iOS la còpia va dins del .app, al costat de main.jsbundle. Un script fa la còpia, i una fase Run Script al final del target Host l’executa. Copia l’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 còpia al binari, la meitat d'iOS. L'última fase Run Script del target Host copia la versió
# encastada de cada remote des d'embed-root/ios a l'app, a cdn/ios/<remote>/<version>/ al costat de
# main.jsbundle, on el host la llegeix mitjançant URL file:// absolutes: els bundles signats i el
# mf-manifest.json de la versió.
#
# Els directoris <remote>/<version>/ es conserven en lloc d'aplanar-se: dos remotes poden portar
# chunks de vendor amb el mateix nom de fitxer, i una còpia plana deixaria que els chunks d'un
# remote sobreescrivissin els de l'altre.
#
# S'executa en totes les configuracions. Una build Debug carrega el bundle del mateix host per http,
# així que no pot llegir la còpia, i allà la còpia només costa el temps que triga. Els seus remotes
# venen dels servidors de desenvolupament quan no hi ha cap CDN configurada, o de la CDN quan n'hi
# ha una.
#
# Si falta embed-root, elimina qualsevol còpia que hagi deixat una build anterior, imprimeix un avís
# i surt amb 0, i la build acaba bé sense res encastat. Aquest és el parany: executa
# `node tools/build-cdn.mjs` abans d'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 deixa l'app exactament amb el que conté embed-root, així que una versió que va encastar
# una build anterior no s'hi queda. Els bytes es copien tal com són: la signatura de cada bundle
# els cobreix.
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 és un sol canvi al fitxer del projecte. A Xcode és una fase Run Script nova al target Host, anomenada «Embed federated remotes (offline fallback)», que executa "$SRCROOT/../scripts/embed-remotes-ios.sh", col·locada després de totes les altres fases, amb «Based on dependency analysis» desmarcat. Va l’última perquè el .app on escriu ja estigui muntat. Amb la casella desmarcada s’executa a totes les builds, cosa que una fase que no declara sortides fa de totes maneres: Xcode només se salta una fase de script quan té sortides per comprovar. L’alternativa és declarar com a sortides els fitxers copiats sota cdn/ios del .app, amb els fitxers d’embed-root/ com a entrades. El fitxer de projecte del tag final porta la fase amb la casella desmarcada, així que copiar-lo fa el mateix canvi:

cp /tmp/pokedex-ref-16/apps/host/ios/Host.xcodeproj/project.pbxproj apps/host/ios/Host.xcodeproj/

Android: una tasca de Gradle i un mòdul natiu

A Android la còpia va als assets de l’APK, el paquet d’Android des del qual s’instal·la una app. Al final d’apps/host/android/app/build.gradle, després dels canvis del post 15, afegeix:

// --- La còpia al binari, la meitat d'Android. build-cdn prepara la versió encastada de cada remote
// sota embed-root/android, els bundles signats i el mf-manifest.json de la versió, i aquesta tasca
// replica aquell arbre als assets de l'APK, a cdn/android, d'on EmbeddedRemotesModule l'extreu a
// l'arrencada. Sync en lloc de Copy, perquè un fitxer que una build anterior va encastar i que
// aquesta ja no encasta s'elimini en lloc d'empaquetar-se.
//
// Si falta embed-root, aquesta tasca no encasta res, warnIfNothingToEmbed imprimeix un avís i la
// build acaba bé igualment. Aquest és el parany: una build de release que acaba bé i no porta cap
// còpia. Executa build-cdn abans d'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 sense origen se salta les seves accions, així que un avís a dins no s'imprimiria mai.
// Gradle continua netejant el que la tasca va encastar abans quan el seu origen desapareix, així
// que no s'empaqueta cap còpia obsoleta. Aquesta tasca no té entrades ni sortides, així que
// s'executa a totes les builds, i imprimeix l'avís quan 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 és un fitxer al disc, i Re.Pack només arriba al directori <remote>/<version>/ d’una còpia a través d’una ruta de fitxer real. Així que un TurboModule petit extreu assets/cdn al directori de fitxers de l’app i resol amb el directori que ha fet servir. La seva spec és prou curta per llegir-la sencera:

import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';

// --- La meitat d'Android de la còpia al binari. Una tasca de Gradle empaqueta la versió encastada
// de cada remote als assets de l'APK, i els assets no són fitxers al disc: el loader file:// de
// Re.Pack necessita una ruta real. prepare() els extreu al directori de fitxers de l'app un cop per
// build instal·lada, conservant l'estructura <remote>/<version>/, i resol amb el directori sobre el
// qual el host construeix les URL file://.
//
// No hi ha implementació per a iOS, perquè iOS llegeix les còpies directament del .app. Per això
// aquest fitxer fa servir TurboModuleRegistry.get, que retorna null allà on el mòdul no existeix,
// en lloc de getEnforcing, que llançaria una excepció a iOS tan bon punt es carregués aquest
// fitxer. ---
export interface Spec extends TurboModule {
  /** Extreu els remotes encastats, un cop per build instal·lada, i retorna el directori. */
  prepare(appVersion: string): Promise<string>;
}

export default TurboModuleRegistry.get<Spec>('EmbeddedRemotesModule');

El costat Kotlin copia l’arbre un cop per instal·lació. Un fitxer marcador guarda la versió de l’app i l’hora en què es va instal·lar o actualitzar l’APK, així que tornar a obrir l’app reutilitza l’arbre extret, i qualsevol instal·lació nova el substitueix, inclòs un APK reconstruït que va conservar el seu número de versió. El mòdul copia els bytes tal com són, perquè la signatura de cada bundle els cobreix. Pren del tag final el mòdul, la seva spec i el HostNativePackage actualitzat, que el registra al costat del mòdul de navegació 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/
iOSAndroid
On va la còpiael .app, a cdn/ios/<remote>/<version>/els assets de l’APK, a cdn/android/<remote>/<version>/, extrets al directori de fitxers a l’arrencada
Què la hi posal’última fase Run Script, embed-remotes-ios.shla tasca Sync embedRemotes, abans de preBuild
Com la troba el hostel directori de main.jsbundle, llegit del mòdul SourceCode de React Nativeel directori amb què resol EmbeddedRemotesModule.prepare()
Sense embed-root/un avís, una build que acaba bé, cap còpiaun avís, una build que acaba bé, cap còpia

Carrega una còpia des del disc

El host canvia en cinc fitxers i els seus tests. Pren-los del tag final. mf-modules.d.ts desapareix, perquè ja res no carrega un remote amb import() (la secció del Try again diu per què), i el design system incorpora una comprovació de contrast per al nou 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

El primer que ha de saber el host és on són les còpies al dispositiu. A iOS la resposta surt del mateix mòdul SourceCode de React Native: una build de release carrega file:///…/Host.app/main.jsbundle, i el directori que conté aquell fitxer és el .app. Una build de desenvolupament carrega el seu bundle des del servidor de desenvolupament per http, així que no se’n pot derivar cap directori ni hi ha cap còpia per fer servir. A iOS, tot el que funciona des d’una còpia necessita una build de release. A Android el directori és aquell amb què hagi resolt 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;

Tots els scripts d’un remote continuen passant pel resolver del post 15. S’hi afegeix una branca: un remote que funciona des de la seva còpia, en una arrencada que no ha pogut fer servir les versions de la CDN, o després que aquell remote hagi fallat en carregar-se des de la CDN, resol a un fitxer dins de la seva còpia:

return {
  kind: 'locate',
  locator: {
    url: `file://${input.embeddedRoot}/cdn/${input.platform}/${remoteName}/${version}/${filename}`,
    cache: true,
    absolute: true,
    verifyScriptSignature: input.verify,
  },
};

absolute: true és el detall fàcil de passar per alt. Sense ell, Re.Pack es queda només amb el nom de fitxer de l’script i el busca al nivell superior de l’app: iOS pregunta als recursos del bundle de l’app a través d’URLForResource:withExtension:, i Android obre un asset amb aquell nom. Cap dels dos no arriba a un directori <remote>/<version>/. Amb ell, Re.Pack llegeix el fitxer a la ruta tal com se li dona.

La verificació continua activa. El loader de fitxers de Re.Pack executa a les dues plataformes la mateixa comprovació de signatura que en descarregar un script, abans que el codi s’executi, a cada càrrega. Això fa la còpia més estricta que una descàrrega en cache, que el post 15 va assenyalar que s’executa des del disc sense una segona comprovació. També és per això que la còpia no es reescriu mai de camí a l’app: canvia un byte del codi d’una còpia instal·lada i la pestanya la rebutja, amb la mateixa fallada de la comprovació de hash que una descàrrega manipulada. A iOS el loader de fitxers informa de la descripció completa de l’error, així que el motiu arriba darrere del prefix genèric del sistema:

[Error: The operation couldn’t be completed. The bundle verification failed because the bundle hash is invalid.]

A Android el missatge es llegeix igual que amb una descàrrega manipulada, sense prefix.

El manifest no necessita cap tracte especial. La còpia porta mf-manifest.json al costat dels seus bundles, i el runtime de la federació el llegeix des d’una URL file:// tal com en llegeix un des de la CDN, perquè el fetch de React Native obre fitxers locals: a través del seu handler de peticions de fitxer a iOS, i a Android a través del handler del mòdul de blobs, que accepta qualsevol URL que no sigui http ni https quan el tipus de resposta és blob, el tipus que demana el fetch de React Native.

Mode bundled: el dia que la CDN no respon

L’arrencada prepara ara les còpies alhora que la sonda, perquè cap de les dues tasques no necessita l’altra, i una arrencada sense un mapa utilitzable funciona des de les còpies en lloc de rendir-se:

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');
}
// --- L'arrencada que no ha pogut fer servir la CDN: sense mapa utilitzable, sense CDN configurada,
// o amb un registre que ha fallat. Amb còpies al binari funciona des d'elles, amb cada remote
// registrat apuntant al manifest de la seva còpia, que el runtime llegeix des del disc. Sense cap,
// o quan registrar-les també falla, no hi ha res a executar. ---
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 apuntant al manifest de la seva còpia, amb el force del post 15, envia el runtime al disc per a tot el que té a veure amb aquell remote. A partir d’aquí res més no és especial: el runtime obté el manifest, el resolver envia cada script a la còpia, i cadascun es verifica en carregar-se. unresolved queda per a una arrencada el binari de la qual no anota cap còpia amb un directori des d’on carregar, o en què registrar les còpies falla.

Prova-ho traient la CDN. Engega el servidor com va fer el post 15, i després fes 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 diu cdn · listApp 1.2.0 · partyApp 1.0.0. Atura el servidor i comprova que el mapa ja no hi és:

curl -s -o /dev/null http://localhost:8000/ios/maps/2.0.0/version-map.json; echo $?

7 vol dir que curl no ha pogut connectar. Tanca l’app i torna-la a obrir. La sonda falla dins del seu segon i mig, el banner es torna lila i diu bundled · listApp 1.2.0 · partyApp 1.0.0, i les dues pestanyes s’obren des de les còpies, sense demanar res a la xarxa llevat de PokéAPI per als Pokémon i GitHub per a les seves il·lustracions.

La build Release amb la CDN aturada: la pestanya Pokédex llista Pokémon des de Bulbasaur endavant sota el xip listApp 1.2.0, i el banner lila a sobre de la barra de pestanyes diu bundled, listApp 1.2.0, partyApp 1.0.0

La còpia està congelada en temps de build, i aquesta és la contrapartida. Un binari construït avui i obert sense la CDN l’any que ve executa les versions d’avui, hagi publicat el que hagi publicat la CDN des de llavors. Aquesta és la part del requisit del post 1 que una còpia pot complir: el codi revisat funciona sense la CDN. Els Pokémon continuen venint de PokéAPI, així que sense cap xarxa la petició de la list falla i la pantalla mostra el seu error, «Couldn’t reach PokéAPI».

Tampoc no es conserva res de la fallada. La següent arrencada torna a preguntar a la CDN, i si arriba un mapa utilitzable i les seves versions es registren, l’app torna a les versions que el mapa anomena.

Un remote que falla en una arrencada en mode CDN

El mode bundled cobreix una arrencada que no ha pogut fer servir les versions de la CDN. L’altra fallada té un abast més reduït: l’arrencada fa servir les versions de la CDN, i després falla un remote. La causa pot ser una versió retirada de la CDN mentre un mapa encara l’anomena, un chunk tallat a mitges, o una release que llança un error mentre el seu mòdul s’inicialitza, que va ser el tercer cas del post 15. A la build del post 15 cadascun d’aquests casos deixa una pestanya morta. Dues xarxes els converteixen en una pestanya que funciona des de la seva còpia, mentre l’altre remote continua a la CDN.

La xarxa de seguretat del manifest

Module Federation descarrega el mf-manifest.json d’un remote abans que es carregui res del seu codi. El boundary d’una pestanya s’assabentaria que aquella descàrrega falla, però només quan el mateix fetch del runtime es rendeix, i el runtime no li posa cap límit de temps. I no totes les càrregues s’executen dins d’un boundary: els mòduls d’estat i d’estils de la party demanen el seu manifest a l’arrencada, des d’un efecte fora de qualsevol pestanya. Així que la primera xarxa de seguretat es col·loca on el runtime demana tots els manifests: el hook fetch d’un plugin del runtime, que el runtime crida abans del fetch que fa ell mateix, i que pot respondre amb una Response o deixar la petició en mans del runtime no retornant res:

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 d’un remote a la CDN es descarrega de la CDN, dins del límit d’un segon i mig de la sonda. Qualsevol petició fallida (un codi d’error com un 404, un timeout o una connexió tallada) passa només aquell remote a la seva còpia i respon amb el manifest de la còpia:

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 arriba amb un codi d’èxit torna al runtime tal com és, encara que no es pugui llegir. El runtime hi falla després, i en la càrrega d’una pestanya la xarxa de seguretat del boundary atrapa aquella fallada.

fallBack afegeix el remote a un conjunt i mostra aquell remote al banner, com listApp 1.2.0 embedded. El resolver llegeix aquell mateix conjunt per a cada script, així que el següent script d’aquell remote ja resol a la seva còpia, mentre tots els altres remotes conserven la seva URL de la CDN. El conjunt viu en memòria a propòsit: un remote passa a la seva còpia només fins a la següent arrencada, que torna a preguntar a la CDN, així que una fallada que només ha estat de connexió no manté el remote a la seva còpia. Recordar les fallades entre arrencades, i revertir una versió dolenta de manera permanent, és cosa del post 17.

Altres eines ja ofereixen parts d’això, i cadascuna funcionaria a la seva capa. El hook errorLoadRemote de Module Federation pot retornar un fallback quan falla una càrrega, el manifest inclòs. El seu plugin oficial de reintents reintenta una càrrega fallida i pot anar alternant entre dominis de reserva, i el locator de scripts de Re.Pack accepta retry i retryDelay. El hook fetch era el que encaixava aquí per dos motius. errorLoadRemote s’assabenta d’una fallada només quan el fetch del runtime es rendeix, com el boundary, mentre que el hook fetch fa la petició ell mateix i li pot posar el límit de temps de la sonda. I reintentar és una altra política: gasta temps tornant a preguntar a la mateixa CDN, mentre que aquesta xarxa respon a l’instant des d’una còpia que el binari ja té i deixa el següent intent per a la següent arrencada. Els reintents podrien anar davant de la xarxa, a canvi d’una espera més llarga abans de la còpia.

Prova la xarxa amb una versió que la CDN ja no té. Engega el servidor un altre cop, treu-ne la versió de la list i arrenca l’app en fred:

mv cdn-root/ios/listApp/1.2.0 /tmp/listApp-1.2.0

El manifest de la list torna amb un 404, la list funciona des de la seva còpia, i la party continua carregant-se des de la CDN, com mostra 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"
La build Release amb la versió de la list absent de la CDN: la pestanya Pokédex llista Pokémon des de Bulbasaur endavant sota el xip listApp 1.2.0, i el banner blau diu cdn, listApp 1.2.0 embedded, partyApp 1.0.0

Torna el directori al seu lloc abans de continuar:

mv /tmp/listApp-1.2.0 cdn-root/ios/listApp/1.2.0

Si tots els remotes passen a la seva còpia, comprova que l'app arriba al servidor. Un banner que marca els dos remotes com a embedded, o que diu bundled, amb el servidor en marxa sol voler dir que l'app no hi ha pogut arribar: l'App Transport Security d'iOS i les regles de cleartext d'Android rebutgen l'http pla si la build no ho permet. Aquest host permet la xarxa local a iOS i, a Android, cleartext només cap a localhost, 127.0.0.1 i 10.0.2.2, amb els seus subdominis.

La xarxa de seguretat del boundary

Algunes fallades venen després d’un manifest que ha arribat bé: un container o un chunk que no arriba o no supera la verificació, o un mòdul que llança un error mentre s’avalua. Fan fallar la càrrega de la pestanya, i la càrrega de la pestanya s’executa dins del RemoteBoundary que el post 11 va posar al voltant de cada pestanya. El boundary és la segona xarxa de seguretat, i continua sent un component de classe, perquè React encara no té cap manera d’escriure un error boundary com a component de funció. Ara fa una pregunta abans de mostrar l’estat d’error: ha fallat la càrrega, de manera que el remote no ha produït mai cap component, i hi ha una còpia a la qual aquest remote encara no ha passat?

componentDidCatch(error: unknown) {
  console.warn(`${this.props.remote} failed`, error);
  if (this.dropsToCopy()) {
    fallBackAndReload(this.props.remote);
    this.nextAttempt();
  }
}
// Una fallada que la còpia al binari pot cobrir: la càrrega ha fallat, així que el remote no ha
// produït mai cap component, i aquest remote té una còpia a la qual encara no ha passat.
dropsToCopy() {
  return !this.loaded() && canFallBack(this.props.remote);
}

Quan pot, el boundary passa el remote a la seva còpia i torna a carregar la pestanya, mostrant mentrestant l’estat de càrrega, així que l’estat d’error no arriba a aparèixer. canFallBack ho permet un cop per remote, només en una arrencada en mode CDN, i només per a un remote que té còpia. Quan no pot, perquè la còpia també ha fallat, o no n’hi ha cap, la pestanya mostra l’estat d’error com abans.

Un remote la càrrega del qual ha retornat un component conserva el seu estat d’error quan aquell component llança un error. És una decisió, i es basa en si el remote ha produït un component. Abans que ho faci, la pestanya encara mostra el seu estat de càrrega, així que un canvi a la còpia és invisible. Un cop la càrrega ha retornat un component, el boundary es queda amb aquella versió, fins i tot quan el component llança un error a la seva primera renderització. El boundary anota l’intent la càrrega del qual ha arribat amb un component, no si la pantalla s’ha arribat a mostrar, així que una fallada en aquell intent compta com a llançada pel codi del mateix remote, que ja s’ha executat en aquesta sessió. Fins que el mapa no anomeni una altra versió, la còpia és de totes maneres aquella mateixa versió. Així que el boundary mostra «This tab stopped working» i ofereix Try again, que torna a renderitzar el remote i recupera la pestanya quan l’error no es repeteix. Un error que es repeteix deixa la pestanya al seu estat d’error durant la resta de la sessió, i després de tornar a obrir l’app mentre el mapa anomeni aquella versió, encara que hi hagi una còpia que funcioni al binari.

La xarxa de seguretat del boundary no cobreix els dos mòduls d’arrencada de la party: es carreguen des d’un efecte fora de qualsevol pestanya, així que el host només registra al log una fallada que la xarxa de seguretat del manifest no cobreix. Quan falla el mòdul d’estat, afegir a la party queda desactivat fins que es carrega la pestanya Party, perquè aquella pestanya injecta l’estat ella mateixa; un mòdul d’estils fallit no afecta la possibilitat d’afegir.

El tercer cas del post 15 és ara una càrrega fallida que la còpia pot cobrir. La seva release llança un error mentre el seu mòdul s’inicialitza, i la finestra d’avaluació del post 15 converteix aquell error en una càrrega fallida en lloc d’una notificació fatal. Construeix la release un altre cop. Afegeix la mateixa línia sota els imports d’apps/list/src/PokedexScreen.tsx:

throw new Error('PokedexScreen failed to initialise');

Després publica-la com a 1.4.0, com va fer 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/

Esborra la línia, apunta el mapa 2.0.0 a 1.4.0 i torna a obrir l’app. La pestanya passa a la còpia 1.2.0: el xip diu 1.2.0, el banner marca la list com a embedded, i el log del simulador no té cap RCTFatal. Torna el mapa a 1.2.0.

La xarxa de seguretat del boundary també atrapa una CDN que desapareix a mitja sessió. Arrenca amb el servidor en marxa i deixa la pestanya Party sense obrir. Afegeix un Pokémon des de la Pokédex, atura el servidor i obre Party per primer cop. A l’arrencada només s’han carregat els mòduls d’estat i d’estils de la party, així que les seves pantalles encara no són al dispositiu: la descàrrega falla, i la pestanya mostra un spinner un moment i després la party des de la seva còpia, amb el Pokémon que has afegit, i el banner diu partyApp 1.0.0 embedded. Treure el Pokémon allà també funciona, perquè les pantalles de la còpia i el mòdul d’estat que s’ha carregat des de la CDN comparteixen l’únic store del host.

Un Try again que reintenta

A la build del post 15, Try again en una pestanya la càrrega de la qual ha fallat no la recupera mai. Tres registres de la càrrega fallida s’interposen entre Try again i un intent nou.

El primer és el de React. Un component lazy crida la seva funció de càrrega un sol cop: la documentació de React diu «Both the returned Promise and the Promise’s resolved value will be cached, so React will not call load more than once», i un rebuig va a l’error boundary més proper. El boundary ja ho resolia, muntant un component lazy nou sota una key nova a cada intent.

El segon és el del bundle del host, i és el que va mantenir mortes les pestanyes del post 15. Un import('listApp/ListStack') es compila en un mòdul del mateix bundle del host, i el bundle conserva cada mòdul que ha avaluat durant la resta de la sessió. Després d’una càrrega fallida conserva el que la fallada ha deixat, un mòdul sense res a dins, i cada import() posterior es resol amb això, encara que el reintent descarregui el remote bé. Mesurat en una build Release d’iOS: el reintent va descarregar el manifest i els dos chunks, i l’import va continuar resolent-se sense component. Així que les pestanyes, i els dos mòduls que la party carrega a l’arrencada, ja no fan servir import(). Ho demanen directament al runtime de la federació, cada vegada:

export async function loadRemoteModule<T>(id: string): Promise<T | undefined> {
  const factory = await loadRemote<() => T>(id, { loadFactory: false, from: 'runtime' });
  return factory ? factory() : undefined;
}

La funció demana la factory del mòdul en lloc dels seus exports, amb loadFactory: false, que és com la finestra d’avaluació del post 15 arriba a embolcallar la factory: el runtime retorna l’embolcall de la finestra, i cridar aquell embolcall aquí avalua el mòdul dins de la finestra.

El tercer és el del runtime de la federació. A @module-federation/runtime-core 2.9.0, la versió que instal·la aquesta sèrie, el seu registre d’un remote sobreviu a una càrrega fallida: conserva l’entrada del remote, el manifest que ha llegit, el container que ha carregat, i una càrrega de container que ha fallat, que lliura a cada petició posterior. El registre de chunks del remote, un array global amb el nom del uniqueName del remote, també conserva tots els chunks que ha descarregat el seu últim container, i un container nou els instal·la tots abans de descarregar res. Try again buida el registre del runtime i el registre de chunks abans de tornar a carregar:

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]];
}

Tornar a registrar el remote amb force elimina el registre del runtime, el global del container inclòs, i apunta la següent càrrega a entry. Try again llegeix entry del runtime en lloc de construir-la, cosa que funciona en tots els modes, desenvolupament inclòs, on només la build coneix la URL del servidor de desenvolupament. Un remote que ha passat a la seva còpia continua reintentant la seva còpia, perquè la xarxa de seguretat del manifest i el resolver envien a la seva còpia qualsevol remote del conjunt.

El cache de scripts de Re.Pack no necessita buidar-se per a una càrrega fallida: un cop la càrrega d’un script resolt acaba, Re.Pack descarta la seva promesa, i una descàrrega que no passa la verificació no s’escriu mai al seu cache, en cap de les dues plataformes. L’excepció és un rebuig del resolver. Arriba abans d’aquella neteja, així que Re.Pack conserva el rebuig i el lliura a cada petició posterior del mateix script. El resolver d’aquest host rebutja només un remote per al qual no té versió ni còpia.

Queda un forat obert: Try again no pot cancel·lar una descàrrega ja en curs. Un chunk que el container fallit encara estava descarregant va a parar al registre que existeixi quan arriba, i quan un script encara s’està carregant, Re.Pack respon a una petició nova d’aquell mateix script amb la promesa que ja està pendent. Quan la còpia és la versió que servia la CDN, el cas per defecte, són els mateixos bytes en qualsevol cas; quan el mapa anomena una altra versió, un chunk tardà de la CDN pot quedar al costat del container de la còpia.

Un error de renderització mostra Try again recuperant una pestanya el codi de la qual ja s’ha carregat. La demo a mitja sessió de la xarxa de seguretat del boundary va aturar el servidor, així que engega’l un altre cop i deixa’l en marxa:

npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors

Després fes que la pantalla de la list llanci un error mentre es renderitza, durant els seus primers vuit segons. Sota els imports d’apps/list/src/PokedexScreen.tsx afegeix:

const LOADED_AT = Date.now();

i com a primeres línies de PokedexScreen():

if (Date.now() - LOADED_AT < 8000) {
  throw new Error('PokedexScreen render failed');
}

L’error ha de durar: React reintenta una renderització que falla, així que, si l’error s’atura massa aviat, la renderització es recupera abans que l’estat d’error del boundary s’arribi a mostrar. Publica la list com a versió pròpia, com es va publicar la 1.4.0, i després esborra les línies i 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/

Torna a obrir l’app i la pestanya mostra «This tab stopped working». Espera que passin els vuit segons i prem Try again: la list es renderitza a la 1.5.0, i el log del servidor no mostra cap petició nova, perquè el codi ja s’havia carregat i només necessitava renderitzar-se un altre cop. Torna el mapa a 1.2.0 després.

La pestanya Pokédex a la list 1.5.0 mostra This tab stopped working amb un botó Try again, es prem el botó, i la list es renderitza amb el xip listApp 1.5.0 mentre el banner diu cdn, listApp 1.5.0, partyApp 1.0.0

Les fallades i el que costa cadascuna, totes en una taula:

FalladaQui l’atrapaQuè veu l’usuari
No s’arriba a la CDN a l’arrencadal’arrencada, en mode bundledtotes les pestanyes des de la seva còpia, i un banner lila
Falla el manifest d’un remote: un 404, un timeout, una connexió talladala xarxa de seguretat del manifestla pestanya des de la seva còpia, marcada com a embedded al banner
El codi del remote d’una pestanya no arriba, no supera la verificació, o llança un error mentre el seu mòdul s’inicialitzala xarxa de seguretat del boundaryun moment de càrrega, i després la pestanya des de la seva còpia
El mòdul d’estat de la party falla a l’arrencada, un cop arribat el seu manifestres: el host ho registra al logafegir a la party queda desactivat fins que es carrega la pestanya Party
El mòdul d’estils de la party falla a l’arrencada, un cop arribat el seu manifestres: el host ho registra al logafegir continua funcionant
La còpia també falla, o no hi ha còpiael boundary«This tab could not load», amb Try again
El component del remote llança un error en renderitzar-seel boundary«This tab stopped working», amb Try again

Ara trenca-ho

Els dos passos que encasten acaben bé sense embed-root/, i aquest és el parany. Treu-lo fora i reconstrueix la build Release, aquest cop amb --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 acaba bé, i l’avís de la fase és l’únic senyal que falta alguna cosa. Sense --verbose, npm run ios mostra un spinner en lloc de la sortida de la build (o passa la sortida per xcbeautify o xcpretty, quan n’hi ha un d’instal·lat), després imprimeix success Successfully built the app i obre l’app. Amb --verbose, l’avís queda entre les línies de la mateixa 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

Atura el servidor i arrenca l’app en fred. El banner continua dient bundled · listApp 1.2.0 · partyApp 1.0.0, perquè el host duu compilades les versions que li han dit que porta, i les dues pestanyes diuen «The app’s own copy of this remote could not be loaded».

Construeix l'arbre de la CDN i després la build de release, sempre. Les fases que encasten copien el que embed-root/ contingui quan s'executen, i quan falta deixen que la build acabi bé sense res més que un avís a la sortida de la build. Una build de release feta abans de node tools/build-cdn.mjs, o després de netejar embed-root/, es construeix bé sense cap còpia a dins, i res no falla fins que l'app necessita una còpia: quan l'arrencada no pot fer servir les versions de la CDN, o quan falla un remote.

Torna’l al seu lloc i reconstrueix abans de continuar:

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 )

Executa-ho

Les còpies i l’arbre de la CDN, i despré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 a iOS, i després Android amb l’adreça amb què l’emulador arriba 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 )

Amb qualsevol de les dues en marxa, atura el servidor i arrenca en fred per al mode bundled, o treu una versió de cdn-root per a la xarxa de seguretat del manifest. A Android les còpies extretes són al directori de fitxers de l’app, sota files/cdn/android/, amb el marcador .installed al costat, a files/cdn/.

Les suites, l’smoke test i els tests del generador de claus:

( 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

El que has construït, i el que ve

Les demos d’aquest post es van executar en una build Release d’iOS, i les còpies, la seva extracció i el mode bundled també en una build de release d’Android.

Un remote que ha passat a la seva còpia s’oblida a la següent arrencada, així que una versió que continua fallant torna a fallar a cada arrencada abans que la seva còpia prengui el relleu. Recordar les fallades va amb l’altre problema obert: el mapa en què confia cada binari continua sent un fitxer de text pla que pot canviar qualsevol que pugui escriure al bucket.

El següent: signar aquell mapa, un comptador que fa que l’app rebutgi un mapa més antic, i una app que reverteix per si sola una versió que falla.

Fonts

Warren de Leon
Warren de Leon

Software Engineering Manager. Recentment he liderat l'equip de Mobile Platform a Hargreaves Lansdown. Escric sobre lideratge tècnic, React Native i com construir bons equips.

Veure perfil