Orchestrator 4.8.0: one build per package, no more stitching.Read the announcement and share your feedback →

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

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:

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

FieldTypeNotes
entryPointsEntryPoint[]{ 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.
externalstring[]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.
outdirstringAbsolute 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).
isMappingOrExposedbooleanDeprecated — read kind. true for the source-code phases. Removed in v5.
platform'browser' | 'node'Forwarded to the bundler's platform setting.
hashbooleanIf true, append a content hash to emitted filenames. The core uses the filename you emit to populate remoteEntry.json.
devbooleanEnable sourcemaps, disable minification, set process.env.NODE_ENV to "development".
watchbooleanInformational — 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.
chunksbooleanInformational. The core decides how to wire chunks into remoteEntry.json; your job is to emit them and return them in NFBuildAdapterResult[].
tsConfigPathstring?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).
mappedPathsPathToImportResolved tsconfig path aliases. Honor these so shared-mapping entries resolve to the same file your app resolves them to.
optimizedMappingsboolean?Deprecated and unused; removed in v5.
cacheFederationCache<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:

5. dispose() semantics

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:

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:

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:

OptionDefaultEffect
onChange–Called with each changed path.
watchfs.watchYour 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.
debounceMs0Coalesce bursts of events before reporting them.
pollIntervalMs300Interval for paths registered with { poll: true }.
dedupeReplaystrueDrop 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.
replayGraceMs2000How 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:

  1. Evict those paths from options.cache.bundlerCache (as shown in §6).
  2. Run the bundler. Most bundlers with a persistent context (esbuild's ctx.rebuild(), Rspack's compiler.watch(), Vite's dev server HMR) will reuse what they have and only redo the affected graph.
  3. 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:

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

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.