DEV Community

Sakthikumaran Navakumar
Sakthikumaran Navakumar

Posted on

Build-Time, End to End - From federation.config to remoteEntry.json

Part 3 of an 8-part series on Native Federation v4 for enterprise architecture teams. New to the series? Start with Part 1: The Mental Model, Revisited.

In Part 2, we mapped the four layers of Native Federation v4 and saw that one file, remoteEntry.json, connects the build to the browser. This article zooms in on the build side and answers three questions:

  1. What do you write in federation.config.mjs, and what does each setting mean?
  2. What happens, step by step, when you run the build, and what files come out?
  3. How does a host find remotes it has never seen when it was built?

Everything here is based on the official docs and the source code of native-federation-core and the Angular adapter, and links point to both so you can check the details yourself.

A few terms first

Part 2 explained host, remote, shared dependency, bundler, import map, semver and singleton. This article adds four more:

  • External: a library that is deliberately left out of your app's bundle, so it can be loaded separately and shared.
  • Exposed module: a piece of your app, such as a component or a set of routes, that other apps are allowed to load.
  • Entry point: the file a build or a code scan starts from. In Angular, that's usually src/main.ts.
  • Manifest: a list of remote names and the URLs of their remoteEntry.json files.

Normal build vs. federated build

In a normal Angular build, your code and every library you use are bundled together. If three apps all use Angular, users download Angular three times.

A federated build splits things up. Your own code is built without the shared libraries inside it. Each shared library gets its own file, so the browser can download it once and reuse it. And the build writes two small description files, remoteEntry.json and importmap.json, so the browser knows what's available.

A normal build produces one main.js with everything inside. A federated build takes your code and federation.config.mjs and produces main.js with your code only, one file per shared library, exposed modules for remotes, and remoteEntry.json plus importmap.json.

The input: federation.config.mjs

Every federated project has a federation.config.mjs next to it. Here is the one the Angular adapter's ng add schematic generates for a remote today, taken from its template in the source code (comments shortened):

import { withNativeFederation, fromPackageJson } from '@angular-architects/native-federation/config';

export default withNativeFederation({
  name: 'mfe1',

  exposes: {
    './Component': './src/app/app.component.ts',
  },

  shared: fromPackageJson({ singleton: true, strictVersion: true, requiredVersion: 'auto', build: 'package' })
    // Share all of @angular/core to prevent version mismatches
    .patch(['@angular/core'], { includeSecondaries: { keepAll: true } }),

  skip: ['rxjs/ajax', 'rxjs/fetch', 'rxjs/testing', 'rxjs/webSocket'],

  features: {
    denseChunking: true,
  },
});
Enter fullscreen mode Exit fullscreen mode

Let's go through it piece by piece.

Choosing what to share

There are three ways to build the shared list, all defined in share-utils.ts:

  • fromPackageJson() is the recommended way. It reads the dependencies in your package.json and shares all of them with the settings you pass in. Exceptions are handled with .skip(), .override() and .patch(). The example above uses .patch() to change the settings of just @angular/core.
  • shareAll() is the older version of the same idea. It still works.
  • share() is for picking libraries by hand, one by one.

If you leave shared out entirely, Native Federation behaves as if you had written fromPackageJson({ singleton: true, strictVersion: true, requiredVersion: 'auto' }) (Core configuration).

The settings on each library

Each shared library carries four settings that matter later, when the browser decides which version to load:

  • singleton: only one copy of this library may exist on the page. Frameworks like Angular need this.
  • strictVersion: if another app needs a version that isn't compatible, don't just use the shared one anyway.
  • requiredVersion: the range of versions this app accepts. 'auto' means "use the range from package.json," for example ^20.1.0.
  • version: the exact version this app was built with. By default it's read from the installed package in node_modules, not from package.json.

Here's how that plays out. Say your package.json lists "@angular/core": "^20.1.0" and npm install gave you 20.1.4. The build records that this app has 20.1.4 and accepts anything from ^20.1.0. The browser later uses both numbers to find one version every app can agree on. Part 5 covers that decision in detail.

Notice that the defaults are strict. Unless you change them, every library is shared as a strict singleton. That's safe, but it's worth knowing before you see your first version warning.

skip: leaving a library out of sharing

skip takes package names, regular expressions or small functions. The important detail: skipping a library does not remove it. It still gets installed and still works, but it's built into your app's own bundle instead of being shared. The template above skips parts of RxJS that most apps never use at runtime.

Some packages are skipped automatically, through a default skip list: Native Federation's own packages, es-module-shims and all @types/* packages.

includeSecondaries and keepAll: the sub-paths

Many libraries have sub-paths, called secondary entry points, like @angular/common/http or @angular/core/rxjs-interop. includeSecondaries controls whether those are shared too.

Setting includeSecondaries: { keepAll: true } goes one step further. It tells the build to keep every sub-path of a library as long as the library itself is used, even sub-paths your app never imports. The next section explains why the template does this for @angular/core.

How the build decides what's "used"

