Skip to content

Plugin host: load plugin UIs at runtime via Module Federation - #2

Open
dauglyon wants to merge 10 commits into
mainfrom
plugin-host
Open

Plugin host: load plugin UIs at runtime via Module Federation#2
dauglyon wants to merge 10 commits into
mainfrom
plugin-host

Conversation

@dauglyon

@dauglyon dauglyon commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator

What

A Module Federation host so next-gen-ui loads 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

  • Loading (src/plugins/host.ts) — plugins load through the MF runtime globals (registerRemotes/loadRemote), which act on the host the vite federation() plugin initialized. That host holds the shared-singleton scope, so a plugin reuses the app's React/Router instead of bundling its own.
  • Shared singletons (sdk/shared.ts) — react, react-dom, @tanstack/react-query, @tanstack/react-router, with version ranges read from package.json so host and plugins can't drift.
  • Contract (sdk/contract.ts) — the host passes each plugin { router, basepath } and refuses to mount one built against a different CONTRACT_VERSION. The plugin runs its own basepath-scoped router over the app's shared history — clean local paths, native back/forward.
  • Route (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 fresh createInstance), and the contract-version gate rejects incompatible plugins.
  • Route failure paths (load error → Retry, render crash → boundary) and the #6064 nested-router guard test.

Deferred (follow-ups)

  • Publish the SDK as @kbase/plugin-ui-sdk so external plugins consume it as a package (and @kbase/design-system can be shared).
  • The registry service — stubbed via MSW here.
  • Per-pod plugins (project routes + state) — the app has no project concept yet.
  • Navigation entry — the route is reachable by URL only.

See src/plugins/README.md.

@dauglyon
dauglyon force-pushed the plugin-host branch 5 times, most recently from 4d815f6 to 81ff342 Compare July 12, 2026 02:13
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
dauglyon force-pushed the plugin-host branch 3 times, most recently from 80530be to 4379b04 Compare July 12, 2026 04:37
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.
dauglyon added 8 commits July 12, 2026 09:43
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant