Tatlong supply line na nagpapakain sa iisang federated app: isang dev server, isang registry, at isang CDN

Ang production build at ang tatlong mode ng isang federated React Native app

Natapos ang post 13 sa isang pangako: “isang production bundle at ang unang federated release artefact.” Ito na ang post na iyon. Ang mas malaking pangako ay sa post 1, na ang bawat feature ay “puwedeng i-deploy at i-update nang mag-isa sa halip na sumakay sa iisang App Store release”, at sa post ding iyon nakalista ang presyo: isang CDN na kailangang patakbuhin, isang URL na attack surface, code na kailangang i-sign at i-verify. Labintatlong post na ang lumipas, at nanggagaling pa rin ang dalawang remote sa dalawang dev server sa machine mo. Binubuo sila ng post na ito para sa production, sinasignan ang bawat chunk, at sineserve sila mula sa isang content delivery network (CDN) habang naka-off ang mga dev server na iyon.

Una, ang totoong saklaw. Ang CDN sa post na ito ay isang directory at isang port: isang version lang ng bawat remote ang hawak nito, at wala pang nagve-verify ng signature, nagbabantay sa pagpili ng version, o nagsisilbing fallback kapag hindi ma-reach ang CDN. Idinaragdag ng susunod na tatlong post ang lahat ng iyon, at bawat isa sa kanila ay gumagamit ng mga artefact na ginagawa ng post na ito.

Magsimula sa tapos na estado ng post 13, ang start tag na post-13-native-handoff. Ang end tag ay post-14-production-build, at ikaw ang magta-type ng lahat ng nasa pagitan ng dalawa.

Tatlong channel, tatlong cadence. Nagtatapos ang arrow ng registry sa node_modules at hindi kailanman umaabot sa tumatakbong app: ang pagbabago sa isang package ay umaabot sa mga user sa loob ng susunod na build ng kung sino man ang mag-install nito. Ang pagbabago sa isang remote ay pumupunta sa CDN at umaabot sa app sa susunod nitong launch. Ang pagbabago sa host ay dumadaan sa store.

Tatlong mode, isang table

Nasa unang row ang serye mula pa sa post 2 at ginagamit na ang pangalawa mula pa sa post 5, nang hindi pinapangalanan ang alinman. Ngayon may pangalan na, at katabi nila ang pangatlo:

ChannelAno ang sineserveSino ang nagbabasa, at kailanAno ang kailangang gawin kapag may pagbabago
Mga dev server, :8082 at :8083Ang manifest, container, at mga chunk ng isang remote, na nire-rebuild sa bawat saveAng host app, sa runtime, kapag hindi naka-set ang MF_CDN_BASE: sa praktika, ang mga development buildIsang save
Registry, Verdaccio sa :4873Mga npm package: @pokedex/contracts, detail, ui, at a11y-testingnpm install, sa build, sa bawat app na naka-depend sa packageIsang publish, tapos reinstall at rebuild ng bawat consumer
CDN, cdn-root/ sa :8000Ang manifest, container, at mga chunk ng isang remote, na binuo nang isang beses at sinignanAng host app, sa runtime, development man o release buildIsang rebuild ng remote at isang copy; kukunin ito ng bawat naka-install na app sa susunod nitong launch

Ang tanong na sinasagot ng table na ito ang unang tanong ng karamihan sa mga team: aling mga pagbabago ang nangangailangan pa rin ng app-store release? Lahat ng dala ng binary: ang native code, ang host shell, at bawat shared singleton na ibinibigay ng host, dahil ang bundle ng host ang lugar ng nag-iisang kopya ng bawat isa. Lahat ng pag-aari ng isang remote ay nangangailangan ng CDN deploy at isang launch. Nasa pagitan ng dalawa ang mga package, at ang gitnang row ang dapat basahin nang dalawang beses.

Ang registry ang karaniwang lugar ng mga versioned artefact, kaya makatuwiran na asahan doon ang mga remote, at doon din unang inilalagay ng isang team na bago sa federation. Pero wala sila roon: mga package ang sineserve ng registry, hindi kailanman mga remote. Umaabot ang package sa mga user kasama ng bundle na nag-compile nito.