Sharing everything in package.json would be wasteful. Most projects list libraries they barely touch. So Native Federation has a feature called ignoreUnusedDeps, which is on by default (with-native-federation.ts). It scans your code, finds which shared libraries are actually imported, and drops the rest from the shared list.

The interesting question is where the scan starts. The answer depends on whether the project is a host or a remote, and it's easy to get wrong.

Flow showing how ignoreUnusedDeps decides what is used. If the project exposes modules, only the exposed files are scanned. If not, the scan starts at main.ts, follows import('./bootstrap'), then the app config, routes and lazy-loaded components. Then libraries imported by those libraries are added, sub-paths are kept or dropped depending on keepAll, and everything else is removed.

If the project exposes modules (a remote), the scan starts from the exposed files only. It does not look at main.ts or bootstrap.ts at all. You can see this in get-used-dependencies.ts: it takes the files listed in exposes, and only uses the fallback entry points when that list is empty. The Angular builder's entryPoints option confirms this. Its description says exposed modules "always take precedence," so you can't use it to override or narrow them.

If the project exposes nothing (a typical host), the scan starts at src/main.ts. In a federated Angular app, main.ts is tiny. It starts federation, then loads the real app with a dynamic import:

initFederation('federation.manifest.json', { hostRemoteEntry: { url: './remoteEntry.json' } })
  .catch(err => console.error(err))
  .then(_ => import('./bootstrap'))
  .catch(err => console.error(err));
Enter fullscreen mode Exit fullscreen mode

The scanner uses TypeScript's import detection, which sees dynamic import() calls as well as normal imports. So it follows import('./bootstrap') into bootstrap.ts, then into your app config, your routes, and the components those routes lazy-load. Anything reachable from there counts as used.

Then the scan adds what your libraries need. If you use @angular/router, the build also counts the packages @angular/router itself imports, such as @angular/common.

Finally, sub-paths are checked. Without keepAll, a sub-path like @angular/core/rxjs-interop is kept only if something imports it exactly. With keepAll, it's kept whenever its parent package is used (remove-unused-deps.ts).

That last rule is why the template sets keepAll on @angular/core. Imagine two remotes on different Angular patch versions, where only one of them imports @angular/core/rxjs-interop. The other remote drops that sub-path from its build. At runtime, the page can end up loading @angular/core from one version and rxjs-interop from another, splitting Angular across versions. The Core configuration docs warn about exactly this, and keepAll on framework libraries is the fix.

What this means for remotes: a library used only by a remote's own startup code, in main.ts or bootstrap.ts, isn't shared by that remote. It still works, because it's bundled into the remote instead. But if you expected the host and the remote to share it, check the remote's remoteEntry.json rather than assuming.

What happens when you build

With the config understood, here's what actually runs when you type ng build. The order comes from the Angular adapter's builder documentation and its source.

The build pipeline: 1. read federation.config.mjs and work out the shared list, 2. find which shared libraries are actually used, 3. reuse each shared library from the cache or build it as its own file, 4. build the exposed modules, 5. write remoteEntry.json and importmap.json, 6. Angular's ApplicationBuilder builds your app with shared libraries left out, producing dist/your-app/browser.

  1. Read the config. withNativeFederation() fills in defaults, applies the skip list and works out the shared list.
  2. Find what's used. The ignoreUnusedDeps scan from the previous section trims the shared list.
  3. Build the shared libraries. Each one becomes its own file. Built libraries are cached in node_modules/.cache/native-federation/<project>, so the next build can reuse them. The cache refreshes itself when installed versions or sharing settings change (Core caching).
  4. Build the exposed modules.
  5. Write remoteEntry.json and importmap.json.
  6. Hand off to Angular. The adapter is a thin wrapper around Angular's own ApplicationBuilder. Angular builds your app as usual, with the shared libraries marked as externals so they stay out of your bundle.

Two practical notes. If the Angular build fails, no federation files are written at all, so a failing build can never ship a half-updated remoteEntry.json. And if a build ever looks stale, deleting node_modules/.cache/native-federation forces a clean rebuild of the shared libraries.

The output: remoteEntry.json and importmap.json

remoteEntry.json: what this app offers and needs

This is the file other apps read. Its shape is defined in federation-info.contract.ts and written by write-federation-info.ts. A simplified example (real file names include content hashes and will differ):

