If a plugin can only run inside the host, every plugin author has to run your entire platform locally: shell, backend, database, auth. Onboarding takes a week and "works on my machine" becomes structural.
Standalone mode removes that. The plugin boots against a stub of the host store, in its own dev server, in seconds.
IS_STANDALONE=true pnpm dev # the scaffold wires this as `pnpm dev:standalone`How the switch works#
createPluginStoreBridge decides between the federated store and the local fallback:
import { createPluginStoreBridge } from "@z0devs/mf-plugin-kit";
import LocalContext from "./LocalContext";
const bridge = createPluginStoreBridge({
loadShellModule: () => import("shell/sharedStore"),
LocalContextFallback: LocalContext,
});
await bridge.ready;
export const { useShellSharedStore } = bridge;- Federated (
IS_STANDALONE !== "true"): importsshell/sharedStoreand calls its default export as a hook. - Standalone: resolves to
useContext(LocalContextFallback). - Federated import failed: logs and falls back to the stub, so a shell that's down doesn't stop your dev loop.
Because process doesn't exist in a browser, the flag has to be inlined at build time:
new DefinePlugin({
"process.env.IS_STANDALONE": JSON.stringify(process.env["IS_STANDALONE"] ?? "false"),
});Why DefinePlugin and not a typeof guard
The bridge reads process.env.IS_STANDALONE by direct dot access inside a try/catch. A
typeof process guard looks safer but is worse: DefinePlugin substitutes the env expression and
leaves typeof process untouched, which silently defeats a correctly-inlined flag.
Need different semantics? Pass your own detector:
createPluginStoreBridge({
loadShellModule: () => import("shell/sharedStore"),
LocalContextFallback: LocalContext,
isStandalone: () => window.location.search.includes("standalone=1"),
});Fail-loud stubs#
The failure you want to avoid is a stub that quietly returns undefined for something the real host
provides — the plugin renders fine locally and breaks in production. createLocalStubContext makes
that impossible: it's a Proxy that throws on any slot you didn't stub.
import { createLocalStubContext } from "@z0devs/mf-plugin-kit";
type LocalShellShape = {
values: {
useAuth: () => { user: { id: string; email: string } | null; isLoading: boolean };
useCurrentUser: () => { id: string; name: string };
};
registries: Record<string, never>;
};
const LocalContext = createLocalStubContext<LocalShellShape>({
values: {
useAuth: () => ({ user: { id: "local-user", email: "dev@localhost" }, isLoading: false }),
useCurrentUser: () => ({ id: "local-user", name: "Local Dev" }),
},
});
export default LocalContext;Read something unstubbed and you get, immediately and by name:
[@z0devs/mf-plugin-kit] missing local stub for "contextualApiFetch"Keeping the stub honest#
Two layers, and you want both:
Type the slice against the real store
Replace the hand-written LocalShellShape with the host's actual type as soon as the shell publishes
one:
import type useShellSharedStore from "shell/sharedStore";
import type { SelectSharedStoreSlice } from "@z0devs/mf-plugin-kit";
type Slice = SelectSharedStoreSlice<
ReturnType<typeof useShellSharedStore>,
{ values: { useAuth: true; contextualApiFetch: true }; registries: { components: true } }
>;Now the stub has to satisfy Slice. If the host changes useAuth's return type, your build fails
— which is where you want to find out.
Wire the host's published types
The host's bundler emits dist/@mf-types (dts.generateTypes). Point your tsconfig at it:
{
"compilerOptions": {
"paths": { "shell/*": ["../shell/dist/@mf-types/shell/*"] }
}
}Or consume the whole emitted bundle with "paths": { "*": ["./@mf-types/*"] }.
Standalone entry point#
The scaffold ships a normal React entry that runs only in standalone mode — under the host, this file
is never loaded because the host imports ./init and your pages directly.
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route, Link } from "react-router-dom";
import HomePage from "./HomePage";
function StandaloneApp() {
return (
<BrowserRouter>
<nav style={{ padding: 12, borderBottom: "1px solid #ccc" }}>
<Link to="/">Home</Link>
</nav>
<Routes>
<Route path="/" element={<HomePage />} />
</Routes>
</BrowserRouter>
);
}
createRoot(document.getElementById("root")!).render(<StandaloneApp />);Because the bridge module top-level-awaits bridge.ready, the store is bound before this file
evaluates — no provider wrapper needed even here.
What standalone does not cover#
| Not covered | Why | What to do instead |
|---|---|---|
| Real registries | The host owns them; your stub's are inert | Assert registrations with @z0devs/mf-testing |
| Route registration | There's no host router to register into | Render pages directly in main.tsx |
| Onboarding gates | OnboardingHost lives in the host | Test predicates as pure functions |
| Cross-plugin components | Another remote publishes them | Stub getItem to return your own fixture |
| Real auth / tenant context | Stub values are fixtures | Smoke-test against a dev shell before release |
A realistic dev loop
Standalone for the inner loop (UI, state, styling), @z0devs/mf-testing for assertions in CI, and one
federated smoke run against a dev shell before you ship a manifest bump. Cross-boundary HMR between
shell and plugin is not supported — live-reload each side independently.