Node.js / SSR
Run the orchestrator's full pipeline server-side.
@softarc/native-federation-orchestrator/node is a
drop-in Node entry that resolves federated modules through
module.register() instead of an HTML
<script type="importmap"> tag.
The /node subpath gives you the same orchestrator
runtime — version resolver, SRI verification, shared-scope handling,
dynamic-init flow — running in a Node process. Use it for SSR, edge
rendering, integration tests, server-side prerendering, or any other
long-lived Node runtime that needs to import federated modules.
It supersedes the deprecated
@softarc/native-federation-node
package: the orchestrator's version resolver, integrity verification,
shared-scope handling, and dynamic-init flow are now all available on
the server side. See
Migrating from @softarc/native-federation-node
below.
Prerequisites
-
Node.js ≥ 20.6.0. That is the floor for stable
module.register(), which the loader uses. The package declares this inengines.node. -
The host application is run as an ES module
(
"type": "module"in yourpackage.jsonor a.mjsentry file). -
One or more remotes that publish a
remoteEntry.json— either reachable over http(s) or available on the local filesystem.
Quick start
import { initNodeFederation } from '@softarc/native-federation-orchestrator/node';
await initNodeFederation('./dist/browser/federation.manifest.json', {
hostRemoteEntry: './dist/browser/remoteEntry.json',
});
// All subsequent imports go through the federation import map.
await import('./server.mjs');
That's the whole integration. The first call:
- Loads the manifest (from disk, http, or an inline object).
- Loads each remote's
remoteEntry.json(from disk or http). - Builds a W3C-spec import map exactly as the browser orchestrator does — running the same version resolver, integrity checks, and shared-scope logic.
-
Installs a Node loader hook (
module.register) and hands it the resolved import map over aMessageChannel. - Resolves once the loader has acknowledged the map (10-second acknowledgement timeout, after which the call rejects).
After that, any bare specifier matching the import map — whether
produced by loadRemoteModule(...) or written directly as
import 'team/remote-a/./Hello' — is rewritten by the
loader and fetched from disk or over http as needed.
Manifest and remote sources
Both the manifest and individual remoteEntry.json URLs
can be:
-
A plain http(s) URL — fetched via the global
fetch. - A
file://URL — read viafs/promises. -
A bare filesystem path (absolute or relative to
process.cwd()) — read viafs/promises. The orchestrator converts bare paths tofile://URLs viapathToFileURLbefore handing them to the resolver.
You can also pass the manifest as a JavaScript object to skip the load step entirely:
await initNodeFederation(
{
'team/remote-a': 'https://cdn.example.com/remote-a/remoteEntry.json',
'team/remote-b': './dist/browser/remote-b/remoteEntry.json',
},
{
hostRemoteEntry: 'file:///app/dist/browser/remoteEntry.json',
}
);
Each manifest entry can also be pinned against an SRI hash for integrity, the same way as in the browser:
await initNodeFederation(
{
'team/remote-a': {
url: 'https://cdn.example.com/remote-a/remoteEntry.json',
integrity: 'sha384-…',
},
},
{
hostRemoteEntry: {
url: './dist/browser/remoteEntry.json',
integrity: 'sha384-…',
},
manifestIntegrity: 'sha384-…',
}
);
See Security — Subresource Integrity for the full picture.
API
import {
initNodeFederation,
type InitNodeFederationOptions,
} from '@softarc/native-federation-orchestrator/node';
import type {
NativeFederationResult,
FederationManifest,
} from '@softarc/native-federation-orchestrator';
declare function initNodeFederation(
remotesOrManifestUrl: string | FederationManifest,
options?: InitNodeFederationOptions
): Promise<NativeFederationResult>;
InitNodeFederationOptions extends the browser
NFOptions shape (see the
Configuration Guide for the full set)
with one server-only field:
export type InitNodeFederationOptions = NFOptions & {
hostInstances?: HostInstancesOption;
};
hostInstances bridges the host's shared singletons into
the loader so a remote's import '@angular/core' resolves
to the host's already-loaded instance — see
Bridging host singletons. The Node entry
also pre-wires sensible server-side defaults:
| Concern | Default on Node |
|---|---|
setImportMapFn |
Posts the map to the loader thread over a
MessageChannel — no DOM mutation.
|
loadModuleFn |
(url) => import(url) — Node's native dynamic
import.
|
reloadBrowserFn |
No-op. |
| Storage |
In-memory (globalThisStorageEntry) — the SSR
process is long-lived; no disk needed.
|
| SSE (build watching) | Disabled and stubbed out — HMR is a browser concern. |
| Manifest provider |
fs-aware (file:// or bare path), falls back to
fetch for http(s).
|
| Remote-entry provider |
fs-aware, same fallback to fetch.
|
Anything in options overrides the default. The returned
NativeFederationResult is identical to the browser one —
loadRemoteModule, load,
initRemoteEntry, as<T>(),
config, and adapters.
How the loader works
initNodeFederation registers a customization hook (an
ESM
loader) that runs in its own worker thread. The main thread and the
loader thread share a MessageChannel:
┌──────────────────────────┐ ┌──────────────────────────┐
│ main thread │ set-import-map │ loader thread │
│ │ set-host-instances │ │
│ │ ────────────────────▶ │ │
│ initNodeFederation() │ │ resolve / load hooks │
│ builds the import map │ ◀──────────────────── │ rewrite specifiers │
│ │ *-applied │ │
└──────────────────────────┘ └──────────────────────────┘
The loader hosts a W3C-compatible import-map resolve algorithm. For
every import(...) call from user code, the
resolve hook:
-
If the specifier is a bridged host instance (see
below), short-circuits it to a
synthetic
nf-host:<specifier>URL — these win over the import map. -
Otherwise walks
scopesmatching the parent URL and falls back to top-levelimports, matching by exact specifier first then by trailing-slash prefix. - Passes the rewritten URL on to the default resolver.
The load hook then handles three cases:
-
nf-host:<spec>→ synthesizes a tiny ES module re-exporting from the host's instance (see below). -
http(s)://…→ short-circuits the default loader and fetches the source over the wire. -
file:///node:/ everything else → falls through normally.
Both the import map and the host-instance set are pushed to the loader
thread over the MessageChannel
(set-import-map → import-map-applied,
set-host-instances → host-instances-applied).
The import map can be updated at any time after the initial install —
e.g. when you add a new remote via
initRemoteEntry(...) — and the loader picks up the new map
on the next resolution. Updates are serialized: each post waits for the
previous one to be acknowledged before posting.
The acknowledgement round-trip has a 10-second timeout
(NODE_LOADER_CLIENT_ACK_TIMEOUT_MS). If the loader
thread fails to ack — e.g. because it crashed or was forcibly
unregistered — initNodeFederation rejects with an
explicit node-loader.client error.
Bridging host singletons (hostInstances)
In the browser, the import map alone guarantees one instance of each
shared singleton. On the server that isn't enough: a remote's
secondary entry point — e.g.
@angular/core/rxjs-interop — can resolve to a private
build-internal chunk instead of the shared @angular/core,
giving you a second core instance and the dreaded
NG0203 ("inject() must be called from an injection
context"). hostInstances closes that gap by routing the
remote's imports to the host's already-loaded instances.
Set it in options.hostInstances:
| Form | Meaning |
|---|---|
'all' |
Every singleton in the host remote entry. Reads
hostRemoteEntry, takes each shared dep marked
singleton, and await import(...)s it
in the orchestrator's realm. Used by the Angular adapter's prod
preload.
|
{ include?, exclude?, load? } |
Same auto-derivation, optionally filtered, with a custom
load. Dev passes
{ load: (s) => import(s) } so capture happens
through the host realm (e.g. Vite's SSR graph), not
the orchestrator's.
|
| explicit map |
A Record<specifier, namespace> you supply
directly.
|
What initNodeFederation does when
hostInstances is set (before the main init flow):
-
resolveHostInstances(...)produces a map ofspecifier → module namespace— for'all'/auto it imports each singleton; for an explicit map it's used as-is. -
Those namespaces are published on
globalThis.__NF_HOST_INSTANCES__. -
The export key names per specifier are pushed to the loader
hook via
setHostInstances(over the sameMessageChannel, acknowledged withhost-instances-applied).
From then on the loader bridges any matching import:
remote SSR code: import { inject } from '@angular/core'
│
▼ resolve(): '@angular/core' is a host instance → nf-host:%40angular%2Fcore (shortCircuit)
▼ load(): synthesize export const inject = globalThis.__NF_HOST_INSTANCES__['@angular/core'].inject
▼
resolves to the host's already-loaded @angular/core ✔ single instance
The synthesized module re-exports every captured key (named exports
plus default), so a remote's
import '@angular/core' returns the same object
the host loaded — one core, no duplication. Failed imports during
auto-capture are logged and skipped rather than aborting init.
Angular SSR. You rarely call this directly — the
adapter's node-preload passes
hostInstances: 'all' for you. See
Angular Adapter → SSR &
Hydration. Outside Angular, treat hostInstances as
an escape hatch for frameworks with the same secondary-entry-point
hazard.
What is not on the Node entry
These are deliberately omitted because they make no sense server-side:
-
SSE (
buildNotificationsEndpoint). The hook is wired to a no-op; settingsse: trueon the Node entry has no effect. -
localStorageEntry/sessionStorageEntry. Storage is always in-memory. - Trusted Types. Node has no Trusted Types — the import-map config simply skips that pipeline.
-
es-module-shims/useShimImportMap. No need; Node loader hooks resolve everything.
If you pass a custom setImportMapFn or
loadModuleFn you take over from the defaults and the
Node loader client will not be exercised.
Migrating from @softarc/native-federation-node
The deprecated package can be replaced one-for-one.
- import { initNodeFederation } from '@softarc/native-federation-node';
+ import { initNodeFederation } from '@softarc/native-federation-orchestrator/node';
await initNodeFederation({
- remotesOrManifestUrl: './dist/browser/federation.manifest.json',
- relBundlePath: '../browser',
+ });
+
+ await initNodeFederation('./dist/browser/federation.manifest.json', {
+ hostRemoteEntry: './dist/browser/remoteEntry.json',
+ });
Notable differences:
-
The signature is
initNodeFederation(manifest, options)to match the browserinitFederation, rather than a single options bag. -
relBundlePathis gone. PointhostRemoteEntrydirectly at your host'sremoteEntry.json(path,file://, or http URL). -
The package no longer writes
node.importmaporfederation-resolver.mjsto your cwd. Everything is handed to the loader in-memory viaMessageChannel. - Version resolution, shared scopes, integrity, and dynamic remote registration now work on the server exactly as they do in the browser — none of these existed in the old package.
The old nfstart CLI is not ported. If you previously
relied on it, replace it with a three-line entry script — see the
Quick start above.
Example: SSR bootstrap
// server.entry.mjs
import { initNodeFederation } from '@softarc/native-federation-orchestrator/node';
import { resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
const BROWSER = resolve(process.cwd(), 'dist/browser');
await initNodeFederation(
pathToFileURL(resolve(BROWSER, 'federation.manifest.json')).href,
{
hostRemoteEntry: pathToFileURL(resolve(BROWSER, 'remoteEntry.json')).href,
logLevel: 'warn',
}
);
// Now bring up the actual server — any of its imports that resolve to a
// federated module go through the loader.
await import('./server.mjs');
Example: integration tests
import { initNodeFederation } from '@softarc/native-federation-orchestrator/node';
const { loadRemoteModule } = await initNodeFederation(
{ 'team/remote-a': './dist/browser/remote-a/remoteEntry.json' },
{ hostRemoteEntry: './dist/browser/remoteEntry.json' }
);
const { greet } = await loadRemoteModule('team/remote-a', './Hello');
expect(greet('world')).toBe('hello, world!');
See also
-
Angular Adapter — SSR & Hydration
— the
node-preloadlaunch model that wires this entry into an Angular SSR app. - The orchestrator node docs — the upstream version of this page.
-
Configuration — the full
initFederationoptions surface; the Node entry accepts the same shape. -
Security — Subresource Integrity
— pinning manifest,
remoteEntry.jsonand modules; works the same way on Node as in the browser. -
Version Resolver — Dynamic init
— adding remotes at runtime; the Node entry's
initRemoteEntryfollows the same rules.