ADR-0010: Multi-Package UI Composition Architecture
- Status: Implemented
- Date: 2026-02-19
- Revised: 2026-03-11
Context
Framework M needs to support frontend UI contributions from multiple independently installable Python packages (e.g. business-m, wms-app, crm-app) — mirroring how the backend uses Python entry points to compose DocTypes and adapters.
Two distinct deployment contexts exist with incompatible constraints:
- Monorepo / bench — apps are
git cloned into a shared workspace;pnpmmanages all JS deps together; Node.js is available at build time. - Enterprise pip-install — apps are installed from PyPI; there is no Node.js, no pnpm, no TypeScript compiler at deploy time.
Decision
Adopt two complementary composition modes:
| Mode | Context | How plugins compose |
|---|---|---|
| Build-Time | Monorepo / bench / CI | Single Vite bundle via @framework-m/vite-plugin |
| Runtime MFE | Enterprise pip-install | Module Federation — pre-built remotes federated at runtime |
These modes are not mutually exclusive. The same package can support both.
Shell Modes (for Runtime MFE)
The host shell — the app that bootstraps the UI and federates plugin remotes — is also swappable:
| Shell | When used |
|---|---|
Bundled Desk (framework-m-standard/static/) | Default. Used when no app overrides the shell. The standard Desk UI is the host; all installed plugin remotes federate into it. |
| Custom App Shell | An app (e.g. business-m, my-erp) builds its own complete frontend shell and declares itself as the host. The Desk shell is bypassed. Plugin packages still publish their MFE remotes; the custom shell federates them instead. |
The backend determines which shell to serve based on an entry point:
# The package that wins the "framework_m.shell" entry point provides the host shell
[project.entry-points."framework_m.shell"]
my_erp = "my_erp.frontend:shell"
If no framework_m.shell entry point is registered, framework-m-standard's bundled Desk is served. If one is registered, its static/ directory is served instead and it becomes the MFE host.
The plugin remote discovery (framework_m.frontend) works identically in both shell modes — remotes are always returned by GET /api/v1/frontend/remotes, regardless of which shell is the host. Inline injection of remote URLs into index.html is explicitly banned (see test_frontend_bootstrap.py::test_mfe_remotes_are_not_injected_into_html).
Evaluated and Rejected
"Compile from pip-installed source at deploy time" — scanned framework_m.frontend entry points at deployment, extracted TypeScript source from the wheel, then ran vite build inside the container. Rejected because:
- Requires Node.js + pnpm + TypeScript in production images.
- Packages on PyPI don't include
node_modules/. - Violates Twelve-Factor App: build and run stages must be separate.
Mode 1: Build-Time Composition (Monorepo / Bench)
Status: ✅ Implemented
The @framework-m/vite-plugin scans the pnpm workspace for packages declaring "framework-m" metadata in package.json. All discovered plugins are composed into a single optimised bundle by Vite.
// Any app's frontend/package.json
{
"name": "@wms/frontend",
"framework-m": {
"plugin": "./src/plugin.config.ts",
"type": "frontend-module"
}
}
m build scans apps/ directory, generates a temp entry.tsx importing all plugins, and runs Vite. Output goes into the Python package that serves it.
Remaining work (bench support):
-
m buildalso discovers plugins from editable installs viaframework_m.frontendentry points +importlib.resources. -
m devdoes the same, generating Vite path aliases for editable sources.
Mode 2: Runtime Module Federation (Enterprise pip-install)
Status: 📋 Planned
Each app package pre-builds its frontend as a Module Federation remote during its own CI pipeline. The host shell fetches GET /api/v1/frontend/remotes at startup and dynamically federates discovered remotes — no build step at deploy time.
Package Structure
wms-app/
└── src/wms_app/
├── doctypes/
└── static/mfe/ ← Pre-built MFE remote (in wheel, gitignored in source)
├── remoteEntry.js
└── assets/
# wms-app/pyproject.toml
[project.entry-points."framework_m.frontend"]
wms_app = "wms_app.frontend:plugin"
[tool.hatch.build.targets.wheel.force-include]
"src/wms_app/static/mfe" = "wms_app/static/mfe"
App CI Build
// frontend/vite.config.mfe.ts
import federation from '@originjs/vite-plugin-federation';
export default defineConfig({
plugins: [
react(),
federation({
name: 'wms_app',
filename: 'remoteEntry.js',
exposes: {
'./plugin': './src/plugin.config.ts',
},
shared: {
react: { singleton: true, requiredVersion: '^19' },
'react-dom': { singleton: true, requiredVersion: '^19' },
'@framework-m/plugin-sdk': { singleton: true },
},
}),
],
build: {
outDir: '../src/wms_app/static/mfe',
emptyOutDir: true,
},
});
Backend Discovery (server startup, not deploy time)
The backend exposes a JSON API endpoint that the host shell fetches on load. This decouples static file hosting from the Python process — static files can be served by nginx, a CDN, or S3; the API is always Python.
# framework_m_standard/adapters/web/frontend_discovery.py
from importlib.metadata import entry_points
import importlib.resources
from pathlib import Path
import os
def discover_mfe_remotes() -> dict[str, str]:
"""
Returns { "wms_app": "<base_url>/mfe/wms_app/remoteEntry.js" }
If FRAMEWORK_M_STATIC_BASE_URL is set (e.g. https://cdn.example.com),
returns absolute CDN URLs. Otherwise returns relative paths for local serving.
"""
base_url = os.getenv("FRAMEWORK_M_STATIC_BASE_URL", "").rstrip("/")
remotes: dict[str, str] = {}
for ep in entry_points(group="framework_m.frontend"):
package_module = ep.module.split(":")[0]
try:
mfe_dir = importlib.resources.files(package_module) / "static" / "mfe"
if (Path(str(mfe_dir)) / "remoteEntry.js").exists():
url = f"{base_url}/mfe/{ep.name}/remoteEntry.js"
remotes[ep.name] = url
except Exception:
pass
return remotes
# Litestar route
@get("/api/v1/frontend/remotes")
async def frontend_remotes() -> dict[str, str]:
return discover_mfe_remotes()
Host Shell — Runtime Remote Loading
The shell fetches the remotes API on startup — not from index.html injection:
// federation-loader.ts
async function loadFederatedPlugins(): Promise<FrameworkMPlugin[]> {
// Works regardless of whether index.html was served by Python, nginx, or CDN
const remotes: Record<string, string> = await fetch("/api/v1/frontend/remotes")
.then(r => r.json())
.catch(() => ({})); // Graceful: if API unreachable, no plugins loaded
const plugins: FrameworkMPlugin[] = [];
for (const [name, remoteEntryUrl] of Object.entries(remotes)) {
try {
// Dynamically load Module Federation remote
await loadRemote(name, remoteEntryUrl);
const mod = await import(/* @vite-ignore */ `${name}/plugin`);
plugins.push(mod.default);
} catch (err) {
console.warn(`[Framework M] Failed to load remote plugin: ${name}`, err);
}
}
return plugins;
}
index.html injection is not used. The API endpoint is the single source of truth.
Progressive Deployment Levels (Zero-Cliff)
The architecture supports a gradual progression of deployment complexity without requiring code changes, enabled by a single environment variable (FRAMEWORK_M_STATIC_BASE_URL) and the discovery API.
| Level | Name | How it works | When to use |
|---|---|---|---|
| L1 | Monorepo Indie | Local pnpm workspace. m dev or m build creates a single bundle. | Local development, single-repository projects. |
| L2 | Monorepo Docker | Build-time composition inside a Dockerfile. All plugin code is present at build time. | Simple production deployments where rebuilds are cheap. |
| L3 | Distributed (PyPI) | Plugins are installed as Python wheels containing pre-built MFEs. Python serves /mfe/{app}/. | Multi-package ecosystem without Node.js in production. |
| L4 | Scaled Delivery | Nginx or API Gateway serves static files from a shared volume or proxies to Python. | High-traffic sites requiring static asset optimization. |
| L5 | Global Enterprise | MFEs are hosted on a CDN (S3/Cloudflare). Discovery API returns absolute URLs. | Enterprise-scale deployments with independent release cycles. |
Level 1-2: Build-Time Composition
Handled by @framework-m/vite-plugin. It generates a virtual entry point importing all discovered plugins. The final output is a single index.html and a set of optimized JS/CSS bundles.
Level 3: Runtime Discovery (Default Distributed)
The host shell (standard or custom) fetches GET /api/v1/frontend/remotes. The discovery service scans installed Python packages for framework_m.frontend entry points and returns relative paths (e.g., /mfe/wms_app/remoteEntry.js). Python mounts these static directories automatically.
Level 4: Infrastructure Proxying
In a containerized environment (e.g., K8s), Nginx can serve the assets.
- Shared Volume: Python mirrors
static/mfe/to a shared volume on startup; Nginx serves it. - Reverse Proxy: Nginx routes
/mfe/and/desk/requests to the Python gunicorn/uvicorn workers.
Level 5: Enterprise CDN
By setting FRAMEWORK_M_STATIC_BASE_URL=https://cdn.example.com, the discovery API switches from relative to absolute URLs.
Deployment Flow:
- CI Pipeline: Build MFE remote -> Upload
static/mfe/to S3/CDN -> Build & Publish Python wheel (containing the same assets for L3 compatibility). - Production:
pip install wms-app. Python process starts. - Runtime: Shell calls
/api/v1/frontend/remotes. Python returnshttps://cdn.example.com/mfe/wms_app/remoteEntry.js. Shell federates it.
This ensures Zero-Cliff Regression: an app developed in L1 (monorepo) works identically in L5 (CDN) without changing a single line of frontend code.
Plugin Manifest (Common to both modes)
// src/plugin.config.ts
import type { FrameworkMPlugin } from "@framework-m/plugin-sdk";
const plugin: FrameworkMPlugin = {
name: "wms",
version: "1.0.0",
menu: [{ name: "wms.inventory", label: "Inventory", route: "/wms/inventory", module: "WMS" }],
routes: [{ path: "/wms/inventory", element: () => import("./pages/Inventory") }],
};
export default plugin;
Implementation Checklist
Mode 1 — Bench support
-
m builddiscovers editable installs viaframework_m.frontendentry points -
m devgenerates Vite path aliases for editable package sources
Mode 2 — Module Federation
- Add
vite.config.mfe.tsto frontend scaffolding template (m new:app --with-frontend) - Implement
discover_mfe_remotes()withFRAMEWORK_M_STATIC_BASE_URLsupport - Implement
discover_shell()— checkframework_m.shellentry point; fall back to bundled Desk - Expose
GET /api/v1/frontend/remotesLitestar endpoint - Mount
/mfe/{name}/static routes in app factory (used when Python serves static files) - Serve custom shell's
static/ifframework_m.shellis registered, else serve bundled Desk - Rebuild Desk shell as MFE host (empty
remotes: {}) - Implement
federation-loader.tsin Desk shell to load remotes at runtime - Add
m verify:mfe <package>command to validate pre-built remote before publish - Update CI template (
.gitlab/ci/build.yml) withbuild-mfe-remotejob
References
- ADR-0006: MX Pattern
- Plugin-Host Composition Patterns
- Frontend Plugin Development Guide
- Vite Plugin Federation
- Twelve-Factor App: Build/Release/Run
Status: This ADR is accepted and fully implemented as of 2026-03-11.