Dues versions de l'app reben, cadascuna, les seves versions dels remotes des de la mateixa CDN mentre canvia una línia del mapa

Lliurament per CDN: el mapa de versions, el resolver i el canvi en viu

El post 14 va acabar amb una promesa: «remotes versionats a la CDN, un mapa de versions, un resolver per arrencada i un binari antic que mai no descarrega codi que no pot executar». Aquest post ho construeix tot i després ho fa servir. Tens una app instal·lada, amb els servidors de desenvolupament apagats, que mostra la versió 1.1.0 de la llista de la Pokédex. Li envies la 1.2.0 amb un directori i una línia de JSON editada, veus com arriba el canvi a la següent arrencada i el reverteixes de la mateixa manera.

L’operació necessita tres peces. Una xarxa de distribució de continguts (CDN) guarda totes les versions publicades de cada remote, així que una versió nova mai no substitueix fitxers que una app instal·lada està carregant. Un mapa per a cada versió publicada de l’app diu a aquell binari quines d’aquestes versions ha de carregar. Un resolver al host converteix cada petició de chunk en una URL versionada, amb verificació de signatura. Juntes compleixen el que el post 1 deia que et dona la federació: «Un bug en un remote és tornar a pujar aquell remote, no un enviament a l’store».

Una regla ho determina tot: un binari antic ha de continuar funcionant mentre algú el tingui instal·lat. La CDN conserva les versions antigues dels remotes igual que un backend conserva els endpoints antics, i cada binari rep només les versions que anomena el seu mapa. Res al dispositiu no comprova que aquestes versions funcionin amb el binari: triar versions amb què s’ha provat aquell binari és feina de l’operador, i el mapa és on queda escrita aquesta tria.

Comença des de l’estat final del post 14, el tag post-14-production-build; aquest post acaba al tag post-15-cdn-flip. Els canvis de configuració i les edicions petites els escrius tu. El nou codi d’arrencada del host i uns quants fitxers més canvien en massa llocs perquè valgui la pena tornar-los a escriure en un post, així que surten del tag final, i les ordres de còpia els anomenen un per un.

El mapa es descarrega abans d’importar res federat, i cada petició posterior porta la versió que ha anomenat el mapa.

Versions al prestatge

Una versió ha d’arribar a tres llocs de la build d’un remote, i tots tres han de coincidir: el directori on s’escriuen els artefactes, el directori on s’escriuen els chunks i el text que la pantalla en execució mostra sobre ella mateixa. Una sola constant els alimenta tots tres. A apps/list/rspack.config.mjs, a sobre de defineRspackConfig:

// --- Quina versió d'aquest remote produeix la build. Decideix dues coses alhora: el directori on
// s'escriuen els artefactes i el text que el codi en execució dona sobre si mateix. Totes dues
// surten d'una sola variable, així que una build no pot escriure els fitxers de la 1.2.0 i dir que
// és la 1.1.0:
//   MF_REMOTE_VERSION=1.2.0 npm run bundle:ios:prod
// Un directori de versió s'escriu una vegada i no es torna a editar. Reconstruir una versió que les
// apps instal·lades ja estan carregant substitueix codi que aquestes apps donen per fix, cosa que
// és justament el pas que aquesta estructura fa innecessari: publica una versió nova en lloc
// seu. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';

Les dues rutes de producció del post 14 passen a portar el segment. A output:

// Una build de producció escriu l'arbre que serveix la CDN, amb la mateixa estructura que la ruta
// de la URL on se serveix: cdn/<platform>/listApp/<version>/. El segment de versió és el que permet
// que una CDN guardi diverses releases d'aquest remote alhora, cadascuna a la seva URL. Una build de
// desenvolupament continua escrivint a build/, d'on la llegeix el servidor de desenvolupament, i no
// porta versió: allà només hi ha una build, l'última que s'ha desat.
path: isProd
  ? `${__dirname}/cdn/[platform]/listApp/${REMOTE_VERSION}`
  : `${__dirname}/build/[platform]`,

i a l’entrada extraChunks del RepackPlugin:

// Els chunks queden al costat del container i del manifest, dins del mateix directori de versió,
// perquè el host els demanarà a URLs relatives al manifest que ha carregat. Aquesta entrada els hi
// copia: Rspack ja els ha escrit sota output.path, així que si aquí falta el segment de versió no
// es trenca res en execució, i el que queda és una segona còpia sense versió de cada chunk al
// costat de les versionades.
outputPath: isProd
  ? `cdn/${platform}/listApp/${REMOTE_VERSION}`
  : `build/${platform}/remote`,

El tercer lloc és un literal compilat dins del bundle. Afegeix DefinePlugin a l’import de @rspack/core, al costat del minimitzador, i una entrada de plugin abans de ModuleFederationPluginV2:

import { DefinePlugin, SwcJsMinimizerRspackPlugin } from '@rspack/core';
// La versió, compilada dins del bundle com a literal perquè la pantalla en execució pugui mostrar
// la build de què surt. En producció es llegeix de la mateixa constant que fa servir la ruta de
// sortida, així que el xip de la pantalla i el directori de la CDN no poden discrepar mai. Una
// build de desenvolupament diu 'dev': no s'ha publicat enlloc, i posar-hi un número seria afirmar
// una versió d'un fitxer que es reconstrueix cada vegada que deses.
new DefinePlugin({
  __REMOTE_VERSION__: JSON.stringify(isProd ? REMOTE_VERSION : 'dev'),
}),

Replica les dues rutes a apps/party/rspack.config.mjs amb partyApp. La seva constant porta un comentari propi, perquè la party no té xip i per això no necessita DefinePlugin:

// --- Quina versió d'aquest remote produeix la build, i el directori on s'escriu. La mateixa
// variable i la mateixa regla que a listApp: un directori de versió s'escriu una vegada i no es
// torna a editar quan les apps instal·lades ja l'han començat a carregar. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';

A l’app list, en canvi, TypeScript necessita una declaració per a __REMOTE_VERSION__, perquè aquest nom no té cap mòdul al darrere. Un apps/list/src/globals.d.ts nou la proporciona:

// --- Noms que el bundler substitueix per literals en temps de build, declarats per al
// compilador. No són imports i no hi ha cap mòdul al darrere: rspack.config.mjs substitueix cada
// un durant la build, així que el bundle publicat conté el valor i mai el nom. ---

/** La versió d'aquest remote que ha produït la build, i el directori de la CDN on s'ha escrit. */
declare const __REMOTE_VERSION__: string;

El xip és el que fa visible un desplegament, perquè dues builds del mateix remote són, per la resta, la mateixa pantalla. A apps/list/src/PokedexScreen.tsx, a sobre d’EMPTY_TYPES:

// --- La versió amb què es va construir aquest bundle, compilada per DefinePlugin. Mostrar-la és el
// que fa visible un desplegament: dues builds d'aquest remote són, per la resta, la mateixa
// pantalla, així que sense ella no hi ha manera de saber des de l'app quina s'està executant. El
// valor per defecte cobreix Jest, on no s'executa cap bundler i el nom no se substitueix mai. ---
const REMOTE_VERSION = typeof __REMOTE_VERSION__ === 'string' ? __REMOTE_VERSION__ : 'dev';

La fila de la capçalera passa a tenir el xip a l’esquerra i el comptador de party a la dreta. El xip és un element accessible independent, així que un lector de pantalla hi pot arribar sense sentir abans el recompte de la party. La live region i la seva etiqueta surten de la fila i passen a un grup propi al voltant de l’etiqueta i la píndola del post 12, així que la fila ja no absorbeix el xip:

ListHeaderComponent={
  <Box className="flex-row items-center justify-between px-1.5 py-2.5">
    {/* Quina build d'aquest remote hi ha a la pantalla. És un element propi en lloc de formar part
        del grup del comptador, perquè un lector de pantalla hi pugui arribar sense que es llegeixi
        en veu alta cada vegada que canvia el recompte de la party. */}
    <Box
      className="rounded-full bg-offGrey px-2 py-0.5 dark:bg-white/10"
      accessible
      accessibilityLabel={`Pokédex remote, version ${REMOTE_VERSION}`}>
      <Text size="xs" className="font-semi text-darkGrey dark:text-lightGrey">
        listApp {REMOTE_VERSION}
      </Text>
    </Box>
    {/* El recompte canvia quan l'usuari afegeix un membre des d'una altra pantalla, sense que el
        focus es mogui cap aquí. Un usuari que veu la pantalla veu canviar el número; a un usuari de
        lector de pantalla no se li diu res si això no és una live region (SC 4.1.3). L'etiqueta
        explicita la proporció, perquè «3/6» es llegeix com a «tres barra sis» o com una data,
        segons el lector. */}
    <Box
      className="flex-row items-center gap-2"
      accessible
      accessibilityLiveRegion="polite"
      accessibilityLabel={`My Party, ${partyCount} of ${MAX_PARTY}`}>
      <Text size="sm" className="font-semi text-darkGrey dark:text-lightGrey">
        My Party
      </Text>
      <Box className="rounded-full bg-lightGreen px-2.5 py-0.5 dark:bg-white/10">
        {/* darkGrey, no darkGreen: darkGreen és #A6D3A0, el farciment de planta, i sobre
            lightGreen mesura 1,53:1. darkGrey és el color que ja fa servir l'etiqueta del costat. */}
        <Text size="xs" className="font-head text-darkGrey dark:text-pokemonGreen">
          {partyCount}/{MAX_PARTY}
        </Text>
      </Box>
    </Box>
  </Box>
}

El tools/build-cdn.mjs del post 14 construïa una versió de cada remote en un arbre pla. Substitueix-lo per un que parteix de dues llistes. REMOTE_VERSIONS diu quines versions de cada remote guarda la CDN. APP_VERSION_MAPS diu quines d’aquestes versions carrega cada versió publicada de l’app. Una versió pot ser a la primera llista sense que cap mapa hi apunti:

// --- Munta el directori que serviria una CDN.
//
// L'estructura és la de les URL, i ara porta una versió:
//
//   cdn-root/<platform>/<remote>/<version>/     el container, els seus chunks i mf-manifest.json
//   cdn-root/<platform>/maps/<appVersion>/      version-map.json, un per versió publicada de l'app
//
// Un fitxer a cdn-root/ios/listApp/1.2.0/mf-manifest.json se serveix a
// <base>/ios/listApp/1.2.0/mf-manifest.json, que és la URL que el host construeix a l'arrencada a
// partir de la versió que li ha donat el mapa.
//
// Hi ha dues llistes més avall, i la diferència entre elles és tota la idea. REMOTE_VERSIONS és el
// que guarda la CDN: totes les versions publicades, que es conserven fins que ningú no les executa.
// APP_VERSION_MAPS és el que cada binari publicat pot carregar de tot això. Enviar un remote a les
// apps instal·lades és una entrada nova a la primera llista i una línia editada a la segona.
//
// Ús: node tools/build-cdn.mjs [ios|android]     (sense argument construeix totes dues)
//
// Després serveix-lo i apunta-hi el host (un emulador Android arriba a aquesta màquina a 10.0.2.2,
// així que la build d'Android rep aquesta adreça en lloc de localhost):
//   npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
//   ( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm start )

import { execSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const REMOTE_APPS = { listApp: 'list', partyApp: 'party' };
const ALL_PLATFORMS = ['ios', 'android'];

// --- Totes les versions de cada remote que guarda la CDN. Un directori de versió s'escriu una
// vegada i després no es toca: les apps instal·lades estan carregant aquests fitxers exactes, així
// que reconstruir una versió publicada és un canvi silenciós en codi que algú ja executa. La feina
// nova porta un número nou.
//
// listApp en té dues: 1.0.0 i 1.1.0 són la mateixa pantalla amb un número de versió diferent, que
// és el que necessita la demo dels dos binaris. El canvi a 1.2.0 hi afegeix la tercera. ---
const REMOTE_VERSIONS = {
  listApp: ['1.0.0', '1.1.0'],
  partyApp: ['1.0.0'],
};

// --- Què pot carregar cada versió publicada de l'app. El host demana la seva entrada pel nom a
// cada arrencada, així que un binari antic continua rebent les versions amb què es va construir,
// per molt que hagi avançat la release més nova. Una entrada es retira quan ja no queda ningú en
// aquella versió de l'app, igual que un endpoint antic d'una API.
//
// Editar-hi una línia reconstrueix tot l'arbre, i aquesta no és l'eina per publicar una versió:
// l'operació que fa el post és editar el fitxer del mapa que ja és a cdn-root, perquè aquest fitxer
// és el que llegeix una app en execució. Això crea una CDN des de zero; no és una operació sobre
// ella.
//
// El mapa no porta res més. Ni signatura, ni comptador, res que permeti a l'app distingir un mapa
// escrit aquí d'un d'escrit per qualsevol altra persona amb accés al bucket. És un forat real i es
// deixa obert a propòsit: és el tema de l'últim post de la sèrie. ---
const APP_VERSION_MAPS = {
  '1.0.0': { listApp: '1.0.0', partyApp: '1.0.0' },
  '2.0.0': { listApp: '1.1.0', partyApp: '1.0.0' },
};

// --- No es publica mai: els source maps són quatre cinquenes parts de l'arbre construït, són un
// artefacte de depuració per a un crash reporter i no una cosa que descarregui un client, i en un
// bucket públic entreguen tot el codi font llegible a qui el demani. mf-stats.json és anàlisi de la
// build i és en la mateixa situació. L'index.bundle del mateix remote es queda, perquè
// mf-manifest.json el cita entre els assets compartits i res d'aquí no ha demostrat que cap ruta no
// el demani. ---
const NEVER_PUBLISHED = /\.map$|^mf-stats\.json$/;

const [platformArg] = process.argv.slice(2);
if (platformArg && !ALL_PLATFORMS.includes(platformArg)) {
  console.error(`Unknown platform "${platformArg}". Use one of: ${ALL_PLATFORMS.join(', ')}`);
  process.exit(1);
}
const platforms = platformArg ? [platformArg] : ALL_PLATFORMS;

// --- Un mapa que anomena una versió que la CDN no té és l'errada que el post mostra a mà, i val la
// pena detectar-la aquí i no a l'arrencada d'un usuari. Es comprova abans de construir res, així
// que una errada de tecleig costa un segon en lloc de dues execucions de bundle. ---
const unknownRemotes = Object.keys(REMOTE_VERSIONS).filter(remote => !REMOTE_APPS[remote]);
if (unknownRemotes.length > 0) {
  console.error(
    `\nNo app to build for: ${unknownRemotes.join(', ')}. Add it to REMOTE_APPS, or remove it from REMOTE_VERSIONS.\n`,
  );
  process.exit(1);
}

const missing = Object.entries(APP_VERSION_MAPS).flatMap(([appVersion, versions]) =>
  Object.entries(versions)
    .filter(([remote, version]) => !REMOTE_VERSIONS[remote]?.includes(version))
    .map(([remote, version]) => `  app ${appVersion} asks for ${remote} ${version}`),
);
if (missing.length > 0) {
  console.error('\nThese versions are mapped but not published:');
  console.error(missing.join('\n'));
  console.error('\nAdd them to REMOTE_VERSIONS, or point the map at a version that exists.\n');
  process.exit(1);
}

for (const platform of platforms) {
  // Esborra només aquesta plataforma, perquè construir-ne una no elimini l'arbre de l'altra.
  rmSync(join(repoRoot, 'cdn-root', platform), { recursive: true, force: true });
  mkdirSync(join(repoRoot, 'cdn-root', platform), { recursive: true });

  for (const [remote, versions] of Object.entries(REMOTE_VERSIONS)) {
    const appDir = join(repoRoot, 'apps', REMOTE_APPS[remote]);
    for (const version of versions) {
      console.log(`\n=== building ${remote} ${version} (${platform}) ===`);
      // S'anomena una sola vegada: el directori que es buida, s'escriu i després es llegeix és un
      // sol lloc, així que cap edició posterior no pot endur-se la sortida de la build a una altra
      // banda i deixar sense origen la còpia que ve després.
      const built = join(appDir, 'cdn', platform, remote, version);
      // Es buida primer, perquè Rspack escriu dins d'un directori en lloc de substituir-lo: un
      // fitxer que va emetre una build anterior i que aquesta ja no emet sobreviuria i es publicaria
      // al costat dels de debò, sense cap motiu que ningú pogués deduir del codi.
      rmSync(built, { recursive: true, force: true });
      // MF_REMOTE_VERSION decideix alhora el que el bundle diu de si mateix i on s'escriu, així que
      // una sola variable no pot produir una build marcada amb una versió i arxivada sota una altra.
      execSync(`npm run bundle:${platform}:prod`, {
        cwd: appDir,
        stdio: 'inherit',
        env: { ...process.env, MF_REMOTE_VERSION: version },
      });
      // La build ha acabat bé, així que si falta el directori és que la seva ruta de sortida i
      // aquesta ruta s'han separat, i val més dir-ho en una línia que amb un stack trace.
      if (!existsSync(built)) {
        console.error(`\n${remote} built but wrote nothing to ${built}.`);
        console.error("Check the output path in that app's rspack.config.mjs.\n");
        process.exit(1);
      }
      cpSync(built, join(repoRoot, 'cdn-root', platform, remote, version), {
        recursive: true,
        filter: source => !NEVER_PUBLISHED.test(source.split('/').pop()),
      });
      console.log(`published -> cdn-root/${platform}/${remote}/${version}`);
    }
  }

  for (const [appVersion, versions] of Object.entries(APP_VERSION_MAPS)) {
    const dir = join(repoRoot, 'cdn-root', platform, 'maps', appVersion);
    mkdirSync(dir, { recursive: true });
    writeFileSync(join(dir, 'version-map.json'), `${JSON.stringify(versions, null, 2)}\n`);
    console.log(`wrote     -> cdn-root/${platform}/maps/${appVersion}/version-map.json`);
  }
}

const HOST_ADDRESS = { ios: 'http://localhost:8000', android: 'http://10.0.2.2:8000' };
const appVersions = Object.keys(APP_VERSION_MAPS);
console.log(`\nCDN assembled at ${join(repoRoot, 'cdn-root')}`);
console.log('Serve it:             npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors');
for (const platform of platforms) {
  console.log(
    `Point the host at it: ( cd apps/host && MF_CDN_BASE=${HOST_ADDRESS[platform]} MF_APP_VERSION=${appVersions.at(-1)} npm start )   # ${platform}`,
  );
}
console.log(`App versions with a map: ${appVersions.join(', ')}`);

Els source maps es deixen de publicar: són quatre cinquenes parts d’un remote construït (16 MB dels 20 MB que escriu listApp a iOS), un artefacte per al crash reporter i no una descàrrega per al client, i en un bucket públic entreguen el codi font llegible a qui el demani. Construeix l’arbre:

node tools/build-cdn.mjs ios
cdn-root/ios/listApp/1.0.0/      cdn-root/ios/maps/1.0.0/version-map.json
cdn-root/ios/listApp/1.1.0/      cdn-root/ios/maps/2.0.0/version-map.json
cdn-root/ios/partyApp/1.0.0/

Cada mapa és tot el que se li diu a un binari:

{
  "listApp": "1.1.0",
  "partyApp": "1.0.0"
}

Dues línies i res més. Els chunks als quals apunta estan signats; el fitxer que tria entre ells, no. El post 17 tanca aquest forat; aquest post el deixa obert i ho diu cada vegada que importa.

Pregunta abans de carregar

El host necessita dues dades en temps de build: on és la CDN i quina versió de l’app és aquest binari. A apps/host/rspack.config.mjs, el comentari del post 14 i const CDN_BASE = process.env.MF_CDN_BASE; passen a ser:

// --- D'on se serveixen els remotes. Defineix MF_CDN_BASE i el host busca a la xarxa de distribució
// de continguts en lloc dels servidors de desenvolupament; deixa-la sense definir i res no canvia.
// El valor es llegeix en temps de BUILD i queda incrustat al bundle, així que una build que l'ha
// oblidat publica les URLs de desenvolupament:
//   MF_CDN_BASE=http://localhost:8000 npm start      (la CDN local, en una build de desenvolupament)
//   MF_CDN_BASE=https://cdn.example.com npm run …    (una de real, en una build de release)
//
// El que canvia en aquest post és qui fa servir el valor. Encara dona forma al mapa de remotes de
// més avall, però ara aquell mapa és un marcador de posició: les seves URLs no porten segment de
// versió, així que contra un arbre de CDN versionat no resolen res. El valor que importa és el que
// arriba al codi en execució a través de DefinePlugin, on src/shell/scriptManager.ts el llegeix,
// pregunta a la CDN quines versions pot executar aquest binari i torna a registrar cada remote en
// una URL versionada abans que es dispari el primer import.
// Les barres finals es retallen, perquè cada URL construïda a partir d'aquest valor hi afegeix el
// seu separador, i una base escrita amb una barra produeix una doble barra al mig de cada ruta. La
// majoria de servidors ho perdonen; un script descarregat es desa al cache amb la URL que el va
// demanar, així que no val la pena esbrinar quins no ho perdonen.
const CDN_BASE = (process.env.MF_CDN_BASE || '').replace(/\/+$/, '');

// --- La versió d'aquest binari, la pregunta que fa a la CDN a l'arrencada. La CDN respon amb les
// versions dels remotes que aquest binari pot executar, i així continua funcionant una instal·lació
// de fa dos anys: continua rebent les versions amb què es va publicar. Una app real llegeix això de
// la versió amb què va sortir; aquí és una variable, perquè un mateix checkout pugui produir dos
// binaris que fan preguntes diferents:
//   MF_APP_VERSION=1.0.0 npm run ios -- --mode Release
const APP_VERSION = process.env.MF_APP_VERSION || '1.0.0';

Les dues dades arriben al codi en execució com a literals: afegeix DefinePlugin a l’import de @rspack/core del host, i aquesta entrada a plugins abans de ModuleFederationPluginV2:

// Les dues dades de temps de build que la capa operativa necessita com a literals dins del bundle:
// on és la CDN i quina versió és aquest binari. Una base buida és el senyal que no s'ha configurat
// cap CDN, i això és el que manté una build de desenvolupament normal als servidors de
// desenvolupament.
new DefinePlugin({
  __MF_CDN_BASE__: JSON.stringify(CDN_BASE),
  __APP_VERSION__: JSON.stringify(APP_VERSION),
}),

La funció remoteUrl i el mapa remotes del post 14 es queden, i canvia el seu paper. Module Federation vol un nom i una entrada per a cada remote declarat en temps de build, per això el mapa es queda; com que les seves URLs no porten versió, contra un arbre versionat no resolen res. El comentari que hi ha a sobre de la funció ho diu:

// El mapa de remotes de temps de build, en una funció: servidor de desenvolupament o CDN, amb el
// mateix nom de manifest en tots dos casos. En mode CDN el que produeix és un marcador de posició i
// no s'hi carrega res: la URL versionada que l'app fa servir de debò es decideix a l'arrencada. Es
// deixa apuntant a un lloc versemblant en lloc d'eliminar-lo, perquè Module Federation vol un nom i
// una entrada per a cada remote declarat en temps de build, i perquè en mode dev això continua sent
// tot el que hi ha.
const remoteUrl = name =>
  CDN_BASE
    ? `${name}@${CDN_BASE}/${platform}/${name}/mf-manifest.json`
    : `${name}@${DEV_REMOTES[name]}/${platform}/mf-manifest.json`;

En mode CDN, el mapa de temps de build és un marcador de posició. La URL que l’app fa servir de debò es decideix a l’arrencada, amb codi del host que copies del tag final en lloc d’escriure’l. Descarrega el tag una vegada; amb les ordres de còpia t’emportes aquell codi, els seus tests i els seus mocks de Jest, i també els altres fitxers que fan servir les seccions següents:

npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-15-cdn-flip /tmp/pokedex-ref-15
cp /tmp/pokedex-ref-15/apps/host/src/shell/{remoteLocator,scriptManager,federationErrors}.ts /tmp/pokedex-ref-15/apps/host/src/shell/FederationBanner.tsx apps/host/src/shell/
cp /tmp/pokedex-ref-15/apps/host/__mocks__/{repack-client,module-federation-runtime}.js apps/host/__mocks__/
cp /tmp/pokedex-ref-15/apps/host/__tests__/{remoteLocator,scriptManager,federationErrors}.test.ts /tmp/pokedex-ref-15/apps/host/__tests__/{App,RemoteBoundary}.test.tsx apps/host/__tests__/
cp /tmp/pokedex-ref-15/apps/host/App.tsx apps/host/
cp /tmp/pokedex-ref-15/scripts/federation-smoke.sh scripts/
cp /tmp/pokedex-ref-15/packages/ui/src/tokens/__tests__/contrast.accessibility.ts packages/ui/src/tokens/__tests__/
cp /tmp/pokedex-ref-15/tools/gen-signing-keys.mjs /tmp/pokedex-ref-15/tools/gen-signing-keys.test.mjs tools/

Llegeix-los en l’ordre en què s’executen. src/shell/scriptManager.ts és l’arrencada, i el primer que fa és descarregar el mapa. Tots els imports federats esperen aquesta resposta, així que la petició necessita una espera limitada: sense aquesta espera, una CDN lenta retindria l’app a l’splash tant com ho permetés la xarxa. L’espera és d’un segon i mig:

const PROBE_TIMEOUT_MS = 1500;

L’imposen un AbortController i un temporitzador, muntats a mà perquè React Native no ofereix cap drecera. AbortSignal.timeout() no hi existeix: la 0.85 instal·la AbortController i AbortSignal des del paquet abort-controller, que no té timeout. La petició tampoc no té timeout propi: React Native construeix el client HTTP d’Android amb tots els timeouts a zero, i a iOS passa el timeout de la petició, que per defecte és zero:

// --- Descarrega i llegeix el mapa de versions d'aquesta versió de l'app. Retorna null davant de
// qualsevol mena d'errada, perquè qui la crida les tracta totes igual: una CDN inabastable, un 404
// per a una versió de l'app de la qual ningú no va publicar cap mapa, un timeout i un mapa que no
// es pot interpretar acaben tots amb aquest binari sense executar cap remote. ---
async function fetchVersionMap(): Promise<Record<string, string> | null> {
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
  try {
    const response = await fetch(versionMapUrl(CDN_BASE, Platform.OS, APP_VERSION), {
      signal: controller.signal,
      // El mapa és l'únic fitxer de la CDN que no s'ha de servir mai des d'un cache: és el registre
      // del que és vigent, i una còpia obsoleta és un desplegament desfet en silenci. Tots els
      // altres fitxers que descarrega l'app porten la versió a la URL i es poden desar al cache per
      // sempre.
      headers: { 'cache-control': 'no-cache' },
    });
    if (!response.ok) {
      console.warn(`[federation] version map returned ${response.status}`);
      return null;
    }
    const versions = parseVersionMap((await response.json()) as unknown, REMOTE_NAMES);
    if (!versions) {
      // L'única errada que no és culpa de la xarxa de ningú ni d'una caiguda: el fitxer s'ha
      // servit, però el seu contingut no és vàlid. Es registra un avís, perquè l'alternativa és una
      // app que arrenca, no carrega res i no dona cap motiu.
      console.warn('[federation] version map was served but could not be read');
    }
    return versions;
  } catch (error) {
    console.warn('[federation] version map could not be fetched', error);
    return null;
  } finally {
    clearTimeout(timer);
  }
}

Amb les versions a la mà, cada remote passa a apuntar al manifest que hi ha dins del seu directori de versió:

// --- Apunta cada remote al manifest versionat que ha resolt aquesta arrencada.
//
// force no és opcional. Cada remote ja està registrat amb el seu nom des del mapa de remotes de
// temps de build, i registerRemotes deixa en pau un nom ja registrat si no se li indica el
// contrari: sense el flag, aquesta crida torna sense dir res, no canvia res i l'app carrega les
// URLs sense versió del marcador de posició. Amb el flag, Module Federation escriu un avís per
// tornar a registrar un remote a cada arrencada, que és el cost previst de fer això. ---
function registerCdnRemotes(versions: Record<string, string>): void {
  registerRemotes(
    REMOTE_NAMES.filter(name => versions[name]).map(name => ({
      name,
      entry: remoteManifestUrl(CDN_BASE, Platform.OS, name, versions[name]),
    })),
    { force: true },
  );
}

registerRemotes sense force no fa res, en silenci, amb un nom que ja està registrat. Al runtime core de Module Federation 2.9.0, la branca per a un nom existent no fa absolutament res quan falta force: ni error, ni avís, ni canvi. El mapa de temps de build va registrar els dos remotes molt abans que s'executés la sonda, així que cada arrencada els torna a registrar amb { force: true }, i el runtime escriu [ Federation Runtime ]: The remote "listApp" is already registered. Please note that overriding it may cause unexpected errors. una vegada per remote a la consola de desenvolupament. Aquesta línia és el senyal que el nou registre ha funcionat. Una arrencada que no l'escriu ha carregat els marcadors de posició.

L’arrencada és una sola funció, desada com a promesa perquè una segona crida que arribi a mitja sonda esperi la mateixa resposta en lloc de llegir l’estat d’abans que comencés:

export function initializeFederation(): Promise<FederationStatus> {
  initialization ??= resolveFederation();
  return initialization;
}

async function resolveFederation(): Promise<FederationStatus> {
  if (!CDN_CONFIGURED) {
    status = __DEV__
      ? { mode: 'dev', source: 'dev servers', versions: {} }
      : { mode: 'unresolved', source: 'no CDN configured', versions: {} };
    return status;
  }

  const versions = await fetchVersionMap();
  if (!versions) {
    // Redactat per cobrir totes les maneres en què això falla, perquè el banner és una afirmació
    // que l'app fa sobre si mateixa: un mapa que s'ha servit i s'ha rebutjat no és un mapa
    // inabastable, i la línia de log del costat ja diu quin dels dos casos ha passat.
    status = { mode: 'unresolved', source: 'no usable version map', versions: {} };
    return status;
  }

  // L'estat es fixa abans del registre perquè el resolver el llegeix, i es desfà si el registre
  // llança una excepció. Anunciar el mode CDN després d'un registre fallit posaria les versions al
  // banner mentre cada càrrega anava a la URL de marcador de posició de temps de build: una app que
  // diu que executa la 1.2.0 i no executa res.
  status = { mode: 'cdn', source: CDN_BASE, versions };
  try {
    registerCdnRemotes(versions);
  } catch (error) {
    console.warn('[federation] remotes could not be re-registered', error);
    status = { mode: 'unresolved', source: 'remotes could not be registered', versions: {} };
  }
  return status;
}

D’aquí surten tres modes: dev és el món del post 14, cdn és el mode per al qual existeix aquest post, i unresolved és una errada amb nom: no hi ha cap mapa utilitzable, així que no hi ha cap remote, rebutjat en lloc de carregat sense verificar. FederationBanner.tsx mostra el mode i les versions resoltes en una píndola a sobre de la barra de pestanyes, perquè una demo es pugui fotografiar en lloc d’haver-se-la de creure.

La comporta és a App.tsx. Les pestanyes del navigator són imports federats amb React.lazy, així que muntar el navigator inicia la primera descàrrega, i això ha d’esperar el nou registre. L’estat i l’efecte són a App, per sobre de SafeAreaProvider, perquè aquest provider no renderitza cap fill fins que ha mesurat els insets: per sota, la comporta esperaria aquesta mesura, i a Jest, on res no mesura, no s’obriria mai:

const [federationReady, setFederationReady] = useState(false);

useEffect(() => {
  let live = true;
  initializeFederation()
    .catch(err => console.warn('federation initialisation failed', err))
    .then(() => {
      if (live) {
        setFederationReady(true);
      }
    });
  return () => {
    live = false;
  };
}, []);
{federationReady ? (
  <>
    <Shell navTheme={navTheme} mode={mode} onReady={() => setNavReady(true)} />
    <FederationBanner />
  </>
) : null}

L’import d’arrencada de partyApp/partySlice del post 8 passa darrere del mateix flag, perquè és una càrrega federada com qualsevol altra. Després d’aquest canvi, res federat no es carrega abans que l’arrencada tingui la seva resposta.

Una sonda fallida no deixa l’app a l’splash. La comporta s’obre igualment, com a màxim un timeout de sonda després, i el mode recull la diferència: després d’una sonda fallida, el banner mostra unresolved, el resolver rebutja tots els remotes i cada pestanya mostra el seu estat d’error.

Els tests del host necessiten dues entrades més a apps/host/jest.config.js, perquè ScriptManager.shared busca un runtime de bundler tan bon punt es toca, i un procés de Jest no en té cap. El mapa i el comentari que té a sobre passen a ser:

// Reanimated executa les animacions a través de la JSI (la JavaScript Interface que fa servir React
// Native per cridar codi natiu), i un procés de Jest no té runtime per a ella, així que se
// substitueix pel mock de __mocks__. Una sola entrada cobreix tota la federació: @pokedex/ui i
// @pokedex/detail importen el mateix especificador de mòdul, així que els seus components animats
// resolen al mateix mock. El mòdul d'estat federat tampoc no té codi font resoluble a Jest; el seu
// mock repeteix l'efecte secundari del mòdul real (la injecció del reducer) perquè es pugui provar
// la preparació de l'arrencada.
// Les entrades del client de Re.Pack i del runtime de Module Federation existeixen perquè
// l'ScriptManager de Re.Pack i el registerRemotes de Module Federation busquen un runtime de
// bundler que un procés de Jest no té, i el host fa servir tots dos a l'àmbit del mòdul, perquè el
// resolver sigui al seu lloc abans que es pugui disparar cap import federat. Els seus mocks
// registren el que se'ls ha demanat.
moduleNameMapper: {
  '^react-native-reanimated$': '<rootDir>/__mocks__/react-native-reanimated.js',
  '^partyApp/partySlice$': '<rootDir>/__mocks__/partyApp-partySlice.js',
  '^partyApp/styles$': '<rootDir>/__mocks__/partyApp-styles.js',
  '^@callstack/repack/client$': '<rootDir>/__mocks__/repack-client.js',
  '^@module-federation/runtime$': '<rootDir>/__mocks__/module-federation-runtime.js',
},

Un sol resolver per a tots els chunks

El mapa anomena una versió per remote. Encara cal que alguna cosa converteixi cada script que carrega la federació en una URL dins del directori d’aquella versió: el container quan el remote s’importa per primera vegada, i després cada chunk que demana el container. L’ScriptManager de Re.Pack consulta els seus resolvers per ordre de prioritat, i guanya el primer que retorna un locator. Aquest post n’hi afegeix un. Les seves decisions són a src/shell/remoteLocator.ts com a funcions normals, que es poden provar sense dispositiu, i cada script rep una de tres respostes:

/** La decisió del resolver per a un script. */
export type Resolution =
  | { kind: 'defer' }
  | { kind: 'locate'; locator: RemoteLocator }
  | { kind: 'refuse'; reason: string };

Quina de les tres depèn del mode, del remote i del mapa:

// --- A quin remote pertany un script. Un container s'identifica sol: el seu id de script ÉS el nom
// del remote. Un chunk no, així que qui el demana és l'únic que diu d'on ve, i per això el
// resolver rep tots dos arguments en lloc de buscar patrons a l'id. ---
function remoteFor(
  scriptId: string,
  caller: string | undefined,
  remoteNames: readonly string[],
): string | undefined {
  if (remoteNames.includes(scriptId)) {
    return scriptId;
  }
  if (caller && remoteNames.includes(caller)) {
    return caller;
  }
  return undefined;
}

// --- Decideix què fer amb un script: localitzar-lo dins del directori de la seva versió,
// rebutjar-lo o delegar-lo en la resolució del mateix Re.Pack.
//
// Delegar-lo passa l'script al resolver següent, i per a un dels remotes d'aquest host el següent
// és el resolver per remote de Re.Pack: respon amb una URL construïda a partir de l'últim manifest
// que s'ha registrat i sense cap comprovació de signatura. Aquesta és la resposta correcta en
// desenvolupament, on els servidors de desenvolupament ho controlen tot, i per a qualsevol script
// que no sigui d'un dels remotes d'aquest host. Fora de desenvolupament no és mai la resposta
// correcta per a un remote: sense mapa de versions, o amb un mapa que no ha anomenat cap versió per
// a aquest remote, delegar-lo carregaria codi des d'una URL sense versió, sense verificar. Per
// això aquestes càrregues es rebutgen, i el rebuig apareix com l'estat d'error de la pestanya. ---
export function resolveRemoteLocator(input: ResolveInput): Resolution {
  if (input.mode === 'dev') {
    return { kind: 'defer' };
  }
  const remoteName = remoteFor(input.scriptId, input.caller, input.remoteNames);
  if (!remoteName) {
    return { kind: 'defer' };
  }
  const version = input.mode === 'cdn' ? input.versions[remoteName] : undefined;
  if (!version) {
    return {
      kind: 'refuse',
      reason:
        input.mode === 'cdn'
          ? `the version map named no version for ${remoteName}`
          : `no version map was read at launch, so ${remoteName} has no version to load`,
    };
  }
  const filename =
    input.scriptId === remoteName
      ? `${remoteName}.container.js.bundle`
      : `${input.scriptId}.chunk.bundle`;
  return {
    kind: 'locate',
    locator: {
      url: `${input.cdnBase}/${input.platform}/${remoteName}/${version}/${filename}`,
      // El cache va per URL, i aquí una URL porta la seva versió, així que un fitxer desat al
      // cache només es pot servir per a la versió amb què es va descarregar. Una versió nova és una
      // URL nova i una descàrrega nova.
      cache: true,
      verifyScriptSignature: input.verify,
    },
  };
}

remoteLocator.test.ts recorre aquest arbre, inclòs un chunk resolt a partir de qui el demana i no del seu id, el cas que demostra que el resolver llegeix de debò el segon argument.

scriptManager.ts el registra a l’àmbit del mòdul, així que és al seu lloc abans que es pugui importar res federat, sigui quin sigui l’ordre en què s’executi l’arrencada: un cop webpack i el runtime de la federació han carregat un container o un chunk, no el tornen a demanar, així que un resolver afegit després de la primera càrrega d’un script no veu mai aquell script.

ScriptManager.shared.addResolver(
  async (scriptId: string, caller?: string) => {
    const resolution = resolveRemoteLocator({
      scriptId,
      caller,
      remoteNames: REMOTE_NAMES,
      mode: status.mode,
      versions: status.versions,
      platform: Platform.OS,
      cdnBase: CDN_BASE,
      verify: VERIFY,
    });
    if (resolution.kind === 'refuse') {
      // Es llança, no es retorna. No retornar res passa l'script al resolver següent, que per a un
      // remote és el del mateix Re.Pack i el carregaria sense verificar. El resolveScript de Re.Pack
      // s'atura al primer resolver que llança, així que la càrrega falla i la pestanya mostra el
      // seu estat d'error.
      throw new Error(`[federation] refused ${scriptId}: ${resolution.reason}`);
    }
    return resolution.kind === 'locate' ? resolution.locator : undefined;
  },
  { key: '__signed_resolver__', priority: 100 },
);

La prioritat és la línia que decideix si res d’això s’executa. El ResolverPlugin de Re.Pack registra un resolver propi per a cada remote en el moment en què aquell remote es registra, amb una clau i sense prioritat, així que agafa la prioritat per defecte de ScriptManager, que és 2 al codi font de la 5.2.5; els resolvers s’executen de més a menys prioritat. Quan l’arrencada torna a registrar els remotes, aquell resolver integrat es reconstrueix a partir de la URL del manifest versionat, però el seu locator no porta cap ajust de verificació, així que un resolver personalitzat amb prioritat inferior a 2 perd davant d’aquest, i perd en silenci: es carreguen les versions correctes, i tots els chunks es carreguen sense verificar. 100 és molt per sobre de 2, i scriptManager.test.ts ho comprova contra el mock.

El mateix resolver integrat explica per què no retornar res és perillós quan un remote no té versió. Si el resolver no retorna res, la càrrega passa a aquell resolver integrat, que descarregaria l’script des de la URL sense versió de temps de build, sense cap comprovació de signatura. Llançar una excepció, en canvi, acaba la cerca, perquè el resolveScript de Re.Pack s’atura al primer resolver que llança.

El mapa arriba per la xarxa, així que parseVersionMap el llegeix com el JSON d’un desconegut. Una versió es converteix en un segment de ruta d’una URL des de la qual l’app descarrega codi, així que cadascuna es comprova primer contra /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/: amb una barra, la ruta sortiria del directori de versió, i el primer caràcter descarta ... Una sola versió incorrecta fa que es rebutgi el mapa sencer, perquè un mapa llegit a mitges arrencaria l’app amb versions que ningú no ha publicat; els noms de remote que aquest binari no coneix s’ignoren, perquè una mateixa CDN pot servir diverses apps. remoteLocator.test.ts cobreix les formes que rebutja el parser i els noms que ignora.

Tanca amb clau

El post 14 va signar tots els chunks de producció i no va llegir cap de les signatures: «una signatura que ningú no verifica és un segell, no un cadenat». El cadenat és un camp del locator:

// --- La verificació de signatures només té sentit on hi ha una clau pública contra la qual
// verificar: la clau va incrustada a l'Info.plist d'iOS i a l'strings.xml d'Android, i enlloc més.
// En aquestes dues plataformes és estricta, cosa que vol dir que un chunk la signatura del qual no
// coincideix amb la clau, o que no porta signatura, es rebutja abans d'executar-se. ---
const SIGNED_PLATFORMS = ['ios', 'android'];
const VERIFY: VerifyMode = SIGNED_PLATFORMS.includes(Platform.OS) ? 'strict' : 'off';

Re.Pack accepta tres valors. lax només verifica quan hi ha un token i deixa passar un chunk sense signar; off no verifica res; strict rebutja igualment una signatura que no coincideix i una que falta, i això és el que converteix el segell en un cadenat.

Cada plataforma llegeix la clau pel nom: iOS des d’Info.plist, a l’entrada RepackPublicKey, i Android des de res/values/strings.xml, en una entrada amb el mateix nom. Tots dos fitxers són al repositori i es compilen dins del binari, així que el generador que has copiat del tag ara hi escriu la clau.

Les dues plataformes la reben en formats diferents. iOS interpreta PEM, el text en base64 entre les línies BEGIN i END, així que rep el fitxer tal com es va desar. Android només fa servir el cos en base64, i en treu aquelles línies i els salts de línia abans de descodificar, així que rep només aquell cos, en una sola línia. Abans de construir cap dels dos formats, el generador converteix els finals de línia de Windows (CRLF, un retorn de carro i un salt de línia) en els d’Unix (LF), així que una clau desada amb qualsevol editor queda incrustada amb el mateix valor. La seva comprovació contra la clau privada compara DER, la forma binària que codifiquen tots dos formats, així que els finals de línia tampoc no la poden trencar:

// --- Posa la meitat pública on la llegeix l'app. Les dues plataformes reben la clau en dos formats:
// iOS interpreta PEM, així que rep el fitxer tal qual; Android en treu la capçalera, el peu i els
// salts de línia abans de descodificar, així que rep només el cos en base64, en una línia. ---
// Els finals de línia es normalitzen abans de construir cap dels dos formats, així que una clau
// pública que ha passat per una eina o un editor que escriu CRLF queda incrustada amb exactament
// els mateixos valors que una desada amb LF. Coincideix amb la seva clau privada en qualsevol cas:
// la comprovació de més amunt compara bytes DER, no text.
const publicPem = readFileSync(publicPath, 'utf8').replace(/\r\n/g, '\n').trim();
const publicBase64 = publicPem
  .split('\n')
  .filter(line => !line.includes('PUBLIC KEY'))
  .join('');

La resta del canvi del generador substitueix el valor d’una entrada que ja existeix a cada fitxer. Tracta la falta d’una entrada com un error i no com un avís, perquè la verificació estricta falla en mode tancat: una clau que no va arribar mai a l’app es manifesta més tard, quan no es pot carregar cap remote, amb un missatge que no diu res del generador. Així que afegeix ara les dues entrades, buides, perquè el generador les ompli. A apps/host/ios/Host/Info.plist, dins del <dict> de primer nivell i a sobre de RCTNewArchEnabled:

<!-- La meitat pública del parell de claus de signatura de chunks. El verificador natiu de Re.Pack
     la llegeix d'aquesta clau pel nom, i sense ella la verificació estricta falla en mode tancat.
     L'escriu aquí tools/gen-signing-keys.mjs; al repositori es deixa buida perquè la clau es
     genera a cada checkout, i una clau pública es pot pujar sense risc, però una d'obsoleta no
     serveix. -->
<key>RepackPublicKey</key>
<string></string>

i a apps/host/android/app/src/main/res/values/strings.xml, després d’app_name:

<!-- La meitat pública del parell de claus de signatura de chunks, que el verificador natiu de
     Re.Pack llegeix pel nom. Només el cos en base64, en una línia: el verificador en treu
     qualsevol capçalera, peu i salt de línia de PEM abans de descodificar. L'escriu
     tools/gen-signing-keys.mjs; és buida al repositori perquè la clau es genera a cada
     checkout. -->
<string name="RepackPublicKey"></string>

Executa el generador. La clau privada del post 14 es conserva, i la meitat pública acaba als dos fitxers; les tres primeres línies de la seva sortida ho diuen:

node tools/gen-signing-keys.mjs
chunk-signing keypair already present, kept
embedded the public key -> apps/host/ios/Host/Info.plist
embedded the public key -> apps/host/android/app/src/main/res/values/strings.xml

La verificació passa en el moment de la descàrrega. A les dues plataformes, la comprovació es fa dins del camí de descàrrega i cache, i un script que ja és al disc s'executa sense tornar-se a verificar. En aquesta build aquesta finestra és una sessió: el cache de locators viu a la memòria (sense setStorage), així que cada arrencada descarrega i verifica tots els chunks des de zero, i els logs del servidor d'aquest post ho mostren. Una build que faci persistent aquest cache amplia la finestra a tota la vida del fitxer desat al cache.

Dos binaris, una CDN

El mapa va per versió de l’app, i no és un sol fitxer per a tothom, perquè la compatibilitat no es pot negociar al dispositiu. La còpia del host de cada singleton compartit és l’única còpia al runtime, carregada abans que cap remote, cosa que és el contracte del post 3; un remote construït contra una altra versió no pot portar la seva. Així que la tria es fa al servidor, per binari. Dues builds del mateix checkout ho mostren. Serveix l’arbre i construeix l’scheme Release dues vegades, una per versió de l’app, en dos simuladors:

npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release --udid <simulator A> )
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=1.0.0 npm run ios -- --mode Release --udid <simulator B> )

