Runtime plugin loading moves failure from build time to your users' browsers. This page is everything the library gives you so those failures are visible, bounded and mostly self-healing.
The lifecycle registry#
Every plugin the host touches gets a record, driven by PluginRouteLoader:
discovered → loading → ready
↘ failed
manifest-invalid ↗ disabled blocked (version / dependency)import { usePluginRuntime, getAllPluginRecords, subscribeToPluginRuntime } from "@z0devs/mf-core";
function PluginStatus() {
const records = usePluginRuntime(); // re-renders on transitions
return (
<ul>
{records.map((r) => (
<li key={r.id}>
{r.name}: {r.status} {r.loadDurationMs ? `(${Math.round(r.loadDurationMs)}ms)` : ""}
</li>
))}
</ul>
);
}Outside React, getAllPluginRecords() reads the same data and subscribeToPluginRuntime(fn)
notifies on change.
Metrics#
getPluginMetrics() flattens the registry into { id, name, status, loadDurationMs, lastError } —
deliberately SDK-agnostic, so it drops into whatever you already run:
import { subscribeToPluginRuntime, getPluginMetrics } from "@z0devs/mf-core";
export function instrumentPlugins(histogram: Histogram, counter: Counter) {
return subscribeToPluginRuntime(() => {
for (const m of getPluginMetrics()) {
if (m.status === "ready" && m.loadDurationMs != null) {
histogram.record(m.loadDurationMs, { plugin: m.id });
} else if (m.status === "failed") {
counter.add(1, { plugin: m.id, error: m.lastError?.kind ?? "unknown" });
}
}
});
}Two dashboards worth having on day one: p95 plugin load duration by plugin (a partner CDN
degrading shows up here first) and failure count by error.kind (tells you whether it's the
network, a version gate or a bad build).
The error union#
Failures are typed, not strings, so you can route them by severity instead of grepping messages:
kind | Meaning | Usual cause |
|---|---|---|
manifest-invalid | Entry failed schema validation | Hand-edited manifest, missing service.remoteEntry |
version-blocked | shellApiVersion incompatible | Plugin built against another shell major |
dependency-unmet | Required plugin dependency missing / out of range | Manifest shipped without its dependency |
integrity-violated | remoteEntryHash mismatch | Bundle changed since it was vetted — never retried |
load-failed | Remote script or chunk failed after retries | Network, CDN, CSP, 404 |
init-threw | The plugin's init threw while rendering | Plugin bug |
render-threw | A federated component threw at render | Plugin bug, caught by the error boundary |
predicate-threw | An OnboardingStep predicate threw — step skipped | Hook or async call inside a predicate |
disabled | Entry was enabled: false | Informational |
pluginErrorSeverity(record) maps kinds to "info" | "warn" | "error" — disabled and
version-blocked are informational, manifest-invalid and predicate-threw warn, the rest escalate.
<PluginRouteLoader
shell={sharedStore}
auth={auth}
manifest={loadManifest}
loader={AppLoader}
onPluginError={(record) => {
const severity = pluginErrorSeverity(record); // "info" | "warn" | "error"
Sentry.captureMessage(`plugin ${record.kind}`, { level: severity, extra: record });
}}
/>onPluginError mounts a PluginErrorProvider for the subtree, so plugin code can report through
usePluginErrorHandler() too. With no provider anywhere, safeEmitPluginError falls back to the
console — which is why an unwired sink means nobody ever sees manifest-invalid.
Wire the sink on day one
manifest-invalid and dependency-unmet are exactly the failures that look like "the plugin just
didn't show up". Without telemetry they're invisible; with it they're a one-line fix.
Retry policy#
BaseRemote retries transient failures and fails fast on everything else:
<PluginRouteLoader
config={{
loadPolicy: {
maxAttempts: 5, // 1 disables retries
backoffMs: (n) => Math.min(2000, 250 * 2 ** n), // default: 500ms × n, linear
isTransient: (e) => /network|timeout|chunk/i.test(e.message),
scriptTimeoutMs: 30_000, // default 60s
},
}}
/* … */
/>Transient by default: ScriptNetworkError, script timeouts, RUNTIME-008, and ChunkLoadError /
"Loading chunk N failed". CSP violations, 404s and parse errors are not retried, because retrying
them just delays the error.
The ChunkLoadError case is worth calling out: an expose chunk of an already-registered remote
failing mid-flight used to fail with zero retries and surface straight to the app error boundary.
Webpack evicts failed chunk promises, so a retried loadRemote genuinely refetches.
Two things this does not cover, by design:
- Chunks fetched after a remote has mounted — that's the remote build's bundler-runtime concern.
- Stale-deploy 404s — the host app should reload once on
ChunkLoadErrorafter a deploy.
Integrity#
For vetted builds served from someone else's infrastructure:
{
"service": {
"id": "acme_analytics",
"remoteEntry": "https://acme.com/plugins/analytics/remoteEntry.js"
},
"remoteEntryHash": "sha256-4f2b…"
}`remoteEntryHash` is top-level, not inside `service`
It is a sibling of service, not a field on it. Nested, entry.remoteEntryHash reads undefined, the
host passes no integrityHash, and verification is silently skipped — no error, no warning, and you
believe you have tamper detection. validateManifestEntry() won't flag it either, since the field is
optional.
BaseRemote fetches the bytes, hashes them with crypto.subtle and compares before
registerRemotes. On mismatch: integrity-violated, no retry, plugin never executes.
What this is and isn't
Supply-chain tamper detection for a build you approved — not runtime isolation. See Trust model.
Prefetch#
import { usePluginPrefetch } from "@z0devs/mf-core";
function PluginNavLink({ entry }) {
const { hoverProps, prefetchOnIdle } = usePluginPrefetch(entry);
useEffect(prefetchOnIdle, [prefetchOnIdle]);
return <NavLink {...hoverProps} to={entry.path}>{entry.name}</NavLink>;
}Idempotent, and it swallows its own errors — a failed warm never breaks the real load. prefetchEntry
is the imperative form if you'd rather trigger it from a router event.
The debug panel#
import { PluginDebugPanel } from "@z0devs/mf-shell-kit";
{import.meta.env.DEV && <PluginDebugPanel />}Every record, status, duration and last error in a table. Ship it behind a dev flag or an internal route — "which plugins are loaded, and how long did they take" is the first question in every plugin-related incident.
The runtime plugin hook#
For cross-cutting instrumentation at the federation level, wrap the runtime itself:
import { createRuntimePlugin } from "@z0devs/mf-core";
import { init } from "@module-federation/runtime";
const instrumented = createRuntimePlugin({
name: "instrumented",
beforeRequest(args) {
console.debug("[mf] request", args.id);
return args;
},
onLoad(args) {
return args;
},
});
init({ name: "shell", remotes: [], plugins: [instrumented] });All @module-federation/runtime hooks are pass-throughs by default — override only what you need.
Every runtime call in the library routes through a single mfRuntime.ts seam, so a runtime-major
swap is one file.