Tres líneas de suministro alimentan una sola app federada: un servidor de desarrollo, un registro y una CDN

La build de producción y los tres modos de una app React Native federada

El post 13 terminó con una promesa: «un bundle de producción y el primer artefacto de release federado». Este es ese post. La promesa grande es la del post 1, que cada feature «se puede desplegar y actualizar por su cuenta en vez de viajar dentro de una única release de la App Store», y ese mismo post puso el precio: una CDN que mantener, una URL que es una superficie de ataque, código que firmar y que verificar. Trece posts después, los dos remotes siguen llegando desde dos servidores de desarrollo en tu máquina. Este post los construye para producción, firma cada chunk y los sirve desde una red de distribución de contenidos (CDN) con esos servidores apagados.

Primero, el alcance real. La CDN de este post es un directorio y un puerto: guarda una sola versión de cada remote, y todavía no hay nada que verifique una firma, que proteja la elección de versión ni que sirva de respaldo cuando la CDN no responde. Los tres posts siguientes añaden todo eso, y cada uno trabaja sobre los artefactos que este produce.

Empieza desde el estado final del post 13, el tag de inicio post-13-native-handoff. El tag final es post-14-production-build, y todo lo que hay entre los dos lo tecleas tú.

Tres canales, tres cadencias. La flecha del registro termina en node_modules y nunca llega a la app en ejecución: un cambio en un paquete llega a los usuarios dentro de la siguiente build de quien lo instale. Un cambio en un remote se sube a la CDN y llega a la app en su siguiente arranque. Un cambio en el host pasa por la tienda.

Tres modos, una tabla

La serie ha vivido en la primera fila desde el post 2 y usa la segunda desde el post 5, sin haber dado nombre a ninguna. Con nombre, y con la tercera al lado:

CanalQué sirveQuién lo lee, y cuándoQué cuesta un cambio
Servidores de desarrollo, :8082 y :8083El manifest, el container y los chunks de un remote, reconstruidos con cada guardadoLa app host, en runtime, siempre que MF_CDN_BASE no esté definida: en la práctica, las builds de desarrolloGuardar el archivo
Registro, Verdaccio en :4873Paquetes npm: @pokedex/contracts, detail, ui y a11y-testingnpm install, en tiempo de build, en cada app que dependa del paquetePublicar, y después reinstalar y reconstruir cada consumidor
CDN, cdn-root/ en :8000El manifest, el container y los chunks de un remote, construidos una vez y firmadosLa app host, en runtime, en una build de desarrollo o de releaseReconstruir el remote y copiarlo; cada app instalada lo recoge en su siguiente arranque

La pregunta que esta tabla existe para responder es la que la mayoría de los equipos hace primero: ¿qué cambios siguen costando una publicación en la tienda? Todo lo que va dentro del binario: el código nativo, el shell del host y cada singleton compartido que provee el host, porque el bundle del host es donde está la única copia de cada uno. Todo lo que pertenece a un remote cuesta un despliegue en la CDN y un arranque. Los paquetes quedan entre los dos, y la fila del medio es la que conviene leer dos veces.

Un registro es donde suelen estar los artefactos versionados, así que es razonable esperar que los remotes estén allí, y es donde un equipo nuevo en federación tiende a ponerlos primero. No están ahí: el registro sirve paquetes, nunca remotes. Un paquete llega a los usuarios dentro del bundle que lo compila.

El post 12 entregó @pokedex/detail 4.0.12 con un publish y una reinstalación en list y party; con los modos de este post, esa misma corrección son dos remotes reconstruidos en la CDN y ninguna publicación en la tienda, porque el host nunca incluye el paquete detail en su bundle. Una corrección en @pokedex/contracts o en @pokedex/ui es otra cosa. El host provee los dos como singletons eager, así que cambia el bundle del host, y el bundle del host va dentro del binario.

Construye un remote de verdad

Cada bundle que ha ejecutado la serie era una build de desarrollo, servida al guardar. Una build de producción es el mismo comando con el interruptor de desarrollo apagado, y cada remote lo recibe en forma de dos scripts. En apps/list/package.json:

