Dos versiones de la app reciben cada una sus propias versiones de los remotes desde la misma CDN mientras cambia una línea del mapa

Entrega por CDN: el mapa de versiones, el resolver y el cambio en vivo

El post 14 terminó con una promesa: «remotes versionados en la CDN, un mapa de versiones, un resolver por arranque y un binario antiguo que nunca descarga código que no puede ejecutar». Este post construye todo eso y luego lo usa. Una app está instalada, con los servidores de desarrollo apagados, y muestra la versión 1.1.0 de la lista de la Pokédex. Le envías la 1.2.0 con un directorio y una línea de JSON editada, ves cómo llega el cambio en su siguiente arranque y lo reviertes de la misma forma.

La operación necesita tres piezas. Una red de distribución de contenidos (CDN) guarda todas las versiones publicadas de cada remote, así que una versión nueva nunca sustituye archivos que una app instalada está cargando. Un mapa por cada versión publicada de la app le dice a ese binario cuáles de esas versiones debe cargar. Un resolver en el host convierte cada petición de chunk en una URL versionada, con verificación de firma. Juntas cumplen lo que el post 1 decía que te da la federación: «Un bug en un remote es volver a subir ese remote, no un envío a la store».

Una regla da forma a todo: un binario antiguo tiene que seguir funcionando mientras alguien lo tenga instalado. La CDN conserva las versiones antiguas de los remotes igual que un backend conserva los endpoints antiguos, y cada binario recibe solo las versiones que nombra su propio mapa. Nada en el dispositivo comprueba que esas versiones funcionen con el binario: elegir versiones con las que ese binario se ha probado es trabajo del operador, y el mapa es donde queda escrita esa elección.

Empieza desde el estado final del post 14, el tag post-14-production-build; este post termina en el tag post-15-cdn-flip. Los cambios de configuración y las ediciones pequeñas los escribes tú. El nuevo código de arranque del host y unos cuantos archivos más cambian en demasiados sitios como para reescribirlos en un post con provecho, así que salen del tag final, y los comandos de copia los nombran uno a uno.

El mapa se descarga antes de importar nada federado, y cada petición posterior lleva la versión que ha nombrado el mapa.

Versiones en la estantería

Una versión tiene que llegar a tres sitios en la build de un remote, y los tres tienen que coincidir: el directorio donde se escriben los artefactos, el directorio donde se escriben los chunks y el texto que la pantalla en ejecución muestra sobre sí misma. Una sola constante alimenta los tres. En apps/list/rspack.config.mjs, encima de defineRspackConfig:

// --- Qué versión de este remote produce la build. Decide dos cosas a la vez: el directorio donde
// se escriben los artefactos y el texto que el código en ejecución da sobre sí mismo. Las dos
// salen de una sola variable, así que una build no puede escribir los archivos de la 1.2.0 y decir
// que es la 1.1.0:
//   MF_REMOTE_VERSION=1.2.0 npm run bundle:ios:prod
// Un directorio de versión se escribe una vez y no se vuelve a editar. Reconstruir una versión que
// las apps instaladas ya están cargando sustituye código que esas apps dan por fijo, lo que es
// justo el paso que esta estructura hace innecesario: publica una versión nueva en su lugar. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';

Las dos rutas de producción del post 14 pasan a llevar el segmento. En output:

// Una build de producción escribe el árbol que sirve la CDN, con la misma estructura que la ruta de
// la URL en la que se sirve: cdn/<platform>/listApp/<version>/. El segmento de versión es lo que
// permite que una CDN guarde varias releases de este remote a la vez, cada una en su propia URL.
// Una build de desarrollo sigue escribiendo en build/, de donde la lee el servidor de desarrollo, y
// no lleva versión: ahí solo hay una build, la última que se guardó.
path: isProd
  ? `${__dirname}/cdn/[platform]/listApp/${REMOTE_VERSION}`
  : `${__dirname}/build/[platform]`,

y en la entrada extraChunks del RepackPlugin:

// Los chunks quedan junto al container y el manifest, dentro del mismo directorio de versión,
// porque el host los pedirá en URLs relativas al manifest que cargó. Esta entrada los copia ahí:
// Rspack ya los ha escrito bajo output.path, así que si aquí falta el segmento de versión no se
// rompe nada en ejecución, y lo que queda es una segunda copia sin versión de cada chunk junto a
// las versionadas.
outputPath: isProd
  ? `cdn/${platform}/listApp/${REMOTE_VERSION}`
  : `build/${platform}/remote`,

El tercer sitio es un literal compilado dentro del bundle. Añade DefinePlugin al import de @rspack/core, junto al minimizador, y una entrada de plugin antes de ModuleFederationPluginV2:

import { DefinePlugin, SwcJsMinimizerRspackPlugin } from '@rspack/core';
// La versión, compilada dentro del bundle como literal para que la pantalla en ejecución pueda
// mostrar la build de la que salió. En producción se lee de la misma constante que usa la ruta de
// salida, así que el chip de la pantalla y el directorio de la CDN no pueden discrepar nunca. Una
// build de desarrollo dice 'dev': nunca se publicó en ningún sitio, y ponerle un número sería
// afirmar una versión de un archivo que se reconstruye cada vez que guardas.
new DefinePlugin({
  __REMOTE_VERSION__: JSON.stringify(isProd ? REMOTE_VERSION : 'dev'),
}),

Replica las dos rutas en apps/party/rspack.config.mjs con partyApp. Su constante lleva un comentario propio, porque la party no tiene chip y por eso no necesita DefinePlugin:

// --- Qué versión de este remote produce la build, y el directorio donde se escribe. La misma
// variable y la misma regla que en listApp: un directorio de versión se escribe una vez y no se
// vuelve a editar cuando las apps instaladas ya han empezado a cargarlo. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';

En la app list, en cambio, TypeScript necesita una declaración para __REMOTE_VERSION__, porque ese nombre no tiene ningún módulo detrás. Un apps/list/src/globals.d.ts nuevo la aporta:

// --- Nombres que el bundler sustituye por literales en tiempo de build, declarados para el
// compilador. No son imports y no hay ningún módulo detrás: rspack.config.mjs sustituye cada uno
// durante la build, así que el bundle publicado contiene el valor y nunca el nombre. ---

/** La versión de este remote que produjo la build, y el directorio de la CDN donde se escribió. */
declare const __REMOTE_VERSION__: string;

El chip es lo que hace visible un despliegue, porque dos builds del mismo remote son, por lo demás, la misma pantalla. En apps/list/src/PokedexScreen.tsx, encima de EMPTY_TYPES:

// --- La versión con la que se construyó este bundle, compilada por DefinePlugin. Mostrarla es lo
// que hace visible un despliegue: dos builds de este remote son, por lo demás, la misma pantalla,
// así que sin ella no hay forma de saber desde la app cuál se está ejecutando. El valor por defecto
// cubre Jest, donde no se ejecuta ningún bundler y el nombre nunca se sustituye. ---
const REMOTE_VERSION = typeof __REMOTE_VERSION__ === 'string' ? __REMOTE_VERSION__ : 'dev';

La fila de la cabecera pasa a tener el chip a la izquierda y el contador de party a la derecha. El chip es un elemento accesible propio, así que un lector de pantalla puede llegar a él sin oír antes el recuento de la party. La live region y su etiqueta salen de la fila y pasan a un grupo propio alrededor de la etiqueta y la píldora del post 12, así que la fila ya no absorbe el chip:

ListHeaderComponent={
  <Box className="flex-row items-center justify-between px-1.5 py-2.5">
    {/* Qué build de este remote está en pantalla. Es un elemento propio en lugar de formar parte
        del grupo del contador, para que un lector de pantalla pueda llegar a él sin que se lea en
        voz alta cada vez que cambia el recuento 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 recuento cambia cuando el usuario añade un miembro desde otra pantalla, sin que el foco
        se mueva aquí. Un usuario que ve la pantalla ve cambiar el número; a un usuario de lector de
        pantalla no se le dice nada si esto no es una live region (SC 4.1.3). La etiqueta enuncia la
        proporción en palabras, porque «3/6» se lee como «tres barra seis» o como una fecha, según
        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 es #A6D3A0, el relleno de planta, y sobre
            lightGreen mide 1,53:1. darkGrey es el color que ya usa la etiqueta de al lado. */}
        <Text size="xs" className="font-head text-darkGrey dark:text-pokemonGreen">
          {partyCount}/{MAX_PARTY}
        </Text>
      </Box>
    </Box>
  </Box>
}

tools/build-cdn.mjs del post 14 construía una versión de cada remote en un árbol plano. Sustitúyelo por uno que parte de dos listas. REMOTE_VERSIONS dice qué versiones de cada remote guarda la CDN. APP_VERSION_MAPS dice cuáles de esas versiones carga cada versión publicada de la app. Una versión puede estar en la primera lista sin que ningún mapa apunte a ella:

// --- Monta el directorio que serviría una CDN.
//
// La estructura es la de las URL, y ahora lleva una versión:
//
//   cdn-root/<platform>/<remote>/<version>/     el container, sus chunks y mf-manifest.json
//   cdn-root/<platform>/maps/<appVersion>/      version-map.json, uno por versión publicada de la app
//
// Un archivo en cdn-root/ios/listApp/1.2.0/mf-manifest.json se sirve en
// <base>/ios/listApp/1.2.0/mf-manifest.json, que es la URL que el host construye en el arranque a
// partir de la versión que le dio el mapa.
//
// Hay dos listas más abajo, y la diferencia entre ellas es toda la idea. REMOTE_VERSIONS es lo que
// guarda la CDN: todas las versiones publicadas, que se conservan hasta que nadie las ejecuta.
// APP_VERSION_MAPS es lo que cada binario publicado puede cargar de todo eso. Enviar un remote a
// las apps instaladas es una entrada nueva en la primera lista y una línea editada en la segunda.
//
// Uso: node tools/build-cdn.mjs [ios|android]     (sin argumento construye las dos)
//
// Después sírvelo y apunta el host hacia él (un emulador Android llega a esta máquina en 10.0.2.2,
// así que la build de Android recibe esa dirección en lugar 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'];

// --- Todas las versiones de cada remote que guarda la CDN. Un directorio de versión se escribe una
// vez y después no se toca: las apps instaladas están cargando esos archivos exactos, así que
// reconstruir una versión publicada es un cambio silencioso en código que alguien ya ejecuta. El
// trabajo nuevo lleva un número nuevo.
//
// listApp tiene dos: 1.0.0 y 1.1.0 son la misma pantalla con distinto número de versión, que es lo
// que necesita la demo de los dos binarios. El cambio a 1.2.0 añade la tercera. ---
const REMOTE_VERSIONS = {
  listApp: ['1.0.0', '1.1.0'],
  partyApp: ['1.0.0'],
};

// --- Qué puede cargar cada versión publicada de la app. El host pide su propia entrada por nombre
// en cada arranque, así que un binario antiguo sigue recibiendo las versiones con las que se
// construyó, por mucho que haya avanzado la release más nueva. Una entrada se retira cuando ya no
// queda nadie en esa versión de la app, igual que un endpoint antiguo de una API.
//
// Editar aquí una línea reconstruye todo el árbol, y esa no es la herramienta para publicar una
// versión: la operación que hace el post es editar el archivo del mapa que ya está en cdn-root,
// porque ese archivo es lo que lee una app en ejecución. Esto crea una CDN desde cero; no es una
// operación sobre ella.
//
// El mapa no lleva nada más. Ni firma, ni contador, nada que permita a la app distinguir un mapa
// escrito aquí de uno escrito por cualquier otra persona con acceso al bucket. Es un agujero real y
// se deja abierto a propósito: es el tema del último post de la serie. ---
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' },
};

// --- Nunca se publica: los source maps son cuatro quintas partes del árbol construido, son un
// artefacto de depuración para un crash reporter y no algo que descargue un cliente, y en un bucket
// público entregan todo el código fuente legible a quien lo pida. mf-stats.json es análisis de la
// build y está en la misma situación. El index.bundle del propio remote se queda, porque
// mf-manifest.json lo nombra entre los assets compartidos y nada aquí ha demostrado que no lo
// pida ninguna ruta. ---
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 nombra una versión que la CDN no tiene es el fallo que el post muestra a mano, y
// merece la pena detectarlo aquí y no en el arranque de un usuario. Se comprueba antes de construir
// nada, así que una errata cuesta un segundo en lugar de dos ejecuciones 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) {
  // Borra solo esta plataforma, para que construir una no elimine el árbol de la otra.
  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}) ===`);
      // Se nombra una sola vez: el directorio que se vacía, se escribe y luego se lee es un solo
      // sitio, así que ninguna edición posterior puede llevarse la salida de la build a otra parte
      // y dejar sin origen la copia que viene después.
      const built = join(appDir, 'cdn', platform, remote, version);
      // Se vacía primero, porque Rspack escribe dentro de un directorio en lugar de sustituirlo:
      // un archivo que emitió una build anterior y esta ya no emite sobreviviría y se publicaría
      // junto a los de verdad, sin ningún motivo que nadie pudiera deducir del código.
      rmSync(built, { recursive: true, force: true });
      // MF_REMOTE_VERSION decide a la vez lo que el bundle dice de sí mismo y dónde se escribe, así
      // que una sola variable no puede producir una build marcada con una versión y archivada bajo
      // otra.
      execSync(`npm run bundle:${platform}:prod`, {
        cwd: appDir,
        stdio: 'inherit',
        env: { ...process.env, MF_REMOTE_VERSION: version },
      });
      // La build ha terminado bien, así que si falta el directorio es que su ruta de salida y esta
      // ruta se han separado, y vale más decirlo en una línea que con 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(', ')}`);

Los source maps dejan de publicarse: son cuatro quintas partes de un remote construido (16 MB de los 20 MB que escribe listApp en iOS), un artefacto para el crash reporter y no una descarga para el cliente, y en un bucket público entregan el código fuente legible a quien lo pida. Construye el árbol:

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 es todo lo que se le dice a un binario:

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

Dos líneas y nada más. Los chunks a los que apunta están firmados; el archivo que elige entre ellos no. El post 17 cierra ese hueco; este post lo deja abierto y lo dice cada vez que importa.

Pregunta antes de cargar

El host necesita dos datos en tiempo de build: dónde está la CDN y qué versión de la app es este binario. En apps/host/rspack.config.mjs, el comentario del post 14 y const CDN_BASE = process.env.MF_CDN_BASE; pasan a ser:

// --- De dónde se sirven los remotes. Define MF_CDN_BASE y el host busca en la red de distribución
// de contenidos en lugar de en los servidores de desarrollo; déjala sin definir y nada cambia. El
// valor se lee en tiempo de BUILD y queda incrustado en el bundle, así que una build que lo olvidó
// publica las URLs de desarrollo:
//   MF_CDN_BASE=http://localhost:8000 npm start      (la CDN local, en una build de desarrollo)
//   MF_CDN_BASE=https://cdn.example.com npm run …    (una real, en una build de release)
//
// Lo que cambia en este post es quién usa el valor. Todavía da forma al mapa de remotes de abajo,
// pero ese mapa es ahora un marcador de posición: sus URLs no llevan segmento de versión, así que
// contra un árbol de CDN versionado no resuelven a nada. El valor que importa es el que llega al
// código en ejecución a través de DefinePlugin, donde src/shell/scriptManager.ts lo lee, pregunta
// a la CDN qué versiones puede ejecutar este binario y vuelve a registrar cada remote en una URL
// versionada antes de que se dispare el primer import.
// Las barras finales se recortan, porque cada URL construida a partir de este valor añade su propio
// separador, y una base escrita con una produce una doble barra en medio de cada ruta. La mayoría
// de los servidores lo perdonan; un script descargado se cachea con la URL que lo pidió, así que no
// merece la pena averiguar cuáles no.
const CDN_BASE = (process.env.MF_CDN_BASE || '').replace(/\/+$/, '');

