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.
{
"$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.
# 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.jsShared 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.
{
"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.
{
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.
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.
{ federation: { sri: { algo: 'sha384' } } }CDN public-path
Set federation.publicPath to bake an absolute CDN URL into every chunk request.
{ 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.
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.
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.
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:
| Severity | Examples | CI |
|---|---|---|
breaking | exposed module removed · remote dropped from host · singleton demoted | exit 1 |
risky | shared dep removed · requiredVersion changed | exit 0 (warned) |
info | remote URL changed · eager toggled · expose path moved | exit 0 |
compatible | new expose · new remote · new shared dep | exit 0 |
# 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-breakingThe 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.
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.
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.
// 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.
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, down → HTTP 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.
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.
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
| Symptom | Cause | Recovery |
|---|---|---|
Invalid hook call after remote loads | Two 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 found | Remote loaded, but container name mismatched | The remote's name in jorvel.federation.jsonmust match the key in the host's remotes map. |
| 404 on remote split chunk | Cross-origin chunks without CORS | Use jorvel dev --proxy-remotes or configure CORS on the CDN. |
| SRI failure in console | CDN cache served a stale remoteEntry.js | Invalidate 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.
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 bucketRemote 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.
// 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' remotesThat's federation. The rest is plumbing.
loadRemoteEntry, RemoteRegistry, and the navigation primitives.