Build Your Own Adapter
How to build a custom Native Federation adapter for a new bundler — implement NFBuildAdapter, plug into the file watcher and rebuild queue, and expose a high-level builder.
Native Federation Core doesn't ship a bundler. Every adapter — esbuild, Angular, Vite — is a thin shim that implements a three-method contract (NFBuildAdapter) and hands its bundler's emitted files back to the core. If you want to support Rspack, Rolldown, swc, or anything else, you write one of those shims — and optionally wrap it in a higher-level builder that plugs into the file watcher and rebuild queue the core already ships.
This page walks through the full surface. If you just want a reference implementation open in another tab, the esbuild adapter source is the smallest complete example, and the corresponding Builder page documents every piece you're about to reimplement.
Before you start
- Read Build Process to understand the lifecycle the core drives (
setup→build→dispose, once per phase). - Read Build Adapters for the contract surface itself.
- Read Build Artifacts so you know exactly which files the core expects you to emit and what it does with them.
- Core doesn't bring a bundler along. If your adapter uses esbuild, add it to your adapter's own
dependencies.
1. The contract
An adapter is any object that implements NFBuildAdapter from @softarc/native-federation/domain:
interface NFBuildAdapter {
readonly externalsCacheKey?: ExternalsCacheKey;
readonly externals?: readonly string[];
setup(name: string, options: NFBuildAdapterOptions): Promise<void>;
build(
name: string,
opts?: { modifiedFiles?: string[]; signal?: AbortSignal }
): Promise<NFBuildAdapterResult[]>;
dispose(name?: string): Promise<void>;
cacheData?(name: string): unknown | Promise<unknown>;
}
interface ExternalsCacheKey {
adapter: string;
options?: Record<string, string | number | boolean>;
}
externalsCacheKey feeds the externals cache checksum. The core's checksum knows its own version, but your adapter produces the bytes, so declare its identity (package name and version, e.g. 'my-adapter@1.2.0') and every option that changes the shared bundles. A change to any of them then rebuilds the cached externals instead of serving output from the old settings. Set it when the adapter is created: the core reads it before the first setup(). Join list-valued options into a string yourself — only you know whether their order matters. The property is optional for now and becomes required in the next major.
externals lists the imports your adapter adds to the output itself — a dev client, a polyfill — so users don't have to list them in their own externals. They become ordinary externals: left as bare imports in every bundle and accepted by the bare-import check. Keep them exact or scoped; one that covers a shared package hides that package's pruned entry points. federationBuilder.init picks them up; if you call normalizeFederationOptions yourself, pass them in its third argument (see §14).
cacheData(name) is for an adapter that needs something from a shared bundle's build — the Angular adapter keeps the packages it inlined, for 3rdpartylicenses.txt. A shared bundle served from the externals cache runs no build, so the core calls cacheData right after build and stores the result with the cache entry. Read it back from federationBuilder.adapterData[bundleName] (or cache.adapterData), on a cache hit too. Keep it small, JSON-serialisable and free of absolute paths — the cache may be restored into another checkout — and change externalsCacheKey when its shape changes. The stored value is frozen.
The core calls these in phases. Each phase has a unique name — use it as the key for a bundler context you keep alive between rebuilds. One persistent bundler context per name is the pattern you want: it's what makes incremental rebuilds cheap and is exactly how the esbuild adapter uses esbuild.context().
2. The phases the core drives
During a full build the core will call your adapter for up to four families of phases:
browser-shared/node-shared— everybuild: 'default'external for that platform bundled in one pass. Called as a matchedsetup→build→disposetriple.browser-<pkg>/node-<pkg>— externals declared withbuild: 'separate'orbuild: 'package', one phase per package group, again as a matched triple.mapping-bundle/mapping-<name>— the sharedtsconfigpaths. Since v4.7 they build apart from the exposed modules: every mapping goes intomapping-bundleunless it setsbuild: 'separate'orbuild: 'package', which gives it amapping-<name>phase of its own.mapping-or-exposed— everyexposesentry.
The source-code phases (the last two families) are special: the core keeps their contexts alive across incremental rebuilds. setup is called once per phase, then build is called again (with modifiedFiles populated) for every subsequent rebuild, and dispose is only called when the whole federation builder shuts down. Key your bundler contexts on name rather than on the literal mapping-or-exposed: a phase is skipped when it has no entry points, and the set of mapping phases follows the config.
options.kind tells you which family you're in: 'shared', 'mapping' or 'exposed'. Shared externals and source-code exposed modules usually need different bundler settings — different resolveExtensions, different plugins (externals are often CommonJS), different platform handling. The esbuild adapter splits this into sourceBuildOptions vs sharedBuildOptions; yours probably wants the same split.
3. NFBuildAdapterOptions
| Field | Type | Notes |
|---|---|---|
entryPoints | EntryPoint[] | { fileName, outName, key? }. Use fileName as the bundler's entry source and outName as the basename of the emitted file — as given, or as <outName>-<hash> when options.hash is set. The core keeps every outName in a bundle unique (./a/Cmp and ./b/Cmp become Cmp and Cmp_2). Keep the order: it decides how shared code is split into chunks. |
external | string[] | Modules the bundler must not inline. A name also covers its subpaths and * is a wildcard, matched against the specifier as written; a match stays as written. A bundler whose externals match exactly has to widen them — matchesExternal from @softarc/native-federation/internal implements the core's rule. |
outdir | string | Absolute target directory for emitted files. For externals it points at that bundle's folder in the cache directory; for the source-code phases it's the project's resolved outputPath. |
kind | 'shared' | 'mapping' | 'exposed' | Which family of phases this is (see §2). |
isMappingOrExposed | boolean | Deprecated — read kind. true for the source-code phases. Removed in v5. |
platform | 'browser' | 'node' | Forwarded to the bundler's platform setting. |
hash | boolean | If true, append a content hash to emitted filenames. The core uses the filename you emit to populate remoteEntry.json. |
dev | boolean | Enable sourcemaps, disable minification, set process.env.NODE_ENV to "development". |
watch | boolean | Informational — the core doesn't drive the watcher for you; it just tells you whether the builder is running in watch mode so you can pick a long-lived context. |
chunks | boolean | Informational. The core decides how to wire chunks into remoteEntry.json; your job is to emit them and return them in NFBuildAdapterResult[]. |
tsConfigPath | string? | Passed through as the user configured it; resolve a relative path against workspaceRoot. Wire into your TypeScript pipeline (esbuild: tsconfig; swc: its config; Vite: forward to its esbuild options). |
mappedPaths | PathToImport | Resolved tsconfig path aliases. Honor these so shared-mapping entries resolve to the same file your app resolves them to. |
optimizedMappings | boolean? | Deprecated and unused; removed in v5. |
cache | FederationCache<TBundlerCache> | Shared cache object. cache.bundlerCache is the only field adapters should touch — see §6. The bundler cache. |
4. What build() must return
Every emitted file — entry, chunk, .map, assets such as an extracted .css — goes into the returned NFBuildAdapterResult[] with an absolute fileName, on every rebuild too, not only the files that changed. Don't try to filter chunks or sourcemaps yourself: the core hashes, scans and lists exactly what you return (federationBuilder.outputFiles), so a file left out goes unchecked.
Write every file flat into outdir. The core knows each file by its base name there, so one written into a subfolder fails the build as missing.
Set entry on an entry's own script to the outName it was built from:
return written.map(fileName => ({ fileName, entry: entryOf(fileName) }));
Without it the core matches entries by file name, cutting the hash at the last - — which can't tell an exposed ./chunk from a bundler's chunk-<hash> chunks.
The esbuild adapter writes files to disk inside build() and returns the paths it wrote. If your bundler already writes to disk, hand the paths back. If it returns in-memory buffers, write them first — the core reads the files back to hash them and to rewrite chunk imports.
The core also renames every chunk after a hash of its content, replacing the hash segment of the name your bundler emitted: a trailing -<hash> of at least 8 characters, as esbuild and Rollup write it (chunk-IXOA6WTM.js). The new hash has the same length, so references keep their byte length and the core shifts the columns of each .map it touches. Emit chunk names in that shape and write a .map next to each file you want source-mapped. A name without a hash segment gets an 8-character hash appended.
If opts.signal aborts:
- Cancel the in-flight bundle — esbuild has
ctx.cancel(); other bundlers vary. - Throw
AbortedErrorfrom@softarc/native-federation/internal. The core checksinstanceof AbortedErrorto distinguish cancellations from real build failures.
5. dispose() semantics
dispose(name)— close a single phase's context. Called at the end of every externals phase.dispose()with no argument — shut everything down. Called fromfederationBuilder.close()or the equivalent in your builder. If your bundler has a daemon (like esbuild), stop it here.
6. The bundler cache
options.cache.bundlerCache is a persistent bag of state the core hands you across rebuilds. The core doesn't look inside it — it's yours. The esbuild adapter uses a Map<string, unknown> where the keys are the absolute paths of every input file esbuild's metafile reported, and the values are null (only the key set matters).
Populate it on every successful build; invalidate entries for modifiedFiles at the start of each rebuild so stale caches don't mask a changed file.
// After a successful build:
for (const input of Object.keys(result.metafile.inputs)) {
bundlerCache.set(input, null);
}
// At the start of the next rebuild:
if (opts.modifiedFiles) {
for (const file of opts.modifiedFiles) bundlerCache.delete(file);
}
The reason this matters is the file watcher — covered next.
7. The file watcher
Native Federation ships a lightweight fs.watch-based watcher in @softarc/native-federation/internal. You don't have to use it — any watcher that produces changed paths works — but it's designed to pair with the bundler cache:
import {
createNfWatcher,
syncNfFileWatcher,
linkedSharedDirs,
type NfFileWatcher,
} from '@softarc/native-federation/internal';
const bundlerCache = new Map<string, unknown>();
const watcher: NfFileWatcher = createNfWatcher({
onChange: path => {
pendingChanges.add(path);
void triggerRebuild();
},
});
// After each build: subscribe to every input file we just compiled.
syncNfFileWatcher(watcher, bundlerCache, linkedSharedDirs(config, options));
syncNfFileWatcher reads the keys from bundlerCache, filters out node_modules, and calls watcher.addPaths() with anything it hasn't seen before. That's why the bundler cache is keyed by input path: one successful build automatically produces the watch set for the next one.
The cache must be keyed by input path. A cache that records its inputs somewhere else yields an empty watch set — and the symptom is silent: the dev server keeps serving stale bundles until you restart it.
What to watch
Two helpers tell you what belongs in the watch set beyond your own compiled inputs:
linkedSharedDirs(config, options)— the directories of shared packages resolved through a symlink (npm link) into a checkout outsidenode_modules. Pass them as the third argument tosyncNfFileWatcher; it registers them for polling, because tools that rewrite theirdistatomically (ng-packagr, for one) replace the inode and defeatfs.watch. This is opt-in: it returns[]unlesswatchLinkedDepsis set on the federation options, and a watching build without it logs which linked packages it is skipping.sharedMappingDirs(config)— the source directory of everysharedMappingsentry point, derived from config alone. Coarser than a build's compiled inputs, but it covers what those can't: files added to a library since the last build. It doesn't follow imports out of the library, so an adapter that can enumerate its build inputs should watch both. How coarse it gets is up to the config — an entry point that isn't a library barrel widens the watch to whatever folder it sits in, and withsharedMappingsunset every tsconfig path counts. Watch these natively, not polled: they're source trees, not thedistoutputlinkedSharedDirsexists for.
Registering the same directory twice is free — one directory means one handle no matter how many tracked files live under it, and a recursive watch supersedes the narrower ones it covers, so each save is still delivered once.
The watcher API:
addPaths(paths, opts?)— start watching files or directories. Directory watches are recursive; pass{ poll: true }for paths that need polling instead of native watching.close()— stop all watches. Always call before process exit.get() / clear() / mutate()— for drivers that want to batch dirty paths manually instead of viaonChange.
The dirty set is filled on every change, including when you pass onChange. A listener-only consumer still has to call clear(), or the set grows for the lifetime of the process.
createNfWatcher takes a few options worth knowing about:
| Option | Default | Effect |
|---|---|---|
onChange | – | Called with each changed path. |
watch | fs.watch | Your own watch implementation, matching WatchPort['watch']. The built-in is dependency-free but sweeps the tree every pollIntervalMs; an event-driven replacement may ignore the poll hint, but then has to survive inode replacement itself — a polled directory supersedes the native watches beneath it, so a missed rename-replace is never re-covered. |
debounceMs | 0 | Coalesce bursts of events before reporting them. |
pollIntervalMs | 300 | Interval for paths registered with { poll: true }. |
dedupeReplays | true | Drop events for files whose content hasn't actually changed. macOS re-delivers "changed" for recently edited files every ~30s; with a few thousand watched sources that replay alone keeps a rebuild loop awake forever. |
replayGraceMs | 2000 | How long after a recorded change an identical-looking event is still let through, so a second save inside one filesystem timestamp tick isn't mistaken for a replay. Raise it on a filesystem with coarse timestamps (NFS, WSL2 drvfs, HFS+) or on a network mount whose clock lags. |
Watch failures are reported: if a directory watch can't be opened — descriptor exhaustion, most often — the first failure logs a warning, because a failed watch means silent staleness rather than a visible error.
8. The rebuild queue
Two file saves in quick succession should produce exactly one successful rebuild — not two races to the same output. The core ships RebuildQueue for that:
import { RebuildQueue, AbortedError } from '@softarc/native-federation/internal';
const rebuildQueue = new RebuildQueue();
async function triggerRebuild(): Promise<void> {
await rebuildQueue.track(async signal => {
try {
await abortableDelay(rebuildDelay, signal);
const files = [...pendingChanges];
pendingChanges.clear();
await rebuildForFederation(config, options, externals, files, signal);
syncNfFileWatcher(watcher, bundlerCache);
return { success: true };
} catch (error) {
if (error instanceof AbortedError) {
return { success: false, cancelled: true };
}
return { success: false };
}
});
}
rebuildQueue.track(fn) aborts any in-flight build before starting a new one and waits for the old build to acknowledge the abort. The signal it hands fn is the same one you forward to build(name, { signal }), which is the same one you listen to inside the adapter to cancel the bundler. End-to-end: a file save while a rebuild is in flight cancels the old rebuild, drains it, and starts a fresh one against the new modifiedFiles set.
rebuildQueue.dispose() aborts everything in flight — call it from your builder's close() method before disposing the adapter.
9. Incremental rebuilds and modifiedFiles
When the core calls build(name, { modifiedFiles }) on an already-setup source-code context, it's telling you: "these input paths changed since the last build — your cached state for them is stale." You must:
- Evict those paths from
options.cache.bundlerCache(as shown in §6). - Run the bundler. Most bundlers with a persistent context (esbuild's
ctx.rebuild(), Rspack'scompiler.watch(), Vite's dev server HMR) will reuse what they have and only redo the affected graph. - Return the full emitted set again. The core reconciles names itself.
The externals phases don't pass modifiedFiles — they run once per federation build. Only the source-code phases are incremental.
10. CommonJS / UMD externals
Real-world externals are frequently CommonJS with dynamic exports tricks (React is the canonical offender). Bundlers that default to ESM will trip over these. The esbuild adapter solves this in two places and you'll likely want to mirror both:
- A CommonJS plugin applied to the externals phase only, which turns a CJS
require()of another external into a real ESMimport, so the import map can resolve it instead of esbuild's throwing__requireshim. - A
fileReplacementslayer that swaps the resolved entry for another file when the package's ownexportsfield points at something unbundlable. See esbuild adapter configuration for how this is exposed to users.
11. Build notifications (optional)
In dev mode the core writes a buildNotificationsEndpoint into remoteEntry.json. Runtime clients long-poll it to know when a remote has rebuilt. If you're writing a builder that owns a dev server, you can implement the endpoint and push one of the BuildNotificationType values (federation-rebuild-complete, federation-rebuild-error, federation-rebuild-cancelled) after each rebuild. It's opt-in and lives entirely outside the adapter contract — a convenience for hot-reload workflows.
12. Minimal adapter skeleton
import type {
NFBuildAdapter,
NFBuildAdapterOptions,
NFBuildAdapterResult,
} from '@softarc/native-federation/domain';
import { AbortedError } from '@softarc/native-federation/internal';
type BundlerCache = Map<string, unknown>;
interface Cached {
ctx: MyBundlerContext;
outdir: string;
bundlerCache: BundlerCache | undefined;
}
export function createMyAdapter(): NFBuildAdapter {
const contexts = new Map<string, Cached>();
return {
async setup(name, options: NFBuildAdapterOptions<BundlerCache>) {
if (contexts.has(name)) return;
const ctx = options.kind === 'shared'
? await createExternalsContext(options)
: await createSourceContext(options);
contexts.set(name, {
ctx,
outdir: options.outdir,
bundlerCache: options.cache?.bundlerCache,
});
},
async build(name, opts = {}) {
const entry = contexts.get(name);
if (!entry) throw new Error(`setup() not called for "${name}"`);
if (opts.signal?.aborted) throw new AbortedError('[build] Aborted before rebuild');
if (opts.modifiedFiles && entry.bundlerCache) {
for (const f of opts.modifiedFiles) entry.bundlerCache.delete(f);
}
const onAbort = () => entry.ctx.cancel();
opts.signal?.addEventListener('abort', onAbort, { once: true });
try {
const result = await entry.ctx.rebuild();
const written = writeOutputs(result, entry.outdir);
if (entry.bundlerCache) {
for (const input of result.inputs) entry.bundlerCache.set(input, null);
}
return written.map(fileName => ({ fileName }));
} catch (error) {
if (opts.signal?.aborted) throw new AbortedError('[build] cancelled');
throw error;
} finally {
opts.signal?.removeEventListener('abort', onAbort);
}
},
async dispose(name) {
if (name) {
await contexts.get(name)?.ctx.dispose();
contexts.delete(name);
return;
}
await Promise.all([...contexts.values()].map(c => c.ctx.dispose()));
contexts.clear();
},
};
}
13. Wiring it into a one-shot build
Simplest case — you just want the federation artifacts written once:
import { federationBuilder } from '@softarc/native-federation';
import { createMyAdapter } from './my-adapter';
await federationBuilder.init({
options: {
workspaceRoot: process.cwd(),
outputPath: 'dist/mfe1',
federationConfig: 'mfe1/federation.config.js',
tsConfig: 'tsconfig.json',
},
adapter: createMyAdapter(),
});
// Build the app itself with federationBuilder.externals marked external.
await myAppBundler.build({ external: federationBuilder.externals });
await federationBuilder.build();
await federationBuilder.close();
14. Wiring it into a watch-mode builder
If you want the same ergonomics as runEsBuildBuilder — a single call that runs the initial build, wires up the watcher and rebuild queue, and returns a close() handle — this is the shape to copy. Everything imported from @softarc/native-federation/internal is shipped specifically for adapter authors.
import {
buildForFederation,
createFederationCache,
getExternals,
normalizeFederationOptions,
rebuildForFederation,
setBuildAdapter,
} from '@softarc/native-federation';
import {
AbortedError,
RebuildQueue,
createNfWatcher,
syncNfFileWatcher,
linkedSharedDirs,
type NfFileWatcher,
} from '@softarc/native-federation/internal';
import { createMyAdapter } from './my-adapter';
export async function runMyBuilder(federationConfigPath: string, options: MyOptions) {
const adapter = createMyAdapter();
setBuildAdapter(adapter);
const bundlerCache = new Map<string, unknown>();
const { config, options: fedOptions } = await normalizeFederationOptions(
{ /* ... */ },
createFederationCache(options.cachePath, bundlerCache),
{ externals: adapter.externals },
);
const externals = getExternals(config);
let federationInfo = await buildForFederation(config, fedOptions, externals);
if (!options.watch) {
return { federationInfo, externals, close: () => adapter.dispose() };
}
const rebuildQueue = new RebuildQueue();
const pendingChanges = new Set<string>();
let closed = false;
const watcher: NfFileWatcher = createNfWatcher({
onChange: path => {
if (closed) return;
pendingChanges.add(path);
void triggerRebuild();
},
});
const linkedDirs = linkedSharedDirs(config, fedOptions);
syncNfFileWatcher(watcher, bundlerCache, linkedDirs);
async function triggerRebuild() {
await rebuildQueue.track(async signal => {
try {
await abortableDelay(options.rebuildDelay ?? 50, signal);
const files = [...pendingChanges];
pendingChanges.clear();
federationInfo = await rebuildForFederation(
config, fedOptions, externals, files, signal,
);
syncNfFileWatcher(watcher, bundlerCache, linkedDirs);
return { success: true };
} catch (error) {
if (error instanceof AbortedError) return { success: false, cancelled: true };
return { success: false };
}
});
}
return {
get federationInfo() { return federationInfo; },
externals,
async close() {
closed = true;
rebuildQueue.dispose();
await watcher.close();
await adapter.dispose();
},
};
}
That's the whole watch-mode machinery. If you squint it's 40 lines of glue: createNfWatcher streams changes into a Set, a debounced RebuildQueue.track drains that set into a rebuildForFederation call, and syncNfFileWatcher re-subscribes to whatever inputs the latest build touched. Disposal unwinds in reverse order.
15. Things that frequently bite
- Relative paths in results. The core matches by basename but reads files by absolute path. A relative
fileNamefails the build. - Returning only the changed files on a rebuild. Return the full set every time; a file missing from the result drops out of
outputFilesand integrity. - Not forwarding
signal. Without this,RebuildQueuewill still fire the next rebuild, but the previous one runs to completion in parallel and wastes CPU — and in the worst case overwrites the newer output. - Skipping
syncNfFileWatcherafter rebuild. New dynamic imports that land in the source graph after the first build won't be watched until you re-sync. Symptom: edits to a newly-imported file don't trigger a rebuild. - Keeping state on the adapter instance instead of the cache. The core can recreate the adapter between builds; long-lived state belongs in
options.cache.bundlerCache. - Forgetting to call
dispose()without a name. Many bundlers leak a subprocess (esbuild's service, swc's worker pool). Calldispose()on process exit.
Built an adapter and want to share it? Open a PR against the native-federation GitHub org or add a link to the Resources page.