// --- La versión de este binario, la pregunta que le hace a la CDN en el arranque. La CDN responde
// con las versiones de los remotes que este binario puede ejecutar, y así sigue funcionando una
// instalación de hace dos años: sigue recibiendo las versiones con las que se publicó. Una app real
// lee esto de la versión con la que salió; aquí es una variable, para que un mismo checkout pueda
// producir dos binarios que hacen preguntas distintas:
//   MF_APP_VERSION=1.0.0 npm run ios -- --mode Release
const APP_VERSION = process.env.MF_APP_VERSION || '1.0.0';

Los dos datos llegan al código en ejecución como literales: añade DefinePlugin al import de @rspack/core del host, y esta entrada a plugins antes de ModuleFederationPluginV2:

// Los dos datos de tiempo de build que la capa operativa necesita como literales dentro del
// bundle: dónde está la CDN y qué versión es este binario. Una base vacía es la señal de que no se
// configuró ninguna CDN, y eso es lo que mantiene una build de desarrollo normal en los servidores
// de desarrollo.
new DefinePlugin({
  __MF_CDN_BASE__: JSON.stringify(CDN_BASE),
  __APP_VERSION__: JSON.stringify(APP_VERSION),
}),

La función remoteUrl y el mapa remotes del post 14 se quedan, y cambia su papel. Module Federation quiere un nombre y una entrada por cada remote declarado en tiempo de build, por eso el mapa se queda; como sus URLs no llevan versión, contra un árbol versionado no resuelven a nada. El comentario encima de la función lo dice:

// El mapa de remotes de tiempo de build, en una función: servidor de desarrollo o CDN, con el mismo
// nombre de manifest en los dos casos. En modo CDN lo que produce es un marcador de posición y no
// se carga nada desde ahí: la URL versionada que la app usa de verdad se decide en el arranque. Se
// deja apuntando a un sitio verosímil en lugar de eliminarlo, porque Module Federation quiere un
// nombre y una entrada por cada remote declarado en tiempo de build, y porque en modo dev esto
// sigue siendo todo lo que hay.
const remoteUrl = name =>
  CDN_BASE
    ? `${name}@${CDN_BASE}/${platform}/${name}/mf-manifest.json`
    : `${name}@${DEV_REMOTES[name]}/${platform}/mf-manifest.json`;

En modo CDN, el mapa de tiempo de build es un marcador de posición. La URL que la app usa de verdad se decide en el arranque, con código del host que copias del tag final en lugar de escribirlo. Descarga el tag una vez; con los comandos de copia te llevas ese código, sus tests y sus mocks de Jest, y también los otros archivos que usan las secciones siguientes:

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/

Léelos en el orden en que se ejecutan. src/shell/scriptManager.ts es el arranque, y lo primero que hace es descargar el mapa. Todos los imports federados esperan esa respuesta, así que la petición necesita una espera acotada: sin ella, una CDN lenta retendría la app en el splash tanto como lo permitiera la red. La espera es de un segundo y medio:

const PROBE_TIMEOUT_MS = 1500;

La imponen un AbortController y un temporizador, montados a mano porque React Native no ofrece ningún atajo. AbortSignal.timeout() no existe ahí: la 0.85 instala AbortController y AbortSignal desde el paquete abort-controller, que no tiene timeout. La petición tampoco tiene timeout propio: React Native construye el cliente HTTP de Android con todos los timeouts a cero, y en iOS pasa el timeout de la petición, que por defecto es cero:

// --- Descarga y lee el mapa de versiones de esta versión de la app. Devuelve null ante cualquier
// tipo de fallo, porque quien la llama los trata todos igual: una CDN inalcanzable, un 404 para una
// versión de la app de la que nadie publicó un mapa, un timeout y un mapa que no se puede
// interpretar terminan todos con este binario sin ejecutar ningún 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 es el único archivo de la CDN que nunca debe servirse desde una caché: es el
      // registro de lo que está vigente, y una copia obsoleta es un despliegue deshecho en silencio.
      // Todos los demás archivos que descarga la app llevan su versión en la URL y se pueden cachear
      // para siempre.
      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) {
      // El único fallo que no es culpa de la red de nadie ni de una caída: el archivo se sirvió,
      // pero su contenido no es válido. Se registra un aviso, porque la alternativa es una app que
      // arranca, no carga nada y no da ningún motivo.
      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);
  }
}

Con las versiones en la mano, cada remote pasa a apuntar al manifest que hay dentro de su directorio de versión:

// --- Apunta cada remote al manifest versionado que ha resuelto este arranque.
//
// force no es opcional. Cada remote ya está registrado con su nombre desde el mapa de remotes de
// tiempo de build, y registerRemotes deja en paz un nombre ya registrado salvo que se le indique lo
// contrario: sin el flag, esta llamada vuelve sin decir nada, no cambia nada y la app carga las URLs
// sin versión del marcador de posición. Con él, Module Federation escribe un aviso por volver a
// registrar un remote en cada arranque, que es el coste esperado de hacer esto. ---
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 sin force no hace nada, en silencio, con un nombre que ya está registrado. En el runtime core de Module Federation 2.9.0, la rama para un nombre existente no hace absolutamente nada cuando falta force: ni error, ni aviso, ni cambio. El mapa de tiempo de build registró los dos remotes mucho antes de que se ejecutara la sonda, así que cada arranque los vuelve a registrar con { force: true }, y el runtime escribe [ Federation Runtime ]: The remote "listApp" is already registered. Please note that overriding it may cause unexpected errors. una vez por remote en la consola de desarrollo. Esa línea es la señal de que el nuevo registro ha funcionado. Un arranque que no la escribe ha cargado los marcadores de posición.

El arranque es una sola función, guardada como promesa para que un segundo llamante que llegue a mitad de la sonda espere la misma respuesta en lugar de leer el estado de antes de que empezara:

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) {
    // Redactado para cubrir todas las formas en que esto falla, porque el banner es una afirmación
    // que la app hace sobre sí misma: un mapa que se sirvió y se rechazó no es un mapa inalcanzable,
    // y la línea de log de al lado ya dice cuál de los dos ha pasado.
    status = { mode: 'unresolved', source: 'no usable version map', versions: {} };
    return status;
  }

  // El estado se fija antes del registro porque el resolver lo lee, y se deshace si el registro
  // lanza una excepción. Anunciar el modo CDN después de un registro fallido pondría las versiones
  // en el banner mientras cada carga iba a la URL de marcador de posición de tiempo de build: una
  // app que dice que ejecuta la 1.2.0 y no ejecuta nada.
  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;
}

De ahí salen tres modos: dev es el mundo del post 14, cdn es el modo para el que existe este post, y unresolved es un fallo con nombre: no hay un mapa utilizable, así que no hay ningún remote, rechazado en lugar de cargado sin verificar. FederationBanner.tsx muestra el modo y las versiones resueltas en una píldora encima de la barra de pestañas, para que una demo se pueda fotografiar en lugar de tener que creérsela.

La compuerta está en App.tsx. Las pestañas del navigator son imports federados con React.lazy, así que montar el navigator inicia la primera descarga, y eso tiene que esperar al nuevo registro. El estado y el efecto están en App, por encima de SafeAreaProvider, porque ese provider no renderiza ningún hijo hasta que ha medido los insets: por debajo, la compuerta esperaría a esa medición, y en Jest, donde nada mide, no se abriría nunca:

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}