Totes dues arrenquen a la pestanya Pokédex sense cap servidor de desenvolupament en marxa. El xip de la build 2.0.0 diu listApp 1.1.0 i el seu banner, cdn · listApp 1.1.0 · partyApp 1.0.0; el xip de la build 1.0.0 diu listApp 1.0.0. El terminal del servidor explica per què (retallat: hi falten els chunks de vendor, els chunks de la party i les línies de l’arrencada 1.0.0 posteriors al seu container):

[2026-09-21T15:51:21.029Z]  "GET /ios/maps/2.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.069Z]  "GET /ios/listApp/1.1.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.076Z]  "GET /ios/partyApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.083Z]  "GET /ios/listApp/1.1.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.095Z]  "GET /ios/partyApp/1.0.0/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.152Z]  "GET /ios/listApp/1.1.0/__federation_expose_ListStack.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.057Z]  "GET /ios/maps/1.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.091Z]  "GET /ios/listApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.116Z]  "GET /ios/listApp/1.0.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"

Primer el mapa, després els manifests i després cada chunk dins del directori que ha anomenat el mapa. La mateixa CDN, dos binaris, dues respostes. Els desfasaments que resol aquest esquema:

SituacióQuè canvia a la CDNQuè carrega cada binari instal·lat
Una correcció en un remote, per a tothomUn directori de versió nou, i després la línia de cada mapa que l’hagi de rebreLa versió del seu mapa, a la següent arrencada
Un remote que necessita un singleton compartit més nouUn directori de versió nou, i una línia només al mapa de la versió nova de l’appEls binaris antics conserven la seva línia; el binari nou rep la versió nova quan es publiqui
Una release del host sense canvis als remotesUn mapa nou, copiat de l’anteriorLes mateixes versions dels remotes que executava la versió anterior de l’app
Una versió defectuosa ja publicadaLa línia del mapa, com era abansLa versió anterior, a la següent arrencada, sense reconstruir res

