El post 13 va acabar amb una promesa: «un bundle de producció i el primer artefacte de release federat». Aquest és aquell post. La promesa gran és la del post 1, que cada feature «es pot desplegar i actualitzar pel seu compte en lloc de viatjar en una sola publicació a l’App Store», i aquell mateix post en va posar el preu: una CDN per mantenir, una URL que és una superfície d’atac, codi per signar i per verificar. Tretze posts després, els dos remotes encara arriben des de dos servidors de desenvolupament a la teva màquina. Aquest post els construeix per a producció, signa cada chunk i els serveix des d’una xarxa de distribució de continguts (CDN) amb aquells servidors apagats.
Primer, l’abast real. La CDN d’aquest post és un directori i un port: guarda una sola versió de cada remote, i encara no hi ha res que verifiqui una signatura, que protegeixi la tria de versió ni que faci de fallback quan la CDN no respon. Els tres posts següents ho afegeixen tot, i cadascun treballa sobre els artefactes que aquest produeix.
Comença des de l’estat final del post 13, el tag d’inici post-13-native-handoff. El tag final és post-14-production-build, i tot el que hi ha entre els dos ho tecleges tu.
Tres canals, tres cadències. La fletxa del registre acaba a node_modules i mai no arriba a l’app en execució: un canvi en un paquet arriba als usuaris dins de la següent build de qui l’instal·li. Un canvi en un remote es puja a la CDN i arriba a l’app en la seva següent arrencada. Un canvi al host passa per la botiga.
Tres modes, una taula
La sèrie ha viscut a la primera fila des del post 2 i fa servir la segona des del post 5, sense haver posat nom a cap de les dues. Amb nom, i amb la tercera al costat:
| Canal | Què serveix | Qui el llegeix, i quan | Què costa un canvi |
|---|---|---|---|
Servidors de desenvolupament, :8082 i :8083 | El manifest, el container i els chunks d’un remote, reconstruïts a cada desada | L’app host, en runtime, sempre que MF_CDN_BASE no estigui definida: a la pràctica, les builds de desenvolupament | Desar el fitxer |
Registre, Verdaccio a :4873 | Paquets npm: @pokedex/contracts, detail, ui i a11y-testing | npm install, en temps de build, a cada app que depengui del paquet | Publicar, i després reinstal·lar i reconstruir cada consumidor |
CDN, cdn-root/ a :8000 | El manifest, el container i els chunks d’un remote, construïts un cop i signats | L’app host, en runtime, en una build de desenvolupament o de release | Reconstruir el remote i copiar-lo; cada app instal·lada el recull a la seva següent arrencada |
La pregunta que aquesta taula existeix per respondre és la que la majoria dels equips fan primer: quins canvis encara costen una publicació a la botiga? Tot el que va dins del binari: el codi natiu, el shell del host i cada singleton compartit que proveeix el host, perquè el bundle del host és on hi ha l’única còpia de cadascun. Tot el que pertany a un remote costa un desplegament a la CDN i una arrencada. Els paquets queden entre els dos, i la fila del mig és la que convé llegir dues vegades.
Un registre és on solen ser els artefactes versionats, així que és raonable esperar-hi els remotes, i és on un equip nou en federació tendeix a posar-los primer. No hi són: el registre serveix paquets, mai remotes. Un paquet arriba als usuaris dins del bundle que el compila.
El post 12 va fer arribar @pokedex/detail 4.0.12 amb un publish i una reinstal·lació a list i party; amb els modes d’aquest post, aquella mateixa correcció són dos remotes reconstruïts a la CDN i cap publicació a la botiga, perquè el host mai no inclou el paquet detail al seu bundle. Una correcció a @pokedex/contracts o a @pokedex/ui és una altra cosa. El host proveeix tots dos com a singletons eager, així que canvia el bundle del host, i el bundle del host va dins del binari.
Construeix un remote de debò
Cada bundle que ha executat la sèrie era una build de desenvolupament, servida en desar. Una build de producció és la mateixa ordre amb l’interruptor de desenvolupament apagat, i cada remote la rep en forma de dos scripts. A 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",
Els mateixos dos van a apps/party/package.json. react-native bundle arriba a Re.Pack perquè el react-native.config.js del post 2 va registrar les seves ordres a la CLI (webpack-bundle és un àlies). --entry-file src/index.js anomena l’entrada del container que el post 2 va donar a cada remote; l’index.js de la plantilla, que és al costat, registra el remote com a app independent, i la CDN mai no serveix aquest fitxer.
A la configuració s’hi afegeix un booleà, que es llegeix en dos llocs que han de coincidir: on escriu la compilació i on queden col·locats els chunks que el host descarregarà. A apps/list/rspack.config.mjs, després de const { mode, platform } = env;:
// Les builds de producció escriuen en un altre lloc i hi reben una signatura; la de desenvolupament no canvia.
const isProd = mode === 'production';
output: {
// Una build de producció escriu l'arbre que serveix la CDN, amb la mateixa forma que la ruta URL
// on se serveix: cdn/<plataforma>/listApp/. Una build de desenvolupament continua escrivint a
// build/, que és d'on llegeix el servidor de desenvolupament.
path: isProd ? `${__dirname}/cdn/[platform]/listApp` : `${__dirname}/build/[platform]`,
uniqueName: 'ListApp',
},
Un tercer bloc no té res a veure amb la federació i tot a veure amb refiar-se d’una build en verd. El minimitzador per defecte de Re.Pack és terser-webpack-plugin, i amb Rspack aquell plugin no fa res des de la seva versió 5.6.0 (callstack/repack#1390): la build acaba bé i cada chunk de producció conserva els comentaris i els espais. Posa-hi al costat el minimitzador SWC del mateix Rspack. Re.Pack combina el teu optimization.minimizer amb la seva llista en lloc de substituir-la, així que el minimitzador per defecte es queda a l’array i continua sense fer res mentre el d’SWC fa la feina. Afegeix l’import al començament del fitxer:
import { SwcJsMinimizerRspackPlugin } from '@rspack/core';
i, després d’output:
optimization: {
// El minimitzador per defecte de Re.Pack és terser-webpack-plugin, i amb Rspack aquell plugin
// no fa res des de terser-webpack-plugin 5.6.0 (callstack/repack#1390): la build acaba bé i
// cada chunk de producció surt amb els comentaris i els espais intactes. Re.Pack combina aquest
// array amb el seu, així que el de per defecte continua a la llista al costat del minimitzador
// SWC de Rspack; el d'SWC fa la minificació que l'altre se salta.
minimizer: [
new SwcJsMinimizerRspackPlugin({
test: /\.(js)?bundle(\?.*)?$/i,
minimizerOptions: { format: { comments: false } },
}),
],
},
El host rep el mateix import i el mateix bloc a apps/host/rspack.config.mjs, perquè el seu bundle de release es construeix de la mateixa manera. Després, encara a la configuració del remote, els chunks:
new Repack.RepackPlugin({
extraChunks: [
{
include: /.*/,
type: 'remote',
// Els chunks queden al costat del container i del manifest, perquè el host els demanarà en
// URLs relatives al manifest que ha carregat.
outputPath: isProd ? `cdn/${platform}/listApp` : `build/${platform}/remote`,
},
],
}),
Replica-ho tot a apps/party/rspack.config.mjs amb partyApp. Els dos arbres de sortida són productes de la build, així que s’afegeixen al .gitignore al costat d’apps/*/build/:
apps/*/cdn/
cdn-root/
Després construeix-ne un i mira què en surt:
( 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
Segueixen setze chunks més, i la majoria són còpies que el remote mateix fa dels singletons compartits: fallbacks que el host mai no demana, perquè els proveeix tots. El log del servidor, més endavant en aquest post, mostra els pocs que sí que demana. Aquí importen tres fitxers. El container és el que el host carrega primer; el chunk exposat conté ./ListStack; i mf-manifest.json és l’agenda d’adreces, que s’emet per defecte al costat del seu bessó mf-stats.json. Un camp seu sembla un error, i és a propòsit:
"publicPath": "noop:///"
L’hi posa la configuració base de Re.Pack, i res no el tracta com una ubicació. El host ha descarregat el manifest des d’una URL, i el plugin resolver de Re.Pack recol·loca sobre aquella URL cada URL de chunk d’aquell remote, així que cada chunk es descarrega del directori d’on ha sortit el manifest. On s’ha construït un remote no influeix en on se serveix, i això és el que permet que una mateixa build passi d’un portàtil a una CDN real sense canvis.
Un script munta l’arbre que serviria una CDN. Crea tools/build-cdn.mjs:
// --- Munta el directori que serviria una CDN. Per a cada remote executa l'script de bundle de
// producció d'aquell remote i després copia la sortida (container, chunks i mf-manifest.json) a
// cdn-root/<plataforma>/<remote>/. Aquesta disposició és la disposició de les URLs: un fitxer a
// cdn-root/ios/listApp/mf-manifest.json se serveix a <base>/ios/listApp/mf-manifest.json, que és
// exactament la URL que demana el mapa de remotes del host.
//
// Aquí hi ha una sola versió de cada remote, en un directori pla. Les releases versionades, i el
// mapa que decideix quina versió pot carregar cada app, arriben al post següent.
//
// Ús: node tools/build-cdn.mjs [ios|android] (sense argument construeix les dues)
//
// Després serveix-lo i apunta-hi el host (un emulador Android arriba a aquesta màquina a
// 10.0.2.2, així que la build d'Android rep aquella adreça en lloc de localhost):
// npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
// ( cd apps/host && MF_CDN_BASE=http://localhost:8000 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) {
// Esborra només aquesta plataforma, perquè construir-ne una no elimini l'arbre de l'altra.
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}`);
}
Signa el que publiques
Els chunks ja surten de la màquina, i aquí comença el cost d’integritat del post 1. La clau privada signa cada chunk de producció i no pot arribar mai al repositori, i aquest repositori és públic, així que la regla d’exclusió hi entra abans que existeixi el generador. A .gitignore:
# Claus de signatura. La clau privada signa cada chunk de producció i no pot entrar mai en un
# commit: aquest directori s'ignora abans que `node tools/gen-signing-keys.mjs` s'executi cap cop.
code-signing/
Ignora
code-signing/abans de generar una clau, no després. Una clau privada que arriba a un repositori públic queda compromesa en el moment en què es puja. En aquest post la reparació és una clau nova i reconstruir cada chunk que va signar l'antiga; quan les apps verifiquin signatures, a partir del post 15, també caldrà substituir la clau pública en què confien aquelles apps, i amb una clau incrustada això és una actualització de l'app. Comprova quegit statusno mostra res sotacode-signing/abans de cada commit a partir d'aquí.
Crea tools/gen-signing-keys.mjs:
// --- Genera el parell de claus que signa cada chunk de producció d'un remote.
//
// RSA-2048 signa cada chunk que emet el CodeSigningPlugin de Re.Pack (un JWT RS256 del hash
// del chunk, afegit al final del bundle). La meitat privada signa en temps de build; la
// meitat pública és aquella contra la qual verifica un host abans d'executar codi descarregat.
//
// La clau privada és a code-signing/, dins del checkout però ignorada per git abans que aquest
// script s'executi per primer cop, i s'escriu llegible només pel seu propietari: el .gitignore la
// manté fora dels commits, el mode del fitxer la manté lluny d'altres usuaris de la màquina. Només
// es genera un parell quan no existeix cap de les dues meitats. Una clau privada existent es
// conserva, mai no es rota: un chunk signat amb una clau nova no passaria la verificació contra
// una clau pública ja incrustada a les apps instal·lades, i una signatura només continua sent
// vàlida per a la clau que l'ha feta. Una meitat pública absent es deriva de la privada; una meitat pública
// sola, o un parell que no coincideix, atura l'script.
//
// Ús: 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;
// Les claus es comparen com a bytes DER, mai com a text PEM: els finals de línia i l'ajust de
// línies no formen part de la identitat.
const der = key => key.export({ type: 'spki', format: 'der' });
if (existsSync(privatePath)) {
// El mode es torna a aplicar a cada execució, perquè una còpia o una eina anterior poden
// haver-lo deixat més obert.
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 clau pública sense clau privada és l'únic estat que aquest script no ha de reparar pel seu
// compte: generar aquí un parell nou canviaria en silenci la identitat de signatura darrere d'una
// clau pública que potser ja està incrustada a apps instal·lades.
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
Dues decisions de l’script tenen a veure amb la vida de la clau després d’aquest post. La meitat privada s’escriu llegible només pel seu propietari, ja que .gitignore la manté fora dels commits i el mode del fitxer la manté lluny d’altres usuaris de la màquina. I un parell existent es conserva en lloc de rotar-se, i una clau pública que està sola mai no se substitueix: un chunk signat amb una clau nova no passaria la verificació contra una clau pública ja incrustada a les apps instal·lades, i una signatura només continua sent vàlida per a la clau que l’ha feta. Quan les apps verifiquin, rotar una clau serà una migració de confiança coordinada, amb la clau antiga i la nova acceptades durant un temps o amb releases separades adreçades a cadascuna, i aquell disseny pertany als posts que afegeixen verificació i versionat. Aquest script es nega a començar-ne una per accident.
Les configuracions dels remotes afegeixen el plugin només en mode producció. Als dos fitxers rspack.config.mjs, al final de plugins:
// Cada chunk de producció rep una signatura RS256 del seu propi hash, afegida al final del fitxer.
// La clau privada és a code-signing/, ignorada per git dins del checkout, i mai no es publica.
// Les builds de desenvolupament es queden sense signar a propòsit: els seus chunks se
// serveixen des d'aquesta màquina i es reconstrueixen a cada desada, i aquest post signa només el
// que en surt.
...(isProd
? [
new Repack.plugins.CodeSigningPlugin({
privateKeyPath: path.resolve(__dirname, '../../code-signing/private-key.pem'),
}),
]
: []),
Reconstrueix i llegeix 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 */ és la marca, i el que ve després és un JSON Web Token: el hash SHA-256 del chunk, signat amb la clau privada mitjançant RS256. El plugin reserva els últims 1.280 bytes del fitxer per a la marca i el token, i omple la resta amb zeros. El container i tots els chunks en porten un. index.bundle no, perquè el plugin se salta el bundle principal per ser sempre local.
Una signatura que ningú no verifica és un segell, no un cadenat. Res en aquesta build no llegeix el token. Verificar és feina d'
ScriptManager, contra una clau pública que l'app incrusta com aRepackPublicKey, i arriba amb el resolver al post 15. Re.Pack 5.2.5, la versió que fa servir aquesta sèrie, configura el plugin ambenabled,privateKeyPathiexcludeChunksi res més; la 5.3.0, publicada el 5 d'agost de 2026, afegeix unpublicKeyPathque incrusta la clau per tu. La sèrie es queda a la 5.2.5 i la incrusta al post que la llegeix, perquè la clau i el seu lector arribin junts. L'arc des d'aquí: el 14 signa, el 15 verifica, el 17 protegeix.
Serveix-ho com a producció
Una CDN, per a tot el que fa aquest post, és un servidor que retorna fitxers estàtics en URLs estables. Construeix els dos remotes en un mateix arbre i serveix-lo:
node tools/build-cdn.mjs ios
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
-c-1 desactiva la capçalera de cache, així que un chunk reconstruït mai no se serveix obsolet mentre iteres. El host és l’única app del workspace amb un mapa de remotes: el post 5 va convertir la pantalla de detall en un paquet, i des d’aleshores cap remote no consumeix l’altre. Així que l’interruptor entre els dos mons és una funció en un sol fitxer. A apps/host/rspack.config.mjs, a sobre de defineRspackConfig:
// --- D'on se serveixen els remotes. Defineix MF_CDN_BASE i cada URL de remote de sota apunta a la
// xarxa de distribució de continguts en lloc dels servidors de desenvolupament; deixa-la sense
// definir i res no canvia. El valor es llegeix en temps de BUILD i queda incrustat al bundle, així que una
// build que l'ha oblidat publica les URLs de desenvolupament:
// MF_CDN_BASE=http://localhost:8000 npm start (la CDN local, en una build de desenvolupament)
// MF_CDN_BASE=https://cdn.example.com npm run … (una de real, en una build de release)
const CDN_BASE = process.env.MF_CDN_BASE;
const DEV_REMOTES = {
listApp: 'http://localhost:8082',
partyApp: 'http://localhost:8083',
};
A dins, abans de la configuració que es retorna:
// Tot l'interruptor entre els dos mons, en una funció: servidor de desenvolupament o CDN, amb el
// mateix nom de manifest en tots dos casos. Res més no canvia al workspace, perquè el host és
// l'única app d'aquí que té 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'),
},
Atura els dos servidors de desenvolupament dels remotes. Arrenca el del host amb la variable definida, i construeix:
( cd apps/host && MF_CDN_BASE=http://localhost:8000 npm start )
( cd apps/host && npm run ios )
L’app es veu exactament igual que al final del post 13, i d’això es tracta. La prova és al 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 i els chunks que el host no podia proveir: el paquet detail, que el host mai no inclou al seu bundle, i el native stack, que el host no comparteix, així que el primer remote que es carrega el proveeix per a tots dos. Ni una petició de React, React Native, Redux o el design system: el host els proveeix tots, com exigeix el contracte de singletons del post 3.
Ara trenca-ho
Tots els «trenca-ho» de la sèrie fins ara han trencat una app en execució. Aquest passa abans que existeixi l’app. Construeix l’scheme Release amb l’arbre exactament com és ara:
( 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
Llegeix la primera línia, no l’última. Metro ha arrencat. La fase «Bundle React Native code and images» de Xcode executa el react-native-xcode.sh de React Native, i el CLI_PATH per defecte d’aquell script és scripts/bundle.js: l’ordre de bundle de Metro, cridada directament. La CLI de React Native mai no arriba a triar el bundler: bundle.js crida cli.js config només per llegir la configuració de projecte de react-native.config.js, i després invoca directament la funció de bundle de Metro, així que l’entrada commands que apunta bundle cap a Re.Pack no es consulta mai. Metro es troba aleshores amb import './global.css' a la línia 6 d’index.js i s’atura a la primera cosa que no sap fer. L’error indica un full d’estils; la fallada és quin bundler s’ha executat. L’arranjament són tres variables que l’script ja llegeix. Afegeix al final d’apps/host/ios/.xcode.env:
# --- El bundle de release el construeix Re.Pack, no Metro. La fase "Bundle React Native code and
# images" de Xcode crida directament l'script de bundle de React Native: aquell script llegeix
# react-native.config.js per a la configuració de projecte, a través de `cli.js config`, però mai
# l'entrada `commands` que lliura `bundle` a Re.Pack, així que, si no es toca, executa Metro, que
# no sap res de global.css i s'atura aquí. CLI_PATH apunta la fase a la CLI de React Native, que
# sí que respecta aquella entrada; BUNDLE_COMMAND anomena l'ordre que Re.Pack hi registra;
# EXTRA_PACKAGER_ARGS li passa la configuració de Rspack. $SRCROOT és aquest directori ios/, així
# que l'arrel de l'app és el seu pare. ---
export CLI_PATH="$SRCROOT/../node_modules/react-native/cli.js"
export BUNDLE_COMMAND=bundle
export EXTRA_PACKAGER_ARGS="--config $SRCROOT/../rspack.config.mjs"
Torna a executar la build Release. L’app arrenca des de la CDN sense cap servidor de desenvolupament en marxa, i les URLs són al bundle de dins de l’app, a la vista de qualsevol:
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
Oblida la variable en una build de release i res no es queixa: s’hi incrusten les URLs dels servidors de desenvolupament, i aquest grep és com ho descobreixes abans que un tester.
Android no necessita cap canvi per arribar a Re.Pack. El bundleCommand del plugin de Gradle val bundle per defecte i el seu cliFile és react-native/cli.js, la ruta a través del fitxer de configuració que l’script de Xcode se salta, i android (Rspack 2.0.5) compiled apareix dins de :app:createBundleReleaseJsAndAssets. Tres coses canvien, això sí. L’emulador arriba a la teva màquina a 10.0.2.2, no a localhost, així que la variable canvia amb la plataforma:
( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 npm run android -- --mode release )
Gradle decideix si executa la tasca de bundle comparant les entrades declarades de la tasca amb l’execució anterior, i una variable d’entorn no n’és cap. Canvia només MF_CDN_BASE i la tasca respon UP-TO-DATE, i es publica el bundle anterior. Declara la variable com a entrada, al final d’apps/host/android/app/build.gradle:
// --- rspack.config.mjs llegeix MF_CDN_BASE quan s'executa la tasca de bundle, i Gradle no ho pot
// veure: una variable d'entorn no és cap de les entrades declarades de la tasca, així que una
// build on només ha canviat el valor deixa createBundleReleaseJsAndAssets UP-TO-DATE i publica el
// bundle anterior, URLs de desenvolupament incloses. Declarar-la com a entrada fa que un valor nou
// reconstrueixi el bundle i que un de sense canvis conservi el cache. ---
tasks.configureEach {
if (name.startsWith("createBundle") && name.endsWith("JsAndAssets")) {
inputs.property("MF_CDN_BASE", System.getenv("MF_CDN_BASE") ?: "")
}
}
I un APK de release rebutja l’http en clar. El plugin de Gradle de React Native posa usesCleartextTraffic a false a les builds de release, i l’arrencada mor amb [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003, amb un Network request failed a sota. Una CDN de producció se serveix per https i mai no es troba amb això. La local no, així que una configuració de seguretat de xarxa permet les adreces de loopback i res més. Crea apps/host/android/app/src/main/res/xml/network_security_config.xml:
<?xml version="1.0" encoding="utf-8"?>
<!-- El substitut local d'una CDN se serveix per http en clar a la màquina de build, i una build
de release es nega a parlar-hi. Això ho permet només per a les adreces de loopback: localhost,
127.0.0.1 i 10.0.2.2, l'adreça a què un emulador arriba a la màquina. Una CDN de producció se
serveix per https, on aquesta excepció no fa res. -->
<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>
i declara-la a l’element <application> d’AndroidManifest.xml:
android:networkSecurityConfig="@xml/network_security_config"
iOS no va necessitar res d’equivalent: l’Info.plist de la plantilla ja porta NSAllowsLocalNetworking, i la build Release va arribar a localhost:8000 sense cap canvi.
Executa-ho
Tres terminals, i en falten dos. Els servidors de desenvolupament dels remotes, un per remote a tots els posts des del post 2, no arrenquen. Claus, 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 )
Les builds de release fan servir el mateix arbre i porten la URL a l’ordre de build, perquè és allà on es llegeix:
( 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 )
Res d’aquest post no toca el codi que cobreixen les suites, i elles ho confirmen:
( for d in apps/host apps/list apps/party; do ( cd "$d" && npx jest --silent ) || exit 1; done )
Afegeix dos Pokémon, obre la pestanya Party, fes un Quick Battle. Tot el que va construir el post 13 continua funcionant, i cada petició que ho ha fet funcionar és una línia al log del servidor.
El que has construït, i el que ve
Dos remotes construïts per a producció, cada chunk amb la seva signatura, disposats com el directori que serveix una CDN; un host que es mou entre els servidors de desenvolupament i aquell directori amb una sola variable d’entorn, llegida en temps de build; i una build de release a cada plataforma que arrenca des d’allà sense cap servidor de desenvolupament en marxa. L’app es veu igual que al post 13. Els terminals, no.
Els límits, cadascun amb el seu propietari. La CDN guarda una sola versió de cada remote, així que una build nova substitueix l’anterior per a tots els binaris instal·lats alhora, puguin executar-la o no; el post 15 afegeix directoris versionats, un mapa de versions i un resolver que tria a cada arrencada. Res no verifica les signatures; aquell resolver sí, contra una clau pública incrustada al seu costat. Una app que no arriba a la CDN no té res a què recórrer; el post 16 posa una còpia de cada remote al binari. I el mapa que decidirà quina versió carrega una app continua sense signar fins al post 17.
El següent: remotes versionats a la CDN, un mapa de versions, un resolver per arrencada i un binari antic que mai no descarrega codi que no pot executar.
Fonts
- Re.Pack: bundle — l’ordre de bundle de producció i els seus flags
--dev,--entry-file,--platformi--config - Re.Pack: CodeSigningPlugin — el propòsit i les opcions del plugin; la pàgina ja documenta el
publicKeyPathque va afegir la 5.3.0 - Re.Pack: ScriptManager —
verifyScriptSignaturei elRepackPublicKeyincrustat a què recorre, la feina del post següent - Re.Pack: ModuleFederationV2Plugin — el format
name@urldel mapa de remotes i el plugin resolver de runtime que el plugin afegeix per defecte - Re.Pack source: CodeSigningPlugin.ts at 5.2.5 — la marca
/* RCSSB */, el hash SHA-256 signat com a token RS256, la reserva de 1.280 bytes i el bundle principal saltat per ser sempre local - Re.Pack source: ResolverPlugin.ts at 5.2.5 — cada URL d’asset recol·locada sobre la URL del manifest que ha descarregat el host
- callstack/repack#1390 — el minimitzador per defecte sense fer res amb Rspack des de terser-webpack-plugin 5.6.0, amb la build encara en verd
- Re.Pack source: getMinimizerConfig.ts at 5.2.5 — terser-webpack-plugin triat per a totes les versions de Rspack menys la 1.4.11
- React Native source: bundle.js at 0.85.3 — l’script que la fase de Xcode crida: configuració de projecte a través de
cli.js config, i després la funció de bundle de Metro directament - Module Federation: manifest —
mf-manifest.jsonimf-stats.json, els noms de fitxer per defecte - React Native source: react-native-xcode.sh at 0.85.3 —
CLI_PATHamb l’scripts/bundle.jsde Metro per defecte, i les variablesBUNDLE_COMMANDiEXTRA_PACKAGER_ARGSque defineix l’arranjament - React Native source: ReactExtension.kt at 0.85.3 —
cliFileambcli.jsper defecte ibundleCommandambbundle, i és per això que Android arriba a Re.Pack sense canvis - React Native source: AgpConfiguratorUtils.kt at 0.85.3 —
usesCleartextTrafficposat afalsea les builds de release - Gradle: incremental build — les comprovacions d’actualitat comparen empremtes de les entrades declarades, i
inputs.propertyen declara una - Android: network security configuration — el trànsit en clar desactivat per defecte des d’Android 9, i el
domain-configque el permet per host - Android: emulator network address space —
10.0.2.2com a àlies de la interfície de loopback de la màquina de desenvolupament - http-server — les opcions
-p,-ci--cors - react-native-module-federation — el repo d’acompanyament, la build al tag
post-14-production-build