El import de arranque de partyApp/partySlice del post 8 pasa detrás del mismo flag, porque es una carga federada como cualquier otra. Tras ese cambio, nada federado se carga antes de que el arranque tenga su respuesta.

Una sonda fallida no deja la app en el splash. La compuerta se abre en cualquier caso, como mucho un timeout de sonda después, y el modo recoge la diferencia: tras una sonda fallida, el banner muestra unresolved, el resolver rechaza todos los remotes y cada pestaña muestra su estado de error.

Los tests del host necesitan dos entradas más en apps/host/jest.config.js, porque ScriptManager.shared busca un runtime de bundler en cuanto se toca, y un proceso de Jest no tiene ninguno. El mapa y el comentario de encima pasan a ser:

// Reanimated ejecuta las animaciones a través de la JSI (la JavaScript Interface que usa React
// Native para llamar a código nativo), y un proceso de Jest no tiene runtime para ella, así que se
// sustituye por el mock de __mocks__. Una sola entrada cubre toda la federación: @pokedex/ui
// y @pokedex/detail importan el mismo especificador de módulo, así que sus componentes animados
// resuelven al mismo mock. El módulo de estado federado tampoco tiene código fuente resoluble en
// Jest; su mock repite el efecto secundario del módulo real (la inyección del reducer) para que se
// pueda probar la preparación del arranque.
// Las entradas del cliente de Re.Pack y del runtime de Module Federation existen porque el
// ScriptManager de Re.Pack y el registerRemotes de Module Federation buscan un runtime de bundler
// que un proceso de Jest no tiene, y el host usa los dos en el ámbito del módulo, para que el
// resolver esté en su sitio antes de que pueda dispararse ningún import federado. Sus mocks
// registran lo que se les pidió.
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 solo resolver para todos los chunks

El mapa nombra una versión por remote. Todavía hace falta que algo convierta cada script que carga la federación en una URL dentro del directorio de esa versión: el container cuando el remote se importa por primera vez, y después cada chunk que pide el container. El ScriptManager de Re.Pack consulta sus resolvers por orden de prioridad, y gana el primero que devuelve un locator. Este post añade uno. Sus decisiones están en src/shell/remoteLocator.ts como funciones normales, que se pueden probar sin dispositivo, y cada script recibe una de tres respuestas:

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

Cuál de ellas depende del modo, del remote y del mapa:

// --- A qué remote pertenece un script. Un container se identifica solo: su id de script ES el
// nombre del remote. Un chunk no, así que el llamante es lo único que dice de dónde viene, y por
// eso el resolver recibe los dos argumentos en lugar de buscar patrones en el id. ---
function remoteFor(
  scriptId: string,
  caller: string | undefined,
  remoteNames: readonly string[],
): string | undefined {
  if (remoteNames.includes(scriptId)) {
    return scriptId;
  }
  if (caller && remoteNames.includes(caller)) {
    return caller;
  }
  return undefined;
}