"bundle:ios:prod": "react-native bundle --platform ios --dev false --entry-file src/index.js --config rspack.config.mjs",
"bundle:android:prod": "react-native bundle --platform android --dev false --entry-file src/index.js --config rspack.config.mjs",

Los mismos dos van en apps/party/package.json. react-native bundle llega a Re.Pack porque el react-native.config.js del post 2 registró sus comandos en la CLI (webpack-bundle es un alias). --entry-file src/index.js nombra la entrada del container que el post 2 dio a cada remote; el index.js de la plantilla, que está al lado, registra el remote como app independiente, una entrada que la CDN nunca sirve.

La configuración recibe un booleano y lo lee en dos sitios que tienen que coincidir: dónde escribe la compilación y dónde quedan colocados los chunks que el host va a descargar. En apps/list/rspack.config.mjs, después de const { mode, platform } = env;:

// Las builds de producción escriben en otro sitio y reciben una firma; la de desarrollo no cambia.
const isProd = mode === 'production';
output: {
  // Una build de producción escribe el árbol que sirve la CDN, con la misma forma que la ruta URL
  // en la que se sirve: cdn/<plataforma>/listApp/. Una build de desarrollo sigue escribiendo en
  // build/, que es de donde lee el servidor de desarrollo.
  path: isProd ? `${__dirname}/cdn/[platform]/listApp` : `${__dirname}/build/[platform]`,
  uniqueName: 'ListApp',
},

Un tercer bloque no tiene nada que ver con la federación y todo que ver con fiarse de una build en verde. El minimizador por defecto de Re.Pack es terser-webpack-plugin, y con Rspack ese plugin no hace nada desde su versión 5.6.0 (callstack/repack#1390): la build termina bien y cada chunk de producción conserva sus comentarios y sus espacios. Pon a su lado el minimizador SWC del propio Rspack. Re.Pack combina tu optimization.minimizer con su lista en vez de sustituirla, así que el minimizador por defecto se queda en el array y sigue sin hacer nada mientras el de SWC hace el trabajo. Añade el import al principio del archivo:

import { SwcJsMinimizerRspackPlugin } from '@rspack/core';

y, después de output:

optimization: {
  // El minimizador por defecto de Re.Pack es terser-webpack-plugin, y con Rspack ese plugin no
  // hace nada desde terser-webpack-plugin 5.6.0 (callstack/repack#1390): la build termina bien y
  // cada chunk de producción sale con sus comentarios y sus espacios intactos. Re.Pack combina
  // este array con el suyo, así que el de por defecto sigue en la lista junto al minimizador SWC
  // de Rspack; el de SWC hace la minificación que el otro se salta.
  minimizer: [
    new SwcJsMinimizerRspackPlugin({
      test: /\.(js)?bundle(\?.*)?$/i,
      minimizerOptions: { format: { comments: false } },
    }),
  ],
},

El host recibe el mismo import y el mismo bloque en apps/host/rspack.config.mjs, porque su bundle de release se construye de la misma manera. Después, todavía en la configuración del remote, los chunks:

new Repack.RepackPlugin({
  extraChunks: [
    {
      include: /.*/,
      type: 'remote',
      // Los chunks quedan junto al container y al manifest, porque el host los va a pedir en URLs
      // relativas al manifest que cargó.
      outputPath: isProd ? `cdn/${platform}/listApp` : `build/${platform}/remote`,
    },
  ],
}),

Replica todo en apps/party/rspack.config.mjs con partyApp. Los dos árboles de salida son productos de la build, así que se suman al .gitignore junto a apps/*/build/:

apps/*/cdn/
cdn-root/

Después construye uno y mira lo que sale:

( cd apps/list && npm run bundle:ios:prod ) && ls apps/list/cdn/ios/listApp | grep -v '\.map$' | head -6
assets
__federation_expose_ListStack.chunk.bundle
index.bundle
listApp.container.js.bundle
mf-manifest.json
mf-stats.json

Siguen dieciséis chunks más, la mayoría copias propias del remote de los singletons compartidos: respaldos que el host nunca pide, porque los provee todos. El log del servidor, más adelante en este post, muestra los pocos que sí pide. Aquí importan tres archivos. El container es lo que el host carga primero; el chunk expuesto contiene ./ListStack; y mf-manifest.json es la agenda de direcciones, que se emite por defecto junto a su gemelo mf-stats.json. Un campo suyo parece un error, y es a propósito:

"publicPath": "noop:///"

Lo pone la configuración base de Re.Pack, y nada lo trata como una ubicación. El host descargó el manifest desde una URL, y el plugin resolver de Re.Pack recoloca sobre esa URL cada URL de chunk de ese remote, así que cada chunk se descarga del directorio del que salió el manifest. Dónde se construyó un remote no influye en dónde se sirve, y eso es lo que permite que una misma build pase de un portátil a una CDN real sin cambios.

Un script monta el árbol que serviría una CDN. Crea tools/build-cdn.mjs:

// --- Monta el directorio que serviría una CDN. Para cada remote ejecuta el script de bundle de
// producción de ese remote y después copia la salida (container, chunks y mf-manifest.json) a
// cdn-root/<plataforma>/<remote>/. Esa disposición es la disposición de las URLs: un archivo en
// cdn-root/ios/listApp/mf-manifest.json se sirve en <base>/ios/listApp/mf-manifest.json, que es
// exactamente la URL que pide el mapa de remotes del host.
//
// Aquí vive una sola versión de cada remote, en un directorio plano. Las releases versionadas, y
// el mapa que decide qué versión puede cargar cada app, llegan en el siguiente post.
//
// 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 npm start )

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

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

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;

const cdnRoot = join(repoRoot, 'cdn-root');

for (const platform of platforms) {
  // Borra solo esta plataforma, para que construir una no elimine el árbol de la otra.
  rmSync(join(cdnRoot, platform), { recursive: true, force: true });
  mkdirSync(join(cdnRoot, platform), { recursive: true });

  for (const [remote, app] of Object.entries(REMOTES)) {
    console.log(`\n=== building ${remote} (${platform}) ===`);
    const appDir = join(repoRoot, 'apps', app);
    execSync(`npm run bundle:${platform}:prod`, { cwd: appDir, stdio: 'inherit' });
    cpSync(join(appDir, 'cdn', platform, remote), join(cdnRoot, platform, remote), {
      recursive: true,
    });
    console.log(`copied -> cdn-root/${platform}/${remote}`);
  }
}

const HOST_ADDRESS = { ios: 'http://localhost:8000', android: 'http://10.0.2.2:8000' };
console.log(`\nCDN assembled at ${cdnRoot}`);
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]} npm start )   # ${platform}`);
}

