JORVEL
Federation

Module Federation

JORVEL sits on top of Rspack's ModuleFederationPlugin and adds three things: a typed contract layer, a runtime origin allowlist, and a security profile (SRI + CSP) that actually works at the edge.

The generated config

jorvel federation reads each app's jorvel.app.json and writes a per-app jorvel.federation.json. That JSON is plumbed straight into Rspack at build time.

apps/dashboard/jorvel.federation.json
json
{
  "$schema": "../../node_modules/@jorvel/types/schemas/jorvel.federation.json",
  "name": "dashboard",
  "filename": "remoteEntry.js",
  "exposes": {
    "./App": "./src/remote.tsx",
    "./pages": "./src/jorvel.routes"
  },
  "shared": {
    "react": { "singleton": true, "requiredVersion": "^18.0.0" },
    "react-dom": { "singleton": true, "requiredVersion": "^18.0.0" },
    "@jorvel/runtime": { "singleton": true, "requiredVersion": false },
    "@jorvel/event-bus": { "singleton": true, "requiredVersion": false }
  }
}

Why no requiredVersion on framework packages?

@jorvel/runtime and @jorvel/event-bus use a globalThis-pinned registry to survive duplicate bundles. Disabling the version check keeps the runtime singleton even if the host and remote ship slightly different JORVEL versions.

Wiring a remote into the host

To plug a remote into an existing host after the fact, use jorvel add remote. It updates the host's federation map (with a sanitized container global for hyphenated names), adds a /<name>/* route to jorvel.routes.host.json, regenerates src/remotes.d.ts, and wires the bootstrap REMOTES map + a NavLink. Run jorvel generate types any time to refresh the ambient remote-module declarations on their own.

bash
# Wire a remote that runs on :3002 into the host
jorvel add remote reports --port 3002

# …or point at a deployed remoteEntry.js
jorvel add remote reports --url https://cdn.example.com/reports/remoteEntry.js

Shared dependencies

JORVEL auto-detects react, react-dom, and the framework packages. Anything else listed under federation.shared in jorvel.config.json is added as a singleton.

jorvel.config.json
json
{
  "federation": {
    "shared": ["zustand", "@tanstack/react-query"],
    "versionCheck": true
  }
}

Runtime origin allowlist

The runtime RemoteRegistryrejects entries that don't match the configured allowlist before fetching remoteEntry.js. Both * (single label) and ** (multi-label) wildcards are supported.

ts
{
  federation: {
    allowlist: [
      'https://cdn.acme.com',                  // exact
      'https://*.acme.com',                    // any subdomain
      'https://**.cdn.cloudflare.net',         // any depth
    ],
  },
}

The allowlist runs at fetch time, not build time.

It's the last line of defense if a misconfigured CI accidentally points federation.remotes at a wrong host. Combined with SRI, you get integrity at both ends.

Subresource Integrity

Set federation.sri = true and the build will compute a SHA-384 hash of every remoteEntry.js. The host's loadRemoteEntry sets integrity + crossorigin="anonymous" on the script element.

ts
{ federation: { sri: { algo: 'sha384' } } }

CDN public-path

Set federation.publicPath to bake an absolute CDN URL into every chunk request.

ts
{ federation: { publicPath: 'https://cdn.acme.com/dashboard/' } }

Contenthash chunk names + Cache-Control

Embed a content hash in every static-asset filename so the CDN can serve them with Cache-Control: public, max-age=31536000, immutable. When the bundle changes, the filename changes — clients never see a stale copy. remoteEntry.js stays unhashed and gets a short revalidating cache so hosts notice publishes.

ts
import { buildChunkNameTemplates, pickCacheControl } from '@jorvel/rspack-route-assets';

const names = buildChunkNameTemplates({ hashLength: 10 });
// rspack.config.mjs
export default {
  output: {
    filename: names.filename,             // 'static/js/[name].[contenthash:10].js'
    chunkFilename: names.chunkFilename,
    cssFilename: names.cssFilename,
    assetModuleFilename: names.assetModuleFilename,
  },
};

// At the edge, pick the right Cache-Control per request:
const header = pickCacheControl(url.pathname);
// '/static/js/app.a1b2c3d4ef.js' → 'public, max-age=31536000, immutable'
// '/remoteEntry.js'              → 'public, max-age=60, must-revalidate'
// '/index.html'                  → 'no-store'

Cross-version warnings

With federation.versionCheck = truethe runtime warns when host and remote bundles disagree on a singleton's version. In production this becomes a Sentry breadcrumb; in dev it's a console warning that points you at the offending package.

Typed contracts

@jorvel/types exports defineFederationContract + InferExposed / InferEmits / InferListens. Validation is async (it actually awaits container.get(key)), so a missing exposure fails the build rather than the page.

libs/contracts/src/dashboard.ts
ts
import { defineFederationContract, type InferExposed } from '@jorvel/types';

export const contract = defineFederationContract({
  name: 'dashboard',
  exposes: { './App': './src/remote.tsx' },
});

export type DashboardExports = InferExposed<typeof contract>;
//   ^? { './App': () => Promise<{ default: ComponentType }> }

Contract tests

@jorvel/types/testingturns a contract into ready-to-run test cases. The host loads the remote's container, the helper verifies every exposed key resolves.

apps/shell/test/dashboard-contract.test.ts
ts
import { describe, it, expect } from 'vitest';
import { contractChecks } from '@jorvel/types/testing';
import { dashboardContract } from '@app/contracts/dashboard';
import { loadRemoteEntry, initRemoteContainer } from '@jorvel/runtime';

async function loadContainer() {
  await loadRemoteEntry({ name: 'dashboard', entryUrl: '/cdn/remoteEntry.js' });
  return initRemoteContainer('dashboard');
}

describe('dashboardContract', () => {
  for (const check of contractChecks(dashboardContract, loadContainer)) {
    it(check.name, async () => {
      expect(await check.run()).toEqual([]);
    });
  }
});

Use generateContractTestSource(...) from @jorvel/types if you want to scaffold the file programmatically.

Contract diff in CI

Contract tests verify a remote still exposes what it claims. The complement — jorvel federation diff — catches a remote removing something a host depends on, before merge. It compares each app's jorvel.federation.json against a git base ref and classifies every change:

SeverityExamplesCI
breakingexposed module removed · remote dropped from host · singleton demotedexit 1
riskyshared dep removed · requiredVersion changedexit 0 (warned)
inforemote URL changed · eager toggled · expose path movedexit 0
compatiblenew expose · new remote · new shared depexit 0
bash
# locally — compare working tree against main
jorvel federation diff --base main

# CI (PR gate) — fail the job on a breaking contract change
jorvel federation diff --base origin/${{ github.base_ref }}

# machine-readable, or report-only
jorvel federation diff --base main --json
jorvel federation diff --base main --allow-breaking

The base side is read with git show <ref>:<path>, so no second checkout is needed. Run it after jorvel federation so the on-disk configs are current.

Runtime resilience (last-good fallback)

Wrap loadRemoteEntry / loadRemoteModule with loadWithFallback so a 404 / timeout / network error retries against the last URL the runtime successfully loaded. Cache lives in localStorage by default; an in-memory store is used on the server. A maxAgeMs guard keeps the fallback bounded.

ts
import {
  ResilientRemoteCache,
  loadWithFallback,
  loadRemoteEntry,
} from '@jorvel/runtime';

const cache = new ResilientRemoteCache({ maxAgeMs: 7 * 24 * 60 * 60 * 1000 });

await loadWithFallback(
  { name: 'dashboard', entryUrl: '/cdn/v2/remoteEntry.js' },
  {
    cache,
    loader: (r) => loadRemoteEntry(r),
    onPhase: (p, detail) =>
      observability.reportMetric({ name: 'jorvel.fallback', value: 1, tags: { phase: p, remote: detail.remote } }),
  },
);

Weighted A/B remotes (canary rollouts)

Roll a remote out gradually by mapping its name to several FederationRemote variants with weights. pickWeightedRemote normalizes the weights and picks deterministically per user when a sticky key is supplied.

ts
import { resolveWeightedRemotes } from '@jorvel/runtime';

const ENTRIES = {
  dashboard: {
    name: 'dashboard',
    variants: [
      { remote: { name: 'dashboard', entryUrl: '/cdn/v1/remoteEntry.js' }, weight: 90, label: 'stable' },
      { remote: { name: 'dashboard', entryUrl: '/cdn/v2/remoteEntry.js' }, weight: 10, label: 'canary' },
    ],
  },
};

// Per-user stickiness — same userId always lands on the same variant.
const { remotes, picks } = resolveWeightedRemotes(ENTRIES, { key: user.id });

// emit telemetry tag so dashboards can compare variants
observability.reportMetric({ name: 'jorvel.variant', value: 1, tags: { variant: picks.dashboard.variant.label! } });

// pass `remotes` straight to <RemoteOutlet remotes={remotes} ... />

Runtime registry

For dynamic remote topologies (auto-scaling clusters, multi-region rollouts) the host can consult a manifest at runtime instead of compiling the remote map. The server side serves the manifest at /jorvel/registry; the client side polls it on a schedule and falls back to the last-known-good map when fetches fail.

ts
// Server (Worker / Vercel Edge / Node)
import { createRegistryHandler } from '@jorvel/runtime';

const registry = createRegistryHandler({
  entries: () => loadFromDatabase(),     // refresh per request
  cacheControl: 'public, max-age=10',
});

// Client (host)
import { ManifestRegistry } from '@jorvel/runtime';

const reg = new ManifestRegistry({
  url: 'https://control-plane/jorvel/registry',
  pollIntervalMs: 30_000,
});
reg.start();
const dashboard = reg.remote('dashboard');

