The library repo ships three example apps under examples/. They are the reference implementations
these docs quote — and the set every feature is verified against before release.
All three are built and served as static files on this site, so you can drive the real thing here:
basic_plugin (rspack)vite_plugin (vite)The shell fetches mfes.json, validates every entry, gates them on version and inter-plugin
dependencies, then mounts one <InitRemote> per survivor. Everything in the sidebar, the toolbar and
the settings page arrived from a registry at runtime — the host imported none of it at build time.
Six manifest entries, and four of them fail on purpose so the guardrails are visible rather than claimed:
| Entry | Outcome | What it proves |
|---|---|---|
basic-plugin | ready | Rspack remote: route, nav, settings panel, flag-gated route, published component |
vite-plugin | ready | Vite remote under an Rspack host, sharing React, reading the store through the TLA bridge |
legacy-reports | version-blocked | Declares shellApiVersion: "^9.0.0"; skipped before a byte is fetched |
invoices | dependency-unmet | Requires a billing plugin that isn't in the manifest |
tampered-widget | integrity-violated | remoteEntryHash doesn't match the bytes — checked before registerRemotes, never retried |
offline-analytics | load-failed | Remote host is down; retried per loadPolicy, then reported |
Things worth clicking:
Flip the beta flag
A nav item, a route and a settings entry appear. They were registered at init with
flags: { required: ["beta"] } — the registry filters them, so neither the plugin nor the shell's nav
component contains a conditional.
Switch org
Both remotes re-render with the new tenant because they read the host's useOrg hook. The event log
shows the matching onOrgChange emit — the part a hook can't express.
Open the Vite plugin
It renders basic_plugin:SalesWidget — a component built by the Rspack remote, resolved by id
from the component registry, rendered inside a Vite bundle. Neither plugin imports the other.
Toggle basic-plugin off, then on
Its route, nav item, settings panel and published component all leave and come back. The Vite page switches to its fallback and back, live, because it subscribes to that one registry item itself — the host hands out nothing extra.
How a plugin re-renders on another plugin's changes
A remote receives PluginRegistryApi — read and append, no emit / addListener, because the shared
event bus is host-only state. But it does get subscribe: being told the listed set changed is a read
capability, so a plugin re-renders on its own. Use the hooks rather than calling getItem in render, which
is correct on first paint and stale after another plugin is toggled:
import { useRegistryItem } from "@z0devs/mf-plugin-kit";
const widget = useRegistryItem(registries.components, "basic_plugin:SalesWidget");
return widget ? <widget.component /> : <Fallback />;useRegistryItem subscribes to that one item, so an unrelated plugin registering a nav entry re-renders
nothing. Earlier versions had no subscribe, which forced every host to hand plugins a revision counter as
a value; that workaround is gone.
basic-shell — the host#
An Rspack host that wires the whole stack in four small files.
examples/basic-shell/
├── rspack.config.ts # exposes ./sharedStore, shares react/react-dom/react-router-dom
├── public/mfes.json # the manifest — six entries: two that load, four that fail on purpose
└── src/
├── main.tsx # MF runtime init + provider stack
├── routes.tsx # PluginRouteLoader inside the dynamic route tree
├── auth/fakeAuth.ts # stub auth adapter
└── store/
├── sharedStore.tsx # defineSharedStore + a demo onboarding step
└── useSharedStore.ts # back-compat flat exposeWhat it demonstrates:
defineSharedStorewithvalues, four registries,features: { onboarding: true, router: true }and a typedeventsemitterexport default sharedStore.useShellSharedStore— the expose contract the plugin bridge expects- A
render-action onboarding step registered by the shell itself PluginRouteLoadermounted as a route element, withinitialRoutesshaped so dynamic routes land in the right placedts.generateTypesso plugins can typeshell/sharedStoreagainst the real store
pnpm --filter @z0devs/basic-shell dev # http://localhost:3400basic-plugin — the remote#
An Rspack remote that runs federated and standalone.
examples/basic-plugin/
├── rspack.config.ts # exposes ./init + ./HomePage, DefinePlugin for IS_STANDALONE, dts on
├── manifest-entry.json # what the host appends to mfes.json
├── public/env-config.js # runtime env injection demo
└── src/
├── init.tsx # registers a route + nav item through namespaced registries
├── HomePage.tsx # reads the host store
├── main.tsx # standalone entry
└── core/
├── LocalContext.ts # createLocalStubContext — fail-loud
└── useShared.tsx # createPluginStoreBridge + top-level `await bridge.ready`What it demonstrates:
- The self-initializing bridge:
await bridge.readyat module scope, so no provider, HOC or Suspense is needed anywhere - Registering routes and nav through
props.registrieswith short local ids - Cleanup on unmount, so host re-inits don't accumulate stale contributions
- Standalone mode via
IS_STANDALONE=true, with a stub that throws on unstubbed reads - Cross-remote type emission (
dist/@mf-types)
pnpm --filter @z0devs/basic-plugin dev # federated, port 3401
pnpm --filter @z0devs/basic-plugin dev:standalone # no host requiredvite-plugin — the cross-bundler case#
A Vite-built remote (@module-federation/vite) mounted inside the Rspack host, sharing the host's
React and reading the host's store through the top-level-await bridge. Its mfes.json entry ships
enabled: true: both remotes reach ready on a cold boot.
pnpm --filter @z0devs/vite-plugin-example build
pnpm --filter @z0devs/vite-plugin-example preview # 3402 — must be preview, not dev
pnpm --filter @z0devs/basic-shell dev # 3400
pnpm --filter @z0devs/basic-plugin dev # 3401http://localhost:3400 then shows a Vite Plugin nav item, and /vite-plugin renders the remote
with the host's user and Mode: federated.
| Command | Port | Browse it directly | Works as the shell's remote |
|---|---|---|---|
preview | 3402 | no — federated build | yes, the supported path |
dev:standalone | 3412 | yes | n/a |
dev | 3402 | no — blank page | no — RUNTIME-015: module is not defined |
What it demonstrates:
- Symmetric share sets including the subpath entrypoints (
react/jsx-runtime,react/jsx-dev-runtime,react-dom/client) — the root cause of duplicate React in this setup import: falseon the remote's shares, conditional on federated mode- A
varcontainer (varFilename) registered by the host withtype: "global", because an ESM container self-initializes its share scope before the host can hand over its own - Serving the built output — the Vite dev server injects a Node-only module into the entry
The full reasoning for each, with the failure mode you get without it, is in cross-bundler settings.
Two ports on purpose
dev:standalone runs on 3412, not 3402, so it can't be mistaken for the preview server the
shell consumes. Point the shell at the standalone dev server and you get
$RefreshSig$ is not defined — that mode enables @vitejs/plugin-react, whose react-refresh
instrumentation needs a Vite-served page.
Running host + plugin together#
Install once at the repo root
pnpm install
pnpm build # builds the packages the examples consumeStart the plugin
pnpm --filter @z0devs/basic-plugin devIt serves http://localhost:3401/remoteEntry.js with Access-Control-Allow-Origin: *.
Start the shell
pnpm --filter @z0devs/basic-shell devpublic/mfes.json already lists the plugin. Open http://localhost:3400.
Confirm the wiring
The nav shows Basic Plugin; /basic-plugin renders the federated page with the user's email from
the host's useAuth; the onboarding overlay appears until you flip FAKE_USER.onboarded in
src/auth/fakeAuth.ts.
Using them as a starting point#
The plugin example is the same source create-z0-plugin ships as its template — so
pnpm create z0-plugin my-plugin gets you basic-plugin with your ports and metadata already
substituted. For the host, copy basic-shell's four files and replace fakeAuth with your real auth
adapter and the demo values with your own.