Ini-ship ng post 12 ang @pokedex/detail 4.0.12 bilang isang publish at isang reinstall sa list at party; sa mga mode ng post na ito, ang parehong fix ay dalawang na-rebuild na remote sa CDN at walang store release, dahil hindi kailanman isinasama ng host ang detail package sa bundle nito. Iba ang fix sa @pokedex/contracts o @pokedex/ui. Ibinibigay ng host ang dalawang iyon bilang mga eager singleton, kaya nagbabago ang bundle ng host, at ang bundle ng host ay nasa loob ng binary.

Bumuo ng remote nang totohanan

Development build ang bawat bundle na pinatakbo ng serye, sineserve sa bawat save. Ang production build ay ang parehong command na naka-off ang development switch, at natatanggap ito ng bawat remote bilang dalawang script. Sa 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",

Idagdag din ang parehong dalawa sa apps/party/package.json. Umaabot ang react-native bundle sa Re.Pack dahil nirehistro ng react-native.config.js ng post 2 ang mga command nito sa CLI (alias ang webpack-bundle). Pinapangalanan ng --entry-file src/index.js ang container entry na ibinigay ng post 2 sa bawat remote; ang index.js ng template na katabi nito ay nagrerehistro ng remote bilang standalone app, at hindi kailanman iyon sineserve ng CDN.

Nakakakuha ang config ng isang boolean at binabasa ito sa dalawang lugar na kailangang magkatugma: kung saan nagsusulat ang compilation, at kung saan nakalatag ang mga chunk na kukunin ng host. Sa apps/list/rspack.config.mjs, pagkatapos ng const { mode, platform } = env;:

// Sa ibang lugar nagsusulat ang mga production build at nakakakuha sila ng signature; hindi nagagalaw ang development.
const isProd = mode === 'production';
output: {
  // Sinusulat ng production build ang tree na sineserve ng CDN, nakalatag gaya ng URL path kung
  // saan ito sineserve: cdn/<platform>/listApp/. Patuloy na nagsusulat ang development build sa
  // build/, kung saan ito binabasa ng dev server.
  path: isProd ? `${__dirname}/cdn/[platform]/listApp` : `${__dirname}/build/[platform]`,
  uniqueName: 'ListApp',
},