La pantalla de detall no té fila perquè no és un remote independent. Des del post 5 és un paquet que es compila dins del remote que l’instal·la, així que una correcció al detall es publica com una versió nova de la list i de la party. La unitat d’un canvi de versió és el remote, i tot el que porta instal·lat canvia amb ell.

Android necessita una declaració més: MF_APP_VERSION s’afegeix a MF_CDN_BASE com a entrada de la tasca de bundle, pel motiu que va donar el post 14, i el bloc que el post 14 va afegir a apps/host/android/app/build.gradle passa a ser:

// --- rspack.config.mjs llegeix MF_CDN_BASE i MF_APP_VERSION quan s'executa la tasca de bundle, i
// Gradle no ho pot veure: una variable d'entorn no és cap de les entrades declarades de la tasca,
// així que una build on només ha canviat el valor deixa createBundleReleaseJsAndAssets UP-TO-DATE i
// publica el bundle anterior, URLs de desenvolupament incloses. Declarar-les com a entrades fa que
// un valor nou reconstrueixi el bundle i que un de sense canvis conservi el cache.
//
// MF_APP_VERSION hi importa pel mateix motiu, i per més: la demo dels dos binaris, executada a
// Android, construeix el mateix codi dues vegades sense cap altra diferència que aquesta variable, així
// que sense aquesta línia la segona build publica en silenci el bundle de la primera i fa a la CDN
// la pregunta de la primera. ---
tasks.configureEach {
    if (name.startsWith("createBundle") && name.endsWith("JsAndAssets")) {
        inputs.property("MF_CDN_BASE", System.getenv("MF_CDN_BASE") ?: "")
        inputs.property("MF_APP_VERSION", System.getenv("MF_APP_VERSION") ?: "")
    }
}
node tools/build-cdn.mjs android && ( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 MF_APP_VERSION=2.0.0 npm run android -- --mode release )

