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 remote initialization (initRemoteEntry) 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 │
│ │ ──────────────────▶ │ │
│ initNodeFederation() │ │ resolve / load hooks │
│ builds the import map │ ◀────────────────── │ rewrite specifiers │
│ │ import-map-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
The Node entry is also handy as a way to load federated modules from a test runner without standing up a browser:
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.