Firma lo que publicas

Los chunks ya salen de la máquina, y aquí empieza el coste de integridad del post 1. La clave privada firma cada chunk de producción y no puede llegar nunca al repositorio, y este repositorio es público, así que la regla del .gitignore entra antes de que exista el generador. En .gitignore:

# Claves de firma. La clave privada firma cada chunk de producción y no puede subirse nunca:
# este directorio se ignora antes de ejecutar `node tools/gen-signing-keys.mjs` por primera vez.
code-signing/

Ignora code-signing/ antes de generar una clave, no después. Una clave privada que llega a un repositorio público queda comprometida en el momento en que se sube. En este post la reparación es una clave nueva y la reconstrucción de cada chunk que firmó la antigua; cuando las apps verifiquen firmas, a partir del post 15, también habrá que sustituir la clave pública en la que confían esas apps, y con una clave embebida eso es una actualización de la app. Comprueba que git status no muestra nada bajo code-signing/ antes de cada commit a partir de aquí.

Crea tools/gen-signing-keys.mjs:

// --- Genera el par de claves que firma cada chunk de producción de un remote.
//
//   RSA-2048   firma cada chunk que emite el CodeSigningPlugin de Re.Pack (un JWT RS256 del hash
//              del chunk, añadido al final del bundle). La mitad privada firma en tiempo de build; la
//              mitad pública es contra la que verifica un host antes de ejecutar código descargado.
//
// La clave privada está en code-signing/, dentro del checkout pero ignorada por git antes de que
// este script se ejecute por primera vez, y se escribe legible solo por su propietario: el
// .gitignore la mantiene fuera de los commits, el modo del archivo la mantiene lejos de otros
// usuarios de la máquina. Solo se genera un par cuando no existe ninguna de las dos mitades. Una
// clave privada existente se conserva, nunca se rota: un chunk firmado con una clave nueva no
// pasaría la verificación contra una clave pública ya embebida en las apps instaladas, y una firma
// solo sigue siendo válida para la clave que la hizo. Una mitad pública ausente se deriva de la privada; una
// mitad pública sola, o un par que no coincide, detiene el script.
//
// Uso: node tools/gen-signing-keys.mjs