{
  "$version": "v4",
  "name": "mfe1",
  "exposes": [
    { "key": "./Component", "outFileName": "Component-HASH.js" }
  ],
  "shared": [
    {
      "packageName": "@angular/core",
      "outFileName": "angular-core-HASH.js",
      "version": "20.1.4",
      "requiredVersion": "^20.1.0",
      "singleton": true,
      "strictVersion": true
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

Every setting from the config shows up here. version is what this app was built with, and requiredVersion is what it accepts. singleton and strictVersion tell the browser how strict to be. The $version: "v4" marker tells runtimes which manifest format they're reading. With denseChunking turned on, there's also a chunks section listing the split code files.

importmap.json: this app's own view

The build also writes an importmap.json (write-import-map.ts). It maps each shared library to the file that holds it:

{
  "imports": {
    "@angular/core": "angular-core-HASH.js",
    "rxjs": "rxjs-HASH.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

The mental model docs describe it as "this project's view of where its externals live." The key word is this project's. It only knows about one app. The import map that actually runs in the browser is built later, by the runtime, from the remoteEntry.json files of the host and every remote together.

Static and dynamic remotes

This is the part that surprises people coming from webpack Module Federation: a Native Federation host never looks at its remotes during the build. It doesn't fetch them, check them, or link against them. The only question is where the host finds the remote URLs at runtime, and the ng add schematic gives you two choices through --type (Angular adapter getting started).

Where the host finds remote URLs. At build time the host build never fetches or checks remotes, and each remote build publishes its own remoteEntry.json. With type host, URLs are written in main.ts and changing them means rebuilding the host. With dynamic-host, URLs live in federation.manifest.json and changing them means editing one file. Remotes can also be added later at runtime with initRemoteEntry, for example plugins. In the browser, the runtime fetches each remoteEntry.json and builds the final import map.

Static remotes (--type host). The remote URLs are written straight into main.ts as an object. The schematic source generates something like this:

initFederation({
  'mfe1': 'http://localhost:4201/remoteEntry.json'
}, { hostRemoteEntry: { url: './remoteEntry.json' } })
Enter fullscreen mode Exit fullscreen mode

It's simple, but the URLs are now part of your host's code. Moving a remote to a new URL, or deploying the same host to test and production, means rebuilding the host.

Dynamic remotes (--type dynamic-host, the recommended default). The URLs live in a federation.manifest.json in your public/ folder:

{
  "mfe1": "http://localhost:4201/remoteEntry.json"
}
Enter fullscreen mode Exit fullscreen mode

main.ts just passes the file name, initFederation('federation.manifest.json', ...), and the manifest is fetched when the app starts. The same host build can run in every environment. Only the manifest changes, and it can even be generated by your deployment pipeline.

Remotes discovered later. Sometimes nobody knows the full list of remotes up front, as in a plugin system where customers enable features. The Orchestrator handles this with initRemoteEntry(), which adds a remote after the app has started (Orchestrator getting started). It only ever adds to the import map and never changes decisions already made.

Two more details from that generated main.ts are worth noticing:

  • The host publishes its own remoteEntry.json. Every generated main.ts passes hostRemoteEntry: { url: './remoteEntry.json' }. That's the host's own build output, and at runtime the host's versions win whenever a library appears in both the host and a remote (Orchestrator configuration).
  • A remote can run on its own. A remote's main.ts calls initFederation({}, ...) with an empty list. It acts as its own host, which is why you can open a remote in the browser and work on it alone.

Where version resolution fits

Everything in this article happens at build time, and the build never chooses a version. It only records them: which version each app has, which range it accepts, and how strict it wants to be. The choosing happens in the browser, when the runtime reads all the remoteEntry.json files together. Part 4 looks at how the Classic Runtime made that choice, and why it often ended up loading two copies of Angular. Part 5 shows how the Orchestrator does it properly.

Key takeaways

  • federation.config.mjs decides what is shared and how strictly. By default, everything is a strict singleton.
  • skip doesn't remove a library. It just stops it from being shared.
  • With ignoreUnusedDeps (on by default), a host is scanned from main.ts through bootstrap.ts, while a remote is scanned from its exposed modules only.
  • Use includeSecondaries: { keepAll: true } on framework libraries, so sub-paths can't split a framework across versions.
  • The build writes remoteEntry.json (what this app offers and needs) and importmap.json (this app's own view).
  • A host never looks at remotes during the build. Prefer dynamic-host so one build can serve every environment.

Series roadmap

  1. The Mental Model, Revisited: why Native Federation exists and what changed in v4
  2. Anatomy of the v4 Package Graph: Core, Adapters, Runtime and Orchestrator as separate layers
  3. Build-Time, End to End: from federation.config to remoteEntry.json (this article)
  4. The Classic Runtime: what it got right, and where it hits its limit
  5. The Orchestrator: how version resolution and caching work, in depth
  6. Version Drift and Resolution Strategy: keeping many independently deployed apps in sync
  7. v3 vs. v4: a detailed comparison and a practical migration plan
  8. Reference Architecture: running Native Federation v4 in a regulated enterprise

Further reading

Official documentation

  1. Mental model
  2. Architecture overview
  3. Core: getting started
  4. Core: configuration
  5. Core: caching
  6. Angular adapter: getting started
  7. Angular adapter: builder
  8. Orchestrator: getting started
  9. Orchestrator: configuration

Source code

  1. native-federation-core: see src/lib/config for the config helpers and the unused-dependency scan, and src/lib/core/output for the files the build writes
  2. angular-adapter: see src/builders/build for the builder and src/schematics/init for what ng add generates

Articles by the maintainers

  1. Auke van Oostenbrugge, Tweaking native-federation, Part II: Sharing externals
  2. Auke van Oostenbrugge, Part II: Composing your own native-federation orchestrator

Next in this series: **The Classic Runtime, what it got right, and where it hits its limit.

Top comments (0)