Publica la 1.2.0

L’app 2.0.0 instal·lada mostra la list 1.1.0. Envia-li un canvi que un usuari vegi: quan la party arriba a sis membres, el comptador de la capçalera de la Pokédex canvia la seva etiqueta de «My Party» a «Party full», i la seva píndola s’intensifica. A PokedexScreen.tsx, després de partyCount:

const partyFull = partyCount >= MAX_PARTY;

El grup del comptador el llegeix tres vegades: per a l’etiqueta visible, per a l’etiqueta que llegeix en veu alta un lector de pantalla i per al farciment de la píndola. Els seus comentaris expliquen per què l’estat complet es marca amb la paraula i amb el color:

{/* El recompte canvia quan l'usuari afegeix un membre des d'una altra pantalla, sense que el focus
    es mogui cap aquí. Un usuari que veu la pantalla veu canviar el número; a un usuari de lector de
    pantalla no se li diu res si això no és una live region (SC 4.1.3). L'etiqueta explicita la
    proporció, perquè «3/6» es llegeix com a «tres barra sis» o com una data, segons el lector. Amb
    sis, a més, llegeix l'estat complet, que és la paraula que un color sol no pot dir. */}
<Box
  className="flex-row items-center gap-2"
  accessible
  accessibilityLiveRegion="polite"
  accessibilityLabel={`${partyFull ? 'Party full' : 'My Party'}, ${partyCount} of ${MAX_PARTY}`}>
  <Text size="sm" className="font-semi text-darkGrey dark:text-lightGrey">
    {partyFull ? 'Party full' : 'My Party'}
  </Text>
  {/* Complet és l'únic estat de la party que val la pena marcar, i es marca dues vegades: canvia
      l'etiqueta del costat d'aquesta píndola, i la píndola s'intensifica. L'etiqueta transmet
      l'estat a tothom, fins i tot a qui no pot fer servir el color (SC 1.4.1); la píndola més
      intensa el fa visible d'un cop d'ull, en tots dos temes.
      En el tema fosc s'intensifica amb l'alfa i no amb el to, perquè la píndola sobre el blau marí
      ja és blanc translúcid, i un farciment verd allà seria un altre component amb la mateixa
      forma.
      darkGrey, no darkGreen: darkGreen és #A6D3A0, el farciment de planta, i sobre lightGreen
      mesura 1,53:1. darkGrey és el color que ja fa servir l'etiqueta del costat, i supera el llistó
      sobre tots dos verds. Les quatre parelles són a la matriu de contrast. */}
  <Box
    className={`rounded-full px-2.5 py-0.5 ${
      partyFull ? 'bg-pokemonGreen dark:bg-white/20' : 'bg-lightGreen dark:bg-white/10'
    }`}>
    <Text size="xs" className="font-head text-darkGrey dark:text-pokemonGreen">
      {partyCount}/{MAX_PARTY}
    </Text>
  </Box>
</Box>

Cada parella de colors que fa servir aquest post està mesurada a la matriu de contrast que va construir el post 12; les quatre files noves són al test del design system que has copiat. Els tests d’accessibilitat de la mateixa list comproven l’etiqueta i la píndola de l’estat complet, en tots dos temes, i el xip que tenen al costat; copia’ls ara del tag:

cp /tmp/pokedex-ref-15/apps/list/__tests__/ListStack.accessibility.tsx apps/list/__tests__/

Ara construeix el canvi com una versió i posa el seu directori a la CDN. No a través de build-cdn, que crea un arbre des de zero a partir de les seves llistes i reescriuria tot el que està servint el servidor: l’operació són dues ordres contra l’arbre que ja hi ha.

( cd apps/list && MF_REMOTE_VERSION=1.2.0 npm run bundle:ios:prod ) && rsync -a --exclude '*.map' --exclude mf-stats.json apps/list/cdn/ios/listApp/1.2.0/ cdn-root/ios/listApp/1.2.0/

No ha canviat res per a ningú. El directori hi és i cap mapa no hi apunta. Després, el segon pas, una línia de cdn-root/ios/maps/2.0.0/version-map.json:

{
  "listApp": "1.2.0",
  "partyApp": "1.0.0"
}

Torna a obrir l’app 2.0.0 instal·lada. No s’ha reconstruït ni reinstal·lat res. Un telèfon rep el canvi de la mateixa manera, a la següent arrencada, i qui ja té l’app oberta conserva fins llavors el codi que va carregar. El log del servidor, amb les línies dels chunks de vendor de la list retallades:

[2026-09-21T15:52:00.957Z]  "GET /ios/maps/2.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.977Z]  "GET /ios/listApp/1.2.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.981Z]  "GET /ios/partyApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.987Z]  "GET /ios/listApp/1.2.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.995Z]  "GET /ios/partyApp/1.0.0/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.038Z]  "GET /ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.185Z]  "GET /ios/partyApp/1.0.0/__federation_expose_styles.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.185Z]  "GET /ios/partyApp/1.0.0/__federation_expose_partySlice.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"

El xip diu listApp 1.2.0, el banner hi coincideix, i amb sis Pokémon afegits la capçalera diu Party full sobre una píndola més intensa. El binari 1.0.0 de l’altre simulador, obert després del canvi, va continuar carregant la listApp 1.0.0: el seu mapa no havia canviat.

El canvi de versió a iOS: la capçalera de la Pokédex diu listApp 1.1.0 i My Party 6/6 sobre una píndola verd clar, una targeta mostra com una línia del mapa de versions passa de 1.1.0 a 1.2.0 sense reconstruir res, i després de tornar a obrir l'app la capçalera diu listApp 1.2.0 i Party full 6/6 sobre una píndola d'un verd més intens

Puja primer el directori de la versió. Canvia el mapa al final. El mapa és el commit. Res en aquesta build no dona a un binari cap altre lloc des d'on carregar un remote, així que un mapa que apunta a un directori que encara no és a la CDN és un 404 per a tots els usuaris que obrin l'app mentrestant, i el que veuen és una pestanya que no s'obre. L'exercici de la versió que falta, a Ara trenca-ho, tres vegades, mostra què passa amb l'ordre contrari.

Registra també la release a l’eina, perquè les seves llistes coincideixin amb el que guarda ara la CDN. A tools/build-cdn.mjs, les dues llistes i els seus comentaris passen a ser:

// --- Totes les versions de cada remote que guarda la CDN. Un directori de versió s'escriu una
// vegada i després no es toca: les apps instal·lades estan carregant aquests fitxers exactes, així
// que reconstruir una versió publicada és un canvi silenciós en codi que algú ja executa. La feina
// nova porta un número nou.
//
// listApp en té tres perquè aquest post en publica dues releases. 1.0.0 i 1.1.0 són la mateixa
// pantalla amb un número de versió diferent, que és el que necessita la demo dels dos binaris; la
// 1.2.0 és la build que va afegir l'estat complet del comptador de la party, i és la que publica el
// canvi. Aquesta eina construeix cada versió a partir del codi que té al davant, així que,
// reconstruïdes des de l'arbre final, totes tres porten aquell estat: el post construeix la 1.0.0 i
// la 1.1.0 abans d'afegir aquest estat al codi, i això és el que les deixa sense. ---
const REMOTE_VERSIONS = {
  listApp: ['1.0.0', '1.1.0', '1.2.0'],
  partyApp: ['1.0.0'],
};

// --- Què pot carregar cada versió publicada de l'app. El host demana la seva entrada pel nom a
// cada arrencada, així que un binari antic continua rebent les versions amb què es va construir,
// per molt que hagi avançat la release més nova. Una entrada es retira quan ja no queda ningú en
// aquella versió de l'app, igual que un endpoint antic d'una API.
//
// La 2.0.0 apuntava a la listApp 1.1.0 fins al canvi; la línia de sota és el que ha canviat, i
// tornar-la al valor anterior és la marxa enrere. Editar-la aquí reconstrueix tot l'arbre, i
// aquesta no és l'eina per a aquesta feina: l'operació que fa el post és editar el fitxer del mapa
// que ja és a cdn-root, perquè aquest fitxer és el que llegeix una app en execució. Això crea una
// CDN des de zero; no és una operació sobre ella.
//
// El mapa no porta res més. Ni signatura, ni comptador, res que permeti a l'app distingir un mapa
// escrit aquí d'un d'escrit per qualsevol altra persona amb accés al bucket. És un forat real i es
// deixa obert a propòsit: és el tema de l'últim post de la sèrie. ---
const APP_VERSION_MAPS = {
  '1.0.0': { listApp: '1.0.0', partyApp: '1.0.0' },
  '2.0.0': { listApp: '1.2.0', partyApp: '1.0.0' },
};

Aquesta edició només serveix per portar el compte; el desplegament ja s’ha fet. També vol dir que, si crees la CDN des de zero amb l’arbre final, es construeixen les tres versions a partir del mateix codi, així que la 1.0.0 i la 1.1.0 també portarien l’estat de party completa: l’ordre que acabes de seguir és el que les deixa sense.

Dues regles de cache fan que l’operació es pugui repetir sense risc. Cada URL de chunk inclou la seva versió, així que la CDN, un proxy o el dispositiu poden guardar aquell fitxer per sempre sense confondre’l amb una release posterior. El mapa de versions canvia amb cada desplegament, així que segueix la regla contrària: la sonda envia cache-control: no-cache, i en local -c-1 fa que http-server enviï cache-control: no-cache, no-store, must-revalidate. Una CDN de producció rep les mateixes dues regles com a configuració.

Aquest host continua descarregant els chunks 1.0.0 de la party, que no han canviat, després de tornar a obrir l’app, com mostra el log, perquè només guarda el cache de locators a la memòria. Fer persistent aquest cache estalviaria aquestes descàrregues, i a més allargaria el temps que un fitxer al disc s’executa sense tornar a comprovar la seva signatura, pel motiu de l’avís «La verificació passa en el moment de la descàrrega».

Fes marxa enrere

La versió publicada és incorrecta. Torna la línia al valor anterior:

{
  "listApp": "1.1.0",
  "partyApp": "1.0.0"
}