import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';

const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const keyDir = join(repoRoot, 'code-signing');
mkdirSync(keyDir, { recursive: true });

const privatePath = join(keyDir, 'private-key.pem');
const publicPath = join(keyDir, 'public-key.pem');
const OWNER_ONLY = 0o600;

// Las claves se comparan como bytes DER, nunca como texto PEM: los finales de línea y el ajuste
// de líneas no forman parte de la identidad.
const der = key => key.export({ type: 'spki', format: 'der' });

if (existsSync(privatePath)) {
  // El modo se vuelve a aplicar en cada ejecución, porque una copia o una herramienta anterior
  // pueden haberlo dejado más abierto.
  chmodSync(privatePath, OWNER_ONLY);
  const derived = createPublicKey(createPrivateKey(readFileSync(privatePath)));
  if (!existsSync(publicPath)) {
    writeFileSync(publicPath, derived.export({ type: 'spki', format: 'pem' }));
    console.log('chunk-signing private key present; public key derived from it');
  } else if (!der(createPublicKey(readFileSync(publicPath))).equals(der(derived))) {
    console.error(
      `${publicPath} does not match ${privatePath}. Neither key's contents were changed: decide which key the installed apps trust before touching either file.`,
    );
    process.exit(1);
  } else {
    console.log('chunk-signing keypair already present, kept');
  }
} else if (existsSync(publicPath)) {
  // Una clave pública sin clave privada es el único estado que este script no debe reparar por su
  // cuenta: generar aquí un par nuevo cambiaría en silencio la identidad de firma detrás de una
  // clave pública que quizá ya está embebida en apps instaladas.
  console.error(
    `${publicPath} exists but ${privatePath} does not. Nothing was changed: restore the private key that made it, or plan a deliberate rotation and remove the public key by hand first.`,
  );
  process.exit(1);
} else {
  const { publicKey, privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
  writeFileSync(privatePath, privateKey.export({ type: 'pkcs1', format: 'pem' }), { mode: OWNER_ONLY });
  writeFileSync(publicPath, publicKey.export({ type: 'spki', format: 'pem' }));
  console.log('generated an RSA-2048 chunk-signing keypair');
}

console.log(`\nprivate key: ${privatePath}  (signs the chunks; never commit it, never ship it)`);
console.log(`public key:  ${publicPath}  (what a host checks the signature against)\n`);
console.log(readFileSync(publicPath, 'utf8').trim());
node tools/gen-signing-keys.mjs

Dos decisiones del script tienen que ver con la vida de la clave después de este post. La mitad privada se escribe legible solo por su propietario, ya que .gitignore la mantiene fuera de los commits y el modo del archivo la mantiene lejos de otros usuarios de la máquina. Y un par existente se conserva en lugar de rotarse, y una clave pública que está sola nunca se sustituye: un chunk firmado con una clave nueva no pasaría la verificación contra una clave pública ya embebida en las apps instaladas, y una firma solo sigue siendo válida para la clave que la hizo. Cuando las apps verifiquen, rotar una clave será una migración de confianza coordinada, con la clave antigua y la nueva aceptadas durante un tiempo o con releases separadas dirigidas a cada una, y ese diseño pertenece a los posts que añaden verificación y versionado. Este script se niega a empezar una por accidente.

Las configuraciones de los remotes añaden el plugin solo en modo producción. En los dos archivos rspack.config.mjs, al final de plugins:

// Cada chunk de producción recibe una firma RS256 de su propio hash, añadida al final del archivo.
// La clave privada está en code-signing/, ignorada por git dentro del checkout, y nunca se
// publica. Las builds de desarrollo se quedan sin firmar a propósito: sus chunks se sirven
// desde esta máquina y se reconstruyen con cada guardado, y este post firma solo lo que sale de ella.
...(isProd
  ? [
      new Repack.plugins.CodeSigningPlugin({
        privateKeyPath: path.resolve(__dirname, '../../code-signing/private-key.pem'),
      }),
    ]
  : []),

Reconstruye y lee el final del container:

( cd apps/list && npm run bundle:ios:prod ) && tail -c 1300 apps/list/cdn/ios/listApp/listApp.container.js.bundle | xxd | head -4
00000000: 646c 652e 6d61 703f 706c 6174 666f 726d  dle.map?platform
00000010: 3d69 6f73 2f2a 2052 4353 5342 202a 2f65  =ios/* RCSSB */e
00000020: 794a 6862 4763 694f 694a 5355 7a49 314e  yJhbGciOiJSUzI1N
00000030: 6949 7349 6e52 3563 4349 3649 6b70 5856  iIsInR5cCI6IkpXV

/* RCSSB */ es la marca, y lo que viene después es un JSON Web Token: el hash SHA-256 del chunk, firmado con la clave privada mediante RS256. El plugin reserva los últimos 1.280 bytes del archivo para la marca y el token, y rellena el resto con ceros. El container y todos los chunks llevan uno. index.bundle no, porque el plugin se salta el bundle principal por ser siempre local.

Una firma que nadie verifica es un sello, no un candado. Nada en esta build lee el token. Verificar es tarea de ScriptManager, contra una clave pública que la app embebe como RepackPublicKey, y llega con el resolver en el post 15. Re.Pack 5.2.5, la versión que usa esta serie, configura el plugin con enabled, privateKeyPath y excludeChunks y nada más; la 5.3.0, publicada el 5 de agosto de 2026, añade un publicKeyPath que embebe la clave por ti. La serie se queda en la 5.2.5 y embebe la clave en el post que la lee, para que la clave y su lector lleguen juntos. El arco desde aquí: el 14 firma, el 15 verifica, el 17 protege.

Sírvelo como en producción

Una CDN, para todo lo que hace este post, es un servidor que devuelve archivos estáticos en URLs estables. Construye los dos remotes en un mismo árbol y sírvelo:

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

-c-1 desactiva la cabecera de caché, así que un chunk reconstruido nunca se sirve obsoleto mientras iteras. El host es la única app del workspace con un mapa de remotes: el post 5 convirtió la pantalla de detalle en un paquete, y desde entonces ningún remote consume al otro. Así que el interruptor entre los dos mundos es una función en un solo archivo. En apps/host/rspack.config.mjs, encima de defineRspackConfig:

// --- De dónde se sirven los remotes. Define MF_CDN_BASE y cada URL de remote de abajo apunta a la
// red de distribución de contenidos en lugar de a 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)
const CDN_BASE = process.env.MF_CDN_BASE;

const DEV_REMOTES = {
  listApp: 'http://localhost:8082',
  partyApp: 'http://localhost:8083',
};

Dentro, antes de la configuración que se devuelve:

// Todo el interruptor entre los dos mundos, en una función: servidor de desarrollo o CDN, con el
// mismo nombre de manifest en ambos casos. Nada más cambia en el workspace, porque el host es la
// única app de aquí que tiene un mapa de remotes.
const remoteUrl = name =>
  CDN_BASE
    ? `${name}@${CDN_BASE}/${platform}/${name}/mf-manifest.json`
    : `${name}@${DEV_REMOTES[name]}/${platform}/mf-manifest.json`;
remotes: {
  listApp: remoteUrl('listApp'),
  partyApp: remoteUrl('partyApp'),
},

Detén los dos servidores de desarrollo de los remotes. Arranca el del host con la variable definida, y construye:

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

La app se ve exactamente igual que al final del post 13, y de eso se trata. La prueba está en el terminal del servidor:

[2026-09-17T22:34:47.686Z]  "GET /ios/partyApp/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.107Z]  "GET /ios/listApp/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.283Z]  "GET /ios/partyApp/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.283Z]  "GET /ios/listApp/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.461Z]  "GET /ios/partyApp/__federation_expose_partySlice.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.461Z]  "GET /ios/partyApp/__federation_expose_styles.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.473Z]  "GET /ios/listApp/vendors-node_modules_pokedex_detail_src_index_ts-node_modules_graphql-request_build_entrypoin-878711.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.475Z]  "GET /ios/partyApp/vendors-node_modules_react-navigation_native-stack_lib_module_index_js.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-17T22:34:48.550Z]  "GET /ios/listApp/__federation_expose_ListStack.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"

