Node.js / SSR

Applies to

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

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:

  1. Loads the manifest (from disk, http, or an inline object).
  2. Loads each remote's remoteEntry.json (from disk or http).
  3. Builds a W3C-spec import map exactly as the browser orchestrator does — running the same version resolver, integrity checks, and shared-scope logic.
  4. Installs a Node loader hook (module.register) and hands it the resolved import map over a MessageChannel.
  5. 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:

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:

  1. 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.
  2. Otherwise walks scopes matching the parent URL and falls back to top-level imports, matching by exact specifier first then by trailing-slash prefix.
  3. Passes the rewritten URL on to the default resolver.

The load hook then handles three cases:

Both the import map and the host-instance set are pushed to the loader thread over the MessageChannel (set-import-mapimport-map-applied, set-host-instanceshost-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):

  1. resolveHostInstances(...) produces a map of specifier → module namespace — for 'all'/auto it imports each singleton; for an explicit map it's used as-is.
  2. Those namespaces are published on globalThis.__NF_HOST_INSTANCES__.
  3. The export key names per specifier are pushed to the loader hook via setHostInstances (over the same MessageChannel, acknowledged with host-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:

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 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