Event Registry

Applies to

An in-page event bus exposed as window.__NF_REGISTRY__. Resolves the orchestrator's ready promise retroactively and gives micro frontends a typed channel for cross-MFE communication.

The orchestrator ships a small event bus that lives on window.__NF_REGISTRY__. It is what init-registry.mjs boots up before the orchestrator runtime arrives, and it covers two jobs:

  1. Resolving init promises retroactively. A subscriber that arrives after the orchestrator has finished initializing still receives orch.init-ready — no race condition window like plain window.addEventListener events.
  2. Robust cross-MFE communication. Micro frontends can publish and subscribe to typed streams without holding direct references to each other.

Opt-in feature — it does not exist until you load it.
window.__NF_REGISTRY__ is only created when the registry has been initialized. For a quickstart host that means adding the init-registry.mjs script (see Installation below) before any consumer code runs. For a custom orchestrator host it means calling createRegistry({ ... }) from @softarc/native-federation-orchestrator/registry and assigning the result to window.__NF_REGISTRY__ yourself. If you skip both, the quickstart silently falls back to the legacy mfe-loader-available custom event and consumer code that calls window.__NF_REGISTRY__.onReady(...) will throw Cannot read properties of undefined.

The registry distinguishes two concepts:

Installation

There are two supported ways to make window.__NF_REGISTRY__ available; pick the one that matches how you ship the orchestrator.

Option A — quickstart hosts: load init-registry.mjs

Include init-registry.mjs before the rest of your scripts (including the orchestrator's quickstart.mjs and any consumer code). Optionally tune the bus via data-* attributes on the script tag:

<script
  src="https://unpkg.com/@softarc/native-federation-orchestrator@4.2.1/init-registry.mjs"
  data-max-streams="50"
  data-max-events="50"
  data-remove-percentage="50"
></script>

After this script runs, window.__NF_REGISTRY__ is a frozen NFEventRegistry instance. The orchestrator's quickstart bundle detects it at the end of init and publishes orch.init-ready on it (see What the orchestrator publishes).

Option B — custom orchestrator hosts: create it yourself

If you bundle your own orchestrator script (calling initFederation directly), init-registry.mjs is the wrong shape — you don't load random unpkg scripts in a bundled host. Instantiate the registry from the /registry subpath and assign it to window yourself, before initFederation resolves:

import { createRegistry } from '@softarc/native-federation-orchestrator/registry';
import { initFederation } from '@softarc/native-federation-orchestrator';

// Same options as init-registry.mjs accepts via data-* attributes.
// removePercentage is a fraction here (0.5), not a percent (50).
window.__NF_REGISTRY__ = Object.freeze(
  createRegistry({ maxEvents: 50, maxStreams: 50, removePercentage: 0.5 })()
);

initFederation(manifest, options).then(loaders => {
  window.__NF_REGISTRY__.register('orch.init-ready', { ...loaders });
});

If neither option runs, the registry doesn't exist. window.__NF_REGISTRY__ is undefined and the orchestrator silently falls back to the legacy mfe-loader-available CustomEvent. Consumer code that calls window.__NF_REGISTRY__.onReady(...) will throw — guard with window.__NF_REGISTRY__?.onReady(...) if both wirings must work side by side.

Configuration

Attribute Default (no attribute set) Effect
data-max-streams unbounded (no LRU eviction) Maximum number of distinct stream types the registry will retain. When exceeded, the least-recently-emitted stream (and its history) is evicted.
data-max-events 1 (latest-only) Per-stream history depth. When exceeded, the oldest events are dropped — see Trimming below.
data-remove-percentage 1 event removed per trim When trimming kicks in, this percentage of maxEvents is evicted in one batch (so subsequent emits don't slice on every call). Expressed in %.

Without data-max-events, the registry only keeps the most recent event per stream — fine for state-channels (BehaviorSubject-style) but probably too aggressive for log-style streams. Set data-max-events explicitly when you want history.

If you instantiate the registry yourself via createRegistry({ ... }) rather than the script, the equivalent options are maxStreams, maxEvents, and removePercentage. removePercentage is a fraction here, not a percent — pass 0.5, not 50. The script tag divides the data-attribute value by 100 before forwarding it.

Trimming

The trim policy is "batch eviction." When a stream's history exceeds maxEvents, the registry drops min(maxEvents - 1, ceil(maxEvents * removePercentage)) of the oldest events at once, leaving the most recent maxEvents - removed events. This avoids slicing on every emit at the cost of a brief overshoot.

At least one event is always retained — even with removePercentage: 1.0 or maxEvents: 1, the most recent event stays available for new subscribers.

API

The full type is NFEventRegistry:

type NFEventRegistry = {
  // Resources (one-shot readiness)
  register<T>(type: string, resource: T | (() => Promise<T> | T)): Promise<void>;
  onReady<T>(type: string, callback: (value: T) => void): NFEventUnsubscribe;

  // Event streams
  emit<T>(type: string, data: T): void;
  update<T>(type: string, reducer: (current: T | undefined) => T): void;
  on<T>(
    type: string,
    callback: (event: { data: T; timestamp: number }) => void,
    opts?: { replay?: number }
  ): NFEventUnsubscribe;

  // Maintenance
  clear(type?: string): void;
};

Resources

register(type, resource)

Stores resource under type. If resource is a function, it is invoked (and awaited) and the result is stored. Any consumers waiting via onReady(type, …) are notified.

__NF_REGISTRY__.register('app-config', async () =>
  fetch('/config.json').then(r => r.json())
);

onReady(type, callback)

If type is already registered, callback fires synchronously with the value. Otherwise it is queued and fires the moment register(type, …) runs.

__NF_REGISTRY__.onReady('orch.init-ready', ({ loadRemoteModule }) => {
  loadRemoteModule('team/mfe1', './Button');
});

Returns an unsubscribe function. When type was already registered at subscribe time, the returned function is a no-op (the callback has already fired).

Event streams

emit(type, data)

Publishes an event on type. The event is wrapped with a timestamp, appended to the stream's history, and delivered synchronously to every current subscriber.

__NF_REGISTRY__.emit('cart.changed', { itemCount: 3 });

update(type, reducer)

Publishes a new event whose value is derived from the previous one — useful when the next state depends on the last (counters, accumulating buffers, toggles).

The reducer receives a structured clone of the last value (or undefined if the stream is empty), so it cannot accidentally mutate stored history.

__NF_REGISTRY__.update('cart.itemCount', current => (current ?? 0) + 1);

Values passed through update must be structured-clone-able when the stream is non-empty (no functions, DOM nodes, or class instances).

on(type, callback, opts?)

Subscribes callback to type for every future event. On subscribe, the most recent opts.replay events from history are delivered via a microtask (so the unsubscribe handle is returned synchronously, and the replay fires after the current task completes).

opts.replay Behavior
1 (default) Deliver only the most recent event — matches the "BehaviorSubject" pattern, where a fresh subscriber always sees the current state. Right default for state-channels.
0 Suppress replay entirely. The subscriber only sees events emitted after it subscribes — right for one-shot signals where past history is irrelevant.
N Deliver up to the last N events in chronological order. Capped implicitly by maxEvents. Right for late-joining consumers that need recent context (e.g. a debug panel that wants the last 10 navigation events).

Replay payloads are structured-cloned, so mutating them in the callback never affects what later subscribers will see.

// Default: get the latest cart state immediately, then every future change.
__NF_REGISTRY__.on('cart.changed', ({ data, timestamp }) => render(data));

// Subscribe without backfill.
__NF_REGISTRY__.on('user.clicked', ({ data }) => track(data), { replay: 0 });

// Backfill the last 10 navigation events.
__NF_REGISTRY__.on('nav.route', ({ data }) => log(data), { replay: 10 });

Returns an unsubscribe function that removes the listener.

Maintenance

clear(type?)

With a type, drops the stream's history, listeners, registered resource, and pending onReady callbacks for that key. Without a type, wipes the entire registry. Useful in tests; rarely needed in production.

What the orchestrator publishes

When the orchestrator's quickstart bundle finishes initializing, it calls register('orch.init-ready', { … }) with the entire NativeFederationResult payload — loadRemoteModule, load, as, config, adapters, initRemoteEntry. Subscribers can destructure whichever pieces they need:

__NF_REGISTRY__.onReady('orch.init-ready', ({ loadRemoteModule, initRemoteEntry }) => {
  loadRemoteModule('team/mfe1', './Button');
});

For a fully custom orchestrator script (you call initFederation yourself), wire the registry the same way the quickstart does:

import { initFederation } from '@softarc/native-federation-orchestrator';

initFederation(manifest, options).then(loaders => {
  window.__NF_REGISTRY__?.register('orch.init-ready', { ...loaders });
});

Patterns

State channel (BehaviorSubject-style)

// Producer
__NF_REGISTRY__.update('cart.itemCount', n => (n ?? 0) + 1);

// Consumer — gets the current count on subscribe + every change after
__NF_REGISTRY__.on('cart.itemCount', ({ data }) => {
  document.querySelector('#cart-badge').textContent = data;
});

Event log (multiple subscribers, no backfill)

// Producer
__NF_REGISTRY__.emit('analytics.click', { id: 'cta-1' });

// Consumer — only sees events after subscribe
__NF_REGISTRY__.on('analytics.click', ({ data }) => track(data), { replay: 0 });

Late-joining diagnostic (bounded backfill)

__NF_REGISTRY__.on(
  'orch.module-loaded',
  ({ data, timestamp }) => debugPanel.append({ ...data, at: timestamp }),
  { replay: 50 }
);

See also