Private beta — the @z0devs packages are available to design partners while we harden for general availability.Try the live demoRequest access

Manifest reference

Every field of MFEManifestEntry — required, optional, curation metadata — plus validation and the error records a bad entry produces.

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#

FieldTypeNotes
namestringPlugin 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.idstringRemote registration id — the library loads ${id}/init. Snake_case by convention, must match the plugin's MF container name.
service.remoteEntrystringAbsolute 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#

FieldTypeDefaultNotes
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.
enabledbooleantruefalse ships an entry without loading it — staged rollout, per-tenant gating. Skipped entries emit a disabled record.
prefetchbooleanfalseAdvisory hint that the host may warm this plugin. The library never auto-prefetches; you call prefetchEntry / usePluginPrefetch.
remoteEntryHashstringSRI-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.
a remote that ships only an ESM container
{ "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.

computing remoteEntryHash
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#

FieldTypeNotes
versionstringThe plugin's own semver. Required in practice if any other plugin depends on you.
shellApiVersionstringSemver 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).
dependenciesRecord<string, string>Inter-plugin requirements: other plugin name/service.id → semver range. Absent or out-of-range → blocked + dependency-unmet.
optionalDependenciesRecord<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.

FieldTypeNotes
vendorstringAuthor or organisation. Free-form.
descriptionstringOne line for admin UIs and tooltips.
iconstringURL or a theme-token key. The library doesn't interpret it.
permissionsstring[]Capability tags the plugin declares it needs. Review metadata, not a grant.
proxyPrefixesstring[]Host proxy routes the plugin intends to call, e.g. ["/api/proxy/acme"]. Your backend's allow-list is the real control.
metaRecord<string, unknown>Free-form bag for host-specific extension data.

Conventional permission tags#

TagCapability
registry:navAdds nav items
registry:routerAdds routes
registry:settingsAdds settings entries
registry:toolbarAdds toolbar slots
registry:componentsPublishes components for cross-plugin use
registry:onboardingRegisters onboarding steps (may redirect, overlay or block)
network:externalCalls non-host URLs
network:shellUses the host's contextualApiFetch
storage:localReads/writes localStorage
storage:sessionReads/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#

SituationRecord
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.

Next#