DEV Community

Cover image for The production build and the three modes of a federated React Native app
Warren de Leon
Warren de Leon

Posted on Originally published at warrendeleon.com

The production build and the three modes of a federated React Native app

πŸ“š React Native Module Federation series β€” read it in full on warrendeleon.com, where new parts land first.

Post 13 ended on a promise: "the build gets real: a production bundle, and the first federated release artefact." This is that post. The larger promise is post 1's, that each feature "can be deployed and updated on its own instead of riding in a single App Store release", and the same post listed the price: a CDN to run, a URL that is an attack surface, code to sign and to verify. Thirteen posts in, both remotes still arrive from two dev servers on your machine. This post builds them for production, signs every chunk, and serves them from a content delivery network (CDN) with those dev servers switched off.

The honest scope first. The CDN in this post is a directory and a port, holding one version of each remote, with nothing yet that verifies a signature, guards the choice of version, or falls back when the CDN is unreachable. The next three posts add all of that, and each of them handles the artefacts this one produces.

Start from post 13's finished state, the start tag post-13-native-handoff. The end tag is post-14-production-build, and everything between the two you type.

πŸ“Š Diagram: view it on warrendeleon.com

Three channels, three cadences. The registry's arrow ends at node_modules and never reaches the running app: a package change ships inside the next build of whoever installs it. A remote change lands on the CDN and reaches the app at its next launch. A host change goes through the store.

Three modes, one table

The series has lived in the first row since post 2 and used the second since post 5, without naming either. Named, with the third beside them:

Channel What it serves Who reads it, and when What a change costs
Dev servers, :8082 and :8083 A remote's manifest, container and chunks, rebuilt on every save The host app, at runtime, whenever MF_CDN_BASE is unset: development builds, in practice A save
Registry, Verdaccio on :4873 npm packages: @pokedex/contracts, detail, ui and a11y-testing npm install, at build time, in every app that depends on the package A publish, then a reinstall and a rebuild of each consumer
CDN, cdn-root/ on :8000 A remote's manifest, container and chunks, built once and signed The host app, at runtime, development or release build A rebuild of the remote and a copy; every installed app picks it up at its next launch

The question the table exists to answer is the one most teams ask first: which changes still cost an app-store release? Anything the binary carries: native code, the host shell, and every shared singleton the host provides, because the host's bundle is where the one copy of each lives. Anything a remote owns costs a CDN deploy and a launch. Packages sit between the two, and the middle row is the one to read twice.

A registry is where versioned artefacts usually live, so it is a reasonable place to expect the remotes, and it is where a team new to federation tends to put them first. It does not hold them: the registry serves packages, never remotes. A package reaches users inside whichever bundle compiles it in.

Post 12 shipped @pokedex/detail 4.0.12 as a publish and a reinstall in list and party; under this post's modes that same fix is two rebuilt remotes on the CDN and no store release, because the host never bundles the detail package. A fix to @pokedex/contracts or @pokedex/ui is different. The host provides both as eager singletons, so the host bundle changes, and the host bundle ships in the binary.

Build a remote for real

Every bundle the series has run was a development build, served on save. A production build is the same command with the development switch off, and each remote gets it as two scripts. In 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",
Enter fullscreen mode Exit fullscreen mode

The same two go in apps/party/package.json. react-native bundle reaches Re.Pack because post 2's react-native.config.js registered its commands with the CLI (webpack-bundle is an alias). --entry-file src/index.js names the container entry post 2 gave each remote; the template's index.js beside it registers the remote as a standalone app, which the CDN never serves.

The config gains one boolean and reads it in two places that have to agree: where the compilation writes, and where the chunks the host will fetch are laid out. In apps/list/rspack.config.mjs, after const { mode, platform } = env;:

// Production builds write somewhere else and gain a signature; development is untouched.
const isProd = mode === 'production';
Enter fullscreen mode Exit fullscreen mode
output: {
  // A production build writes the tree the CDN serves, laid out as the URL path it is served
  // at: cdn/<platform>/listApp/. A development build keeps writing to build/, where the dev
  // server reads it from.
  path: isProd ? `${__dirname}/cdn/[platform]/listApp` : `${__dirname}/build/[platform]`,
  uniqueName: 'ListApp',
},
Enter fullscreen mode Exit fullscreen mode