Torna a obrir l’app i el log torna a 1.1.0 sense reconstruir res, perquè el directori antic no es va esborrar mai. Les dues llistes de build-cdn ho fan possible: la CDN conserva la 1.1.0 mentre res no hi apunta, així que fer marxa enrere és una edició i mai una build.

El mateix fitxer pla que converteix la marxa enrere en l’edició d’una línia permet a qualsevol que pugui escriure al bucket portar totes les apps instal·lades a la versió que vulgui, fins i tot una d’antiga amb una errada coneguda. Els chunks estan protegits; el mapa està al descobert. El post 17 tracta d’aquest fitxer.

Ara trenca-ho, tres vegades

Un chunk rebutjat i una versió que falta es veuen igual a la pantalla i volen dir coses oposades. Una release amb un mòdul que llança un error en inicialitzar-se no es veu diferent. Prova’ls tots tres a la build Release, amb el mapa apuntant una altra vegada a la 1.2.0.

Primer, el sistema funcionant. Canvia un byte dins del chunk exposat a la CDN i torna a obrir l’app:

printf 'X' | dd of=cdn-root/ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle bs=1 seek=2000 conv=notrunc

El servidor serveix el chunk, el dispositiu el descarrega i la verificació el rebutja abans que se n’executi ni una línia. Una build Release no té consola de Metro, així que llegeix el log del mateix simulador:

xcrun simctl spawn <simulator A> log show --last 1m --predicate 'process == "Host"' --style compact | grep -A17 'Failed to load script'

La coincidència i les línies que importen de les disset següents, sense el prefix del mateix log:

'[ScriptManager] Failed to load script:', '[ScriptDownloadFailure]', { scriptId: '__federation_expose_ListStack',
  caller: 'listApp',
     url: 'http://localhost:8000/ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle',
   { [Error: The bundle verification failed because the bundle hash is invalid.]
     code: 'ScriptDownloadFailure',

La pestanya Pokédex mostra l’estat d’error del design system, «This tab could not load» amb un botó Try again; el banner continua dient cdn · listApp 1.2.0 · partyApp 1.0.0; la pestanya Party s’obre i funciona. La línia de sota el títol depèn del mode de l’arrencada: des de la CDN diu que el remote no s’ha pogut descarregar, verificar o iniciar, i suggereix tornar a obrir l’app, mentre que el text del post 11 remetia a un servidor de desenvolupament que aquesta build no té.

Si, en canvi, hi afegeixes bytes després del final del fitxer, l’error del log passa a ser no token for the bundle was found: el verificador llegeix els últims 1280 bytes del fitxer i espera que comencin amb la marca de la signatura, i els bytes afegits han desplaçat aquella finestra més enllà. El mode estricte també ho rebutja. Restaura el chunk a partir de la còpia construïda abans de continuar.

La build Release després de manipular un chunk: la pestanya Pokédex mostra This tab could not load, una línia que diu que el remote no s'ha pogut descarregar, verificar o iniciar, i un botó Try again, mentre el banner de la federació, a sobre de la barra de pestanyes, continua dient cdn, listApp 1.2.0, partyApp 1.0.0 i la pestanya Party continua disponible

Segon, l’errada operativa. Apunta el mapa 2.0.0 a una versió el directori de la qual no existeix, la 1.3.0, i torna a obrir l’app:

[2026-09-21T15:54:25.541Z]  "GET /ios/listApp/1.3.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:54:25.542Z]  "GET /ios/listApp/1.3.0/mf-manifest.json" Error (404): "Not found"

El mateix estat d’error a la mateixa pestanya, i el banner indica listApp 1.3.0, la versió que se li ha indicat que carregués. Ningú no ha manipulat res; el mapa s’ha escrit abans que el directori que anomena, cosa que és l’ordre equivocat contra el qual adverteix l’avís «El mapa és el commit», vist des del costat de l’usuari. Copia qualsevol versió construïda a cdn-root/ios/listApp/1.3.0/ i la següent arrencada la carrega. Torna el mapa a la 1.2.0.

Tercer, una release amb un bug. Afegeix una línia sota els imports d’apps/list/src/PokedexScreen.tsx:

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

Publica-la igual que la 1.2.0, com una versió a part, i després apunta el mapa 2.0.0 a la 1.4.0:

( cd apps/list && MF_REMOTE_VERSION=1.4.0 npm run bundle:ios:prod ) && rsync -a --exclude '*.map' --exclude mf-stats.json apps/list/cdn/ios/listApp/1.4.0/ cdn-root/ios/listApp/1.4.0/

Els bundles de JavaScript que es demanen es descarreguen i passen la verificació de la signatura, però la pestanya torna a mostrar el mateix estat d’error, mentre el banner indica listApp 1.4.0. Aquesta vegada no ha fallat cap càrrega: el codi de l’app list ha arribat intacte i ha llançat un error en inicialitzar-se el seu mòdul. Esborra la línia i torna el mapa a la 1.2.0.

Cap dels tres estats d’error no va aparèixer la primera vegada que cada errada es va executar en una build Release d’iOS: l’app es va morir abans de poder-ne dibuixar cap. Android ja havia mostrat la mateixa mort al post 14, on la petició del manifest rebutjada en una build de release va acabar amb l’arrencada.

En els dos primers, la causa és l’ordre en què es notifica l’errada. Quan el mòdul d’un remote no es pot carregar, el runtime de remotes de webpack registra l’error, afegeix while loading "./ListStack" from … al seu missatge i substitueix la factory del mòdul per una que llança. Re.Pack substitueix el require de webpack per una versió protegida a cada bundle que construeix: quan un require llança, aquella versió captura l’error, el notifica com a fatal al handler global d’errors de React Native i no retorna res. Aquesta notificació arriba primer, abans que React hagi intentat renderitzar la pestanya.

En una build de desenvolupament, la notificació fatal és una caixa vermella sobre una app que funciona. En una build Release, el handler la passa al tractament natiu d’errors de React Native, on la ruta fatal acaba el procés: a iOS el mòdul d’excepcions crida RCTFatal, que llança una excepció que res no captura, i a Android llança una JavascriptException que el host per defecte torna a llançar.

Si el procés sobreviu a aquesta notificació, la pestanya arriba igualment al seu estat d’error. Que el require protegit no retorni res fa que l’import de la pestanya es resolgui sense component, React es nega a renderitzar-lo (Element type is invalid) i RemoteBoundary, que el post 11 va posar al voltant de cada pestanya, captura aquest error de renderització i mostra l’estat d’error. El boundary no veu mai l’error de càrrega original; gestiona l’errada que ve després.

src/shell/federationErrors.ts fa que el procés sobrevisqui. Embolcalla el handler global de React Native i, en els dos primers casos, descarta la notificació el missatge de la qual acaba amb el sufix que ha afegit el runtime de remotes, que el runtime escriu en un únic lloc i sempre com a última línia del missatge:

const HANDLED_BY_REMOTE_RUNTIME = /\nwhile loading "[^"\n]+" from \S+$/;

export function isHandledRemoteLoadError(error: unknown): boolean {
  if (typeof error !== 'object' || error === null) {
    return false;
  }
  const { message } = error as { message?: unknown };
  return typeof message === 'string' && HANDLED_BY_REMOTE_RUNTIME.test(message);
}

Abans es van escriure, mesurar i descartar dues versions d’aquest matcher, i totes dues són ara casos de test. La primera buscava ChunkLoadError pel nom i a més exigia que la part del sufix corresponent al container contingués el nom del remote. En una build de desenvolupament aquesta part diu webpack/container/reference/listApp; en una build Release aquest mòdul es minimitza al seu id numèric, 77469, així que la comprovació passava en desenvolupament i fallava just on importava. La segona va treure la comprovació del container, però continuava buscant ChunkLoadError pel nom, que és com arriba una signatura incorrecta; una versió que la CDN no té arriba com [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003 amb el mateix sufix, i continuava fent caure l’app. En una càrrega fallida, la protecció busca només el sufix.

El sufix cobreix els dos primers casos, però no el tercer. El codi de l’app list es va descarregar i verificar, i va llançar un error mentre s’avaluava el seu mòdul. El container del remote és un altre bundle que ha construït Re.Pack, de manera que el require més extern que conté també està protegit, i notifica l’error com a fatal abans que cap crida no retorni el control. El runtime de remotes va veure una càrrega correcta, així que ningú no hi va afegir cap sufix, i amb només el matcher la build Release es va morir a l’arrencada:

*** Terminating app due to uncaught exception 'RCTFatalException: Unhandled JS Exception: Error: PokedexScreen failed to initialise'

En canvi, la protecció pot reconèixer la notificació del tercer cas pel moment en què arriba: mentre s’avalua un mòdul d’un remote. Quan el host importa un mòdul d’un remote, el runtime de remotes demana a Module Federation la factory d’aquell mòdul sense executar, amb loadFactory: false, i Module Federation passa aquesta factory al hook onLoad de cada plugin del runtime abans que res no la cridi. Una funció retornada pel hook substitueix la factory. src/shell/scriptManager.ts instal·la un plugin que retorna, en lloc de la factory, una funció que l’executa dins d’evaluateRemoteModule, així que cada mòdul d’un remote que importa el host, a les pestanyes i a l’arrencada, s’hi avalua:

const evaluationWindow: ModuleFederationRuntimePlugin = {
  name: 'evaluation-window',
  onLoad({ exposeModuleFactory }) {
    if (typeof exposeModuleFactory !== 'function') {
      return undefined;
    }
    return () => evaluateRemoteModule(exposeModuleFactory);
  },
};
registerPlugins([evaluationWindow]);

evaluateRemoteModule, a federationErrors.ts, obre una finestra al voltant de la factory. Qualsevol notificació fatal que es produeixi amb la finestra oberta ve de l’avaluació d’aquell mòdul, així que la protecció la reté en lloc de deixar-la passar. Quan la factory retorna, evaluateRemoteModule llança l’error retingut:

export function evaluateRemoteModule<T>(factory: () => T): T {
  const globals = store();
  const outer = globals[EVALUATING];
  const evaluation: Evaluation = {};
  globals[EVALUATING] = evaluation;
  try {
    let exports: T;
    try {
      exports = factory();
    } catch (error) {
      throw remember(error);
    }
    if (evaluation.failure) {
      throw remember(evaluation.failure.error);
    }
    return exports;
  } finally {
    globals[EVALUATING] = outer;
  }
}

La finestra és exacta perquè l’avaluació és síncrona: no s’executa res més entre obrir-la i tancar-la. Només es retenen les notificacions fatals, perquè només una notificació fatal acaba el procés.

Després, l’error llançat des de la finestra arriba al require protegit del host mateix, que el notifica com a fatal per segona vegada, ja fora de la finestra. remember guarda cada error llançat des de la finestra, i la protecció descarta aquesta segona notificació igual que descarta les que porten el sufix. L’import es resol sense el mòdul i la pestanya arriba al mateix estat d’error que en els altres dos casos.

La protecció instal·lada combina les dues rutes. Reté una notificació fatal que es produeix dins d’una finestra, descarta una notificació que acaba amb el sufix o que repeteix un error llançat des d’una finestra, i passa tota la resta al handler que hi havia abans:

// --- On desa la protecció el handler que embolcalla. Fast Refresh pot tornar a avaluar aquest mòdul
// en una app en execució, i cada avaluació comença amb un estat de mòdul nou, així que un flag en
// aquest fitxer no pot distingir una segona instal·lació de la primera. En lloc d'això,
// l'embolcall porta el handler que té a sota, en una propietat que totes les avaluacions del mòdul
// coneixen pel nom. ---
const WRAPPED = '__federationGuardWrapped';
type GuardHandler = GlobalErrorHandler & { [WRAPPED]?: GlobalErrorHandler };

export function guardHandledRemoteLoadErrors(): void {
  const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsShape }).ErrorUtils;
  if (!errorUtils) {
    return;
  }
  const current: GuardHandler = errorUtils.getGlobalHandler();
  const previous = current[WRAPPED] ?? current;
  const guard: GuardHandler = (error, isFatal) => {
    const evaluation = store()[EVALUATING] as Evaluation | undefined;
    if (evaluation && isFatal) {
      evaluation.failure ??= { error };
      console.warn(
        '[federation] a remote module threw while it was evaluated; the import that asked for it settles without it',
        error,
      );
      return;
    }
    if (isHandledRemoteLoadError(error) || isThrownFromEvaluation(error)) {
      // Es registra al log, no s'amaga: el motiu de l'estat d'error d'una pestanya ha d'aparèixer a
      // la consola de qui ho estigui mirant.
      console.warn(
        '[federation] a remote failed to load; its tab will show the error state',
        error,
      );
      return;
    }
    previous(error, isFatal);
  };
  guard[WRAPPED] = previous;
  errorUtils.setGlobalHandler(guard);
}