Ang pangatlong block ay tungkol sa kung mapagkakatiwalaan ang isang successful na build. Wala itong kinalaman sa federation. Ang default minimiser ng Re.Pack ay terser-webpack-plugin, at sa ilalim ng Rspack ay wala nang ginagawa ang plugin na iyon mula pa sa 5.6.0 release nito (callstack/repack#1390): nagre-report ng success ang build at nananatili ang mga comment at whitespace ng bawat production chunk. Idagdag sa listahan ang SWC minimiser ng Rspack, kasama ng default minimiser. Pinagsasama ng Re.Pack ang optimization.minimizer mo sa sarili nitong listahan sa halip na palitan ito, kaya nananatili ang default sa array at patuloy na walang ginagawa habang ang SWC ang gumagawa ng minifying. Idagdag ang import sa itaas ng file:

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

at, pagkatapos ng output:

optimization: {
  // Ang default minimiser ng Re.Pack ay terser-webpack-plugin, at sa ilalim ng Rspack ay wala
  // nang ginagawa ang plugin na iyon mula pa sa terser-webpack-plugin 5.6.0
  // (callstack/repack#1390): nagtatagumpay ang build at lumalabas ang bawat production chunk na
  // buo pa ang mga comment at whitespace. Pinagsasama ng Re.Pack ang array na ito sa sarili
  // nito, kaya nananatili ang default sa listahan katabi ng SWC minimiser ng Rspack; ang SWC ang
  // gumagawa ng minifying na nilalaktawan ng default.
  minimizer: [
    new SwcJsMinimizerRspackPlugin({
      test: /\.(js)?bundle(\?.*)?$/i,
      minimizerOptions: { format: { comments: false } },
    }),
  ],
},

Nakakakuha ang host ng parehong import at block sa apps/host/rspack.config.mjs, dahil ganoon din binubuo ang release bundle nito. Pagkatapos, sa config pa rin ng remote, ang mga chunk:

new Repack.RepackPlugin({
  extraChunks: [
    {
      include: /.*/,
      type: 'remote',
      // Katabi ng container at ng manifest napupunta ang mga chunk, dahil hihingin sila ng host
      // sa mga URL na relative sa manifest na na-load nito.
      outputPath: isProd ? `cdn/${platform}/listApp` : `build/${platform}/remote`,
    },
  ],
}),

I-mirror ang lahat ng iyon sa apps/party/rspack.config.mjs gamit ang partyApp. Build product ang dalawang output tree, kaya sumasama sila sa .gitignore katabi ng apps/*/build/:

apps/*/cdn/
cdn-root/

Tapos, i-build ang isa at tingnan kung ano ang lumalabas:

( 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

May labing-anim pang chunk na kasunod, karamihan ay mga kopya ng mga shared singleton na gawa mismo ng remote: mga fallback na hindi kailanman hinihingi ng host, dahil ibinibigay nito ang lahat ng iyon. Ipinapakita ng server log, mamaya sa post na ito, ang iilan na hinihingi nga nito. Tatlong file ang mahalaga rito. Ang container ang unang nilo-load ng host; dala ng exposed chunk ang ./ListStack; at ang mf-manifest.json ang address book, na ini-emit by default kasama ng kakambal nitong mf-stats.json. Isang field dito ang mukhang mali, at sinadya iyon:

"publicPath": "noop:///"

Itinatakda ito ng base config ng Re.Pack, at walang nagtuturing dito bilang lokasyon. Kinuha ng host ang manifest mula sa isang URL, at inire-rebase ng resolver plugin ng Re.Pack ang bawat chunk URL ng remote na iyon sa URL na iyon, kaya kinukuha ang bawat chunk mula sa directory na pinanggalingan ng manifest. Walang kinalaman kung saan binuo ang remote sa kung saan ito sineserve, at iyon ang dahilan kung bakit puwedeng lumipat ang iisang build mula sa laptop patungo sa totoong CDN nang walang pagbabago.

Isang script ang nagbubuo ng tree na iseserve ng isang CDN. Gumawa ng tools/build-cdn.mjs:

// --- Binubuo ang directory na iseserve ng isang CDN. Para sa bawat remote, pinapatakbo nito ang
// production bundle script ng remote na iyon, tapos kinokopya ang output (container, mga chunk,
// at mf-manifest.json) sa cdn-root/<platform>/<remote>/. Ang layout na iyon ang URL layout: ang
// file sa cdn-root/ios/listApp/mf-manifest.json ay sineserve sa <base>/ios/listApp/mf-manifest.json,
// na eksaktong URL na hinihingi ng remotes map ng host.
//
// Isang version ng bawat remote ang nandito, sa iisang flat na directory. Darating sa susunod na
// post ang mga versioned release at ang map na nagpapasya kung aling version ang puwedeng i-load
// ng isang app.
//
// Gamit: node tools/build-cdn.mjs [ios|android]     (kapag walang argument, pareho ang bini-build)
//
// Tapos i-serve ito at ituro rito ang host (umaabot ang Android emulator sa machine na ito sa
// 10.0.2.2, kaya iyon ang address na natatanggap ng Android build sa halip na 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) {
  // Burahin lang ang platform na ito, para hindi mabura ng pag-build ng isa ang tree ng isa pa.
  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}`);
}

I-sign ang ini-ship mo

Umaalis na ang mga chunk sa machine, at dito nagsisimula ang integrity cost ng post 1. Sinasignan ng private key ang bawat production chunk, at hindi dapat umabot kailanman sa repository ang key na iyon; public ang repository na ito, kaya nauuna ang ignore rule bago pa man umiral ang generator. Sa .gitignore:

# Mga signing key. Sinasignan ng private key ang bawat production chunk at hindi ito dapat ma-commit
# kailanman: naka-ignore ang directory na ito bago pa man unang patakbuhin ang `node tools/gen-signing-keys.mjs`.
code-signing/

I-ignore ang code-signing/ bago ka mag-generate ng key, hindi pagkatapos. Ang private key na umabot sa public repository ay compromised na sa sandaling ma-push ito. Sa post na ito, ang ayos ay bagong key at rebuild ng bawat chunk na sinignan ng luma; kapag nagve-verify na ng signature ang mga app, mula sa post 15, kailangan ding palitan ang public key na pinagkakatiwalaan ng mga app na iyon, at sa embedded key ay app update iyon. Siguraduhing walang ipinapakita ang git status sa ilalim ng code-signing/ bago ang bawat commit mula rito.

Gumawa ng tools/gen-signing-keys.mjs:

// --- Nagge-generate ng keypair na nagsa-sign sa bawat production remote chunk.
//
//   RSA-2048   nagsa-sign sa bawat chunk na ini-emit ng CodeSigningPlugin ng Re.Pack (isang RS256
//              JWT ng hash ng chunk, idinudugtong sa dulo ng bundle). Ang private half ang nagsa-sign
//              sa build; ang public half ang tinitingnan ng host bago nito patakbuhin ang
//              na-download na code.
//
// Nasa code-signing/ ang private key, sa loob ng checkout pero naka-gitignore bago pa man unang
// patakbuhin ang script na ito, at isinusulat ito na mababasa lang ng may-ari: ang .gitignore ang
// pumipigil na mapasama ito sa mga commit, ang file mode ang pumipigil na mabasa ito ng ibang user
// ng machine. Nagge-generate lang ng pair ang script kapag walang umiiral sa dalawang kalahati.
// Pinananatili ang umiiral na private key, hindi kailanman niro-rotate: hindi mave-verify ang chunk
// na sinignan ng bagong key laban sa public key na naka-embed na sa mga naka-install na app, at
// valid lang ang signature para sa key na gumawa nito. Ang nawawalang public half ay dine-derive
// mula sa private; ang public half na nag-iisa, o ang pair na hindi magkatugma, ay nagpapahinto sa
// script.
//
// Gamit: 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;

// Bilang DER bytes kinukumpara ang mga key, hindi kailanman bilang PEM text: hindi bahagi ng
// identity ang line ending at ang wrapping.
const der = key => key.export({ type: 'spki', format: 'der' });

if (existsSync(privatePath)) {
  // Muling ina-apply ang mode sa bawat run, dahil baka mas bukas ang naiwan ng isang copy o ng
  // isang naunang tool.
  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)) {
  // Ang public key na walang private key ang nag-iisang estado na hindi dapat ayusin ng script na
  // ito nang mag-isa: ang pag-generate dito ng bagong pair ay tahimik na magpapalit ng signing
  // identity sa likod ng public key na baka naka-embed na sa mga naka-install na app.
  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

Dalawa sa mga desisyon ng script ang tungkol sa buhay ng key pagkatapos ng post na ito. Isinusulat ang private half na mababasa lang ng may-ari, dahil ang .gitignore ang pumipigil na mapasama ito sa mga commit at ang file mode ang pumipigil na mabasa ito ng ibang user ng machine. At pinananatili ang umiiral na pair sa halip na i-rotate, at hindi kailanman pinapalitan ang public key na nag-iisa: hindi mave-verify ang chunk na sinignan ng bagong key laban sa public key na naka-embed na sa mga naka-install na app, at valid lang ang signature para sa key na gumawa nito. Kapag nagve-verify na ang mga app, ang pag-rotate ng key ay isang coordinated na trust migration, na pareho munang pinagkakatiwalaan ang luma at bagong key nang ilang panahon o may magkahiwalay na release para sa bawat isa, at ang mga post na magdaragdag ng verification at versioning ang may-ari ng disenyong iyon. Tumatanggi ang script na ito na magsimula ng isa nang hindi sinasadya.

Idinaragdag ng mga config ng remote ang plugin sa production mode lang. Sa dalawang rspack.config.mjs file, sa dulo ng plugins:

// Nakakakuha ang bawat production chunk ng RS256 signature ng sarili nitong hash, idinudugtong sa
// dulo ng file. Nasa code-signing/ ang private key, naka-gitignore sa loob ng checkout, at hindi
// kailanman naisi-ship. Nananatiling hindi sinignan ang mga development build dahil pinili iyon:
// sineserve ang mga chunk nila mula sa machine na ito at nire-rebuild sa bawat save, at ang
// umaalis lang dito ang sinasignan ng post na ito.
...(isProd
  ? [
      new Repack.plugins.CodeSigningPlugin({
        privateKeyPath: path.resolve(__dirname, '../../code-signing/private-key.pem'),
      }),
    ]
  : []),

Mag-rebuild at basahin ang dulo ng 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

Ang /* RCSSB */ ang marker, at ang kasunod nito ay isang JSON Web Token: ang SHA-256 hash ng chunk, na sinignan ng private key gamit ang RS256. Inirereserba ng plugin ang huling 1,280 byte ng file para sa marker at sa token, at pinupuno ng zero ang natitira. May signature ang container at ang bawat chunk. Walang signature ang index.bundle, dahil nilalaktawan ng plugin ang main bundle, na laging local.

Ang signature na walang nagve-verify ay tatak, hindi kandado. Walang nagbabasa ng token sa build na ito. Trabaho ng ScriptManager ang pag-verify, laban sa public key na ini-embed ng app bilang RepackPublicKey, at darating iyon kasama ng resolver sa post 15. Ang Re.Pack 5.2.5, ang version na ginagamit ng seryeng ito, ay nagko-configure ng plugin gamit ang enabled, privateKeyPath, at excludeChunks at wala nang iba; ang 5.3.0, inilabas noong Agosto 5, 2026, ay nagdaragdag ng publicKeyPath na nag-e-embed ng key para sa iyo. Nananatili ang serye sa 5.2.5 at ini-embed ito sa post na magbabasa nito, para sabay na dumating ang key at ang magbabasa nito. Ang arc mula rito: nagsa-sign ang 14, nagve-verify ang 15, nagbabantay ang 17.

I-serve ito na parang production

Ang CDN, para sa lahat ng ginagawa ng post na ito, ay isang server na nagbabalik ng mga static file sa mga stable na URL. I-build ang dalawang remote sa iisang tree at i-serve ito:

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

Pinapatay ng -c-1 ang cache header, kaya hindi kailanman sineserve nang stale ang nire-rebuild na chunk habang nag-iiterate ka. Ang host lang ang app sa workspace na may remotes map: ginawang package ng post 5 ang detail screen, at mula noon ay walang remote na kumokonsumo sa isa pa. Kaya isang function sa isang file lang ang switch sa pagitan ng dalawang mundo. Sa apps/host/rspack.config.mjs, sa itaas ng defineRspackConfig:

// --- Kung saan sineserve ang mga remote. I-set ang MF_CDN_BASE at ang bawat remote URL sa ibaba
// ay tumuturo sa content delivery network sa halip na sa mga dev server; huwag itong i-set at
// walang magbabago. Binabasa ang value sa oras ng BUILD at naka-bake sa bundle, kaya ang build na
// nakalimot dito ay nagsi-ship ng mga dev URL:
//   MF_CDN_BASE=http://localhost:8000 npm start      (ang local CDN, sa development build)
//   MF_CDN_BASE=https://cdn.example.com npm run …    (isang totoong CDN, sa release build)
const CDN_BASE = process.env.MF_CDN_BASE;

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

Sa loob nito, bago ang ibinabalik na config:

// Ang buong switch sa pagitan ng dalawang mundo, sa isang function: dev server o CDN, parehong
// manifest filename sa alinman. Wala nang ibang nagbabago sa workspace, dahil ang host lang ang
// app dito na may hawak na remotes map.
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'),
},

Ihinto ang dalawang dev server ng mga remote. Simulan ang sa host na naka-set ang variable, at mag-build:

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

Eksaktong kamukha ng app ang sa dulo ng post 13, at iyon mismo ang punto. Nasa terminal ng server ang patunay:

[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"

Dalawang manifest, dalawang container, at ang mga chunk na hindi kayang ibigay ng host: ang detail package, na hindi kailanman isinasama ng host sa bundle nito, at ang native stack, na hindi ini-share ng host, kaya ang unang remote na nag-load ang nagbibigay nito para sa dalawa. Walang kahit isang request para sa React, React Native, Redux, o sa design system: ibinibigay ng host ang lahat ng iyon, gaya ng hinihingi ng singleton contract ng post 3.

Ngayon, sirain natin

Bawat break-it sa serye hanggang ngayon ay sumira ng tumatakbong app. Ang isang ito ay nangyayari bago pa umiral ang app. I-build ang Release scheme habang eksaktong ganito ang tree:

( 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

Basahin ang unang linya, hindi ang huli. Nag-start ang Metro. Pinapatakbo ng “Bundle React Native code and images” phase ng Xcode ang react-native-xcode.sh ng React Native, at ang default na CLI_PATH ng script na iyon ay scripts/bundle.js: ang bundle command ng Metro, na direktang tinatawag. Hindi kailanman nakakapili ng bundler ang React Native CLI: tinatawag ng bundle.js ang cli.js config para lang basahin ang project config ng react-native.config.js, tapos direkta nitong ini-invoke ang bundle function ng Metro, kaya hindi kailanman kinokonsulta ang commands entry na nagtuturo ng bundle sa Re.Pack. Pagkatapos ay nasasalubong ng Metro ang import './global.css' sa line 6 ng index.js, at humihinto ang Metro sa unang bagay na hindi nito kaya. Stylesheet ang pinapangalanan ng error; kung aling bundler ang tumakbo ang totoong mali. Ang ayos ay tatlong variable na binabasa na ng script. Idugtong sa apps/host/ios/.xcode.env:

# --- Re.Pack ang bumubuo ng release bundle, hindi Metro. Direktang tinatawag ng "Bundle React
# Native code and images" phase ng Xcode ang bundle script ng React Native: binabasa ng script na
# iyon ang react-native.config.js para sa project config, sa pamamagitan ng `cli.js config`, pero
# hindi kailanman ang `commands` entry na nagbibigay ng `bundle` sa Re.Pack, kaya kapag hinayaan,
# Metro ang pinapatakbo nito, na walang alam tungkol sa global.css at humihinto roon. Itinuturo ng
# CLI_PATH ang phase sa React Native CLI, na sumusunod sa entry na iyon; pinapangalanan ng
# BUNDLE_COMMAND ang command na nirerehistro roon ng Re.Pack; ibinibigay ng EXTRA_PACKAGER_ARGS ang
# Rspack config. Ang $SRCROOT ay ang ios/ directory na ito, kaya ang app root ay ang parent nito. ---
export CLI_PATH="$SRCROOT/../node_modules/react-native/cli.js"
export BUNDLE_COMMAND=bundle
export EXTRA_PACKAGER_ARGS="--config $SRCROOT/../rspack.config.mjs"

Patakbuhin ulit ang Release build. Nagbu-boot ito mula sa CDN nang walang kahit anong dev server na tumatakbo, at nasa bundle sa loob ng app ang mga URL, na puwedeng tingnan ng kahit sino:

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

Kalimutan ang variable sa release build at walang magrereklamo: ang mga dev-server URL ang maba-bake sa halip, at ang grep na ito ang paraan para malaman mo bago pa malaman ng tester.

Walang kailangang pagbabago ang Android para maabot ang Re.Pack. Ang bundleCommand ng Gradle plugin ay bundle by default at ang cliFile nito ay react-native/cli.js, ang daan sa pamamagitan ng config file na nilalaktawan ng script ng Xcode, at lumalabas ang android (Rspack 2.0.5) compiled sa loob ng :app:createBundleReleaseJsAndAssets. Pero tatlong bagay ang naiiba. Umaabot ang emulator sa machine mo sa 10.0.2.2, hindi sa localhost, kaya nagbabago ang variable kasabay ng platform:

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

Nagpapasya ang Gradle kung patatakbuhin man lang ang bundle task sa pamamagitan ng pagkumpara ng mga declared input ng task sa nakaraang run, at hindi kabilang doon ang environment variable. Baguhin lang ang MF_CDN_BASE at magre-report ang task ng UP-TO-DATE, at ang nakaraang bundle ang mai-ship. I-declare ang variable bilang input, sa dulo ng apps/host/android/app/build.gradle:

// --- Binabasa ng rspack.config.mjs ang MF_CDN_BASE kapag tumatakbo ang bundle task, at hindi iyon
// nakikita ng Gradle: hindi kabilang sa mga declared input ng task ang environment variable, kaya
// nananatiling UP-TO-DATE ang createBundleReleaseJsAndAssets sa build na ang value lang ang
// nagbago, at nagsi-ship ito ng nakaraang bundle, kasama ang mga dev-server URL. Ang pag-declare
// dito bilang input ay nagpapa-rebuild ng bundle kapag nagbago ang value at nagpapanatili ng cache
// kapag hindi. ---
tasks.configureEach {
    if (name.startsWith("createBundle") && name.endsWith("JsAndAssets")) {
        inputs.property("MF_CDN_BASE", System.getenv("MF_CDN_BASE") ?: "")
    }
}

At tumatanggi ang release APK sa plain http. Itinatakda ng Gradle plugin ng React Native ang usesCleartextTraffic sa false para sa mga release build, at namamatay ang boot sa [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003, na may Network request failed sa ilalim. Sineserve sa https ang production CDN at hindi kailanman nasasalubong ito. Hindi ang local, kaya may network security config na nagpapahintulot sa mga loopback address at wala nang iba. Gumawa ng apps/host/android/app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<!-- Sineserve sa plain http sa build machine ang local na pamalit sa CDN, at tumatanggi ang release
     build na makipag-usap dito. Pinapahintulutan ito rito para sa mga loopback address lang:
     localhost, 127.0.0.1, at 10.0.2.2, ang address kung saan naaabot ng emulator ang machine.
     Sineserve sa https ang production CDN, kung saan wala talagang ginagawa ang exception na ito. -->
<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>

at i-reference ang configuration na ito sa <application> element ng AndroidManifest.xml:

android:networkSecurityConfig="@xml/network_security_config"

Walang kinailangang katumbas ang iOS: dala na ng Info.plist ng template ang NSAllowsLocalNetworking, at naabot ng Release build ang localhost:8000 nang walang pagbabago.

Patakbuhin mo

Tatlong terminal, at dalawa ang nawawala. Hindi nagsi-start ang mga dev server ng mga remote, isa bawat remote sa bawat post mula pa sa post 2. Mga key, build, serve:

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 )

Ginagamit ng mga release build ang parehong tree at dala nila ang URL sa build command, dahil doon ito binabasa:

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

Walang ginagalaw ang post na ito sa code na sinasaklaw ng mga suite, at sinasabi nila iyon:

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

Magdagdag ng dalawang Pokémon, buksan ang Party tab, magpatakbo ng Quick Battle. Gumagana pa rin ang lahat ng binuo ng post 13, at bawat request na nagpagana nito ay isang linya sa log ng server.

Ang binuo mo, at ang susunod

Dalawang remote na binuo para sa production, bawat chunk ay may dalang signature, nakalatag bilang directory na sineserve ng CDN; isang host na lumilipat sa pagitan ng mga dev server at ng directory na iyon sa pamamagitan ng iisang environment variable, na binabasa sa build; at isang release build sa bawat platform na nagbu-boot mula rito nang walang tumatakbong dev server. Kamukha pa rin ng app ang sa post 13. Hindi ang mga terminal.

Ang mga limitasyon, at bawat isa ay may may-ari. Isang version lang ng bawat remote ang hawak ng CDN, kaya pinapalitan ng bagong build ang luma para sa lahat ng naka-install na binary nang sabay-sabay, kaya man o hindi ng binary na iyon na patakbuhin ito; nagdaragdag ang post 15 ng mga versioned directory, isang version map, at isang resolver na pumipili sa bawat launch. Walang nagve-verify ng mga signature; ang resolver na iyon ang gagawa, laban sa public key na naka-embed sa tabi nito. Ang app na hindi makaabot sa CDN ay walang fallback; naglalagay ang post 16 ng kopya ng bawat remote sa binary. At ang map na magpapasya kung aling version ang ilo-load ng app ay hindi pa rin sinignan hanggang sa post 17.

Susunod: mga versioned remote sa CDN, isang version map, isang resolver bawat launch, at isang lumang binary na hindi kailanman nagda-download ng code na hindi nito kayang patakbuhin.

Mga Sanggunian

Warren de Leon
Warren de Leon

Software Engineering Manager. Pinakahuling pinamunuan ang Mobile Platform team sa Hargreaves Lansdown. Sumusulat tungkol sa engineering leadership, React Native, at pagbuo ng magagandang team.

Tingnan ang profile