The Import Map
Everything the runtime does — resolving shared dependencies, loading
exposed modules, keeping two remotes' rxjs apart — is
implemented as entries in a single browser import map. This page walks
through how that map is built, how it ends up in the DOM, and what the
scoping rules actually mean at runtime.
The shape of the map
The runtime's import map follows the standard browser shape:
type Imports = Record<string, string>;
type Scopes = Record<string, Imports>;
interface ImportMap {
imports: Imports;
scopes: Scopes;
}
imports applies to bare specifiers anywhere on the page.
scopes applies only when the importing module's URL
starts with the scope key. That distinction is what lets two remotes
bring their own rxjs without stepping on each other.
A typical merged map for a host + two remotes looks roughly like this:
{
"imports": {
"@angular/core": "./angular-core-A9B2.js",
"rxjs": "./rxjs-1123.js",
"mfe1/Component": "http://localhost:3001/Component-QX77.js",
"mfe2/Orders": "http://localhost:3002/Orders-P4A9.js"
},
"scopes": {
"http://localhost:3001/": {
"@angular/core": "./angular-core-A9B2.js",
"rxjs": "http://localhost:3001/rxjs-1123.js"
},
"http://localhost:3002/": {
"@angular/core": "./angular-core-A9B2.js",
"rxjs": "http://localhost:3002/rxjs-F802.js"
}
}
}
Host contributions — root imports
The host's remoteEntry.json is fetched from
./remoteEntry.json (same origin as the host page). Every
shared[] entry becomes a root-level import, keyed by
package name and pointing at a relative path under the host's bundle
directory (default ./):
// From processHostInfo
imports[shared.packageName] = relBundlesPath + shared.outFileName;
Host shared deps go into root imports rather than a scope because the host loads first and should be the shared-dep baseline for remotes that can reuse its versions. The externals registry (below) is what makes that reuse possible.
Remote contributions — root imports & scopes
Every remote contributes two things:
-
Exposed modules go into root imports, keyed by
<remoteName>/<exposedKey>(for examplemfe1/Component). That is what makesloadRemoteModule('mfe1', './Component')resolve to an absolute URL on the remote's origin. -
Shared deps go into a scope keyed
by the remote's base URL (the directory of its
remoteEntry.json) with a trailing slash. That scope contains one entry per shared package, pointing at the remote's own bundled copy — unless the externals registry hands back a URL that was already registered (typically the host's).
// From processRemoteImports
for (const shared of remoteInfo.shared) {
const outFileName = getExternalUrl(shared) ?? joinPaths(baseUrl, shared.outFileName);
setExternalUrl(shared, outFileName);
scopedImports[shared.packageName] = outFileName;
}
scopes[baseUrl + '/'] = scopedImports;
Because the scope key is the remote's base URL, browser import-map
matching ensures that code loaded from that remote resolves
@angular/core against the scoped entry, while code loaded
from the host uses the root-level entry. Both entries point at URLs —
the same one when the externals registry reused an earlier
registration.
Chunks
If the build emitted a chunks map in
remoteEntry.json (shared code split across multiple
files), each chunk file is also added to the map. Host chunks go into
root imports; remote chunks go into the remote's scope. The key is
derived from the chunk's filename via the core's
toChunkImport helper, so the runtime does not have to
invent names itself.
The externals registry
The runtime maintains a small in-memory registry — a
Map<string, string> keyed by
<packageName>@<version>:
// From externals.ts
function getExternalKey(shared: SharedInfo) {
return `${shared.packageName}@${shared.version}`;
}
When a shared dep is registered, the runtime looks up its
packageName@version in the registry. If a URL is already
there, it reuses that URL; if not, it stores the current remote's URL.
That means the first remote to register a given
packageName@version wins: any subsequent remote with the
same packageName@version gets pointed at the first
remote's copy instead of shipping its own.
This is how the classic runtime deduplicates shared deps:
exact version match on packageName@version. If the versions differ at all — even by a patch — every remote
keeps its own copy under its own scope.
The classic runtime has no semver-range resolution.
It does not look at requiredVersion, it does not
compare ranges, it does not pick a common version — the
version string must be byte-identical for two
remotes to share a bundle. Range-aware version selection is what the
Orchestrator adds on top.
The registry lives on
globalThis.__NATIVE_FEDERATION__.externals along with the
remotes registry (remoteNamesToRemote,
baseUrlToRemoteNames). That puts it in the same object
that @angular-architects/module-federation historically
exposed, which matters for the getShared() interop helper
used when mixing with Module Federation remotes.
Injection into the DOM
Once initFederation has merged the host + remote maps, it
injects the result as a script tag at the end of
document.head:
document.head.appendChild(
Object.assign(document.createElement('script'), {
type: 'importmap-shim',
textContent: JSON.stringify(importMap),
}),
);
A second call (for example, when loadRemoteModule lazily
registers a new remote) appends another
importmap-shim script. The es-module-shims polyfill
merges them on the fly, so later entries add to rather than replace
earlier ones.
Trusted Types
If the page enforces
Trusted Types, assigning a plain string to a script tag's type or
textContent would be blocked. The runtime creates a
Trusted Types policy named native-federation the first
time it needs one, and pipes both values through
policy.createScript(...) before assigning them. If the
browser has no Trusted Types, or the policy cannot be created (for
example because the name is already registered), the runtime silently
falls back to the raw string.
If your CSP restricts trusted-types, allow the
native-federation policy name.
es-module-shims
The runtime uses the shimmed flavour of the spec
(type="importmap-shim",
globalThis.importShim()) rather than the native one for
two reasons:
-
Multiple maps. Browsers only accept
one native
<script type="importmap">per page, and it must appear before the first module import. The runtime needs to append additional maps as remotes are lazily registered after the page has already started running — which is exactly what es-module-shims allows. - Broader compatibility. Native import maps are well supported today, but the shimmed path gives the same behaviour across the long tail of engines the Angular/React ecosystem still targets.
The practical consequence is that
every host page must load es-module-shims
before initFederation runs. The native
import() fallback inside
loadRemoteModule exists for tests and SSR — it will
happily resolve absolute URLs, but it won't honour the shimmed import
map, so remote-originated bare specifiers will not resolve.
Related
-
remoteEntry.jsonreference — the shape the runtime reads. -
initFederation— what actually drives the merge and injection. - Orchestrator — for range-based deduplication and share scopes.