Tornar a instal·lar la protecció substitueix la que troba en lloc d’embolcallar-la. Fast Refresh pot tornar a executar el mòdul durant el desenvolupament, i un segon embolcall deixaria les comprovacions del primer funcionant per sota; federationErrors.test.ts torna a avaluar el mòdul per exigir que hi hagi una sola protecció. La finestra i el registre que manté remember es guarden a l’objecte global pel mateix motiu, de manera que totes les avaluacions del mòdul els comparteixen.

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.

El que permeten les botigues

En tot aquest post es descarrega codi en una app instal·lada, així que s’hi apliquen les regles de les plataformes, i tots dos proveïdors ja han reformulat abans aquestes clàusules. Totes les cites d’aquesta secció surten de les pàgines vigents.

PlataformaText que regeixQuè diuCondició
Apple, revisióApp Review Guideline 2.5.2Les apps no poden «download, install, or execute code which introduces or changes features or functionality of the app, including other apps»El binari revisat defineix el que fa l’app; el codi descarregat no pot ampliar ni canviar aquesta funcionalitat
Apple, llicènciaDeveloper Program License Agreement 3.3.1(B)«Interpreted code may be downloaded to an Application but only so long as» es compleixin tres condicionsManté el propòsit previst i anunciat de l’app; no fa bypass de la signatura, el sandbox ni altres funcions de seguretat del sistema operatiu; en una app de l’App Store, no crea cap botiga ni aparador per a altres apps
Google PlayPolítica Device and Network AbuseUna app «may not download executable code (such as dex, JAR, .so files) from a source other than Google Play»La restricció «does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs»

Els dos documents d’Apple tracen límits diferents, i s’apliquen tots dos. La clàusula 3.3.1(B) de l’acord de llicència permet el codi interpretat descarregat només mentre «does not change the primary purpose of the Application by providing features or functionality that are inconsistent with the intended and advertised purpose of the Application», «does not bypass signing, sandbox, or other security features of the OS» i, «for Applications distributed on the App Store, does not create a store or storefront for other Applications». La guideline 2.5.2 és més estricta: la primera condició de la llicència protegeix el propòsit de l’app, mentre que la guideline descarta el codi descarregat que introdueix o canvia les features o la funcionalitat de l’app. Així que complir les condicions de la llicència no demostra que es compleixin les directrius de revisió. La lectura amb què treballen els serveis over-the-air és limitar el que s’envia d’aquesta manera a correccions i ajustos dins de la funcionalitat que Apple ja ha revisat, acceptant que la redacció deixa a Apple marge per no estar-hi d’acord.

La regla de Google anomena una excepció, no una condició: la restricció «does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs (such as JavaScript in a webview or browser)». La política no anomena React Native ni Hermes (el motor de JavaScript que React Native fa servir per defecte), així que si Hermes hi encaixa és una inferència. És una inferència sòlida: el JavaScript que s’executa a Hermes només arriba a les APIs d’Android a través dels mòduls compilats de manera nativa que ja conté el binari, cosa que és accés indirecte en els termes mateixos de la clàusula. Els chunks d’aquesta CDN són JavaScript pla i no bytecode de Hermes, perquè cap remote de la sèrie no es compila a bytecode; la línia de la política tracta de com s’executa el codi, no del format en què es distribueix. La mateixa pàgina afegeix que el codi interpretat «loaded at run time (for example, not packaged with the app) must not allow potential violations of Google Play policies», així que el que fa un remote està subjecte a les mateixes regles que l’app on s’executa.

Per a React Native en concret, l’orientació més propera ve dels proveïdors que ofereixen actualitzacions over-the-air per a aquesta plataforma. CodePush, de Microsoft, va cobrir aquest mercat durant anys i es va retirar el 31 de març de 2025. EAS Update, el servei over-the-air d’Expo Application Services, continua en marxa, i la seva documentació sotmet les actualitzacions a les regles de les botigues: «you need to follow the rules of the platforms and app stores you are building for», les actualitzacions «need to follow the App Store and Play Store guidelines, including the content of the updates and how you use them» i «This usually means changes to your app’s behavior need to be reviewed». La seva taula de quan fer servir una actualització marca «Change to native code or native dependencies» i «Anything that requires a new app binary version» com a casos que demanen un binari nou.

La regla pràctica: envia correccions i millores de features que la botiga ja ha revisat, mai un propòsit principal nou, i mantén el binari revisat capaç de funcionar per si sol. El canvi d’aquest post és el tipus de canvi que descriu la primera meitat: modifica com mostra un estat una feature que ja existia, el comptador de la party, i no afegeix res de nou. Si un canvi concret queda dins del que s’ha revisat ho decideix la botiga, i cap build no ho pot demostrar. La segona meitat necessita la còpia dins del binari que afegeix el post 16, perquè avui un binari que no arriba a la CDN no té res per mostrar.

Executa-ho

Claus, arbre, servidor:

node tools/gen-signing-keys.mjs && node tools/build-cdn.mjs ios
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors

La build Release, amb l’adreça de la CDN i la versió d’aquest binari:

( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release )

Una build de desenvolupament funciona igual, amb les dues variables a l’ordre del servidor de desenvolupament:

( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm start )
( cd apps/host && npm run ios )

Android, amb l’adreça amb què l’emulador arriba a la màquina:

node tools/build-cdn.mjs android && ( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 MF_APP_VERSION=2.0.0 npm run android -- --mode release )

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

( 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

L’smoke test construeix ara els dos remotes a la 9.9.9, una versió que cap configuració no fa servir per defecte, i segueix la llista de chunks del mateix manifest dins del directori de versió, així que si falta el segment de versió a output.path falla en temps de build i no a l’arrencada d’un usuari.

Executat des de l’arbre final, build-cdn construeix les tres versions de la list a partir del mateix codi i crea el mapa 2.0.0 amb la 1.2.0, així que les tres versions només es diferencien pel xip. Per veure-hi un canvi de versió, edita cdn-root/ios/maps/2.0.0/version-map.json perquè anomeni la listApp 1.1.0 i torna a obrir l’app; després torna-hi la 1.2.0 i torna-la a obrir. Veure arribar l’estat de party completa en canviar de versió és cosa del build-along, l’única ruta que construeix la 1.0.0 i la 1.1.0 abans d’afegir aquest estat al codi.

El que has construït, i el que ve

Un binari instal·lat demana ara a la CDN el seu mapa a l’arrencada, carrega les versions signades que anomena aquell mapa i rebutja qualsevol remote al qual el mapa no doni versió. Publicar un remote és un directori i una línia, i fer marxa enrere és tornar la línia al seu valor; cada binari rep qualsevol de les dues coses a la següent arrencada. Quan un remote no es pot carregar, o el seu mòdul llança un error en inicialitzar-se, la build Release continua funcionant amb una pestanya morta, cosa mesurada a iOS amb un chunk rebutjat, una versió que falta i un mòdul que llança un error en inicialitzar-se, i a Android amb una versió que falta.

Queden dos límits. El mapa no està signat, així que qualsevol que pugui escriure al bucket pot dirigir totes les instal·lacions; el post 17 el signa, hi afegeix un comptador que deixa sense valor el mapa d’ahir i fa que l’app reverteixi pel seu compte una versió que falla. El límit més proper és l’abast: un binari que no arriba a la CDN, o que no pot llegir el seu mapa, arrenca en mode unresolved sense res per mostrar a cap de les dues pestanyes.

El següent: el dia que la CDN no respon. Un fallback offline inclòs al binari, i una xarxa de seguretat dins de la sessió per al remote que falla en ple ús.

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