// --- Decide qué hacer con un script: localizarlo dentro del directorio de su versión, rechazarlo o
// delegarlo en la resolución del propio Re.Pack.
//
// Delegarlo pasa el script al siguiente resolver, y para uno de los remotes de este host el siguiente
// es el resolver por remote de Re.Pack: responde con una URL construida a partir del último
// manifest que se registró y sin ninguna comprobación de firma. Esa es la respuesta correcta en
// desarrollo, donde los servidores de desarrollo lo controlan todo, y para cualquier script que no
// sea de uno de los remotes de este host. Fuera de desarrollo nunca es la respuesta correcta para un
// remote: sin mapa de versiones, o con un mapa que no nombró ninguna versión para este remote,
// delegarlo cargaría código desde una URL sin versión, sin verificar. Así que esas cargas se
// rechazan, y el rechazo aparece como el estado de error de la pestaña. ---
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}`,
      // La caché va por URL, y aquí una URL lleva su versión, así que un archivo cacheado solo se
      // puede servir para la versión con la que se descargó. Una versión nueva es una URL nueva y
      // una descarga nueva.
      cache: true,
      verifyScriptSignature: input.verify,
    },
  };
}

remoteLocator.test.ts recorre este árbol, incluido un chunk resuelto por su llamante y no por su id, el caso que demuestra que el resolver lee de verdad su segundo argumento.

scriptManager.ts lo registra en el ámbito del módulo, así que está en su sitio antes de que se pueda importar nada federado, sea cual sea el orden en que se ejecute el arranque: una vez que webpack y el runtime de la federación han cargado un container o un chunk, no vuelven a pedirlo, así que un resolver añadido después de la primera carga de un script nunca ve ese 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') {
      // Se lanza, no se devuelve. No devolver nada pasa el script al siguiente resolver, que para
      // un remote es el del propio Re.Pack y lo cargaría sin verificar. El resolveScript de Re.Pack
      // se detiene en el primer resolver que lanza, así que la carga falla y la pestaña muestra su
      // estado de error.
      throw new Error(`[federation] refused ${scriptId}: ${resolution.reason}`);
    }
    return resolution.kind === 'locate' ? resolution.locator : undefined;
  },
  { key: '__signed_resolver__', priority: 100 },
);

La prioridad es la línea que decide si algo de esto se ejecuta. El ResolverPlugin de Re.Pack registra un resolver propio para cada remote en el momento en que ese remote se registra, con una clave y sin prioridad, así que toma la prioridad por defecto de ScriptManager, que es 2 en el código fuente de la 5.2.5; los resolvers se ejecutan de mayor a menor. Cuando el arranque vuelve a registrar los remotes, ese resolver integrado se reconstruye a partir de la URL del manifest versionado, pero su locator no lleva ningún ajuste de verificación, así que un resolver personalizado con prioridad menor que 2 pierde frente a él, y pierde en silencio: se cargan las versiones correctas, y todos los chunks se cargan sin verificar. 100 está muy por encima de 2, y scriptManager.test.ts lo comprueba contra el mock.

Ese mismo resolver integrado explica por qué no devolver nada es peligroso cuando un remote no tiene versión. Si el resolver no devuelve nada, la carga pasa a ese resolver integrado, que descargaría el script desde la URL sin versión de tiempo de build, sin ninguna comprobación de firma. Lanzar una excepción, en cambio, termina la búsqueda, porque el resolveScript de Re.Pack se detiene en el primer resolver que lanza.

El mapa llega por la red, así que parseVersionMap lo lee como el JSON de un desconocido. Una versión se convierte en un segmento de ruta de una URL desde la que la app descarga código, así que cada una se comprueba primero contra /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/: con una barra, la ruta saldría del directorio de versión, y el primer carácter descarta ... Una sola versión incorrecta hace que se rechace el mapa entero, porque un mapa leído a medias arrancaría la app con versiones que nadie ha publicado; los nombres de remote que este binario no conoce se ignoran, porque una misma CDN puede servir a varias apps. remoteLocator.test.ts cubre las formas que rechaza el parser y los nombres que ignora.

Echa la llave

El post 14 firmó todos los chunks de producción y no leyó ninguna de las firmas: «una firma que nadie verifica es un sello, no un candado». El candado es un campo del locator:

// --- La verificación de firmas solo tiene sentido donde hay una clave pública contra la que
// verificar: la clave va incrustada en el Info.plist de iOS y en el strings.xml de Android, y en
// ningún otro sitio. En esas dos plataformas es estricta, lo que significa que un chunk cuya firma
// no coincide con la clave, o que no lleva firma, se rechaza antes de ejecutarse. ---
const SIGNED_PLATFORMS = ['ios', 'android'];
const VERIFY: VerifyMode = SIGNED_PLATFORMS.includes(Platform.OS) ? 'strict' : 'off';

Re.Pack acepta tres valores. lax solo verifica cuando hay un token y deja pasar un chunk sin firmar; off no verifica nada; strict rechaza por igual una firma que no coincide y una que falta, y eso es lo que convierte el sello en un candado.

Cada plataforma lee la clave por nombre: iOS desde Info.plist, en la entrada RepackPublicKey, y Android desde res/values/strings.xml, en una entrada con el mismo nombre. Los dos archivos están en el repositorio y se compilan dentro del binario, así que el generador que has copiado del tag escribe ahora la clave en ellos.

Las dos plataformas la reciben en formatos distintos. iOS interpreta PEM, el texto en base64 entre las líneas BEGIN y END, así que recibe el archivo tal cual se guardó. Android solo usa el cuerpo en base64, quitando esas líneas y los saltos de línea antes de decodificar, así que recibe únicamente ese cuerpo, en una sola línea. Antes de construir cualquiera de los dos formatos, el generador convierte los finales de línea de Windows (CRLF, un retorno de carro y un salto de línea) en los de Unix (LF), así que una clave guardada con cualquier editor queda incrustada con el mismo valor. Su comprobación contra la clave privada compara DER, la forma binaria que codifican los dos formatos, así que los finales de línea tampoco pueden romperla:

// --- Pon la mitad pública donde la lee la app. Las dos plataformas reciben la clave en dos
// formatos: iOS interpreta PEM, así que recibe el archivo tal cual; Android quita la cabecera, el
// pie y los saltos de línea antes de decodificar, así que recibe solo el cuerpo en base64, en una
// línea. ---
// Los finales de línea se normalizan antes de construir cualquiera de los dos formatos, así que una
// clave pública que ha pasado por una herramienta o un editor que escribe CRLF queda incrustada con
// exactamente los mismos valores que una guardada con LF. Coincide con su clave privada en
// cualquier caso: la comprobación de arriba compara bytes DER, no texto.
const publicPem = readFileSync(publicPath, 'utf8').replace(/\r\n/g, '\n').trim();
const publicBase64 = publicPem
  .split('\n')
  .filter(line => !line.includes('PUBLIC KEY'))
  .join('');

El resto del cambio del generador sustituye el valor de una entrada que ya existe en cada archivo. Trata la falta de una entrada como un error y no como un aviso, porque la verificación estricta falla en modo cerrado: una clave que nunca llegó a la app se manifiesta más tarde: todos los remotes se niegan a cargar, con un mensaje que no dice nada del generador. Así que añade ahora las dos entradas, vacías, para que el generador las rellene. En apps/host/ios/Host/Info.plist, dentro del <dict> de primer nivel y encima de RCTNewArchEnabled:

<!-- La mitad pública del par de claves de firma de chunks. El verificador nativo de Re.Pack la
     lee de esta clave por nombre, y sin ella la verificación estricta falla en modo cerrado. La
     escribe aquí tools/gen-signing-keys.mjs; en el repositorio se deja vacía porque la clave se
     genera en cada checkout, y una clave pública se puede subir sin riesgo, pero una obsoleta no
     sirve. -->
<key>RepackPublicKey</key>
<string></string>

y en apps/host/android/app/src/main/res/values/strings.xml, después de app_name:

<!-- La mitad pública del par de claves de firma de chunks, que el verificador nativo de Re.Pack
     lee por nombre. Solo el cuerpo en base64, en una línea: el verificador quita cualquier
     cabecera, pie y salto de línea de PEM antes de decodificar. La escribe
     tools/gen-signing-keys.mjs; está vacía en el repositorio porque la clave se genera en cada
     checkout. -->
<string name="RepackPublicKey"></string>

Ejecuta el generador. La clave privada del post 14 se conserva, y la mitad pública acaba en los dos archivos; las tres primeras líneas de su salida lo dicen:

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ón ocurre en el momento de la descarga. En las dos plataformas, la comprobación se hace dentro del camino de descarga y caché, y un script que ya está en disco se ejecuta sin volver a verificarse. En esta build esa ventana es una sesión: la caché de locators vive en memoria (sin setStorage), así que cada arranque descarga y verifica todos los chunks desde cero, y los logs del servidor de este post lo muestran. Una build que persista esa caché amplía la ventana a toda la vida del archivo cacheado.

Dos binarios, una CDN

El mapa va por versión de la app, y no es un solo archivo para todo el mundo, porque la compatibilidad no se puede negociar en el dispositivo. La copia del host de cada singleton compartido es la única copia en el runtime, cargada antes que cualquier remote, lo que es el contrato del post 3; un remote construido contra otra versión no puede traer la suya. Así que la elección se hace en el servidor, por binario. Dos builds del mismo checkout lo muestran. Sirve el árbol y construye el scheme Release dos veces, una por versión de la app, en dos simuladores:

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> )

Las dos arrancan en la pestaña Pokédex sin ningún servidor de desarrollo en marcha. El chip de la build 2.0.0 dice listApp 1.1.0 y su banner, cdn · listApp 1.1.0 · partyApp 1.0.0; el chip de la build 1.0.0 dice listApp 1.0.0. El terminal del servidor explica por qué (recortado: faltan los chunks de vendor, los chunks de la party y las líneas del arranque 1.0.0 posteriores a su 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"

Primero el mapa, después los manifests y después cada chunk dentro del directorio que nombró el mapa. La misma CDN, dos binarios, dos respuestas. Los desfases que resuelve este esquema:

SituaciónQué cambia en la CDNQué carga cada binario instalado
Un arreglo en un remote, para todo el mundoUn directorio de versión nuevo, y después la línea de cada mapa que deba recibirloLa versión de su propio mapa, en su siguiente arranque
Un remote que necesita un singleton compartido más nuevoUn directorio de versión nuevo, y una línea solo en el mapa de la versión nueva de la appLos binarios antiguos conservan su línea; el binario nuevo recibe la versión nueva cuando se publique
Una release del host sin cambios en los remotesUn mapa nuevo, copiado del anteriorLas mismas versiones de los remotes que ejecutaba la versión anterior de la app
Una versión defectuosa ya publicadaLa línea del mapa, como estabaLa versión anterior, en el siguiente arranque, sin reconstruir nada

La pantalla de detalle no tiene fila porque no es un remote propio. Desde el post 5 es un paquete que se compila dentro del remote que lo instala, así que un arreglo en el detalle se publica como una versión nueva de la list y de la party. La unidad de un cambio de versión es el remote, y todo lo que lleva instalado cambia con él.

Android necesita una declaración más: MF_APP_VERSION se une a MF_CDN_BASE como entrada de la tarea de bundle, por el motivo que dio el post 14, y el bloque que el post 14 añadió a apps/host/android/app/build.gradle pasa a ser:

// --- rspack.config.mjs lee MF_CDN_BASE y MF_APP_VERSION cuando se ejecuta la tarea de bundle, y
// Gradle no puede verlo: una variable de entorno no es una de las entradas declaradas de la tarea,
// así que una build en la que solo cambió el valor deja createBundleReleaseJsAndAssets UP-TO-DATE y
// publica el bundle anterior, URLs de desarrollo incluidas. Declararlas como entradas hace que un
// valor nuevo reconstruya el bundle y que uno sin cambios conserve la caché.
//
// MF_APP_VERSION importa aquí por el mismo motivo, y por más: la demo de los dos binarios, ejecutada
// en Android, construye el mismo código dos veces sin más diferencia que esa variable, así que sin
// esta línea la segunda build publica en silencio el bundle de la primera y le hace 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

La app 2.0.0 instalada muestra la list 1.1.0. Envíale un cambio que un usuario vea: cuando la party llega a seis miembros, el contador de la cabecera de la Pokédex cambia su etiqueta de «My Party» a «Party full», y su píldora se intensifica. En PokedexScreen.tsx, después de partyCount:

const partyFull = partyCount >= MAX_PARTY;

El grupo del contador lo lee tres veces: para la etiqueta visible, para la etiqueta que lee en voz alta un lector de pantalla y para el relleno de la píldora. Sus comentarios explican por qué el estado completo se marca con la palabra y con el color:

{/* El recuento cambia cuando el usuario añade un miembro desde otra pantalla, sin que el foco se
    mueva aquí. Un usuario que ve la pantalla ve cambiar el número; a un usuario de lector de
    pantalla no se le dice nada si esto no es una live region (SC 4.1.3). La etiqueta enuncia la
    proporción en palabras, porque «3/6» se lee como «tres barra seis» o como una fecha, según el
    lector. Con seis, además, lee el estado completo, que es la palabra que un color solo no puede
    decir. */}
<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>
  {/* Completo es el único estado de la party que merece marcarse, y se marca dos veces: cambia
      la etiqueta junto a esta píldora, y la píldora se intensifica. La etiqueta transmite el estado
      a todo el mundo, incluido quien no puede usar el color (SC 1.4.1); la píldora más intensa lo
      hace visible de un vistazo, en los dos temas.
      En el tema oscuro se intensifica con el alfa y no con el tono, porque la píldora sobre el azul
      marino ya es blanco translúcido, y un relleno verde ahí sería otro componente con la misma
      forma.
      darkGrey, no darkGreen: darkGreen es #A6D3A0, el relleno de planta, y sobre
      lightGreen mide 1,53:1. darkGrey es el color que ya usa la etiqueta de al lado, y supera
      el listón sobre los dos verdes. Los cuatro pares están en la matriz de contraste. */}
  <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 par de colores que usa este post está medido en la matriz de contraste que construyó el post 12; las cuatro filas nuevas están en el test del design system que has copiado. Los tests de accesibilidad de la propia list comprueban la etiqueta y la píldora del estado completo, en los dos temas, y el chip que tienen al lado; cópialos ahora del tag:

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

Ahora construye el cambio como una versión y pon su directorio en la CDN. No a través de build-cdn, que crea un árbol desde cero a partir de sus listas y reescribiría todo lo que está sirviendo el servidor: la operación son dos comandos contra el árbol que ya existe.

( 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 cambiado nada para nadie. El directorio está ahí y ningún mapa apunta a él. Después, el segundo paso, una línea de cdn-root/ios/maps/2.0.0/version-map.json:

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

Vuelve a abrir la app 2.0.0 instalada. No se ha reconstruido ni reinstalado nada. Un teléfono recibe el cambio de la misma forma, en su siguiente arranque, y quien ya tiene la app abierta conserva el código que cargó hasta entonces. El log del servidor, con las líneas de los chunks de vendor de la list recortadas:

[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 chip dice listApp 1.2.0, el banner coincide, y con seis Pokémon añadidos la cabecera dice Party full sobre una píldora más intensa. El binario 1.0.0 del otro simulador, abierto después del cambio, siguió cargando la listApp 1.0.0: su propio mapa no cambió.

El cambio de versión en iOS: la cabecera de la Pokédex dice listApp 1.1.0 y My Party 6/6 sobre una píldora verde claro, una tarjeta muestra cómo una línea del mapa de versiones pasa de 1.1.0 a 1.2.0 sin reconstruir nada, y después de volver a abrir la app la cabecera dice listApp 1.2.0 y Party full 6/6 sobre una píldora de un verde más intenso

Sube primero el directorio de la versión. Cambia el mapa al final. El mapa es el commit. Nada en esta build le da a un binario otro sitio desde el que cargar un remote, así que un mapa que apunta a un directorio que todavía no está en la CDN es un 404 para todos los usuarios que arranquen la app mientras tanto, y lo que ven es una pestaña que no se abre. El ejercicio de la versión que falta, en Ahora rómpelo, tres veces, muestra qué pasa con el orden contrario.

Registra también la release en la herramienta, para que sus listas coincidan con lo que guarda ahora la CDN. En tools/build-cdn.mjs, las dos listas y sus comentarios pasan a ser:

// --- Todas las versiones de cada remote que guarda la CDN. Un directorio de versión se escribe una
// vez y después no se toca: las apps instaladas están cargando esos archivos exactos, así que
// reconstruir una versión publicada es un cambio silencioso en código que alguien ya ejecuta. El
// trabajo nuevo lleva un número nuevo.
//
// listApp tiene tres porque este post publica dos releases suyas. 1.0.0 y 1.1.0 son la misma
// pantalla con distinto número de versión, que es lo que necesita la demo de los dos binarios; la
// 1.2.0 es la build que añadió el estado completo del contador de la party, y es la que publica el
// cambio. Esta herramienta construye cada versión a partir del código que tiene delante, así que,
// reconstruidas desde el árbol final, las tres llevan ese estado: el post construye la 1.0.0 y la
// 1.1.0 antes de añadir ese estado al código, y eso es lo que las deja sin él. ---
const REMOTE_VERSIONS = {
  listApp: ['1.0.0', '1.1.0', '1.2.0'],
  partyApp: ['1.0.0'],
};

// --- Qué puede cargar cada versión publicada de la app. El host pide su propia entrada por nombre
// en cada arranque, así que un binario antiguo sigue recibiendo las versiones con las que se
// construyó, por mucho que haya avanzado la release más nueva. Una entrada se retira cuando ya no
// queda nadie en esa versión de la app, igual que un endpoint antiguo de una API.
//
// La 2.0.0 apuntaba a la listApp 1.1.0 hasta el cambio; la línea de abajo es lo que cambió, y
// devolverla a su valor anterior es la vuelta atrás. Editarla aquí reconstruye todo el árbol, y esa
// no es la herramienta para ese trabajo: la operación que hace el post es editar el archivo del
// mapa que ya está en cdn-root, porque ese archivo es lo que lee una app en ejecución. Esto crea
// una CDN desde cero; no es una operación sobre ella.
//
// El mapa no lleva nada más. Ni firma, ni contador, nada que permita a la app distinguir un mapa
// escrito aquí de uno escrito por cualquier otra persona con acceso al bucket. Es un agujero real y
// se deja abierto a propósito: es el tema del último post de la serie. ---
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' },
};

Esa edición solo sirve para llevar la cuenta; el despliegue ya ha ocurrido. También significa que, si creas la CDN desde cero con el árbol final, se construyen las tres versiones a partir del mismo código, así que la 1.0.0 y la 1.1.0 también llevarían el estado de party completa: el orden que acabas de seguir es lo que las deja sin él.

Dos reglas de caché hacen que la operación se pueda repetir sin riesgo. Cada URL de chunk incluye su versión, así que la CDN, un proxy o el dispositivo pueden guardar ese archivo para siempre sin confundirlo con una release posterior. El mapa de versiones cambia con cada despliegue, así que sigue la regla contraria: la sonda envía cache-control: no-cache, y en local -c-1 hace que http-server envíe cache-control: no-cache, no-store, must-revalidate. Una CDN de producción recibe las mismas dos reglas como configuración.

Este host sigue descargando los chunks 1.0.0 de la party, que no han cambiado, después de volver a abrir la app, como muestra el log, porque solo guarda la caché de locators en memoria. Persistir esa caché ahorraría esas descargas, y además alargaría el tiempo que un archivo en disco se ejecuta sin volver a comprobar su firma, por el motivo del aviso «La verificación ocurre en el momento de la descarga».

Da marcha atrás

La versión publicada está mal. Devuelve la línea a como estaba:

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

Vuelve a abrir la app y el log regresa a 1.1.0 sin reconstruir nada, porque el directorio antiguo nunca se borró. Las dos listas de build-cdn lo hacen posible: la CDN conserva la 1.1.0 mientras nada apunta a ella, así que volver atrás es una edición y nunca una build.

El mismo archivo plano que convierte la vuelta atrás en la edición de una línea permite a cualquiera que pueda escribir en el bucket llevar todas las apps instaladas a la versión que quiera, incluida una antigua con un fallo conocido. Los chunks están protegidos; el mapa está al descubierto. El post 17 trata de ese archivo.

Ahora rómpelo, tres veces

Un chunk rechazado y una versión que falta se ven igual en pantalla y significan cosas opuestas. Una release cuyo módulo lanza un error al inicializarse no se ve distinta. Prueba las tres en la build Release, con el mapa apuntando otra vez a la 1.2.0.

Primero, el sistema funcionando. Cambia un byte dentro del chunk expuesto en la CDN y vuelve a abrir la 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 sirve el chunk, el dispositivo lo descarga y la verificación lo rechaza antes de que se ejecute una sola línea. Una build Release no tiene consola de Metro, así que lee el log del propio simulador:

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

La coincidencia y las líneas que importan de las diecisiete siguientes, sin el prefijo del propio 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 pestaña Pokédex muestra el estado de error del design system, «This tab could not load» con un botón Try again; el banner sigue diciendo cdn · listApp 1.2.0 · partyApp 1.0.0; la pestaña Party se abre y funciona. La línea bajo el título depende del modo del arranque: desde la CDN dice que el remote no se pudo descargar, verificar o iniciar, y sugiere volver a abrir la app, mientras que el texto del post 11 remitía a un servidor de desarrollo que esta build no tiene.

Si en lugar de eso añades bytes después del final del archivo, el error del log pasa a ser no token for the bundle was found: el verificador lee los últimos 1280 bytes del archivo y espera que empiecen con la marca de la firma, y los bytes añadidos han desplazado esa ventana más allá de ella. El modo estricto también lo rechaza. Restaura el chunk a partir de la copia construida antes de seguir.

La build Release después de manipular un chunk: la pestaña Pokédex muestra This tab could not load, una línea que dice que el remote no se pudo descargar, verificar o iniciar, y un botón Try again, mientras el banner de la federación, encima de la barra de pestañas, sigue diciendo cdn, listApp 1.2.0, partyApp 1.0.0 y la pestaña Party sigue disponible

Segundo, el fallo operativo. Apunta el mapa 2.0.0 a una versión cuyo directorio no existe, la 1.3.0, y vuelve a abrir la 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 mismo estado de error en la misma pestaña, y el banner indica listApp 1.3.0, la versión que se le indicó que cargara. Nadie ha manipulado nada; el mapa se escribió antes que el directorio que nombra, lo que es el orden equivocado contra el que advierte el aviso «El mapa es el commit», visto desde el lado del usuario. Copia cualquier versión construida en cdn-root/ios/listApp/1.3.0/ y el siguiente arranque la carga. Devuelve el mapa a la 1.2.0.

Tercero, una release con un bug. Añade una línea debajo de los imports de apps/list/src/PokedexScreen.tsx:

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

Publícala igual que la 1.2.0, como una versión aparte, y despué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/

Los bundles de JavaScript que se piden se descargan y pasan la verificación de la firma, pero la pestaña vuelve a mostrar el mismo estado de error, mientras el banner indica listApp 1.4.0. Esta vez no ha fallado ninguna carga: el código de la app list ha llegado intacto y ha lanzado un error al inicializarse su módulo. Borra la línea y devuelve el mapa a la 1.2.0.

Ninguno de los tres estados de error apareció la primera vez que cada fallo se ejecutó en una build Release de iOS: la app murió antes de poder dibujar ninguno. Android ya había mostrado la misma muerte en el post 14, donde la petición del manifest rechazada en una build de release terminó con el arranque.

En los dos primeros, la causa es el orden en que se notifica el fallo. Cuando el módulo de un remote no se puede cargar, el runtime de remotes de webpack registra el error, añade while loading "./ListStack" from … a su mensaje y sustituye la factory del módulo por una que lanza. Re.Pack sustituye el require de webpack por una versión protegida en cada bundle que construye: cuando un require lanza, esa versión captura el error, lo notifica como fatal al handler global de errores de React Native y no devuelve nada. Esa notificación llega primero, antes de que React haya intentado renderizar la pestaña.

En una build de desarrollo, la notificación fatal es una caja roja sobre una app que funciona. En una build Release, el handler se la pasa al tratamiento nativo de errores de React Native, donde la ruta fatal termina el proceso: en iOS el módulo de excepciones llama a RCTFatal, que lanza una excepción que nada captura, y en Android lanza una JavascriptException que el host por defecto vuelve a lanzar.

Si el proceso sobrevive a esa notificación, la pestaña llega igualmente a su estado de error. Que el require protegido no devuelva nada hace que el import de la pestaña se resuelva sin componente, React se niega a renderizarlo (Element type is invalid) y RemoteBoundary, que el post 11 puso alrededor de cada pestaña, captura ese error de renderizado y muestra el estado de error. El boundary nunca ve el error de carga original; gestiona el fallo que viene después.

src/shell/federationErrors.ts hace que el proceso sobreviva. Envuelve el handler global de React Native y, en los dos primeros casos, descarta la notificación cuyo mensaje termina con el sufijo que añadió el runtime de remotes, que el runtime escribe en un único sitio y siempre como última línea del mensaje:

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

Antes se escribieron, se midieron y se descartaron dos versiones de ese matcher, y las dos son ahora casos de test. La primera buscaba ChunkLoadError por nombre y además exigía que la parte del sufijo correspondiente al container contuviera el nombre del remote. En una build de desarrollo esa parte dice webpack/container/reference/listApp; en una build Release ese módulo se minimiza a su id numérico, 77469, así que la comprobación pasaba en desarrollo y fallaba justo donde importaba. La segunda quitó la comprobación del container, pero seguía buscando ChunkLoadError por nombre, que es como llega una firma incorrecta; una versión que la CDN no tiene llega como [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003 con el mismo sufijo, y seguía tumbando la app. Para una carga fallida, la protección busca solo el sufijo.

El sufijo cubre los dos primeros casos, pero no el tercero. El código de la app list se descargó y se verificó, y lanzó un error mientras se evaluaba su módulo. El container del remote es otro bundle construido por Re.Pack, por lo que el require más externo que contiene también está protegido, y notifica el error como fatal antes de que ninguna llamada devuelva el control. El runtime de remotes vio una carga correcta, así que nadie añadió ningún sufijo, y con solo el matcher la build Release murió en el arranque:

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

La protección puede reconocer en cambio la notificación del tercer caso por el momento en que llega: mientras se está evaluando un módulo de un remote. Cuando el host importa un módulo de un remote, el runtime de remotes pide a Module Federation la factory de ese módulo sin ejecutar, con loadFactory: false, y Module Federation pasa esa factory al hook onLoad de cada plugin del runtime antes de que nada la llame. Una función devuelta por el hook sustituye a la factory. src/shell/scriptManager.ts instala un plugin que devuelve, en lugar de la factory, una función que la ejecuta dentro de evaluateRemoteModule, así que cada módulo de un remote que importa el host, en las pestañas y en el arranque, se evalúa ahí:

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

evaluateRemoteModule, en federationErrors.ts, abre una ventana alrededor de la factory. Cualquier notificación fatal que se produzca con la ventana abierta viene de la evaluación de ese módulo, así que la protección la retiene en lugar de dejarla pasar. En cuanto la factory devuelve el control, evaluateRemoteModule lanza el error retenido:

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 ventana es exacta porque la evaluación es síncrona: no se ejecuta nada más entre abrirla y cerrarla. Solo se retienen las notificaciones fatales, porque solo una notificación fatal termina el proceso.

El error lanzado desde la ventana llega después al require protegido del propio host, que lo notifica como fatal por segunda vez, ya fuera de la ventana. remember guarda cada error lanzado desde la ventana, y la protección descarta esa segunda notificación igual que descarta las que llevan el sufijo. El import se resuelve sin el módulo y la pestaña llega al mismo estado de error que en los otros dos casos.

La protección instalada combina las dos rutas. Retiene una notificación fatal que se produce dentro de una ventana, descarta una notificación que termina con el sufijo o que repite un error lanzado desde una ventana, y pasa todo lo demás al handler que había antes:

// --- Dónde guarda la protección el handler que envuelve. Fast Refresh puede volver a evaluar este
// módulo en una app en ejecución, y cada evaluación empieza con un estado de módulo nuevo, así que
// un flag en este archivo no puede distinguir una segunda instalación de la primera. En su lugar,
// el envoltorio lleva el handler que tiene debajo, en una propiedad que todas las evaluaciones del
// módulo conocen por su nombre. ---
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)) {
      // Se registra en el log, no se oculta: el motivo del estado de error de una pestaña debe
      // aparecer en la consola de quien lo esté mirando.
      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);
}

Volver a instalar la protección sustituye la que encuentra en lugar de envolverla. Fast Refresh puede volver a ejecutar el módulo durante el desarrollo, y un segundo envoltorio dejaría las comprobaciones del primero funcionando por debajo; federationErrors.test.ts vuelve a evaluar el módulo para exigir que haya una sola protección. La ventana y el registro que mantiene remember se guardan en el objeto global por el mismo motivo, así que todas las evaluaciones del módulo los comparten.

Nada de esto le da al binario otro sitio desde el que cargar: la pestaña muerta muestra el fallo sin ocultarlo, pero no es una app que funcione.

Lo que permiten las tiendas

En todo este post se descarga código en una app instalada, así que se aplican las reglas de las plataformas, y los dos proveedores ya han reformulado antes estas cláusulas. Todas las citas de esta sección salen de las páginas vigentes.

PlataformaTexto que rigeQué diceCondición
Apple, revisiónApp Review Guideline 2.5.2Las apps no pueden «download, install, or execute code which introduces or changes features or functionality of the app, including other apps»El binario revisado define lo que hace la app; el código descargado no puede ampliar ni cambiar esa funcionalidad
Apple, licenciaDeveloper Program License Agreement 3.3.1(B)«Interpreted code may be downloaded to an Application but only so long as» se cumplan tres condicionesMantiene el propósito previsto y anunciado de la app; no hace bypass de la firma, el sandbox ni otras funciones de seguridad del sistema operativo; en una app de la App Store, no crea ninguna tienda ni escaparate para otras 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ón «does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs»

Los dos documentos de Apple trazan límites distintos, y se aplican los dos. La cláusula 3.3.1(B) del acuerdo de licencia permite el código interpretado descargado solo mientras «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» y, «for Applications distributed on the App Store, does not create a store or storefront for other Applications». La guideline 2.5.2 es más estricta: la primera condición de la licencia protege el propósito de la app, mientras que la guideline descarta el código descargado que introduce o cambia las features o la funcionalidad de la app. Así que cumplir las condiciones de la licencia no demuestra que se cumplan las directrices de revisión. La lectura con la que trabajan los servicios over-the-air es limitar lo que se envía de esta forma a arreglos y ajustes dentro de la funcionalidad que Apple ya revisó, aceptando que la redacción deja a Apple margen para no estar de acuerdo.

La regla de Google nombra una excepción, no una condición: la restricción «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 nombra React Native ni Hermes (el motor de JavaScript que React Native usa por defecto), así que si Hermes encaja en ella es una inferencia. Es una inferencia sólida: el JavaScript que se ejecuta en Hermes solo llega a las APIs de Android a través de los módulos compilados de forma nativa que ya contiene el binario, lo que es acceso indirecto en los propios términos de la cláusula. Los chunks de esta CDN son JavaScript plano y no bytecode de Hermes, porque ningún remote de la serie se compila a bytecode; la línea de la política trata de cómo se ejecuta el código, no del formato en que se distribuye. La misma página añade que el código interpretado «loaded at run time (for example, not packaged with the app) must not allow potential violations of Google Play policies», así que lo que hace un remote está sujeto a las mismas reglas que la app en la que se ejecuta.

Para React Native en concreto, la orientación más cercana viene de los proveedores que ofrecen actualizaciones over-the-air para él. CodePush, de Microsoft, cubrió ese mercado durante años y se retiró el 31 de marzo de 2025. EAS Update, el servicio over-the-air de Expo Application Services, sigue en marcha, y su documentación somete las actualizaciones a las reglas de las tiendas: «you need to follow the rules of the platforms and app stores you are building for», las actualizaciones «need to follow the App Store and Play Store guidelines, including the content of the updates and how you use them» y «This usually means changes to your app’s behavior need to be reviewed». Su tabla de cuándo usar una actualización marca «Change to native code or native dependencies» y «Anything that requires a new app binary version» como casos que piden un binario nuevo.

La regla práctica: envía arreglos y mejoras de features que la tienda ya revisó, nunca un propósito principal nuevo, y mantén el binario revisado capaz de funcionar por sí solo. El cambio de este post es el tipo de cambio que describe la primera mitad: modifica cómo muestra un estado una feature que ya existía, el contador de la party, y no añade nada nuevo. Si un cambio concreto se queda dentro de lo revisado lo decide la tienda, y ninguna build puede demostrarlo. La segunda mitad necesita la copia dentro del binario que añade el post 16, porque hoy un binario que no llega a la CDN no tiene nada que mostrar.

Ejecútalo

Claves, árbol, 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, con la dirección de la CDN y la versión de este binario:

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

Una build de desarrollo funciona igual, con las dos variables en el comando del servidor de desarrollo:

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

Android, con la dirección con la que el emulador llega 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 )

Las suites, el smoke test y los 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

El smoke test construye ahora los dos remotes en la 9.9.9, una versión que ninguna configuración usa por defecto, y sigue la propia lista de chunks del manifest dentro del directorio de versión, así que si falta el segmento de versión en output.path falla en tiempo de build y no en el arranque de un usuario.

Ejecutado desde el árbol final, build-cdn construye las tres versiones de la list a partir del mismo código y crea el mapa 2.0.0 con la 1.2.0, así que las tres versiones solo se diferencian en el chip. Para ver aquí un cambio de versión, edita cdn-root/ios/maps/2.0.0/version-map.json para que nombre la listApp 1.1.0 y vuelve a abrir la app; después devuelve la 1.2.0 y vuelve a abrirla. Ver llegar el estado de party completa al cambiar de versión es cosa del build-along, la única ruta que construye la 1.0.0 y la 1.1.0 antes de añadir ese estado al código.

Lo que has construido, y lo que viene

Un binario instalado pide ahora a la CDN su propio mapa en el arranque, carga las versiones firmadas que nombra ese mapa y rechaza cualquier remote al que el mapa no dé versión. Publicar un remote es un directorio y una línea, y volver atrás es devolver la línea a su valor; cada binario recibe cualquiera de las dos cosas en su siguiente arranque. Cuando un remote no llega a cargar, o su módulo lanza un error al inicializarse, la build Release sigue funcionando con una pestaña muerta, algo medido en iOS con un chunk rechazado, una versión que falta y un módulo que lanza un error al inicializarse, y en Android con una versión que falta.

Quedan dos límites. El mapa no está firmado, así que cualquiera que pueda escribir en el bucket puede dirigir todas las instalaciones; el post 17 lo firma, añade un contador que deja sin valor el mapa de ayer y hace que la app revierta por sí sola una versión que falla. El límite más cercano es el alcance: un binario que no llega a la CDN, o que no puede leer su mapa, arranca en modo unresolved sin nada que mostrar en ninguna de las dos pestañas.

Lo siguiente: el día que la CDN no responde. Un fallback offline incluido en el binario, y una red de seguridad dentro de la sesión para el remote que falla en pleno uso.

Fuentes

Warren de Leon
Warren de Leon

Software Engineering Manager. Recientemente lideré el equipo de Mobile Platform en Hargreaves Lansdown. Escribo sobre liderazgo técnico, React Native y cómo construir buenos equipos.

Ver perfil