A third block has nothing to do with federation and everything to do with trusting a green build. Re.Pack's default minimiser is terser-webpack-plugin, and under Rspack that plugin has done nothing since its 5.6.0 release (callstack/repack#1390): the build reports success and every production chunk keeps its comments and whitespace. Name Rspack's own SWC minimiser beside it. Re.Pack merges your optimization.minimizer into its own list rather than replacing it, so the default stays in the array and goes on doing nothing while the SWC one does the minifying. Add the import at the top of the file:

import { SwcJsMinimizerRspackPlugin } from '@rspack/core';
Enter fullscreen mode Exit fullscreen mode

and, after output:

optimization: {
  // Re.Pack's default minimiser is terser-webpack-plugin, and under Rspack that plugin has done
  // nothing since terser-webpack-plugin 5.6.0 (callstack/repack#1390): the build succeeds and
  // every production chunk ships with its comments and whitespace intact. Re.Pack merges this
  // array into its own, so the default still sits in the list beside Rspack's SWC minimiser;
  // the SWC one does the minifying the default skips.
  minimizer: [
    new SwcJsMinimizerRspackPlugin({
      test: /\.(js)?bundle(\?.*)?$/i,
      minimizerOptions: { format: { comments: false } },
    }),
  ],
},
Enter fullscreen mode Exit fullscreen mode

The host gets the same import and block in apps/host/rspack.config.mjs, because its release bundle is built the same way. Then, still in the remote's config, the chunks:

new Repack.RepackPlugin({
  extraChunks: [
    {
      include: /.*/,
      type: 'remote',
      // The chunks land beside the container and the manifest, because the host will ask
      // for them at URLs relative to the manifest it loaded.
      outputPath: isProd ? `cdn/${platform}/listApp` : `build/${platform}/remote`,
    },
  ],
}),
Enter fullscreen mode Exit fullscreen mode

Mirror all of it in apps/party/rspack.config.mjs with partyApp. Both output trees are build products, so they join .gitignore next to apps/*/build/:

apps/*/cdn/
cdn-root/
Enter fullscreen mode Exit fullscreen mode

Then build one and look at what comes out:

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

Sixteen more chunks follow, most of them the remote's own copies of the shared singletons: fallbacks the host never requests, because it provides every one. The server log later in this post shows the few it does ask for. Three files matter here. The container is what the host loads first; the exposed chunk carries ./ListStack; and mf-manifest.json is the address book, emitted by default alongside its mf-stats.json twin. One field in it looks wrong on purpose:

"publicPath": "noop:///"
Enter fullscreen mode Exit fullscreen mode

Re.Pack's base config sets it, and nothing treats it as a location. The host fetched the manifest from a URL, and Re.Pack's resolver plugin rebases every chunk URL for that remote onto it, so each chunk is fetched from the directory the manifest came from. Where a remote was built has no bearing on where it is served, which is what lets one build move from a laptop to a real CDN unchanged.

One script assembles the tree a CDN would serve. Create tools/build-cdn.mjs:

// --- Assembles the directory a CDN would serve. For each remote it runs that remote's production
// bundle script, then copies the output β€” container, chunks and mf-manifest.json β€” into
// cdn-root/<platform>/<remote>/. That layout is the URL layout: a file at
// cdn-root/ios/listApp/mf-manifest.json is served at <base>/ios/listApp/mf-manifest.json, which is
// exactly the URL the host's remotes map asks for.
//
// One version of each remote lives here, in one flat directory. Versioned releases, and the map
// that decides which version a given app may load, arrive in the next post.
//
// Usage: node tools/build-cdn.mjs [ios|android]     (no argument builds both)
//
// Then serve it and point the host at it (an Android emulator reaches this machine at 10.0.2.2,
// so the Android build gets that address instead of 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) {
  // Wipe this platform only, so building one does not delete the other's tree.
  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}`);
}
Enter fullscreen mode Exit fullscreen mode

Sign what you ship

The chunks now leave the machine, and post 1's integrity cost starts here. The private key signs every production chunk and must never reach the repository, and this repository is public, so the ignore rule goes in before the generator exists. In .gitignore:

# Signing keys. The private key signs every production chunk and must never be committed:
# this directory is ignored before `node tools/gen-signing-keys.mjs` is ever run.
code-signing/
Enter fullscreen mode Exit fullscreen mode

Ignore code-signing/ before you generate a key, not after. A private key that reaches a public repository is compromised the moment it is pushed. In this post the repair is a new key and a rebuild of every chunk the old one signed; once apps verify signatures, from post 15, it also means replacing the public key those apps trust, and with an embedded key that is an app update. Check that git status shows nothing under code-signing/ before every commit from here on.

Create tools/gen-signing-keys.mjs:

// --- Generates the keypair that signs every production remote chunk.
//
//   RSA-2048   signs each chunk Re.Pack's CodeSigningPlugin emits (an RS256 JWT of the chunk's
//              hash, appended to the bundle). The private half signs at build time; the public
//              half is what a host verifies against before it executes downloaded code.
//
// The private key lives in code-signing/, inside the checkout but git-ignored before this script
// is ever run, and it is written readable by its owner only: .gitignore keeps it out of commits,
// the file mode keeps it from other users of the machine. A pair is generated only when neither
// half exists. An existing private key is kept, never rotated: a chunk signed with a new key
// would not verify against a public key already embedded in installed apps, and a signature
// stays valid only for the key that made it. A missing public half is derived from the private
// one; a public half on its own, or a pair that does not match, stops the script.
//
// Usage: 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;

// Keys are compared as DER bytes, never as PEM text: line endings and wrapping are not identity.
const der = key => key.export({ type: 'spki', format: 'der' });

if (existsSync(privatePath)) {
  // The mode is re-applied on every run, because a copy or an earlier tool may have left it wider.
  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)) {
  // A public key with no private key is the one state this script must not repair on its own:
  // generating a fresh pair here would silently change the signing identity behind a public key
  // that may already be embedded in installed apps.
  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());
Enter fullscreen mode Exit fullscreen mode
node tools/gen-signing-keys.mjs
Enter fullscreen mode Exit fullscreen mode

Two of the script's choices are about the key's life after this post. The private half is written readable by its owner only, since .gitignore keeps it out of commits and the file mode keeps it from other users of the machine. And an existing pair is kept rather than rotated, and a public key on its own is never replaced: a chunk signed with a new key would not verify against a public key already embedded in installed apps, and a signature stays valid only for the key that made it. Once apps verify, rotating a key is a coordinated trust migration, with the old and new keys both trusted for a while or separate releases routed to each, and the posts that add verification and versioning own that design. This script refuses to start one by accident.

The remotes' configs add the plugin in production mode only. In both rspack.config.mjs files, at the end of plugins:

// Each production chunk gets an RS256 signature of its own hash, appended to the file. The
// private key sits in code-signing/, git-ignored inside the checkout, and never ships.
// Development builds stay unsigned by choice: their chunks are served from this machine
// and rebuilt on every save, and this post signs only what leaves it.
...(isProd
  ? [
      new Repack.plugins.CodeSigningPlugin({
        privateKeyPath: path.resolve(__dirname, '../../code-signing/private-key.pem'),
      }),
    ]
  : []),
Enter fullscreen mode Exit fullscreen mode

Rebuild and read the end of the container:

( cd apps/list && npm run bundle:ios:prod ) && tail -c 1300 apps/list/cdn/ios/listApp/listApp.container.js.bundle | xxd | head -4
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

/* RCSSB */ is the marker, and what follows it is a JSON Web Token: the chunk's SHA-256 hash, signed with the private key using RS256. The plugin reserves the last 1,280 bytes of the file for the marker and the token, and pads the rest with zeros. The container and every chunk carry one. index.bundle does not, because the plugin skips the main bundle as always local.

A signature nobody verifies is a stamp, not a lock. Nothing in this build reads the token. Verification is ScriptManager's job, against a public key the app embeds as RepackPublicKey, and it arrives with the resolver in post 15. Re.Pack 5.2.5, the version this series runs, configures the plugin with enabled, privateKeyPath and excludeChunks and nothing else; 5.3.0, released on 5 August 2026, adds a publicKeyPath that embeds the key for you. The series stays on 5.2.5 and embeds it in the post that reads it, so the key and its reader arrive together. The arc from here: 14 signs, 15 verifies, 17 guards.

Serve it like production

A CDN, for everything this post does, is a server that returns static files at stable URLs. Build both remotes into one tree and serve it:

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

-c-1 turns the cache header off, so a rebuilt chunk is never served stale while you iterate. The host is the only app in the workspace with a remotes map: post 5 made the detail screen a package, and neither remote has consumed the other since. So the switch between the two worlds is one function in one file. In apps/host/rspack.config.mjs, above defineRspackConfig:

// --- Where the remotes are served from. Set MF_CDN_BASE and every remote URL below points at the
// content delivery network instead of the dev servers; leave it unset and nothing changes. The
// value is read at BUILD time and baked into the bundle, so a build that forgot it ships the dev
// URLs:
//   MF_CDN_BASE=http://localhost:8000 npm start      (the local CDN, on a development build)
//   MF_CDN_BASE=https://cdn.example.com npm run …    (a real one, on a release build)
const CDN_BASE = process.env.MF_CDN_BASE;

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

Inside it, before the returned config:

// The whole switch between the two worlds, in one function: dev server or CDN, same manifest
// filename either way. Nothing else in the workspace changes, because the host is the only app
// here that holds a remotes map.
const remoteUrl = name =>
  CDN_BASE
    ? `${name}@${CDN_BASE}/${platform}/${name}/mf-manifest.json`
    : `${name}@${DEV_REMOTES[name]}/${platform}/mf-manifest.json`;
Enter fullscreen mode Exit fullscreen mode
remotes: {
  listApp: remoteUrl('listApp'),
  partyApp: remoteUrl('partyApp'),
},
Enter fullscreen mode Exit fullscreen mode

Stop the two remote dev servers. Start the host's with the variable set, and build:

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

The app looks exactly as it did at the end of post 13, which is the point. The proof is in the server's terminal:

[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"
Enter fullscreen mode Exit fullscreen mode

Two manifests, two containers, and the chunks the host could not provide: the detail package, which the host never bundles, and the native stack, which the host does not share, so the first remote to load supplies it for both. Not one request for React, React Native, Redux or the design system: the host provides all of those, as post 3's singleton contract requires.

Now break it

Every break-it in the series so far has broken a running app. This one happens before the app exists. Build the Release scheme with the tree exactly as it is now:

( cd apps/host && MF_CDN_BASE=http://localhost:8000 npm run ios -- --mode Release )
Enter fullscreen mode Exit fullscreen mode
                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
Enter fullscreen mode Exit fullscreen mode

Read the first line, not the last. Metro started. Xcode's "Bundle React Native code and images" phase runs React Native's react-native-xcode.sh, and that script's default CLI_PATH is scripts/bundle.js: Metro's bundle command, called directly. The React Native CLI never gets to choose the bundler: bundle.js calls cli.js config only to read react-native.config.js's project config, then invokes Metro's bundle function directly, so the commands entry that points bundle at Re.Pack is never consulted. Metro then meets import './global.css' on line 6 of index.js and stops at the first thing it cannot do. The error names a stylesheet; the fault is which bundler ran. The fix is three variables the script already reads. Append to apps/host/ios/.xcode.env:

# --- The release bundle is built by Re.Pack, not Metro. Xcode's "Bundle React Native code and
# images" phase calls React Native's bundle script directly: that script reads
# react-native.config.js for the project config, through `cli.js config`, but never the
# `commands` entry that hands `bundle` to Re.Pack, so left alone it runs Metro, which knows
# nothing about global.css and stops there. CLI_PATH points the phase at the React Native CLI,
# which does honour that entry; BUNDLE_COMMAND names the command Re.Pack registers there;
# EXTRA_PACKAGER_ARGS hands it the Rspack config. $SRCROOT is this ios/ directory, so the app
# root is its parent. ---
export CLI_PATH="$SRCROOT/../node_modules/react-native/cli.js"
export BUNDLE_COMMAND=bundle
export EXTRA_PACKAGER_ARGS="--config $SRCROOT/../rspack.config.mjs"
Enter fullscreen mode Exit fullscreen mode

Run the Release build again. It boots from the CDN with no dev server of any kind running, and the URLs sit in the bundle inside the app for anyone to check:

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

Forget the variable on a release build and nothing complains: the dev-server URLs bake in instead, and this grep is how you find out before a tester does.

Android needs no change to reach Re.Pack. The Gradle plugin's bundleCommand defaults to bundle and its cliFile to react-native/cli.js, the route through the config file that Xcode's script skips, and android (Rspack 2.0.5) compiled appears inside :app:createBundleReleaseJsAndAssets. Three things differ, though. The emulator reaches your machine at 10.0.2.2, not localhost, so the variable changes with the platform:

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

Gradle decides whether to run the bundle task at all by comparing the task's declared inputs with the previous run, and an environment variable is not one of them. Change only MF_CDN_BASE and the task reports UP-TO-DATE, and the previous bundle ships. Declare the variable as an input, at the end of apps/host/android/app/build.gradle:

// --- MF_CDN_BASE is read by rspack.config.mjs when the bundle task runs, and Gradle cannot see
// that: an environment variable is not one of the task's declared inputs, so a build where only
// the value changed leaves createBundleReleaseJsAndAssets UP-TO-DATE and ships the previous
// bundle, dev-server URLs and all. Declaring it as an input makes a changed value rebuild the
// bundle and an unchanged one keep the cache. ---
tasks.configureEach {
    if (name.startsWith("createBundle") && name.endsWith("JsAndAssets")) {
        inputs.property("MF_CDN_BASE", System.getenv("MF_CDN_BASE") ?: "")
    }
}
Enter fullscreen mode Exit fullscreen mode

And a release APK refuses plain http. React Native's Gradle plugin sets usesCleartextTraffic to false for release builds, and the boot dies on [ Federation Runtime ]: Failed to get manifest. #RUNTIME-003, with Network request failed underneath. A production CDN is served over https and never meets this. The local one is not, so a network security config permits the loopback addresses and nothing else. Create apps/host/android/app/src/main/res/xml/network_security_config.xml:

<?xml version="1.0" encoding="utf-8"?>
<!-- The local stand-in for a CDN is served over plain http on the build machine, which a release
     build refuses to talk to. This permits it for the loopback addresses only: localhost, 127.0.0.1
     and 10.0.2.2, the address an emulator reaches the machine on. A production CDN is served over
     https, where this exception does nothing at all. -->
<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>
Enter fullscreen mode Exit fullscreen mode

and name it on the <application> element in AndroidManifest.xml:

android:networkSecurityConfig="@xml/network_security_config"
Enter fullscreen mode Exit fullscreen mode

iOS needed no equivalent: the template's Info.plist already carries NSAllowsLocalNetworking, and the Release build reached localhost:8000 with no change.

Run it

Three terminals, and two are missing. The remotes' dev servers, one per remote in every post since post 2, do not start. Keys, build, serve:

node tools/gen-signing-keys.mjs && node tools/build-cdn.mjs ios
Enter fullscreen mode Exit fullscreen mode
npx http-server@14.1.1 cdn-root -p 8000 -c-1 --cors
Enter fullscreen mode Exit fullscreen mode
( cd apps/host && MF_CDN_BASE=http://localhost:8000 npm start )
Enter fullscreen mode Exit fullscreen mode
( cd apps/host && npm run ios )
Enter fullscreen mode Exit fullscreen mode

The release builds use the same tree and carry the URL on the build command, since that is where it is read:

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

Nothing in this post touches the code the suites cover, and they say so:

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

Add two PokΓ©mon, open the Party tab, run a Quick Battle. Everything post 13 built still works, and every request that made it work is a line in the server's log.

What you built, and what's next

Two remotes built for production, each chunk carrying a signature, laid out as the directory a CDN serves; a host that moves between the dev servers and that directory on one environment variable, read at build time; and a release build on each platform that boots from it with no dev server running. The app looks the same as it did in post 13. The terminals do not.

The limits, each with its owner. The CDN holds one version of each remote, so a new build replaces the old one for every installed binary at once, whether or not that binary can run it; post 15 adds versioned directories, a version map and a resolver that chooses at each launch. Nothing verifies the signatures; that resolver does, against a public key embedded beside it. An app that cannot reach the CDN has nothing to fall back to; post 16 puts a copy of each remote in the binary. And the map that will decide which version an app loads is itself unsigned until post 17.

Next: versioned remotes on the CDN, a version map, a per-launch resolver, and an old binary that never downloads code it cannot run.

Sources

Top comments (0)