Dos manifests, dos containers y los chunks que el host no podía proveer: el paquete detail, que el host nunca incluye en su bundle, y el native stack, que el host no comparte, así que el primer remote que se carga lo provee para los dos. Ni una petición de React, React Native, Redux o el design system: el host los provee todos, como exige el contrato de singletons del post 3.

Ahora rómpelo

Todos los «rómpelo» de la serie hasta ahora han roto una app en ejecución. Este ocurre antes de que exista la app. Construye el scheme Release con el árbol exactamente como está ahora:

( cd apps/host && MF_CDN_BASE=http://localhost:8000 npm run ios -- --mode Release )
                Welcome to Metro v0.84.5
              Fast - Scalable - Integrated

UnableToResolveError: Unable to resolve module ./global.css from …/react-native-module-federation/apps/host/index.js:

None of these files exist:
  * global.css(.ios.js|.native.js|.js|.ios.jsx|.native.jsx|.jsx|.ios.json|.native.json|.json|.ios.ts|.native.ts|.ts|.ios.tsx|.native.tsx|.tsx)
  * global.css

Lee la primera línea, no la última. Metro ha arrancado. La fase «Bundle React Native code and images» de Xcode ejecuta el react-native-xcode.sh de React Native, y el CLI_PATH por defecto de ese script es scripts/bundle.js: el comando de bundle de Metro, llamado directamente. La CLI de React Native nunca llega a elegir el bundler: bundle.js llama a cli.js config solo para leer la configuración de proyecto de react-native.config.js, y después invoca directamente la función de bundle de Metro, así que la entrada commands que apunta bundle hacia Re.Pack no se consulta nunca. Metro se encuentra entonces con import './global.css' en la línea 6 de index.js y se detiene en lo primero que no sabe hacer. El error nombra una hoja de estilos; el fallo es qué bundler se ha ejecutado. El arreglo son tres variables que el script ya lee. Añade al final de apps/host/ios/.xcode.env:

# --- El bundle de release lo construye Re.Pack, no Metro. La fase "Bundle React Native code and
# images" de Xcode llama directamente al script de bundle de React Native: ese script lee
# react-native.config.js para la configuración de proyecto, a través de `cli.js config`, pero
# nunca la entrada `commands` que entrega `bundle` a Re.Pack, así que, si no se toca, ejecuta
# Metro, que no sabe nada de global.css y se detiene ahí. CLI_PATH apunta la fase a la CLI de
# React Native, que sí respeta esa entrada; BUNDLE_COMMAND nombra el comando que Re.Pack registra
# ahí; EXTRA_PACKAGER_ARGS le pasa la configuración de Rspack. $SRCROOT es este directorio ios/,
# así que la raíz de la app es su padre. ---
export CLI_PATH="$SRCROOT/../node_modules/react-native/cli.js"
export BUNDLE_COMMAND=bundle
export EXTRA_PACKAGER_ARGS="--config $SRCROOT/../rspack.config.mjs"

Ejecuta otra vez la build Release. La app arranca desde la CDN sin ningún servidor de desarrollo en marcha, y las URLs están en el bundle dentro de la app, a la vista de cualquiera:

grep -ao 'http://[^"]*mf-manifest\.json' ~/Library/Developer/Xcode/DerivedData/Host-*/Build/Products/Release-iphonesimulator/Host.app/main.jsbundle
http://localhost:8000/ios/listApp/mf-manifest.json
http://localhost:8000/ios/partyApp/mf-manifest.json

Olvida la variable en una build de release y nada se queja: se incrustan las URLs de los servidores de desarrollo, y este grep es la forma de descubrirlo antes que un tester.

Android no necesita ningún cambio para llegar a Re.Pack. El bundleCommand del plugin de Gradle vale bundle por defecto y su cliFile es react-native/cli.js, la ruta a través del archivo de configuración que el script de Xcode se salta, y android (Rspack 2.0.5) compiled aparece dentro de :app:createBundleReleaseJsAndAssets. Tres cosas cambian, eso sí. El emulador llega a tu máquina en 10.0.2.2, no en localhost, así que la variable cambia con la plataforma:

( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 npm run android -- --mode release )

Gradle decide si ejecuta siquiera la tarea de bundle comparando las entradas declaradas de la tarea con la ejecución anterior, y una variable de entorno no es una de ellas. Cambia solo MF_CDN_BASE y la tarea responde UP-TO-DATE, y se publica el bundle anterior. Declara la variable como entrada, al final de apps/host/android/app/build.gradle:

// --- rspack.config.mjs lee MF_CDN_BASE 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. Declararla como entrada hace que un valor nuevo
// reconstruya el bundle y que uno sin cambios conserve la caché. ---
tasks.configureEach {
    if (name.startsWith("createBundle") && name.endsWith("JsAndAssets")) {
        inputs.property("MF_CDN_BASE", System.getenv("MF_CDN_BASE") ?: "")
    }
}

Y un APK de release rechaza el http en claro. El plugin de Gradle de React Native pone usesCleartextTraffic a false en las builds de release, y el arranque muere con [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003, con un Network request failed debajo. Una CDN de producción se sirve por https y nunca se encuentra con esto. La local no, así que una configuración de seguridad de red permite las direcciones de loopback y nada más. Crea apps/host/android/app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<!-- El sustituto local de una CDN se sirve por http en claro en la máquina de build, y una build
     de release se niega a hablar con él. Esto lo permite solo para las direcciones de loopback:
     localhost, 127.0.0.1 y 10.0.2.2, la dirección en la que un emulador llega a la máquina. Una
     CDN de producción se sirve por https, donde esta excepción no hace nada. -->
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">localhost</domain>
        <domain includeSubdomains="true">127.0.0.1</domain>
        <domain includeSubdomains="true">10.0.2.2</domain>
    </domain-config>
</network-security-config>

y nómbrala en el elemento <application> de AndroidManifest.xml:

android:networkSecurityConfig="@xml/network_security_config"

iOS no necesitó nada equivalente: el Info.plist de la plantilla ya lleva NSAllowsLocalNetworking, y la build Release llegó a localhost:8000 sin ningún cambio.

Ejecútalo

Tres terminales, y faltan dos. Los servidores de desarrollo de los remotes, uno por remote en todos los posts desde el post 2, no arrancan. Claves, build, 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
( cd apps/host && MF_CDN_BASE=http://localhost:8000 npm start )
( cd apps/host && npm run ios )

Las builds de release usan el mismo árbol y llevan la URL en el comando de build, porque es ahí donde se lee:

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

Nada de este post toca el código que cubren las suites, y ellas lo confirman:

( for d in apps/host apps/list apps/party; do ( cd "$d" && npx jest --silent ) || exit 1; done )

Añade dos Pokémon, abre la pestaña Party, lanza un Quick Battle. Todo lo que construyó el post 13 sigue funcionando, y cada petición que lo hizo funcionar es una línea en el log del servidor.

Lo que has construido, y lo que viene

Dos remotes construidos para producción, cada chunk con su firma, dispuestos como el directorio que sirve una CDN; un host que se mueve entre los servidores de desarrollo y ese directorio con una sola variable de entorno, leída en tiempo de build; y una build de release en cada plataforma que arranca desde ahí sin ningún servidor de desarrollo en marcha. La app se ve igual que en el post 13. Los terminales, no.

Los límites, cada uno con su dueño. La CDN guarda una sola versión de cada remote, así que una build nueva sustituye a la anterior para todos los binarios instalados a la vez, puedan ejecutarla o no; el post 15 añade directorios versionados, un mapa de versiones y un resolver que elige en cada arranque. Nada verifica las firmas; ese resolver sí, contra una clave pública embebida a su lado. Una app que no llega a la CDN no tiene nada a lo que recurrir; el post 16 mete una copia de cada remote en el binario. Y el mapa que decidirá qué versión carga una app sigue sin firmar hasta el post 17.

Lo siguiente: 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.

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