Natapos ang post 14 sa isang pangako: “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.” Binubuo ng post na ito ang lahat ng iyon, at pagkatapos ay ginagamit. May app na naka-install, nakapatay ang mga dev server, at version 1.1.0 ng Pokédex list ang ipinapakita nito. Ipapadala mo rito ang 1.2.0 gamit ang isang directory at isang in-edit na linya ng JSON, makikita mong dumating ang pagbabago sa susunod nitong launch, at ibabalik mo ito sa parehong paraan.
Tatlong piraso ang kailangan ng operasyon. Isang content delivery network (CDN) ang nag-iingat ng bawat na-publish na version ng bawat remote, kaya hindi kailanman napapalitan ng bagong version ang mga file na nilo-load ng isang naka-install na app. Isang map para sa bawat na-release na version ng app ang nagsasabi sa binary na iyon kung alin sa mga version na iyon ang ilo-load nito. Isang resolver sa host ang gumagawa ng versioned na URL, na may signature verification, para sa bawat chunk request. Magkakasama, tinutupad nila ang sinabi ng post 1 na ibinibigay ng federation: “Ang isang bug sa isang remote ay re-upload ng remote na iyon, hindi isang store submission.”
Isang panuntunan ang humuhubog sa lahat: dapat patuloy na gumana ang isang lumang binary hangga’t may naka-install pa nito. Iniingatan ng CDN ang mga lumang remote version gaya ng pag-iingat ng isang backend sa mga lumang endpoint, at ang mga version lang na tinutukoy ng sarili nitong map ang natatanggap ng bawat binary. Walang anuman sa device na nagche-check kung gumagana ang mga version na iyon sa binary: trabaho ng operator na pumili ng mga version na nasubukan na sa binary na iyon, at sa map nakasulat ang pagpiling iyon.
Magsimula sa huling estado ng post 14, ang tag na post-14-production-build; nagtatapos ang post na ito sa tag na post-15-cdn-flip. Ikaw ang magta-type ng mga pagbabago sa configuration at ng maliliit na edit. Masyadong maraming bahagi ang nagbabago sa bagong launch code ng host at sa ilan pang file para sulit pang i-type ulit sa isang post, kaya kinukuha ang mga iyon sa huling tag, at tinutukoy ng mga copy command ang bawat isa.
Kinukuha ang map bago i-import ang anumang federated, at dala ng bawat kasunod na request ang version na tinukoy ng map.
Mga version sa shelf
Kailangang umabot ang isang version sa tatlong lugar sa build ng isang remote, at dapat magkakatugma ang tatlo: ang directory na sinusulatan ng mga artefact, ang directory na sinusulatan ng mga chunk, at ang string na ipinapakita ng tumatakbong screen tungkol sa sarili nito. Iisang constant ang pinanggagalingan ng tatlo. Sa apps/list/rspack.config.mjs, sa itaas ng defineRspackConfig:
// --- Kung aling version ng remote na ito ang binubuo ng build. Dalawang bagay ang sabay nitong
// pinagpapasyahan: ang directory na sinusulatan ng mga artefact, at ang string na sinasabi ng
// tumatakbong code tungkol sa sarili nito. Iisang variable ang pinanggagalingan ng dalawa, kaya
// hindi puwedeng isulat ng isang build ang mga file ng 1.2.0 habang sinasabing 1.1.0 ito:
// MF_REMOTE_VERSION=1.2.0 npm run bundle:ios:prod
// Isang beses lang sinusulat ang isang version directory at hindi na ito ine-edit pagkatapos. Ang
// pag-rebuild ng version na nilo-load na ng mga naka-install na app ay pumapalit sa code na
// itinuturing ng mga app na iyon na hindi nagbabago, at iyon mismo ang hakbang na hindi na
// kailangan dahil sa layout na ito: mag-ship ka na lang ng bagong version. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';
Nadaragdagan ng segment ang dalawang production path ng post 14. Sa output:
// Isinusulat ng production build ang tree na sineserve ng CDN, na kapareho ng ayos ng URL path
// kung saan ito sineserve: cdn/<platform>/listApp/<version>/. Ang version segment ang dahilan kung
// bakit kayang hawakan ng iisang CDN ang ilang release ng remote na ito nang sabay, bawat isa sa
// sarili nitong URL. Sa build/ pa rin nagsusulat ang development build, kung saan ito binabasa ng
// dev server, at wala itong version: iisang build lang ang naroon, ang huling na-save.
path: isProd
? `${__dirname}/cdn/[platform]/listApp/${REMOTE_VERSION}`
: `${__dirname}/build/[platform]`,
at sa extraChunks entry ng RepackPlugin:
// Napupunta ang mga chunk sa tabi ng container at ng manifest, sa loob ng parehong version
// directory, dahil hihingin sila ng host sa mga URL na relative sa manifest na na-load nito.
// Kinokopya sila roon ng entry na ito: naisulat na sila ng Rspack sa ilalim ng output.path, kaya
// kapag nawala rito ang version segment, walang nasisira sa runtime, at ang natitira ay isang
// pangalawang kopya ng bawat chunk na walang version, katabi ng mga may version.
outputPath: isProd
? `cdn/${platform}/listApp/${REMOTE_VERSION}`
: `build/${platform}/remote`,
Ang pangatlong lugar ay isang literal na naka-compile sa loob ng bundle. Idagdag ang DefinePlugin sa import ng @rspack/core, katabi ng minimiser, at isang plugin entry bago ang ModuleFederationPluginV2:
import { DefinePlugin, SwcJsMinimizerRspackPlugin } from '@rspack/core';
// Ang version na naka-compile sa bundle bilang literal, para maipakita ng tumatakbong screen kung
// saang build ito galing. Sa production, binabasa ito mula sa parehong constant na ginagamit ng
// output path, kaya hindi kailanman magkakaiba ang chip sa screen at ang directory sa CDN. 'dev' ang
// sinasabi ng development build: hindi ito kailanman na-publish kahit saan, at ang paglalagay ng
// numero dito ay pag-claim ng version para sa file na nire-rebuild sa bawat save.
new DefinePlugin({
__REMOTE_VERSION__: JSON.stringify(isProd ? REMOTE_VERSION : 'dev'),
}),
Gayahin ang dalawang path sa apps/party/rspack.config.mjs gamit ang partyApp. May sariling comment ang constant nito, dahil walang chip ang party at kaya hindi nito kailangan ng DefinePlugin:
// --- Kung aling version ng remote na ito ang binubuo ng build, at ang directory na sinusulatan
// nito. Parehong variable at parehong panuntunan gaya sa listApp: isang beses lang sinusulat ang
// isang version directory at hindi na ito ine-edit kapag nagsimula na itong i-load ng mga
// naka-install na app. ---
const REMOTE_VERSION = process.env.MF_REMOTE_VERSION || '1.0.0';
Sa list app naman, kailangan ng TypeScript ng declaration para sa __REMOTE_VERSION__, dahil walang module sa likod ng pangalang iyon. Isang bagong apps/list/src/globals.d.ts ang nagbibigay nito:
// --- Mga pangalang pinapalitan ng bundler ng literal sa build time, idineklara para sa compiler.
// Hindi sila import at walang module sa likod nila: pinapalitan ng rspack.config.mjs ang bawat isa
// habang nagbi-build, kaya ang value ang laman ng na-ship na bundle at hindi kailanman ang pangalan. ---
/** Ang version ng remote na ito na binuo ng build, at ang CDN directory kung saan ito isinulat. */
declare const __REMOTE_VERSION__: string;
Ang chip ang nagpapakita na may nangyaring deploy, dahil kung wala ito, iisang screen lang ang dalawang build ng parehong remote. Sa apps/list/src/PokedexScreen.tsx, sa itaas ng EMPTY_TYPES:
// --- Ang version kung saan binuo ang bundle na ito, na naka-compile gamit ang DefinePlugin. Ang
// pagpapakita nito ang dahilan kung bakit nakikita ang isang deploy: kung wala ito, iisang screen
// lang ang dalawang build ng remote na ito, kaya walang paraan para malaman mula sa app kung alin
// ang tumatakbo. Para sa Jest ang fallback, kung saan walang tumatakbong bundler at hindi kailanman
// napapalitan ang pangalan. ---
const REMOTE_VERSION = typeof __REMOTE_VERSION__ === 'string' ? __REMOTE_VERSION__ : 'dev';
Nagiging chip sa kaliwa at party counter sa kanan ang header row. Sariling accessible element ang chip, kaya naaabot ito ng screen reader nang hindi muna naririnig ang bilang ng party. Inaalis ang live region at ang label nito sa row at inililipat sa sarili nilang group sa paligid ng label at pill ng post 12, kaya hindi na sakop ng row ang chip:
ListHeaderComponent={
<Box className="flex-row items-center justify-between px-1.5 py-2.5">
{/* Kung aling build ng remote na ito ang nasa screen. Sariling element ito sa halip na bahagi
ng group ng counter, para maabot ito ng screen reader nang hindi ito binabasa nang malakas
tuwing nagbabago ang bilang ng party. */}
<Box
className="rounded-full bg-offGrey px-2 py-0.5 dark:bg-white/10"
accessible
accessibilityLabel={`Pokédex remote, version ${REMOTE_VERSION}`}>
<Text size="xs" className="font-semi text-darkGrey dark:text-lightGrey">
listApp {REMOTE_VERSION}
</Text>
</Box>
{/* Nagbabago ang bilang kapag nagdagdag ang user ng member mula sa ibang screen, nang hindi
lumilipat dito ang focus. Nakikita ng user na nakakakita ang pagbabago ng numero; walang
sinasabi sa user ng screen reader maliban kung live region ito (SC 4.1.3). Isinusulat ng
label ang ratio sa mga salita, dahil ang "3/6" ay binabasa bilang "three slash six" o bilang
petsa, depende sa reader. */}
<Box
className="flex-row items-center gap-2"
accessible
accessibilityLiveRegion="polite"
accessibilityLabel={`My Party, ${partyCount} of ${MAX_PARTY}`}>
<Text size="sm" className="font-semi text-darkGrey dark:text-lightGrey">
My Party
</Text>
<Box className="rounded-full bg-lightGreen px-2.5 py-0.5 dark:bg-white/10">
{/* darkGrey, hindi darkGreen: #A6D3A0 ang darkGreen, ang grass fill, at sa lightGreen ay
1.53:1 lang ang sukat nito. darkGrey ang kulay na ginagamit na ng label sa tabi nito. */}
<Text size="xs" className="font-head text-darkGrey dark:text-pokemonGreen">
{partyCount}/{MAX_PARTY}
</Text>
</Box>
</Box>
</Box>
}
Isang version lang ng bawat remote ang binuo ng tools/build-cdn.mjs ng post 14, sa isang flat na tree. Palitan ito ng bago na nagsisimula sa dalawang list. Sinasabi ng REMOTE_VERSIONS kung aling mga version ng bawat remote ang hawak ng CDN. Sinasabi ng APP_VERSION_MAPS kung alin sa mga version na iyon ang ilo-load ng bawat na-release na version ng app. Puwedeng nasa unang list ang isang version kahit walang map na tumuturo rito:
// --- Binubuo ang directory na sineserve ng isang CDN.
//
// Ang layout ay ang layout ng URL, at may version na ito ngayon:
//
// cdn-root/<platform>/<remote>/<version>/ ang container, ang mga chunk nito at mf-manifest.json
// cdn-root/<platform>/maps/<appVersion>/ version-map.json, isa sa bawat na-release na version ng app
//
// Ang file sa cdn-root/ios/listApp/1.2.0/mf-manifest.json ay sineserve sa
// <base>/ios/listApp/1.2.0/mf-manifest.json, na siyang URL na binubuo ng host sa launch mula sa
// version na ibinigay ng map.
//
// Dalawang list sa ibaba, at ang pagkakaiba nila ang buong ideya. Ang REMOTE_VERSIONS ang hawak ng
// CDN: bawat version na na-publish kailanman, iniingatan hanggang wala nang nagpapatakbo nito. Ang
// APP_VERSION_MAPS ang puwedeng i-load ng bawat na-release na binary mula roon. Ang pag-ship ng
// remote sa mga naka-install na app ay isang bagong entry sa unang list at isang in-edit na linya
// sa pangalawa.
//
// Paggamit: node tools/build-cdn.mjs [ios|android] (kapag walang argument, pareho ang binubuo)
//
// Pagkatapos, i-serve ito at ituro ang host dito (naaabot ng Android emulator ang 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 MF_APP_VERSION=2.0.0 npm start )
import { execSync } from 'node:child_process';
import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
const REMOTE_APPS = { listApp: 'list', partyApp: 'party' };
const ALL_PLATFORMS = ['ios', 'android'];
// --- Bawat version ng bawat remote na hawak ng CDN. Isang beses lang sinusulat ang isang version
// directory at hindi na ito ginagalaw pagkatapos: ang mismong mga file na iyon ang nilo-load ng mga
// naka-install na app, kaya ang pag-rebuild ng na-publish na version ay tahimik na pagbabago sa
// code na pinapatakbo na ng isang tao. Dapat may bagong version number ang bawat bagong release.
//
// May dalawang version ang listApp: iisang screen ang 1.0.0 at 1.1.0 na magkaiba lang ng version
// number, at iyon ang kailangan ng demo ng dalawang binary. Ang flip papuntang 1.2.0 ang
// nagdaragdag ng pangatlo. ---
const REMOTE_VERSIONS = {
listApp: ['1.0.0', '1.1.0'],
partyApp: ['1.0.0'],
};
// --- Kung ano ang puwedeng i-load ng bawat na-release na version ng app. Hinihingi ng host ang
// sarili nitong entry ayon sa pangalan sa bawat launch, kaya patuloy na natatanggap ng isang lumang
// binary ang mga version kung saan ito binuo, gaano man kalayo na ang narating ng pinakabagong
// release. Inaalis ang isang entry kapag wala nang natitira sa version na iyon ng app, gaya ng
// isang lumang API endpoint.
//
// Kapag nag-edit ka ng linya rito, nire-rebuild ang buong tree, at hindi iyon ang tamang tool sa
// pag-ship ng version: ang operasyong ginagawa ng post ay pag-edit sa map file na nasa cdn-root na,
// dahil ang file na iyon ang binabasa ng tumatakbong app. Pag-seed ito ng isang CDN, hindi
// operasyon sa isang CDN.
//
// Wala nang iba pa sa map. Walang signature, walang counter, walang anumang makapagsasabi sa app
// kung ang map ay isinulat dito o ng ibang taong may access sa bucket. Totoong butas ito at
// sinasadyang iniiwang bukas: ito ang paksa ng huling post sa serye. ---
const APP_VERSION_MAPS = {
'1.0.0': { listApp: '1.0.0', partyApp: '1.0.0' },
'2.0.0': { listApp: '1.1.0', partyApp: '1.0.0' },
};
// --- Hindi kailanman pina-publish: apat na ikalima ng na-build na tree ang mga source map,
// artefact sila sa pag-debug para sa crash reporter at hindi bagay na dina-download ng client, at
// sa isang public na bucket, ibinibigay nila ang buong nababasang source sa sinumang humingi. Build
// analysis ang mf-stats.json at pareho ang sitwasyon nito. Nananatili ang sariling index.bundle ng
// remote, dahil kasama ito sa mga shared asset na nakalista sa mf-manifest.json at walang
// napatunayan dito na walang path na kumukuha nito. ---
const NEVER_PUBLISHED = /\.map$|^mf-stats\.json$/;
const [platformArg] = process.argv.slice(2);
if (platformArg && !ALL_PLATFORMS.includes(platformArg)) {
console.error(`Unknown platform "${platformArg}". Use one of: ${ALL_PLATFORMS.join(', ')}`);
process.exit(1);
}
const platforms = platformArg ? [platformArg] : ALL_PLATFORMS;
// --- Ang map na tumutukoy sa version na wala sa CDN ang failure na ipinapakita ng post nang
// mano-mano, at mas mabuting mahuli ito rito kaysa sa launch ng isang user. Chine-check ito bago
// mag-build ng kahit ano, kaya isang segundo lang ang halaga ng typo sa halip na dalawang bundle
// run. ---
const unknownRemotes = Object.keys(REMOTE_VERSIONS).filter(remote => !REMOTE_APPS[remote]);
if (unknownRemotes.length > 0) {
console.error(
`\nNo app to build for: ${unknownRemotes.join(', ')}. Add it to REMOTE_APPS, or remove it from REMOTE_VERSIONS.\n`,
);
process.exit(1);
}
const missing = Object.entries(APP_VERSION_MAPS).flatMap(([appVersion, versions]) =>
Object.entries(versions)
.filter(([remote, version]) => !REMOTE_VERSIONS[remote]?.includes(version))
.map(([remote, version]) => ` app ${appVersion} asks for ${remote} ${version}`),
);
if (missing.length > 0) {
console.error('\nThese versions are mapped but not published:');
console.error(missing.join('\n'));
console.error('\nAdd them to REMOTE_VERSIONS, or point the map at a version that exists.\n');
process.exit(1);
}
for (const platform of platforms) {
// Ang platform lang na ito ang binubura, para hindi mabura ang tree ng isa kapag binuo ang isa.
rmSync(join(repoRoot, 'cdn-root', platform), { recursive: true, force: true });
mkdirSync(join(repoRoot, 'cdn-root', platform), { recursive: true });
for (const [remote, versions] of Object.entries(REMOTE_VERSIONS)) {
const appDir = join(repoRoot, 'apps', REMOTE_APPS[remote]);
for (const version of versions) {
console.log(`\n=== building ${remote} ${version} (${platform}) ===`);
// Isang beses lang pinapangalanan: iisang lugar ang directory na nililinis, sinusulatan at
// pagkatapos ay binabasa, kaya walang susunod na edit na makapaglilipat ng output ng build
// palayo sa kopyang sumusunod dito.
const built = join(appDir, 'cdn', platform, remote, version);
// Nililinis muna, dahil nagsusulat ang Rspack sa loob ng isang directory sa halip na palitan
// ito: ang file na inilabas ng naunang build pero hindi na ng build na ito ay mananatili at
// mapa-publish katabi ng mga totoo, nang walang dahilang mahuhulaan ng kahit sino mula sa code.
rmSync(built, { recursive: true, force: true });
// Ang MF_REMOTE_VERSION ang sabay na nagpapasya kung ano ang sinasabi ng bundle tungkol sa
// sarili nito at kung saan ito isinusulat, kaya hindi makakagawa ang iisang variable ng build
// na may tatak ng isang version pero nakalagay sa ilalim ng iba.
execSync(`npm run bundle:${platform}:prod`, {
cwd: appDir,
stdio: 'inherit',
env: { ...process.env, MF_REMOTE_VERSION: version },
});
// Nag-report ng success ang build, kaya kapag nawawala ang directory dito, ibig sabihin ay
// naghiwalay na ang output path nito at ang path na ito, at mas mabuting sabihin iyon sa isang
// linya kaysa sa stack trace.
if (!existsSync(built)) {
console.error(`\n${remote} built but wrote nothing to ${built}.`);
console.error("Check the output path in that app's rspack.config.mjs.\n");
process.exit(1);
}
cpSync(built, join(repoRoot, 'cdn-root', platform, remote, version), {
recursive: true,
filter: source => !NEVER_PUBLISHED.test(source.split('/').pop()),
});
console.log(`published -> cdn-root/${platform}/${remote}/${version}`);
}
}
for (const [appVersion, versions] of Object.entries(APP_VERSION_MAPS)) {
const dir = join(repoRoot, 'cdn-root', platform, 'maps', appVersion);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'version-map.json'), `${JSON.stringify(versions, null, 2)}\n`);
console.log(`wrote -> cdn-root/${platform}/maps/${appVersion}/version-map.json`);
}
}
const HOST_ADDRESS = { ios: 'http://localhost:8000', android: 'http://10.0.2.2:8000' };
const appVersions = Object.keys(APP_VERSION_MAPS);
console.log(`\nCDN assembled at ${join(repoRoot, 'cdn-root')}`);
console.log('Serve it: npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors');
for (const platform of platforms) {
console.log(
`Point the host at it: ( cd apps/host && MF_CDN_BASE=${HOST_ADDRESS[platform]} MF_APP_VERSION=${appVersions.at(-1)} npm start ) # ${platform}`,
);
}
console.log(`App versions with a map: ${appVersions.join(', ')}`);
Hindi na pina-publish ang mga source map: apat na ikalima sila ng isang na-build na remote (16 MB sa 20 MB na isinusulat ng listApp sa iOS), artefact sila ng crash reporter at hindi download ng client, at sa isang public na bucket, ibinibigay nila ang nababasang source sa sinumang humingi. I-build ang tree:
node tools/build-cdn.mjs ios
cdn-root/ios/listApp/1.0.0/ cdn-root/ios/maps/1.0.0/version-map.json
cdn-root/ios/listApp/1.1.0/ cdn-root/ios/maps/2.0.0/version-map.json
cdn-root/ios/partyApp/1.0.0/
Ang bawat map ang kabuuan ng sinasabi sa isang binary:
{
"listApp": "1.1.0",
"partyApp": "1.0.0"
}
Dalawang linya at wala nang iba. Naka-sign ang mga chunk na tinuturo nito; hindi naka-sign ang file na pumipili sa pagitan nila. Isinasara iyon ng post 17; iniiwan itong bukas ng post na ito at sinasabi iyon tuwing mahalaga.
Magtanong bago mag-load
Dalawang bagay ang kailangang malaman ng host sa build time: kung nasaan ang CDN, at kung aling version ng app ang binary na ito. Sa apps/host/rspack.config.mjs, ang comment ng post 14 at ang const CDN_BASE = process.env.MF_CDN_BASE; ay nagiging:
// --- Kung saan sineserve ang mga remote. I-set ang MF_CDN_BASE at sa content delivery network
// titingin ang host 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)
//
// Ang nagbago sa post na ito ay kung sino ang gumagamit ng value. Hinuhubog pa rin nito ang remotes
// map sa ibaba, pero placeholder na ang map na iyon: walang version segment ang mga URL nito, kaya
// sa isang versioned na CDN tree, wala silang nare-resolve. Ang value na mahalaga ay ang ibinibigay
// sa tumatakbong code sa pamamagitan ng DefinePlugin, kung saan ito binabasa ng
// src/shell/scriptManager.ts, tinatanong nito sa CDN kung aling mga version ang puwedeng patakbuhin
// ng binary na ito, at nire-register ulit ang bawat remote sa isang versioned na URL bago tumakbo
// ang unang import.
// Tinatanggal ang mga trailing slash, dahil nagdaragdag ng sariling separator ang bawat URL na
// binubuo mula sa value na ito, at ang base na may slash sa dulo ay nagbubunga ng dobleng slash sa
// gitna ng bawat path. Pinalalampas iyon ng karamihan ng server; naka-cache ang isang na-download
// na script ayon sa URL na kumuha nito, kaya hindi na sulit alamin kung alin ang hindi.
const CDN_BASE = (process.env.MF_CDN_BASE || '').replace(/\/+$/, '');
// --- Ang sariling version ng binary na ito, ang tanong na ibinibigay nito sa CDN sa launch. Sumasagot
// ang CDN ng mga remote version na pinapayagang patakbuhin ng binary na ito, at ganito patuloy na
// gumagana ang isang install na dalawang taon na: patuloy nitong natatanggap ang mga version na
// kasama nito noong na-ship. Binabasa ito ng isang totoong app mula sa version kung saan ito
// na-release; variable ito rito, para makagawa ang iisang checkout ng dalawang binary na magkaiba
// ang tanong:
// MF_APP_VERSION=1.0.0 npm run ios -- --mode Release
const APP_VERSION = process.env.MF_APP_VERSION || '1.0.0';
Umaabot ang dalawang bagay na ito sa tumatakbong code bilang mga literal: idagdag ang DefinePlugin sa import ng @rspack/core ng host, at ang entry na ito sa plugins bago ang ModuleFederationPluginV2:
// Ang dalawang bagay mula sa build time na kailangan ng operational layer bilang mga literal sa
// bundle: kung nasaan ang CDN, at kung aling version ang binary na ito. Ang walang lamang base ang
// senyales na walang CDN na na-configure, at iyon ang nagpapanatili sa isang ordinaryong
// development build sa mga dev server.
new DefinePlugin({
__MF_CDN_BASE__: JSON.stringify(CDN_BASE),
__APP_VERSION__: JSON.stringify(APP_VERSION),
}),
Nananatili ang remoteUrl function at ang remotes map ng post 14, pero nagbabago ang trabaho nila. Gusto ng Module Federation ng pangalan at entry para sa bawat remote na idineklara sa build time, kaya nananatili ang map; walang version ang mga URL nito, kaya sa isang versioned na tree, wala silang nare-resolve. Sinasabi iyon ng comment sa itaas ng function:
// Ang remotes map sa build time, sa iisang function: dev server o CDN, parehong manifest filename
// alinman sa dalawa. Sa CDN mode, placeholder ang nabubuo nito at walang nilo-load mula rito: sa
// launch pinagpapasyahan ang versioned na URL na talagang ginagamit ng app. Iniiwan itong nakaturo
// sa isang kapani-paniwalang lugar sa halip na tanggalin, dahil gusto ng Module Federation ng
// pangalan at entry para sa bawat remote na idineklara sa build time, at dahil sa dev mode, ito pa
// rin ang buong kuwento.
const remoteUrl = name =>
CDN_BASE
? `${name}@${CDN_BASE}/${platform}/${name}/mf-manifest.json`
: `${name}@${DEV_REMOTES[name]}/${platform}/mf-manifest.json`;
Sa CDN mode, placeholder ang remotes map sa build time. Ang host code na kinokopya mo mula sa huling tag, sa halip na i-type, ang nagpapasya sa launch kung aling URL ang talagang gagamitin ng app. I-fetch ang tag nang isang beses; kinukuha ng mga copy command ang code na iyon kasama ang mga test at Jest mock nito, pati ang iba pang file na ginagamit ng mga susunod na section:
npx degit@3.8.0 --force warrendeleon/react-native-module-federation#post-15-cdn-flip /tmp/pokedex-ref-15
cp /tmp/pokedex-ref-15/apps/host/src/shell/{remoteLocator,scriptManager,federationErrors}.ts /tmp/pokedex-ref-15/apps/host/src/shell/FederationBanner.tsx apps/host/src/shell/
cp /tmp/pokedex-ref-15/apps/host/__mocks__/{repack-client,module-federation-runtime}.js apps/host/__mocks__/
cp /tmp/pokedex-ref-15/apps/host/__tests__/{remoteLocator,scriptManager,federationErrors}.test.ts /tmp/pokedex-ref-15/apps/host/__tests__/{App,RemoteBoundary}.test.tsx apps/host/__tests__/
cp /tmp/pokedex-ref-15/apps/host/App.tsx apps/host/
cp /tmp/pokedex-ref-15/scripts/federation-smoke.sh scripts/
cp /tmp/pokedex-ref-15/packages/ui/src/tokens/__tests__/contrast.accessibility.ts packages/ui/src/tokens/__tests__/
cp /tmp/pokedex-ref-15/tools/gen-signing-keys.mjs /tmp/pokedex-ref-15/tools/gen-signing-keys.test.mjs tools/
Basahin sila ayon sa pagkakasunod ng pagtakbo nila. Ang src/shell/scriptManager.ts ang launch, at ang pag-fetch ng map ang una nitong trabaho. Hinihintay ng bawat federated import ang sagot na iyon, kaya kailangan ng request ng may hangganang paghihintay: kung wala nito, mapipigilan ng isang mabagal na CDN ang app sa splash screen nito hangga’t pinapayagan ng network. Isa’t kalahating segundo ang paghihintay:
const PROBE_TIMEOUT_MS = 1500;
Isang AbortController at isang timer ang nagpapatupad nito, na mano-manong ginawa dahil walang shortcut ang React Native para dito. Walang AbortSignal.timeout() doon: ini-install ng 0.85 ang AbortController at AbortSignal mula sa abort-controller package, na walang timeout. Wala ring sariling timeout ang request: binubuo ng React Native ang HTTP client ng Android na zero ang lahat ng timeout, at sa iOS, ipinapasa nito ang timeout ng request, na zero bilang default:
// --- I-fetch at basahin ang version map para sa version na ito ng app. Nagbabalik ng null sa bawat
// uri ng failure, dahil pare-pareho ang pagtrato sa kanila ng tumatawag: ang CDN na hindi maabot,
// ang 404 para sa version ng app na walang nag-publish ng map, ang timeout, at ang map na hindi
// ma-parse ay pare-parehong nagtatapos sa binary na ito na walang pinapatakbong remote. ---
async function fetchVersionMap(): Promise<Record<string, string> | null> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
try {
const response = await fetch(versionMapUrl(CDN_BASE, Platform.OS, APP_VERSION), {
signal: controller.signal,
// Ang map ang nag-iisang file sa CDN na hindi kailanman dapat i-serve mula sa cache: ito ang
// record ng kung ano ang kasalukuyan, at ang lumang kopya ay isang deploy na tahimik na
// nabawi. Dala ng bawat iba pang file na kinukuha ng app ang version nito sa URL at puwede
// itong i-cache nang habambuhay.
headers: { 'cache-control': 'no-cache' },
});
if (!response.ok) {
console.warn(`[federation] version map returned ${response.status}`);
return null;
}
const versions = parseVersionMap((await response.json()) as unknown, REMOTE_NAMES);
if (!versions) {
// Ang nag-iisang failure na hindi kasalanan ng network ng kahit sino at hindi outage:
// na-serve ang file, pero hindi valid ang laman nito. Nila-log ang warning, dahil ang
// alternatibo ay app na nagla-launch, walang nilo-load, at walang ibinibigay na dahilan.
console.warn('[federation] version map was served but could not be read');
}
return versions;
} catch (error) {
console.warn('[federation] version map could not be fetched', error);
return null;
} finally {
clearTimeout(timer);
}
}
Hawak na ang mga version, itinuturo ang bawat remote sa manifest na nasa loob ng version directory nito:
// --- Ituro ang bawat remote sa versioned na manifest na na-resolve ng launch na ito.
//
// Hindi opsyonal ang force. Naka-register na ang bawat remote sa pangalan nito mula sa remotes map
// sa build time, at hindi ginagalaw ng registerRemotes ang pangalang naka-register na maliban kung
// sabihin dito: kung wala ang flag, tahimik na bumabalik ang call na ito, walang binabago, at
// nilo-load ng app ang mga placeholder URL na walang version. Kapag may flag, nagla-log ang Module
// Federation ng warning tungkol sa muling pag-register ng remote sa bawat launch, at iyon ang
// inaasahang kapalit ng paggawa nito. ---
function registerCdnRemotes(versions: Record<string, string>): void {
registerRemotes(
REMOTE_NAMES.filter(name => versions[name]).map(name => ({
name,
entry: remoteManifestUrl(CDN_BASE, Platform.OS, name, versions[name]),
})),
{ force: true },
);
}
Tahimik na walang ginagawa ang
registerRemotesna walangforcepara sa pangalang naka-register na. Sa runtime core ng Module Federation 2.9.0, talagang walang ginagawa ang branch para sa umiiral na pangalan kapag wala angforce: walang error, walang warning, walang pagbabago. Na-register ng build-time map ang dalawang remote bago pa tumakbo ang probe, kaya sa bawat launch ay nire-register ulit sila gamit ang{ force: true }, at inilalabas ng runtime ang[ Federation Runtime ]: The remote "listApp" is already registered. Please note that overriding it may cause unexpected errors.nang isang beses bawat remote sa development console. Ang linyang iyon ang palatandaan na gumagana ang ayos. Ang launch na hindi naglalabas nito ay nag-load ng mga placeholder.
Iisang function ang launch, na hawak bilang promise para ang pangalawang tumatawag na dumating sa gitna ng probe ay maghintay sa parehong sagot sa halip na basahin ang state bago ito nagsimula:
export function initializeFederation(): Promise<FederationStatus> {
initialization ??= resolveFederation();
return initialization;
}
async function resolveFederation(): Promise<FederationStatus> {
if (!CDN_CONFIGURED) {
status = __DEV__
? { mode: 'dev', source: 'dev servers', versions: {} }
: { mode: 'unresolved', source: 'no CDN configured', versions: {} };
return status;
}
const versions = await fetchVersionMap();
if (!versions) {
// Isinulat para masaklaw ang lahat ng paraan ng pagkabigo nito, dahil claim ng app tungkol sa
// sarili nito ang banner: ang map na na-serve at tinanggihan ay hindi map na hindi maabot, at
// sinasabi na ng log line sa tabi nito kung alin sa dalawa ang nangyari.
status = { mode: 'unresolved', source: 'no usable version map', versions: {} };
return status;
}
// Sine-set ang status bago ang registration dahil binabasa ito ng resolver, at ibinabalik kapag
// nag-throw ang registration. Kapag sinabing CDN mode pagkatapos ng pumalyang re-registration,
// lalabas ang mga version sa banner habang papunta ang bawat load sa placeholder URL mula sa
// build time: isang app na nagsasabing 1.2.0 ang pinapatakbo nito at walang pinapatakbong kahit ano.
status = { mode: 'cdn', source: CDN_BASE, versions };
try {
registerCdnRemotes(versions);
} catch (error) {
console.warn('[federation] remotes could not be re-registered', error);
status = { mode: 'unresolved', source: 'remotes could not be registered', versions: {} };
}
return status;
}
Tatlong mode ang lumalabas dito: ang dev ay ang mundo ng post 14, ang cdn ang dahilan kung bakit may post na ito, at ang unresolved ay isang failure na may pangalan: walang magagamit na map, kaya walang anumang remote, tinanggihan sa halip na i-load nang hindi vine-verify. Ipinapakita ng FederationBanner.tsx ang mode at ang mga na-resolve na version bilang pill sa itaas ng tab bar, para makunan ng litrato ang isang demo sa halip na paniwalaan lang.
Nasa App.tsx ang gate. Mga React.lazy na federated import ang mga tab ng navigator, kaya ang pag-mount ng navigator ang nagsisimula sa unang download, at kailangang maghintay iyon sa re-registration. Nasa App ang state at ang effect, sa itaas ng SafeAreaProvider, dahil walang nire-render na child ang provider na iyon hangga’t hindi pa nito nasusukat ang mga inset: kung nasa ilalim ng provider ang mga ito, hihintayin ng gate ang pagsukat na iyon, at sa Jest, kung saan walang sumusukat, hindi ito kailanman bubukas:
const [federationReady, setFederationReady] = useState(false);
useEffect(() => {
let live = true;
initializeFederation()
.catch(err => console.warn('federation initialisation failed', err))
.then(() => {
if (live) {
setFederationReady(true);
}
});
return () => {
live = false;
};
}, []);
{federationReady ? (
<>
<Shell navTheme={navTheme} mode={mode} onReady={() => setNavReady(true)} />
<FederationBanner />
</>
) : null}
Inililipat din sa likod ng parehong flag ang boot import ng partyApp/partySlice mula sa post 8, dahil federated load din ito gaya ng iba. Pagkatapos ng paglipat na iyon, walang federated na nilo-load bago magkaroon ng sagot ang launch.
Hindi naiipit ang app sa splash screen kapag pumalya ang probe. Bumubukas pa rin ang gate, sa loob ng isang probe timeout sa pinakamatagal, at ang mode ang nagtatala ng pagkakaiba: pagkatapos ng pumalyang probe, unresolved ang ipinapakita ng banner, tinatanggihan ng resolver ang bawat remote, at ipinapakita ng bawat tab ang error state nito.
Kailangan ng mga test ng host ng dalawa pang entry sa apps/host/jest.config.js, dahil naghahanap ng bundler runtime ang ScriptManager.shared sa sandaling magalaw ito, at walang ganoon ang isang Jest process. Nagiging ganito ang map at ang comment sa itaas nito:
// Pinapatakbo ng Reanimated ang mga animation sa pamamagitan ng JSI (ang JavaScript Interface na
// ginagamit ng React Native para tumawag ng native code), na walang runtime sa isang Jest process,
// kaya pinapalitan ito ng mock sa __mocks__. Isang entry ang sumasaklaw sa buong federation:
// pareho ang module specifier na ini-import ng @pokedex/ui at @pokedex/detail, kaya sa iisang mock
// nare-resolve ang kanilang mga animated component. Wala ring nare-resolve na source ang federated
// state module sa Jest; inuulit ng mock nito ang side effect ng totoong module (ang reducer
// injection) para ma-test ang boot readiness.
// Umiiral ang mga entry ng Re.Pack client at ng Module Federation runtime dahil parehong naghahanap
// ng bundler runtime na wala sa isang Jest process ang ScriptManager ng Re.Pack at ang
// registerRemotes ng Module Federation, at ginagamit ng host ang dalawa sa module scope, para nasa
// lugar na ang resolver bago tumakbo ang anumang federated import. Itinatala ng mga mock nila kung
// ano ang hiningi sa kanila.
moduleNameMapper: {
'^react-native-reanimated$': '<rootDir>/__mocks__/react-native-reanimated.js',
'^partyApp/partySlice$': '<rootDir>/__mocks__/partyApp-partySlice.js',
'^partyApp/styles$': '<rootDir>/__mocks__/partyApp-styles.js',
'^@callstack/repack/client$': '<rootDir>/__mocks__/repack-client.js',
'^@module-federation/runtime$': '<rootDir>/__mocks__/module-federation-runtime.js',
},
Iisang resolver para sa lahat ng chunk
Tinutukoy ng map kung aling version ang ilo-load para sa bawat remote. May kailangan pa ring gumawa ng URL sa loob ng directory ng version na iyon para sa bawat script na nilo-load ng federation: ang container kapag unang na-import ang remote, at pagkatapos ay ang bawat chunk na hinihingi ng container. Tinatanong ng ScriptManager ng Re.Pack ang mga resolver nito ayon sa priority, at ang unang magbalik ng locator ang panalo. Nagdaragdag ng isa ang post na ito. Nasa src/shell/remoteLocator.ts ang mga desisyon nito bilang mga ordinaryong function, na puwedeng i-test nang walang device, at isa sa tatlong sagot ang natatanggap ng bawat script:
/** Ang desisyon ng resolver para sa isang script. */
export type Resolution =
| { kind: 'defer' }
| { kind: 'locate'; locator: RemoteLocator }
| { kind: 'refuse'; reason: string };
Kung alin ang sagot ay nakadepende sa mode, sa remote at sa map:
// --- Kung saang remote kabilang ang isang script. Kilala na ang isang container sa sarili nito:
// ang script id nito AY ang pangalan ng remote. Hindi ganoon ang isang chunk, kaya ang caller lang
// ang nagsasabi kung saan ito galing, at iyon ang dahilan kung bakit parehong argument ang
// tinatanggap ng resolver sa halip na mag-pattern-match sa id. ---
function remoteFor(
scriptId: string,
caller: string | undefined,
remoteNames: readonly string[],
): string | undefined {
if (remoteNames.includes(scriptId)) {
return scriptId;
}
if (caller && remoteNames.includes(caller)) {
return caller;
}
return undefined;
}
// --- Pagpasyahan ang isang script: hanapin ito sa loob ng directory ng version nito, tanggihan
// ito, o ipaubaya ito sa sariling resolution ng Re.Pack.
//
// Kapag ipinaubaya, napupunta ang script sa susunod na resolver, at para sa isa sa mga remote ng
// host na ito, ang susunod ay ang per-remote resolver ng Re.Pack: sumasagot ito ng URL na binuo mula
// sa manifest na huling na-register at walang anumang signature check. Iyon ang tamang sagot sa
// development, kung saan hawak ng mga dev server ang lahat, at para sa anumang script na hindi
// galing sa isa sa mga remote ng host na ito. Sa labas ng development, hindi ito kailanman ang
// tamang sagot para sa isang remote: kapag walang version map, o may map na walang tinukoy na
// version para sa remote na ito, maglo-load ng code ang pagpapaubaya mula sa URL na walang version,
// nang hindi vine-verify. Kaya tinatanggihan ang mga load na iyon, at lumalabas ang pagtanggi bilang
// error state ng tab. ---
export function resolveRemoteLocator(input: ResolveInput): Resolution {
if (input.mode === 'dev') {
return { kind: 'defer' };
}
const remoteName = remoteFor(input.scriptId, input.caller, input.remoteNames);
if (!remoteName) {
return { kind: 'defer' };
}
const version = input.mode === 'cdn' ? input.versions[remoteName] : undefined;
if (!version) {
return {
kind: 'refuse',
reason:
input.mode === 'cdn'
? `the version map named no version for ${remoteName}`
: `no version map was read at launch, so ${remoteName} has no version to load`,
};
}
const filename =
input.scriptId === remoteName
? `${remoteName}.container.js.bundle`
: `${input.scriptId}.chunk.bundle`;
return {
kind: 'locate',
locator: {
url: `${input.cdnBase}/${input.platform}/${remoteName}/${version}/${filename}`,
// Per URL ang caching, at dala ng URL dito ang version nito, kaya ang naka-cache na file ay
// maise-serve lang para sa version kung saan ito na-fetch. Ang bagong version ay bagong URL
// at bagong download.
cache: true,
verifyScriptSignature: input.verify,
},
};
}
Dinadaanan ng remoteLocator.test.ts ang tree na ito, kasama ang chunk na na-resolve ayon sa caller nito at hindi sa id nito, ang kaso na nagpapatunay na talagang binabasa ng resolver ang pangalawang argument nito.
Nire-register ito ng scriptManager.ts sa module scope, kaya nasa lugar na ito bago pa ma-import ang anumang federated, anuman ang pagkakasunod ng pagtakbo ng launch: kapag na-load na ng webpack at ng federation runtime ang isang container o chunk, hindi na nila ito hinihingi ulit, kaya hindi kailanman nakikita ng resolver na idinagdag pagkatapos ng unang load ng isang script ang script na iyon.
ScriptManager.shared.addResolver(
async (scriptId: string, caller?: string) => {
const resolution = resolveRemoteLocator({
scriptId,
caller,
remoteNames: REMOTE_NAMES,
mode: status.mode,
versions: status.versions,
platform: Platform.OS,
cdnBase: CDN_BASE,
verify: VERIFY,
});
if (resolution.kind === 'refuse') {
// Tine-throw, hindi ibinabalik. Kapag walang ibinalik, napupunta ang script sa susunod na
// resolver, na para sa isang remote ay ang sariling resolver ng Re.Pack at ilo-load ito nang
// hindi vine-verify. Humihinto ang resolveScript ng Re.Pack sa unang resolver na nag-throw,
// kaya pumapalya ang load at ipinapakita ng tab ang error state nito.
throw new Error(`[federation] refused ${scriptId}: ${resolution.reason}`);
}
return resolution.kind === 'locate' ? resolution.locator : undefined;
},
{ key: '__signed_resolver__', priority: 100 },
);
Ang priority ang linyang nagpapasya kung tatakbo man lang ang alinman dito. Nagre-register ang ResolverPlugin ng Re.Pack ng sarili nitong resolver para sa bawat remote sa sandaling ma-register ang remote na iyon, na may key at walang priority, kaya nakukuha nito ang default ng ScriptManager, na 2 sa source ng 5.2.5; mula pinakamataas pababa tumatakbo ang mga resolver. Kapag nire-register ulit ng launch ang mga remote, binubuo ulit ang built-in resolver na iyon mula sa versioned na manifest URL, pero walang verification setting ang locator nito, kaya natatalo rito ang custom resolver na mas mababa sa 2 ang priority, at tahimik ang pagkatalo: tamang mga version ang nilo-load, at nilo-load ang bawat chunk nang hindi vine-verify. Malayong mas mataas sa 2 ang 100, at ina-assert iyon ng scriptManager.test.ts laban sa mock.
Ang parehong built-in resolver din ang dahilan kung bakit delikadong walang ibalik ang resolver kapag walang version ang isang remote. Kapag walang ibinalik ang resolver, sa built-in resolver na iyon napupunta ang load, at ida-download nito ang script mula sa URL na walang version sa build time, nang walang anumang signature check. Ang pag-throw naman ang tumatapos sa paghahanap, dahil humihinto ang resolveScript ng Re.Pack sa unang resolver na nag-throw.
Dumarating ang map mula sa network, kaya binabasa ito ng parseVersionMap bilang JSON ng isang estranghero. Nagiging path segment ang isang version sa URL na pinagda-download-an ng app ng code, kaya chine-check muna ang bawat isa laban sa /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/: kapag may slash, lalabas ang path sa version directory, at dahil sa unang character ng pattern, hindi tinatanggap ang version na nagsisimula sa ... Kapag may isang maling version, tinatanggihan ang buong map, dahil ang map na kalahati lang ang nabasa ay magla-launch ng app sa mga version na walang nag-publish; hindi pinapansin ang mga pangalan ng remote na hindi kilala ng binary na ito, dahil puwedeng mag-serve ang iisang CDN sa ilang app. Sinasaklaw ng remoteLocator.test.ts ang mga anyong tinatanggihan ng parser at ang mga pangalang hindi nito pinapansin.
Ikandado mo
Sinign ng post 14 ang bawat production chunk at wala itong binasang kahit isang signature: “ang signature na walang nagve-verify ay tatak, hindi kandado.” Isang field sa locator ang kandado:
// --- May saysay lang ang signature verification kung saan may public key na pagve-verify-an:
// naka-embed ang key sa Info.plist ng iOS at sa strings.xml ng Android, at wala nang iba pang lugar. Sa
// dalawang platform na iyon, strict ito, ibig sabihin ay tinatanggihan bago tumakbo ang chunk na
// hindi tugma ang signature sa key, o walang signature. ---
const SIGNED_PLATFORMS = ['ios', 'android'];
const VERIFY: VerifyMode = SIGNED_PLATFORMS.includes(Platform.OS) ? 'strict' : 'off';
Tatlong value ang tinatanggap ng Re.Pack. Nagve-verify lang ang lax kapag may token at pinalulusot nito ang chunk na walang signature; walang vine-verify ang off; parehong tinatanggihan ng strict ang signature na hindi tugma at ang nawawalang signature, at iyon ang dahilan kung bakit kandado na ang tatak.
Binabasa ng bawat platform ang key ayon sa pangalan: ang iOS mula sa Info.plist, sa entry na RepackPublicKey, at ang Android mula sa res/values/strings.xml, sa entry na may parehong pangalan. Parehong naka-commit ang dalawang file at naka-compile sa binary, kaya ang generator na kinopya mo mula sa tag na ang nagsusulat ng key sa mga iyon ngayon.
Magkaiba ang anyo ng key na natatanggap ng dalawang platform. PEM ang pina-parse ng iOS, ang base64 na text sa pagitan ng mga linyang BEGIN at END, kaya ang file mismo, gaya ng pagka-save nito, ang natatanggap nito. Ang base64 body lang ang ginagamit ng Android, at tinatanggal nito ang mga linyang iyon at ang mga line break bago mag-decode, kaya ang body lang na iyon, sa iisang linya, ang natatanggap nito. Bago buuin ang alinman sa dalawang anyo, ginagawa ng generator na Unix line ending (LF) ang mga Windows line ending (CRLF, isang carriage return at isang line feed), kaya pareho ang value na ine-embed ng generator, anumang editor ang ginamit sa pag-save ng key. DER ang kinukumpara ng check nito laban sa private key, ang binary na anyo na parehong ine-encode ng dalawang anyo, kaya hindi rin ito masisira ng mga line ending:
// --- Ilagay ang public half kung saan ito binabasa ng app. Sa dalawang anyo tumatanggap ng key ang
// dalawang platform: PEM ang pina-parse ng iOS, kaya ang file mismo ang natatanggap nito; tinatanggal
// ng Android ang header, ang footer at ang mga line break bago mag-decode, kaya ang base64 body lang,
// sa iisang linya, ang ibinibigay dito. ---
// Nino-normalise ang mga line ending bago buuin ang alinman sa dalawang anyo, kaya eksaktong
// parehong value ang ine-embed ng generator para sa public key na dumaan sa tool o editor na
// nagsusulat ng CRLF at para sa key na na-save gamit ang LF. Tugma pa rin ang public key sa private
// key nito alinman ang mangyari: DER bytes ang kinukumpara ng check sa itaas, hindi text.
const publicPem = readFileSync(publicPath, 'utf8').replace(/\r\n/g, '\n').trim();
const publicBase64 = publicPem
.split('\n')
.filter(line => !line.includes('PUBLIC KEY'))
.join('');
Pinapalitan ng natitirang bahagi ng pagbabago sa generator ang value ng isang umiiral na entry sa bawat file. Itinuturing nitong error, hindi warning, ang nawawalang entry, dahil fail-closed ang strict verification: ang key na hindi kailanman nakarating sa app ay lumalabas sa bandang huli bilang pagtanggi ng bawat remote na mag-load, na may mensaheng walang sinasabi tungkol sa generator. Kaya idagdag na ngayon ang dalawang entry, walang laman, para punan ng generator. Sa apps/host/ios/Host/Info.plist, sa loob ng top-level na <dict> at sa itaas ng RCTNewArchEnabled:
<!-- Ang public half ng keypair para sa pag-sign ng mga chunk. Binabasa ito ng native verifier ng
Re.Pack mula sa key na ito ayon sa pangalan, at fail-closed ang strict verification kapag wala
ito. Isinusulat ito rito ng tools/gen-signing-keys.mjs; iniiwan itong walang laman sa repository
dahil gine-generate ang key sa bawat checkout, at ligtas i-commit ang public key pero walang
silbi ang luma. -->
<key>RepackPublicKey</key>
<string></string>
at sa apps/host/android/app/src/main/res/values/strings.xml, pagkatapos ng app_name:
<!-- Ang public half ng keypair para sa pag-sign ng mga chunk, na binabasa ng native verifier ng
Re.Pack ayon sa pangalan. Ang base64 body lang, sa iisang linya: tinatanggal ng verifier ang
anumang PEM header, footer at line break bago mag-decode. Isinusulat ito ng
tools/gen-signing-keys.mjs; walang laman ito sa repository dahil gine-generate ang key sa bawat
checkout. -->
<string name="RepackPublicKey"></string>
Patakbuhin ang generator. Pinapanatili ang private key ng post 14, at napupunta sa dalawang file ang public half; sinasabi iyon ng unang tatlong linya ng output nito:
node tools/gen-signing-keys.mjs
chunk-signing keypair already present, kept
embedded the public key -> apps/host/ios/Host/Info.plist
embedded the public key -> apps/host/android/app/src/main/res/values/strings.xml
Sa oras ng download nangyayari ang verification. Sa parehong platform, tumatakbo ang check sa loob ng download-and-cache path, at pinapatakbo nang hindi na ulit vine-verify ang script na nasa disk na. Sa build na ito, isang session ang window na iyon: nasa memory ang locator cache (walang
setStorage), kaya sa bawat launch ay dina-download at vine-verify ulit ang bawat chunk mula sa simula, at ipinapakita iyon ng mga server log sa post na ito. Ang build na nagpe-persist sa cache na iyon ay nagpapahaba ng window hanggang sa buong buhay ng naka-cache na file.
Dalawang binary, isang CDN
Per version ng app ang map, at hindi iisang file para sa lahat, dahil walang compatibility negotiation na nangyayari sa device. Ang kopya ng host ng bawat shared singleton ang nag-iisang kopya sa runtime, na nilo-load bago ang anumang remote, at iyon ang contract ng post 3; hindi makapagdadala ng sarili nitong kopya ang remote na binuo laban sa ibang version. Kaya sa server nangyayari ang pagpili, per binary. Ipinapakita iyon ng dalawang build mula sa iisang checkout. I-serve ang tree, pagkatapos ay i-build nang dalawang beses ang Release scheme, isa sa bawat version ng app, sa dalawang simulator:
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release --udid <simulator A> )
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=1.0.0 npm run ios -- --mode Release --udid <simulator B> )
Parehong nagbu-boot sa Pokédex tab nang walang tumatakbong dev server. listApp 1.1.0 ang nakasulat sa chip ng 2.0.0 build at cdn · listApp 1.1.0 · partyApp 1.0.0 sa banner nito; listApp 1.0.0 ang nakasulat sa chip ng 1.0.0 build. Sinasabi ng terminal ng server kung bakit (pinaikli: inalis ang mga vendor chunk, ang mga chunk ng party, at ang mga linya ng 1.0.0 launch pagkatapos ng container nito):
[2026-09-21T15:51:21.029Z] "GET /ios/maps/2.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.069Z] "GET /ios/listApp/1.1.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.076Z] "GET /ios/partyApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.083Z] "GET /ios/listApp/1.1.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.095Z] "GET /ios/partyApp/1.0.0/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:51:21.152Z] "GET /ios/listApp/1.1.0/__federation_expose_ListStack.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.057Z] "GET /ios/maps/1.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.091Z] "GET /ios/listApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:53:56.116Z] "GET /ios/listApp/1.0.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
Una ang map, pagkatapos ang mga manifest, pagkatapos ang bawat chunk sa loob ng directory na tinukoy ng map. Iisang CDN, dalawang binary, dalawang sagot. Ang mga skew na kayang hawakan ng ayos na ito:
| Sitwasyon | Ano ang nagbabago sa CDN | Ano ang nilo-load ng bawat naka-install na binary |
|---|---|---|
| Isang fix sa isang remote, para sa lahat | Isang bagong version directory, pagkatapos ang linya sa bawat map na dapat tumanggap nito | Ang version ng sarili nitong map, sa susunod nitong launch |
| Isang remote na nangangailangan ng mas bagong shared singleton | Isang bagong version directory, at isang linya sa map ng bagong version ng app lang | Nananatili sa lumang linya nila ang mga lumang binary; natatanggap ng bagong binary ang bagong version kapag na-ship na ang binary na iyon |
| Isang release ng host na walang pagbabago sa remote | Isang bagong map, kinopya mula sa nauna | Ang parehong mga remote version na pinatakbo ng nakaraang version ng app |
| Isang maling version na na-ship na | Ang linya sa map, ibinalik sa dati | Ang naunang version, sa susunod na launch, nang walang nire-rebuild |
Walang row ang detail screen dahil hindi ito sariling remote. Mula noong post 5, package na ito na kino-compile sa loob ng alinmang remote na nag-i-install nito, kaya sini-ship ang isang fix sa detail bilang bagong version ng list at ng party. Ang remote ang unit ng isang flip, at kasama nitong gumagalaw ang lahat ng naka-install dito.
Kailangan ng Android ng isa pang declaration: sumasama ang MF_APP_VERSION sa MF_CDN_BASE bilang input ng bundle task, sa parehong dahilang ibinigay ng post 14, at nagiging ganito ang block na idinagdag ng post 14 sa apps/host/android/app/build.gradle:
// --- Binabasa ng rspack.config.mjs ang MF_CDN_BASE at MF_APP_VERSION 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 sa kanila bilang input ay nagpapa-rebuild ng bundle kapag nagbago ang value at
// nagpapanatili ng cache kapag hindi.
//
// Mahalaga rito ang MF_APP_VERSION dahil sa parehong dahilan, at higit pa: ang demo ng dalawang
// binary, kapag pinatakbo sa Android, ay dalawang beses na nagbi-build ng parehong source na ang
// variable lang na iyon ang pagkakaiba, kaya kung wala ang linyang ito, tahimik na isi-ship ng
// pangalawang build ang bundle ng una at itatanong sa CDN ang tanong ng una. ---
tasks.configureEach {
if (name.startsWith("createBundle") && name.endsWith("JsAndAssets")) {
inputs.property("MF_CDN_BASE", System.getenv("MF_CDN_BASE") ?: "")
inputs.property("MF_APP_VERSION", System.getenv("MF_APP_VERSION") ?: "")
}
}
node tools/build-cdn.mjs android && ( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 MF_APP_VERSION=2.0.0 npm run android -- --mode release )
I-ship ang 1.2.0
List 1.1.0 ang ipinapakita ng naka-install na 2.0.0 app. Padalhan ito ng pagbabagong makikita ng user: kapag umabot sa anim na member ang party, nagbabago ang label ng counter sa header ng Pokédex mula “My Party” tungo sa “Party full”, at tumitingkad ang pill nito. Sa PokedexScreen.tsx, pagkatapos ng partyCount:
const partyFull = partyCount >= MAX_PARTY;
Tatlong beses itong binabasa ng counter group: para sa nakikitang label, para sa label na binibigkas ng screen reader, at para sa fill ng pill. Ipinapaliwanag ng mga comment nito kung bakit minamarkahan ang full state ng salita at ng kulay:
{/* Nagbabago ang bilang kapag nagdagdag ang user ng member mula sa ibang screen, nang hindi
lumilipat dito ang focus. Nakikita ng user na nakakakita ang pagbabago ng numero; walang
sinasabi sa user ng screen reader maliban kung live region ito (SC 4.1.3). Isinusulat ng label
ang ratio sa mga salita, dahil ang "3/6" ay binabasa bilang "three slash six" o bilang petsa,
depende sa reader. Pagdating sa anim, binabasa rin nito ang full state, ang salitang hindi
kayang sabihin ng kulay lang. */}
<Box
className="flex-row items-center gap-2"
accessible
accessibilityLiveRegion="polite"
accessibilityLabel={`${partyFull ? 'Party full' : 'My Party'}, ${partyCount} of ${MAX_PARTY}`}>
<Text size="sm" className="font-semi text-darkGrey dark:text-lightGrey">
{partyFull ? 'Party full' : 'My Party'}
</Text>
{/* Ang full ang nag-iisang state ng party na dapat markahan, at dalawang beses itong
minamarkahan: nagbabago ang label sa tabi ng pill na ito, at tumitingkad ang pill. Ang label
ang nagdadala ng state para sa lahat, kasama ang sinumang hindi makagamit ng kulay
(SC 1.4.1); ang mas matingkad na pill ang nagpapakita nito sa isang tingin, sa parehong theme.
Sa dark theme, tumitingkad ito sa alpha at hindi sa hue, dahil translucent na puti na ang
pill sa ibabaw ng navy, at ang berdeng fill doon ay ibang component na nasa parehong hugis.
darkGrey, hindi darkGreen: #A6D3A0 ang darkGreen, ang grass fill, at sa lightGreen ay
1.53:1 lang ang sukat nito. darkGrey ang kulay na ginagamit na ng label sa tabi nito, at
pasado ito sa parehong berde. Nasa contrast matrix ang apat na pares. */}
<Box
className={`rounded-full px-2.5 py-0.5 ${
partyFull ? 'bg-pokemonGreen dark:bg-white/20' : 'bg-lightGreen dark:bg-white/10'
}`}>
<Text size="xs" className="font-head text-darkGrey dark:text-pokemonGreen">
{partyCount}/{MAX_PARTY}
</Text>
</Box>
</Box>
Sinusukat sa contrast matrix na binuo ng post 12 ang bawat pares ng kulay na ginagamit ng post na ito; nasa design-system test na kinopya mo ang apat na bagong row. Chine-check ng sariling accessibility test ng list ang label at pill ng full state, sa parehong theme, at ang chip sa tabi nila; kopyahin na sila mula sa tag:
cp /tmp/pokedex-ref-15/apps/list/__tests__/ListStack.accessibility.tsx apps/list/__tests__/
Ngayon, i-build ang pagbabago bilang isang version at ilagay ang directory nito sa CDN. Hindi sa pamamagitan ng build-cdn, na nagse-seed ng tree mula sa mga list nito at magsusulat ulit ng lahat ng sineserve ng server: dalawang command lang ang operasyon laban sa tree na naroon na.
( cd apps/list && MF_REMOTE_VERSION=1.2.0 npm run bundle:ios:prod ) && rsync -a --exclude '*.map' --exclude mf-stats.json apps/list/cdn/ios/listApp/1.2.0/ cdn-root/ios/listApp/1.2.0/
Wala pang nagbago para sa kahit sino. Naroon na ang directory at walang map na tumuturo rito. Pagkatapos, ang pangalawang hakbang, isang linya ng cdn-root/ios/maps/2.0.0/version-map.json:
{
"listApp": "1.2.0",
"partyApp": "1.0.0"
}
Buksan ulit ang naka-install na 2.0.0 app. Walang ni-rebuild at walang in-install ulit. Ganito rin tinatanggap ng isang phone ang pagbabago, sa susunod nitong launch, at ang user na bukas na ang app ay patuloy na gumagamit ng code na na-load nito hanggang sa susunod na launch. Ang log ng server, na tinanggal ang mga linya ng vendor chunk ng list para umikli:
[2026-09-21T15:52:00.957Z] "GET /ios/maps/2.0.0/version-map.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.977Z] "GET /ios/listApp/1.2.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.981Z] "GET /ios/partyApp/1.0.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.987Z] "GET /ios/listApp/1.2.0/listApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:00.995Z] "GET /ios/partyApp/1.0.0/partyApp.container.js.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.038Z] "GET /ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.185Z] "GET /ios/partyApp/1.0.0/__federation_expose_styles.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:52:01.185Z] "GET /ios/partyApp/1.0.0/__federation_expose_partySlice.chunk.bundle" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
listApp 1.2.0 na ang nakasulat sa chip, sang-ayon ang banner, at kapag nakapagdagdag ng anim na Pokémon, Party full na ang sinasabi ng header sa mas matingkad na pill. listApp 1.0.0 pa rin ang na-load ng 1.0.0 binary sa kabilang simulator, na binuksan pagkatapos ng flip: hindi nagbago ang sarili nitong map.
I-upload muna ang version directory. I-flip ang map sa huli. Ang map ang commit. Walang anuman sa build na ito na nagbibigay sa binary ng ibang lugar na pagkukunan ng remote, kaya ang map na tumuturo sa directory na wala pa sa CDN ay 404 para sa bawat user na magla-launch ng app sa pagitan niyon, at ang makikita nila ay tab na hindi bumubukas. Ang pagsubok sa nawawalang version, sa Ngayon, sirain natin nang tatlong beses, ang nagpapakita kung ano ang hitsura ng kabaligtarang pagkakasunod.
Itala rin ang release sa tool, para tugma ang mga list nito sa hawak na ngayon ng CDN. Sa tools/build-cdn.mjs, nagiging ganito ang dalawang list at ang mga comment nila:
// --- Bawat version ng bawat remote na hawak ng CDN. Isang beses lang sinusulat ang isang version
// directory at hindi na ito ginagalaw pagkatapos: ang mismong mga file na iyon ang nilo-load ng mga
// naka-install na app, kaya ang pag-rebuild ng na-publish na version ay tahimik na pagbabago sa
// code na pinapatakbo na ng isang tao. Dapat may bagong version number ang bawat bagong release.
//
// May tatlong version ang listApp dahil dalawang release nito ang sini-ship ng post na ito. Iisang
// screen ang 1.0.0 at 1.1.0 na magkaiba lang ng version number, at iyon ang kailangan ng demo ng
// dalawang binary; ang 1.2.0 ang build na nagdagdag ng full state ng party counter, at ito ang
// sini-ship ng flip. Binubuo ng tool na ito ang bawat version mula sa source na nasa harap nito,
// kaya kapag ni-rebuild mula sa tapos nang tree, dala ng tatlo ang state na iyon: binubuo ng post
// ang 1.0.0 at 1.1.0 bago gawin ang pagbabago, at iyon ang dahilan kung bakit wala iyon sa
// kanila. ---
const REMOTE_VERSIONS = {
listApp: ['1.0.0', '1.1.0', '1.2.0'],
partyApp: ['1.0.0'],
};
// --- Kung ano ang puwedeng i-load ng bawat na-release na version ng app. Hinihingi ng host ang
// sarili nitong entry ayon sa pangalan sa bawat launch, kaya patuloy na natatanggap ng isang lumang
// binary ang mga version kung saan ito binuo, gaano man kalayo na ang narating ng pinakabagong
// release. Inaalis ang isang entry kapag wala nang natitira sa version na iyon ng app, gaya ng
// isang lumang API endpoint.
//
// Nakaturo ang 2.0.0 sa listApp 1.1.0 hanggang sa flip; ang linya sa ibaba ang nagbago, at ang
// pagbabalik nito sa dati ang rollback. Kapag in-edit ito rito, nire-rebuild ang buong tree, at
// hindi iyon ang tamang tool para sa trabahong iyon: ang operasyong ginagawa ng post ay pag-edit
// sa map file na nasa cdn-root na, dahil ang file na iyon ang binabasa ng tumatakbong app. Pag-seed
// ito ng isang CDN, hindi operasyon sa isang CDN.
//
// Wala nang iba pa sa map. Walang signature, walang counter, walang anumang makapagsasabi sa app
// kung ang map ay isinulat dito o ng ibang taong may access sa bucket. Totoong butas ito at
// sinasadyang iniiwang bukas: ito ang paksa ng huling post sa serye. ---
const APP_VERSION_MAPS = {
'1.0.0': { listApp: '1.0.0', partyApp: '1.0.0' },
'2.0.0': { listApp: '1.2.0', partyApp: '1.0.0' },
};
Pagtatala lang ang edit na iyon; nangyari na ang deploy. Ibig sabihin din nito, kapag nag-seed ulit mula sa tapos nang tree, binubuo ang tatlong version mula sa iisang source, kaya dadalhin din ng 1.0.0 at 1.1.0 ang full-party state: ang pagkakasunod na sinundan mo ang dahilan kung bakit wala iyon sa kanila.
Dalawang caching rule ang dahilan kung bakit ligtas ulitin ang operasyon. Kasama ang version sa bawat chunk URL, kaya puwedeng itago ng CDN, ng proxy o ng device ang file na iyon nang habambuhay nang hindi ito napagkakamalang mas bagong release. Nagbabago ang version map sa bawat deploy, kaya kabaligtaran ang rule nito: nagpapadala ang probe ng cache-control: no-cache, at sa local, ginagawa ng -c-1 na magpadala ang http-server ng cache-control: no-cache, no-store, must-revalidate. Natatanggap ng isang production CDN ang parehong dalawang rule bilang configuration.
Dina-download pa rin ng host na ito ang hindi nagbagong 1.0.0 chunk ng party pagkatapos buksan ulit ang app, gaya ng ipinapakita ng log, dahil sa memory lang nito itinatago ang locator cache. Kapag pinersist ang cache na iyon, makakatipid sa mga download na iyon, pero hahaba rin ang panahong tumatakbo ang isang file sa disk nang hindi muling chine-check ang signature nito, para sa dahilang nasa callout na “Sa oras ng download nangyayari ang verification.”
Ibalik sa dati
Mali ang version na na-ship. Ibalik ang linya:
{
"listApp": "1.1.0",
"partyApp": "1.0.0"
}
Buksan ulit ang app, at bumabalik sa 1.1.0 ang log nang walang nire-rebuild, dahil hindi kailanman inalis ang lumang directory. Ang dalawang list sa build-cdn ang nagpapaposible nito: iniingatan ng CDN ang 1.1.0 habang walang tumuturo rito, kaya edit ang rollback at hindi kailanman build.
Dahil sa parehong plain file na ito, isang linyang edit lang ang rollback, pero pinapayagan din nito ang sinumang makakasulat sa bucket na ituro ang bawat naka-install na app sa anumang version na gusto nila, kasama ang lumang may kilalang depekto. Protektado ang mga chunk; nakalantad ang map. Tungkol sa file na iyon ang post 17.
Ngayon, sirain natin nang tatlong beses
Magkamukha sa screen ang tinanggihang chunk at ang nawawalang version, pero magkasalungat ang ibig sabihin nila. Wala ring pinagkaiba ang itsura ng isang release na nagtu-throw ang module nito habang nag-i-initialise. Subukan ang tatlo sa Release build, na nakaturo ulit ang map sa 1.2.0.
Una, ang system na gumagana. Baguhin ang isang byte sa loob ng exposed chunk sa CDN at buksan ulit ang app:
printf 'X' | dd of=cdn-root/ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle bs=1 seek=2000 conv=notrunc
Sineserve ng server ang chunk, dina-download ito ng device, at tinatanggihan ito ng verification bago tumakbo ang kahit isang linya nito. Walang Metro console ang Release build, kaya basahin ang sariling log ng simulator:
xcrun simctl spawn <simulator A> log show --last 1m --predicate 'process == "Host"' --style compact | grep -A17 'Failed to load script'
Ang match at ang mga linyang mahalaga sa labimpitong kasunod nito, na tinanggal ang sariling prefix ng log:
'[ScriptManager] Failed to load script:', '[ScriptDownloadFailure]', { scriptId: '__federation_expose_ListStack',
caller: 'listApp',
url: 'http://localhost:8000/ios/listApp/1.2.0/__federation_expose_ListStack.chunk.bundle',
{ [Error: The bundle verification failed because the bundle hash is invalid.]
code: 'ScriptDownloadFailure',
Ipinapakita ng Pokédex tab ang error state ng design system, “This tab could not load” na may Try again button; cdn · listApp 1.2.0 · partyApp 1.0.0 pa rin ang nakasulat sa banner; bumubukas at gumagana ang Party tab. Nakadepende sa mode ng launch ang linya sa ilalim ng title: mula sa CDN, sinasabi nitong hindi ma-download, ma-verify o ma-start ang remote, at iminumungkahing buksan ulit ang app, samantalang ang wording ng post 11 ay nakaturo sa dev server na wala sa build na ito.
Kapag nagdagdag ka naman ng bytes pagkatapos ng dulo ng file, nagiging no token for the bundle was found ang na-log na error: binabasa ng verifier ang huling 1,280 bytes ng file at inaasahang nagsisimula ang mga iyon sa marker ng signature, at itinulak ng mga idinagdag na byte ang window na iyon lampas dito. Tinatanggihan din iyon ng strict mode. Ibalik ang chunk mula sa na-build na kopya bago magpatuloy.
Pangalawa, ang operational failure. Ituro ang 2.0.0 map sa version na walang directory, ang 1.3.0, at buksan ulit ang app:
[2026-09-21T15:54:25.541Z] "GET /ios/listApp/1.3.0/mf-manifest.json" "Host/1 CFNetwork/3860.500.112 Darwin/27.0.0"
[2026-09-21T15:54:25.542Z] "GET /ios/listApp/1.3.0/mf-manifest.json" Error (404): "Not found"
Parehong error state sa parehong tab, at listApp 1.3.0 ang iniuulat ng banner, ang version na sinabihan itong i-load. Walang pinakialaman; naisulat lang ang map bago ang directory na tinutukoy nito, at iyon ang maling pagkakasunod na ibinababala ng callout na “Ang map ang commit”, na nakikita mula sa panig ng user. Kopyahin ang anumang na-build na version sa cdn-root/ios/listApp/1.3.0/ at ilo-load ito ng susunod na launch. Ibalik ang map sa 1.2.0.
Pangatlo, isang release na may bug. Magdagdag ng isang linya sa ilalim ng mga import ng apps/list/src/PokedexScreen.tsx:
throw new Error('PokedexScreen failed to initialise');
I-ship ito tulad ng pag-ship sa 1.2.0, bilang sarili nitong version, at saka ituro ang 2.0.0 map sa 1.4.0:
( cd apps/list && MF_REMOTE_VERSION=1.4.0 npm run bundle:ios:prod ) && rsync -a --exclude '*.map' --exclude mf-stats.json apps/list/cdn/ios/listApp/1.4.0/ cdn-root/ios/listApp/1.4.0/
Nada-download ang mga JavaScript bundle na hinihingi ng app, at pumapasa ang mga ito sa signature verification. Pero ipinapakita ulit ng tab ang parehong error state, at listApp 1.4.0 ang iniuulat ng banner. Walang pumalyang pag-load ngayon: buo ang dating ng code ng list, at nag-throw ito habang nag-i-initialise ang module nito. Burahin ang linya at ibalik ang map sa 1.2.0.
Walang lumabas sa tatlong error state noong unang beses na tumakbo ang bawat failure sa isang iOS Release build: namatay ang app bago pa maipakita ang alinman sa kanila. Naipakita na ng Android ang parehong pagkamatay sa post 14, kung saan tinapos ng tinanggihang manifest request ng isang release build ang boot.
Sa unang dalawa, ang sanhi ay ang pagkakasunod ng pag-report sa failure. Kapag hindi ma-load ang module ng isang remote, itinatala ng remote runtime ng webpack ang error, idinadagdag ang while loading "./ListStack" from … sa message nito, at pinapalitan ang factory ng module ng isa na nagtu-throw. Pinapalitan ng Re.Pack ang require ng webpack ng isang guarded na version sa bawat bundle na binubuo nito: kapag nag-throw ang isang require, sinasalo ng version na iyon ang error, nire-report ito bilang fatal sa global error handler ng React Native, at walang ibinabalik. Nauuna ang report na iyon, bago pa subukang i-render ng React ang tab.
Sa isang development build, pulang box sa ibabaw ng gumaganang app ang fatal report. Sa isang Release build, ipinapasa ito ng handler sa native error handling ng React Native, kung saan tinatapos ng fatal path ang process: sa iOS, tinatawag ng exceptions module ang RCTFatal, na nagtu-throw ng exception na walang sumasalo, at sa Android, nagtu-throw ang exceptions module ng JavascriptException na tine-throw ulit ng default host.
Kapag nakaligtas ang process sa report na iyon, naaabot pa rin ng tab ang error state nito. Dahil walang ibinalik ang guarded require, nare-resolve ang import ng tab nang walang component, tumatanggi ang React na i-render ito (Element type is invalid), at sinasalo ng RemoteBoundary, na inilagay ng post 11 sa paligid ng bawat tab, ang render error na iyon at ipinapakita ang error state. Hindi kailanman nakikita ng boundary ang orihinal na loading error; ang sumunod na failure ang hinahawakan nito.
Ang src/shell/federationErrors.ts ang nagpapaligtas sa process. Binabalot nito ang global handler ng React Native at, sa unang dalawang failure, itinatapon ang report na nagtatapos ang message sa suffix na idinagdag ng remote runtime, na isinusulat ng runtime sa iisang lugar lang at palaging bilang huling linya ng message:
const HANDLED_BY_REMOTE_RUNTIME = /\nwhile loading "[^"\n]+" from \S+$/;
export function isHandledRemoteLoadError(error: unknown): boolean {
if (typeof error !== 'object' || error === null) {
return false;
}
const { message } = error as { message?: unknown };
return typeof message === 'string' && HANDLED_BY_REMOTE_RUNTIME.test(message);
}
Dalawang naunang version ng matcher na iyon ang isinulat, sinukat at inalis, at pareho na silang test case ngayon. Ang una ay nagma-match ng ChunkLoadError ayon sa pangalan at hinihingi rin nitong pangalanan ng container na bahagi ng suffix ang remote. Sa isang development build, webpack/container/reference/listApp ang nakasulat sa bahaging iyon; sa isang Release build, mini-minify ang module na iyon sa numeric id nito, 77469, kaya pumasa ang check sa development at pumalya mismo kung saan ito mahalaga. Tinanggal ng pangalawa ang container check pero ChunkLoadError pa rin ang hinahanap nito ayon sa pangalan, na siyang anyo ng maling signature; ang version na wala sa CDN ay dumarating bilang [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003 na may parehong suffix, at pinabagsak pa rin nito ang app. Sa isang pumalyang load, ang suffix lang ang mina-match ng guard.
Sakop ng suffix ang unang dalawang failure pero hindi ang pangatlo. Na-download at na-verify ang code ng list, at saka ito nag-throw habang ine-evaluate ang module nito. Bundle rin na binuo ng Re.Pack ang container ng remote, kaya guarded din ang pinakalabas na require sa loob nito, at nire-report nito bilang fatal ang throw bago pa mag-return ang anumang call. Matagumpay na load ang nakita ng remote runtime, kaya walang nagdagdag ng suffix, at kung matcher lang ang meron, namatay ang Release build sa launch:
*** Terminating app due to uncaught exception 'RCTFatalException: Unhandled JS Exception: Error: PokedexScreen failed to initialise'
Sa halip, makikilala ng guard ang report ng pangatlong failure sa oras ng pagdating nito: dumarating ito habang ine-evaluate ang isang remote module. Kapag nag-import ang host ng isang remote module, hinihingi ng remote runtime sa Module Federation ang factory ng module na iyon nang hindi pa ito pinapatakbo, gamit ang loadFactory: false, at ipinapasa ng Module Federation ang factory na iyon sa onLoad hook ng bawat runtime plugin bago pa ito tawagin ng kahit ano. Pinapalitan ng function na ibinalik ng hook ang factory. Nag-i-install ang src/shell/scriptManager.ts ng plugin na nagbabalik ng function na nagpapatakbo sa totoong factory sa loob ng evaluateRemoteModule, kaya doon ine-evaluate ang bawat remote module na ini-import ng host, para sa mga tab man o sa boot:
const evaluationWindow: ModuleFederationRuntimePlugin = {
name: 'evaluation-window',
onLoad({ exposeModuleFactory }) {
if (typeof exposeModuleFactory !== 'function') {
return undefined;
}
return () => evaluateRemoteModule(exposeModuleFactory);
},
};
registerPlugins([evaluationWindow]);
Nagbubukas ang evaluateRemoteModule, sa federationErrors.ts, ng isang window sa paligid ng factory. Galing sa pag-evaluate ng module na iyon ang anumang fatal report habang bukas ang window, kaya hinahawakan ito ng guard sa halip na ipasa. Kapag nag-return na ang factory, ito-throw ng evaluateRemoteModule ang error na hinawakan:
export function evaluateRemoteModule<T>(factory: () => T): T {
const globals = store();
const outer = globals[EVALUATING];
const evaluation: Evaluation = {};
globals[EVALUATING] = evaluation;
try {
let exports: T;
try {
exports = factory();
} catch (error) {
throw remember(error);
}
if (evaluation.failure) {
throw remember(evaluation.failure.error);
}
return exports;
} finally {
globals[EVALUATING] = outer;
}
}
Eksakto ang window dahil synchronous ang evaluation: walang ibang tumatakbo sa pagitan ng pagbukas at pagsara nito. Fatal report lang ang hinahawakan, dahil fatal report lang ang tumatapos sa process.
Pagkatapos, umaabot ang error na na-throw mula sa window sa sariling guarded require ng host, na nagre-report nito bilang fatal sa pangalawang pagkakataon, sa labas na ng window. Tinatandaan ng remember ang bawat error na na-throw mula sa window, at itinatapon ng guard ang pangalawang report na iyon tulad ng pagtapon nito sa mga report na may suffix. Nare-resolve ang import nang walang module, at naaabot ng tab ang parehong error state tulad ng sa ibang dalawang failure.
Pinagsasama ng naka-install na guard ang dalawang path. Hinahawakan nito ang fatal report na lumabas sa loob ng isang window, itinatapon ang report na nagtatapos sa suffix o umuulit ng error na na-throw mula sa isang window, at ipinapasa ang lahat ng iba pa sa handler na naroon na dati:
// --- Kung saan itinatago ng guard ang handler na binabalot nito. Puwedeng i-evaluate ulit ng Fast
// Refresh ang module na ito sa isang tumatakbong app, at bagong module state ang simula ng bawat
// evaluation, kaya hindi kayang ipagkaiba ng isang flag sa file na ito ang pangalawang installation
// sa una. Sa halip, dala ng wrapper ang handler na nasa ilalim nito, sa isang property na alam ng
// bawat evaluation ng module ang pangalan. ---
const WRAPPED = '__federationGuardWrapped';
type GuardHandler = GlobalErrorHandler & { [WRAPPED]?: GlobalErrorHandler };
export function guardHandledRemoteLoadErrors(): void {
const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsShape }).ErrorUtils;
if (!errorUtils) {
return;
}
const current: GuardHandler = errorUtils.getGlobalHandler();
const previous = current[WRAPPED] ?? current;
const guard: GuardHandler = (error, isFatal) => {
const evaluation = store()[EVALUATING] as Evaluation | undefined;
if (evaluation && isFatal) {
evaluation.failure ??= { error };
console.warn(
'[federation] a remote module threw while it was evaluated; the import that asked for it settles without it',
error,
);
return;
}
if (isHandledRemoteLoadError(error) || isThrownFromEvaluation(error)) {
// Ina-log, hindi itinatago: nararapat na makita sa console ng sinumang tumitingin ang dahilan
// ng error state ng isang tab.
console.warn(
'[federation] a remote failed to load; its tab will show the error state',
error,
);
return;
}
previous(error, isFatal);
};
guard[WRAPPED] = previous;
errorUtils.setGlobalHandler(guard);
}
Kapag in-install ulit ang guard, pinapalitan nito ang guard na nadatnan nito sa halip na balutin ito. Puwedeng patakbuhin ulit ng Fast Refresh ang module habang nagde-develop, at kapag may pangalawang wrapper, patuloy na tatakbo sa ilalim nito ang mga check ng una; ine-evaluate ulit ng federationErrors.test.ts ang module para matiyak na iisa lang ang guard. Sa parehong dahilan, nasa global object ang window at ang mga error na tinatandaan ng remember, kaya pareho ang nakikita ng lahat ng evaluation ng module.
Wala sa mga ito ang nagbibigay sa binary ng ibang lugar na pagkukunan: ipinapakita ng patay na tab ang failure sa halip na itago ito, pero hindi iyon gumaganang app.
Ang pinapayagan ng mga store
Sa buong post na ito, nagda-download ng code sa isang naka-install na app, kaya sakop ito ng mga panuntunan ng platform, at pareho nang binago ng dalawang vendor ang wording ng mga clause na ito dati. Mula sa kasalukuyang mga page ang bawat sipi sa section na ito.
| Platform | Namamahalang teksto | Ang sinasabi | Kondisyon |
|---|---|---|---|
| Apple, review | App Review Guideline 2.5.2 | Hindi puwedeng “download, install, or execute code which introduces or changes features or functionality of the app, including other apps” ang mga app | Ang na-review na binary ang nagtatakda ng ginagawa ng app; hindi puwedeng dagdagan o baguhin ng na-download na code ang functionality na iyon |
| Apple, lisensya | Developer Program License Agreement 3.3.1(B) | “Interpreted code may be downloaded to an Application but only so long as” natutugunan ang tatlong kondisyon | Pinapanatili nito ang layunin ng app na inilaan at in-advertise; hindi nito bina-bypass ang signing, sandbox o iba pang security feature ng operating system; sa isang app sa App Store, hindi ito gumagawa ng store o storefront para sa ibang app |
| Google Play | Device and Network Abuse policy | Ang isang app ay “may not download executable code (such as dex, JAR, .so files) from a source other than Google Play” | Ang restriction ay “does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs” |
Magkaibang linya ang iginuguhit ng dalawang dokumento ng Apple, at pareho silang umiiral. Pinapayagan ng clause 3.3.1(B) ng license agreement ang na-download na interpreted code basta ito ay “does not change the primary purpose of the Application by providing features or functionality that are inconsistent with the intended and advertised purpose of the Application”, “does not bypass signing, sandbox, or other security features of the OS”, at, “for Applications distributed on the App Store, does not create a store or storefront for other Applications”. Mas mahigpit ang Guideline 2.5.2: pinoprotektahan ng unang kondisyon ng lisensya ang layunin ng app, samantalang hindi pinapayagan ng guideline ang na-download na code na nagdaragdag o nagbabago ng mga feature o functionality ng app. Kaya hindi napapatunayan ng pagtugon sa mga kondisyon ng lisensya na pasado ka sa review. Ang pagbasang ginagamit ng mga over-the-air service ay limitahan ang ipinapadala sa ganitong paraan sa mga fix at adjustment sa loob ng functionality na na-review na ng Apple, at tanggapin na may puwang ang wording para hindi sumang-ayon ang Apple.
Exception ang tinutukoy ng panuntunan ng Google, hindi kondisyon: ang restriction ay “does not apply to code that runs in a virtual machine or an interpreter where either provides indirect access to Android APIs (such as JavaScript in a webview or browser)”. Hindi pinapangalanan ng policy ang React Native o ang Hermes (ang JavaScript engine na default na pinapatakbo ng React Native), kaya inference kung pasok ang Hermes. Makatwirang inference ito: naaabot lang ng JavaScript na tumatakbo sa Hermes ang mga Android API sa pamamagitan ng mga natively compiled na module na nasa binary na, at iyon ang indirect access ayon mismo sa mga salita ng clause. Plain JavaScript ang mga chunk sa CDN na ito at hindi Hermes bytecode, dahil walang remote sa serye na kino-compile sa bytecode; tungkol sa kung paano tumatakbo ang code ang linya ng policy, hindi sa anyo kung paano ito ipinapadala. Idinadagdag ng parehong page na ang interpreted code na “loaded at run time (for example, not packaged with the app) must not allow potential violations of Google Play policies”, kaya ang ginagawa ng isang remote ay sakop ng parehong mga panuntunan gaya ng app na pinapatakbuhan nito.
Para sa React Native mismo, galing ang pinakamalapit na gabay sa mga vendor na nagbibigay ng over-the-air update para dito. Pinagsilbihan ng CodePush ng Microsoft ang market na iyon nang maraming taon at na-retire ito noong 31 March 2025. Tumatakbo pa ang EAS Update, ang over-the-air service ng Expo Application Services, at itinatali ng documentation nito ang mga update sa mga panuntunan ng mga store: “you need to follow the rules of the platforms and app stores you are building for”, ang mga update ay “need to follow the App Store and Play Store guidelines, including the content of the updates and how you use them”, at “This usually means changes to your app’s behavior need to be reviewed”. Minamarkahan ng table nito kung kailan gagamit ng update ang “Change to native code or native dependencies” at “Anything that requires a new app binary version” bilang mga kaso na bagong binary ang kailangan.
Ang praktikal na panuntunan: mag-ship ng mga fix at improvement sa mga feature na na-review na ng store, hindi kailanman ng bagong pangunahing layunin, at panatilihing kayang gumana nang mag-isa ang na-review na binary. Ang flip sa post na ito ay ang uri ng pagbabagong inilalarawan ng unang bahagi: binabago nito kung paano ipinapakita ng isang umiiral na feature, ang party counter, ang isang state, at wala itong idinadagdag na bago. Ang store ang nagpapasya kung nananatili sa loob ng na-review ang isang partikular na pagbabago, at walang build na makapagpapatunay niyon. Kailangan ng pangalawang bahagi ang kopya sa loob ng binary na idinadagdag ng post 16, dahil sa ngayon, walang maipapakita ang binary na hindi makaabot sa CDN.
Patakbuhin mo
Mga key, tree, server:
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
Ang Release build, na sinabihan kung nasaan ang CDN at kung aling binary ito:
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm run ios -- --mode Release )
Ganito rin gumagana ang development build, pero nasa command ng dev server ang dalawang variable:
( cd apps/host && MF_CDN_BASE=http://localhost:8000 MF_APP_VERSION=2.0.0 npm start )
( cd apps/host && npm run ios )
Android, gamit ang address ng machine para sa emulator:
node tools/build-cdn.mjs android && ( cd apps/host && MF_CDN_BASE=http://10.0.2.2:8000 MF_APP_VERSION=2.0.0 npm run android -- --mode release )
Ang mga suite, ang smoke test at ang mga test ng generator:
( for d in apps/host apps/list apps/party packages/ui; do ( cd "$d" && npx jest --silent ) || exit 1; done ) && sh scripts/federation-smoke.sh && node --test tools/gen-signing-keys.test.mjs
Binubuo na ngayon ng smoke test ang dalawang remote sa 9.9.9, isang version na walang config na gumagamit bilang default, at sinusundan nito ang sariling chunk list ng manifest papasok sa version directory, kaya kapag nawawala ang version segment sa output.path, pumapalya ito sa build time at hindi sa launch ng isang user.
Kapag pinatakbo mula sa tapos nang tree, binubuo ng build-cdn ang tatlong version ng list mula sa iisang source at sine-seed ang 2.0.0 map ng 1.2.0, kaya ang chip lang ang pinagkaiba ng tatlong version. Para makita rito ang isang flip, i-edit ang cdn-root/ios/maps/2.0.0/version-map.json para ituro ang listApp sa 1.1.0 at buksan ulit ang app, pagkatapos ay ibalik ang 1.2.0 at buksan ulit. Sa build-along lang makikita ang full-party state na dumarating kasabay ng flip, dahil iyon lang ang rutang bumubuo ng 1.0.0 at 1.1.0 bago ang pagbabago.
Ang binuo mo, at ang susunod
Humihingi na ngayon ang isang naka-install na binary ng sarili nitong map sa CDN sa launch, nilo-load ang mga naka-sign na version na tinutukoy ng map na iyon, at tinatanggihan ang anumang remote na walang ibinigay na version ang map. Isang directory at isang linya ang pag-ship ng remote, at ang rollback ay ang pagbabalik ng linya; natatanggap ng bawat binary ang alinman sa dalawa sa susunod nitong launch. Kapag hindi ma-load ang isang remote, o nag-throw ang module nito habang nag-i-initialise, tuloy ang takbo ng Release build na may isang patay na tab, na sinukat sa iOS gamit ang tinanggihang chunk, ang nawawalang version at ang module na nag-throw habang nag-i-initialise, at sa Android gamit ang nawawalang version.
Dalawang limitasyon ang natitira. Walang signature ang map, kaya ang sinumang makakasulat sa bucket ay kayang itutok ang bawat install; sini-sign ito ng post 17, dinadagdagan ng counter na nagpapawalang-bisa sa map kahapon, at pinapa-rollback sa app mismo ang pumapalyang version. Ang mas malapit na limitasyon ay ang abot: ang binary na hindi makaabot sa CDN, o hindi mabasa ang map nito, ay nagla-launch sa unresolved mode na walang maipapakita sa alinmang tab.
Susunod: ang araw na hindi maabot ang CDN. Isang offline fallback na naka-bake sa binary, at isang in-session na safety net para sa remote na pumapalya habang ginagamit.
Mga Sanggunian
- Module Federation: runtime hooks — ang
onLoad, ang hook na tumatanggap sa factory ng isang remote module bago pa ito tawagin ng kahit ano, kapag hiningi ang factory na iyon gamit angloadFactory: false - Module Federation: runtime API — ang
registerRemotesat angforceoption nito: ino-overwrite ang module na naka-register na at binubura ang cache nito, at may warning na nagsasabing delikado ang operasyon - Module Federation source: runtime-core remote/index.ts at 2.9.0 — ang branch para sa pangalang naka-register na, na walang ginagawa kapag walang
forceat nagwa-warning kapag mayroon, at angloadRemote, na nagpapasa saonLoadhook ng factory na hindi pa pinapatakbo kapag false angloadFactory, at nagbabalik ng function na ibinibigay ng hook kapalit nito - Module Federation source: webpack-bundler-runtime remotes.ts at 2.9.0 — ang error handler na nagdaragdag ng
while loading "…" from …at pinapalitan ang pumalyang module ng isa na nagtu-throw, at ang paghingi sa factory ng bawat remote module gamit angloadFactory: false - Module Federation source: runtime-core module/index.ts at 2.9.0 — ang
get, na nagpapatakbo sa factory ng isang module bago mag-return, maliban kung false angloadFactory - Re.Pack: ScriptManager — ang
addResolverkasama angpriorityatkeyoptions nito, ang default priority na 2, at angverifyScriptSignature - Re.Pack: CodeSigningPlugin — ang plugin na kinonfigure ng post 14 at pinagve-verify-an ng post na ito
- Re.Pack source: ScriptManager.ts at 5.2.5 — ang
DEFAULT_RESOLVER_PRIORITY = 2, ang mga resolver na nakaayos mula pinakamataas, angresolveScriptna nagtatanong sa susunod na resolver tuwing walang ibinabalik ang isa at humihinto kapag may nag-throw, at ang locator cache sa memory - Re.Pack source: ResolverPlugin.ts at 5.2.5 — ang per-remote resolver, na naka-register na may key at walang priority
- Re.Pack source: OutputPlugin.ts at 5.2.5 — ang mga remote chunk na kinokopya sa
extraChunks.outputPathmula sa kung saan sila isinulat ng Rspack, hindi inililipat - Re.Pack source: guardedRequire.ts at 5.2.5 — ang
requirena inilalagay ng Re.Pack sa bawat bundle, na sumasalo sa module na nagtu-throw, nire-report ang error saErrorUtilsbilang fatal, at walang ibinabalik - Re.Pack source: ScriptManager types at 5.2.5 — ang
verifyScriptSignaturebilang'strict' | 'lax' | 'off', at kung ano ang kinukumpara ngcache - Re.Pack source: ScriptManager.mm at 5.2.5 — ang verification sa loob ng download path ng iOS, palagi sa
strictat salaxkapag may token lang - Re.Pack source: RemoteScriptLoader.kt at 5.2.5 — ang parehong check sa Android, at ang naka-cache na file na pinapatakbo nang wala ito
- Re.Pack source: CodeSigningErrors.swift, CodeSigningUtils.swift at CodeSigningUtils.kt at 5.2.5 — ang mga verification failure na sinipi sa post na ito, ang token na binabasa mula sa huling 1,280 bytes, at ang
RepackPublicKeyna binabasa mula saInfo.plistat sastrings.xml - React Native source: setUpXHR.js at 0.85.3 — ang
AbortControlleratAbortSignalna ini-install mula saabort-controllerpackage, na walangtimeout - React Native source: error-guard.js at 0.85.3 — ang
ErrorUtils.reportFatalError, na tumatawag sa global handler na naka-set ang fatal flag, at angsetGlobalHandleratgetGlobalHandler - React Native source: ExceptionsManager.js at 0.85.3 — LogBox sa development; kung hindi, ibinibigay ang error sa native error handler, na ini-install ng new architecture
- React Native source: RCTInstance.mm at RCTExceptionsManager.mm at 0.85.3 — ang error na walang host delegate na umaako, ipinapasa sa exceptions module, na ang fatal path ay tumatawag sa
RCTFatal - React Native source: RCTAssert.m at 0.85.3 — ang
RCTFatalna nagtu-throw ng exception na debug build lang ang sumasalo - React Native source: ExceptionsManagerModule.kt at 0.85.3 — ang fatal report na tine-throw bilang
JavascriptException - React Native source: ReactInstance.kt at DefaultReactHost.kt at 0.85.3 — ang throw na iyon na idinadaan sa exception handler ng host, na nagtu-throw ulit nito bilang default
- App Store Review Guidelines, 2.5.2 — ang constraint na self-contained dapat ang mga app, sinipi mula sa kasalukuyang teksto
- Apple Developer Program License Agreement, 3.3.1(B) — ang pahintulot para sa interpreted code at ang tatlong kondisyon nito, sinipi mula sa public na kopya
- Google Play: Device and Network Abuse policy — ang restriction sa executable code, ang exception nito para sa mga interpreter, at ang panuntunan nito para sa interpreted code na nilo-load sa run time, sinipi mula sa kasalukuyang teksto
- Expo: EAS Update introduction — ang sagot tungkol sa mga store guideline at ang table kung kailan gagamit ng update, sinipi mula sa kasalukuyang page
- microsoft/react-native-code-push — ang naka-archive na repository, kasama ang abiso nitong na-retire ang CodePush noong 31 March 2025
- http-server — ang
-coption, kung saan dini-disable ng-c-1ang caching - react-native-module-federation — ang companion repo, ang build sa tag na
post-15-cdn-flip