The manifest is the host's list of plugins: an array of MFEManifestEntry. It is the only source of
remotes, and the boundary where curation happens.
import type { MFEManifestEntry } from "@z0devs/mf-core";Required fields#
| Field | Type | Notes |
|---|---|---|
name | string | Plugin identifier. Shown in admin UIs and used as the key other plugins depend on. Keep it aligned with the plugin's Module Federation name. |
service.id | string | Remote registration id — the library loads ${id}/init. Snake_case by convention, must match the plugin's MF container name. |
service.remoteEntry | string | Absolute URL of remoteEntry.js (or an mf-manifest.json). Must be CORS-enabled and, in production, HTTPS. |
{
"name": "billing",
"service": { "id": "billing_mfe", "remoteEntry": "https://cdn.example.com/billing/remoteEntry.js" }
}That is a valid entry. Everything below is optional.
Loading#
| Field | Type | Default | Notes |
|---|---|---|---|
service.type | "global" | "module" | "global" | "global" = classic script that sets a window global (Webpack/Rspack containers). "module" = loaded via dynamic import() (ESM containers, e.g. Vite builds). The runtime cannot infer this from the URL. |
enabled | boolean | true | false ships an entry without loading it — staged rollout, per-tenant gating. Skipped entries emit a disabled record. |
prefetch | boolean | false | Advisory hint that the host may warm this plugin. The library never auto-prefetches; you call prefetchEntry / usePluginPrefetch. |
remoteEntryHash | string | — | SRI-style "<algo>-<base64>" (sha256/sha384/sha512). Verified against the fetched bytes before registration; mismatch → integrity-violated, no retry. Top-level — a sibling of service, not a field on it. Nested, it is silently ignored: entry.remoteEntryHash reads undefined, no integrityHash is passed, and the check never runs. |
{ "service": { "id": "esm_only_mfe", "remoteEntry": "https://cdn/…/esmRemoteEntry.js", "type": "module" } }A Vite remote built with `z0Federation` uses `global`, not `module`
"module" is correct for a remote whose entry really is an ES module. But z0Federation({ target: "vite" })
emits two containers — an ESM one plus a var one at varFilename — and the host registers the var
container with "type": "global". Point it at the ESM container instead and it self-initializes its own
share scope before the host can hand over its, which fails with S is not a function. See
Cross-bundler.
openssl dgst -sha256 -binary remoteEntry.js | openssl base64 -A
# manifest value: "sha256-<output>"Manifest-protocol URLs self-describe
An entry pointing at an mf-manifest.json carries its own format metadata, so service.type isn't
needed there.
Compatibility#
| Field | Type | Notes |
|---|---|---|
version | string | The plugin's own semver. Required in practice if any other plugin depends on you. |
shellApiVersion | string | Semver range the plugin requires of the host API, e.g. "^0.1.0" or ">=0.1.0 <0.2.0". Checked against sharedStore.apiVersion under versionPolicy, which defaults to SHELL_API_VERSION (0.1.0). |
dependencies | Record<string, string> | Inter-plugin requirements: other plugin name/service.id → semver range. Absent or out-of-range → blocked + dependency-unmet. |
optionalDependencies | Record<string, string> | Same shape; a missing target is tolerated, a present-but-incompatible one still blocks. |
{
"name": "invoices",
"version": "1.0.0",
"shellApiVersion": "^0.1.0",
"dependencies": { "billing": "^2.1.0" },
"optionalDependencies": { "analytics": "^3.0.0" }
}Use "*" for presence-only ("any version, just needs to be loaded"). Resolution is a single pass over
the eligible manifest set — no transitive cascade, which is sufficient for whole-manifest boot.
Not for npm packages
dependencies here means plugins. React, design systems and other shared libraries are negotiated by
Module Federation's shared config (requiredVersion / singleton / strictVersion).
Curation metadata#
None of these are enforced at runtime; they exist so a human — or your admin UI — can review an entry before it ships.
| Field | Type | Notes |
|---|---|---|
vendor | string | Author or organisation. Free-form. |
description | string | One line for admin UIs and tooltips. |
icon | string | URL or a theme-token key. The library doesn't interpret it. |
permissions | string[] | Capability tags the plugin declares it needs. Review metadata, not a grant. |
proxyPrefixes | string[] | Host proxy routes the plugin intends to call, e.g. ["/api/proxy/acme"]. Your backend's allow-list is the real control. |
meta | Record<string, unknown> | Free-form bag for host-specific extension data. |
Conventional permission tags#
| Tag | Capability |
|---|---|
registry:nav | Adds nav items |
registry:router | Adds routes |
registry:settings | Adds settings entries |
registry:toolbar | Adds toolbar slots |
registry:components | Publishes components for cross-plugin use |
registry:onboarding | Registers onboarding steps (may redirect, overlay or block) |
network:external | Calls non-host URLs |
network:shell | Uses the host's contextualApiFetch |
storage:local | Reads/writes localStorage |
storage:session | Reads/writes sessionStorage |
The list is free-form — pick tags that describe your plugin honestly so review means something.
A full entry#
{
"name": "acme-analytics",
"service": {
"id": "acme_analytics",
"remoteEntry": "https://acme.com/plugins/analytics/remoteEntry.js",
"type": "global"
},
"remoteEntryHash": "sha256-4f2b8c1d…",
"version": "0.5.0",
"shellApiVersion": ">=0.1.0 <0.2.0",
"vendor": "Acme Corp",
"description": "Sales analytics dashboard",
"icon": "https://acme.com/plugins/analytics/icon.svg",
"permissions": ["registry:nav", "registry:router", "network:external"],
"proxyPrefixes": ["/api/proxy/acme"],
"dependencies": { "billing": "^2.1.0" },
"prefetch": true,
"enabled": true,
"meta": { "team": "growth", "tier": "premium" }
}Validation#
import { validateManifestEntry } from "@z0devs/mf-core";
const result = validateManifestEntry(raw);
if (result.valid) {
acceptEntry(result.entry); // typed MFEManifestEntry
} else {
console.error(result.errors); // human-readable strings
}The result is a discriminated union:
type ManifestEntryValidation =
| { valid: true; entry: MFEManifestEntry; errors: [] }
| { valid: false; entry: null; errors: string[] };PluginRouteLoader runs this on every entry at load time — invalid entries are skipped and
surfaced as manifest-invalid, never silently dropped. Running it yourself is still worth it as a
pre-deploy lint:
node -e "
const { validateManifestEntry } = require('@z0devs/mf-core');
const entries = require('./public/mfes.json');
for (const e of entries) {
const r = validateManifestEntry(e);
if (!r.valid) { console.error(e?.name, r.errors); process.exitCode = 1; }
}
"What a rejected entry produces#
| Situation | Record |
|---|---|
| Fails schema validation | { kind: "manifest-invalid", rawEntry, errors } |
enabled: false | { kind: "disabled", pluginName } |
Incompatible shellApiVersion | { kind: "version-blocked", pluginName, pluginShellApiVersion, shellApiVersion } |
Unmet dependencies | { kind: "dependency-unmet", pluginName, unmet: [{ dep, required, resolved?, optional }] } |
remoteEntryHash mismatch | { kind: "integrity-violated", pluginName, remoteUrl, expectedHash, actualHash } |
All of them arrive at onPluginError. Wire it, or they're invisible.
Serving it#
The manifest is a function on the host — manifest={loadManifest} — so it can be a static file, a
per-tenant endpoint, or a filtered view:
const loadManifest = async () => {
const res = await fetch("/api/plugins/manifest", {
credentials: "include",
headers: { "X-Tenant-Id": currentTenant.id },
});
if (!res.ok) throw new Error(`manifest fetch failed: ${res.status}`);
return res.json();
};The manifest is the trust boundary
remoteEntryHash protects the bundle, not the manifest. If the manifest itself is compromised, every
guarantee below it is void — serve it from a trusted, access-controlled endpoint.