Plugin host: load plugin UIs at runtime via Module Federation - #2
Open
dauglyon wants to merge 10 commits into
Open
Plugin host: load plugin UIs at runtime via Module Federation#2dauglyon wants to merge 10 commits into
dauglyon wants to merge 10 commits into
Conversation
dauglyon
force-pushed
the
plugin-host
branch
5 times, most recently
from
July 12, 2026 02:13
4d815f6 to
81ff342
Compare
Enable next-gen-ui to load plugin UIs at runtime as Module Federation
remotes listed by a plugin registry — no rebuild, no per-plugin server.
- vite.config: federation() host; share react, react-dom,
@tanstack/react-query, and @tanstack/react-router as singletons
(design-system sharing deferred — a source alias MF can't share)
- src/plugins/registry.ts: the registry list, Zod-validated (MSW-stubbed)
- src/plugins/host.ts: the Module Federation instance (register/load) plus
the plugin contract. The host hands each plugin the app's TanStack
Router (whole API) and its basepath (/{id}); the plugin runs its own
basepath-scoped router over the app's shared history — clean local
paths, native back/forward, no sync bridge
- routes/$pluginId.$: plugins mount at the top level (/{id} and below).
Static routes match first, so a plugin claims any id no real route
uses; this route also serves the top-level not-found page
- route test: mount + basepath, app-level navigation, not-found
Only global plugins mount; per-pod plugins wait on a project concept.
Registry service and design-system sharing are follow-ups
(see src/plugins/README.md).
dauglyon
force-pushed
the
plugin-host
branch
3 times, most recently
from
July 12, 2026 04:37
80530be to
4379b04
Compare
Introduce src/plugins/sdk/ as the single source of truth for the plugin
contract, consumed by the host and every plugin:
- SHARED_SINGLETONS — the Module Federation singletons; the app's
vite.config and every plugin's import it, so versions can't drift apart
- PluginProps / Plugin — the contract types (moved out of host.ts)
- definePlugin(routeTree) — the plugin runtime (mounts a plugin's own
router over the host's shared history)
- pluginFederation({ name }) — a plugin's vite preset
examples/example-plugin/ is a working remote built with the SDK
(build:example-plugin / dev:example-plugin), with its own tsconfig so its
routing types resolve against its own routes, not the app's.
The SDK will be published as @kbase/plugin-ui-sdk (follow-up); external
plugins consume the package, the in-repo example consumes it as source —
the same pattern next-gen-ui uses for @kbase/design-system.
A plugin is just { id, manifestUrl }; the global/per-pod scope distinction is
removed (per-pod was never a real category). registry.ts, the route loader,
and the tests no longer pass or expect a scope.
- host.ts loads plugins through the app's Module Federation host runtime (which holds the shared-singleton scope) instead of a fresh createInstance, so a plugin reuses the app's React/Router instead of bundling its own - add a plugin contract version the host checks before mounting, rejecting incompatible plugins with a clear message - the plugin route gets a real not-found page for unknown ids, distinct from a registry/load failure (which offers a Retry) - restore the nested-router guard test (a nested plugin router unmounts cleanly on cross-navigation; TanStack #6064)
- nginx reverse-proxies /plugin-registry/ to the registry service, so plugin manifests, assets, and remote chunks are same-origin - CSP gains an explicit script-src 'self' (plugins are same-origin, so no third script origin is trusted) and keeps frame-ancestors 'none' - Dockerfile substitutes __REGISTRY_UPSTREAM__ (default plugin-registry:8080) alongside the existing AUTH_ORIGIN/IDP_ORIGINS placeholders
- shared.ts reads each singleton's version range from the host's package.json instead of hardcoding it, so the Module Federation shared config can't drift from what the app actually installs; it now only declares WHICH deps are shared, package.json owns the versions - stop re-exporting SHARED_SINGLETONS from the SDK barrel: its initializer imports package.json and the runtime imports the barrel, so the re-export dragged the whole package.json into the app bundle. vite.config and pluginFederation import it directly from ./shared (build-time only)
- host.test.ts guards the shared-React fix: host.ts must load through the MF runtime globals (registerRemotes/loadRemote), and enforces the contract version gate (rejects no-Component and wrong-version modules) - route tests cover the two failure paths: a failed load offers Retry and recovers, and a plugin render crash is contained instead of blanking the app
The headline guarantee — a loaded plugin reuses the host's React instead of bundling its own — now has a faithful proof: build the example remote and the host, load the real remote through the real host loader in Chromium, and assert React identity (same useState fn). jsdom can't run Module Federation's script- injection loader, so this is the only faithful check; host.test.ts still guards the wiring at unit level. - main.tsx exposes the host React on window under VITE_EXPOSE_REACT (off in normal builds); the example remote exposes its own; the test asserts they are the same instance - remote assets are copied same-origin into the host build, so no second server or CORS is needed - test:e2e script + a CI job build and run it; vitest excludes e2e/
The example plugin isn't part of this task. Drops examples/example-plugin, the Playwright shared-React e2e that loaded it, and its wiring (VITE_EXPOSE_REACT sentinel, test:e2e script, CI e2e job, @playwright/test dep). The host.ts runtime-wiring unit test still guards the shared-singleton behavior.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
A Module Federation host so
next-gen-uiloads plugin UIs at runtime from a registry — no app rebuild, no per-plugin server. A plugin is a separately-built remote the registry lists; the app registers, loads, and mounts it at/{id}. Ships the host + an in-repo plugin SDK. The registry is stubbed via MSW here.How it works
src/plugins/host.ts) — plugins load through the MF runtime globals (registerRemotes/loadRemote), which act on the host the vitefederation()plugin initialized. That host holds the shared-singleton scope, so a plugin reuses the app's React/Router instead of bundling its own.sdk/shared.ts) —react,react-dom,@tanstack/react-query,@tanstack/react-router, with version ranges read frompackage.jsonso host and plugins can't drift.sdk/contract.ts) — the host passes each plugin{ router, basepath }and refuses to mount one built against a differentCONTRACT_VERSION. The plugin runs its own basepath-scoped router over the app's shared history — clean local paths, native back/forward.src/routes/$pluginId.$.tsx) — plugins mount at/{id}; static routes match first. Distinct states: not-found for an unknown id, Retry on a load failure, an error boundary for a render crash.Trust / security
Plugins are curated first-party code. CSP stays
script-src 'self'— plugin assets are served same-origin via an nginx reverse-proxy of the registry (nginx.conf).Tests
91 tests, green; typecheck, lint, build clean.
host.test.ts— guards the shared-React fix: loading goes through the MF runtime globals (not a freshcreateInstance), and the contract-version gate rejects incompatible plugins.Deferred (follow-ups)
@kbase/plugin-ui-sdkso external plugins consume it as a package (and@kbase/design-systemcan be shared).See
src/plugins/README.md.