// Health-aware filtering — disable entries whose /jorvel/health returns 'down'
await reg.withHealth((e) => `https://${e.name}.host/jorvel/health`);

Health endpoint

Each remote can expose /jorvel/health so the host registry can mark it up, degraded, or down before loading remoteEntry.js. Probes run in parallel; a probe that throws is reported with ok: false + the error message.

apps/dashboard/server/health.ts
ts
import { createHealthHandler } from '@jorvel/runtime';

export const health = createHealthHandler({
  name: 'dashboard',
  version: process.env.APP_VERSION ?? '0.0.0',
  build: process.env.GIT_SHA,
  shared: { react: '18.3.1', 'react-dom': '18.3.1' },
  probes: {
    db: async () => ({ ok: await pingDb() }),
    api: async () => ({ ok: await pingApi() }),
  },
});

// In a Worker: `if (url.pathname.startsWith('/jorvel/health')) return toResponse(await health(req));`

The host polls each remote with fetchHealth(url); up HTTP 200, downHTTP 503, the JSON body always carries the diagnostic detail.

Blue/green registry swap

Stage a candidate manifest, run a health gate, then promote atomically. Concurrent readers of registry.current() always see one full manifest — never a half-blend. shapeHealthCheck ships as a ready-made gate that rejects empty lists, duplicate names, and large shrinks.

ts
import { BlueGreenRegistry, shapeHealthCheck } from '@jorvel/runtime';

const registry = new BlueGreenRegistry({
  initial: { remotes: currentRemotes },
  healthCheck: shapeHealthCheck({ previous: { remotes: currentRemotes }, maxShrinkRatio: 0.5 }),
  healthTimeoutMs: 5_000,
  onTransition: (e) => observability.report('blue-green', e),
});

const slot = registry.stage({ remotes: nextRemotes });
try {
  await registry.promote(slot);
} catch (err) {
  // health gate failed — blue is untouched
}

// Manual revert (e.g. canary metrics regressed):
registry.rollback();

Remote manifest

Hosts can discover remotes at runtime via a JSON manifest instead of hard-coding the list at build time. Pair with federation.allowlist so a misconfigured manifest cannot inject an untrusted origin.

ts
import { getRemoteRegistry } from '@jorvel/runtime';

const registry = getRemoteRegistry({
  allowedOrigins: ['https://cdn.acme.com'],
});

await registry.load('https://cdn.acme.com/jorvel/remotes.json');
// remotes.json:
// [
//   { "name": "dashboard", "entryUrl": "https://cdn.acme.com/dashboard/remoteEntry.js", "integrity": "sha384-..." },
//   { "name": "billing",   "entryUrl": "https://cdn.acme.com/billing/remoteEntry.js",   "integrity": "sha384-..." }
// ]

registry.get('dashboard');  // → RemoteDescriptor
registry.list();            // → RemoteDescriptor[]

Failure modes and recovery

SymptomCauseRecovery
Invalid hook call after remote loadsTwo copies of React (singleton mis-configured)Make sure react + react-dom are singleton: true on both sides; host sets eager: true.
Container 'foo' was not foundRemote loaded, but container name mismatchedThe remote's name in jorvel.federation.jsonmust match the key in the host's remotes map.
404 on remote split chunkCross-origin chunks without CORSUse jorvel dev --proxy-remotes or configure CORS on the CDN.
SRI failure in consoleCDN cache served a stale remoteEntry.jsInvalidate the cache; re-run jorvel build --compute-sri.

Blue/green & weighted canaries

Ship a new remote version to a slice of traffic. pickWeightedRemote buckets users (sticky by a key via FNV-1a) across versions; blue-green flips a whole remote between two entry URLs. Dial the canary weight up as confidence grows.

ts
import { resolveWeightedRemotes, pickWeightedRemote } from '@jorvel/runtime';

const dashboard = resolveWeightedRemotes([
  { entryUrl: 'https://cdn/app/v2/remoteEntry.js', weight: 10 }, // 10% canary
  { entryUrl: 'https://cdn/app/v1/remoteEntry.js', weight: 90 }, // 90% stable
]);
const chosen = pickWeightedRemote(dashboard, { stickyKey: userId }); // same user → same bucket

Remote registry (dynamic discovery)

Instead of hard-coding remote URLs, let remotes self-register and have the host fetch a manifest at boot. createRegistryHandler (server) + ManifestRegistry (client) provide discovery with version + health filtering.

ts
// registry server (any runtime)
import { createRegistryHandler } from '@jorvel/runtime';
export const handler = createRegistryHandler({ /* store */ });

// host boot
import { ManifestRegistry } from '@jorvel/runtime';
const registry = new ManifestRegistry({ url: '/registry/manifest.json', pollMs: 30_000 });
const remotes = (await registry.fetch()).withHealth(); // only 'up' remotes

That's federation. The rest is plumbing.

Read Security for the CSP/SRI/rate-limit/audit side, or @jorvel/runtime API for loadRemoteEntry, RemoteRegistry, and the navigation primitives.