Skip to main content

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:

  1. Monorepo / bench — apps are git cloned into a shared workspace; pnpm manages all JS deps together; Node.js is available at build time.
  2. 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:

ModeContextHow plugins compose
Build-TimeMonorepo / bench / CISingle Vite bundle via @framework-m/vite-plugin
Runtime MFEEnterprise pip-installModule 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:

ShellWhen 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 ShellAn 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 build also discovers plugins from editable installs via framework_m.frontend entry points + importlib.resources.
  • m dev does 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.

LevelNameHow it worksWhen to use
L1Monorepo IndieLocal pnpm workspace. m dev or m build creates a single bundle.Local development, single-repository projects.
L2Monorepo DockerBuild-time composition inside a Dockerfile. All plugin code is present at build time.Simple production deployments where rebuilds are cheap.
L3Distributed (PyPI)Plugins are installed as Python wheels containing pre-built MFEs. Python serves /mfe/{app}/.Multi-package ecosystem without Node.js in production.
L4Scaled DeliveryNginx or API Gateway serves static files from a shared volume or proxies to Python.High-traffic sites requiring static asset optimization.
L5Global EnterpriseMFEs 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:

  1. CI Pipeline: Build MFE remote -> Upload static/mfe/ to S3/CDN -> Build & Publish Python wheel (containing the same assets for L3 compatibility).
  2. Production: pip install wms-app. Python process starts.
  3. Runtime: Shell calls /api/v1/frontend/remotes. Python returns https://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 build discovers editable installs via framework_m.frontend entry points
  • m dev generates Vite path aliases for editable package sources

Mode 2 — Module Federation

  • Add vite.config.mfe.ts to frontend scaffolding template (m new:app --with-frontend)
  • Implement discover_mfe_remotes() with FRAMEWORK_M_STATIC_BASE_URL support
  • Implement discover_shell() — check framework_m.shell entry point; fall back to bundled Desk
  • Expose GET /api/v1/frontend/remotes Litestar endpoint
  • Mount /mfe/{name}/ static routes in app factory (used when Python serves static files)
  • Serve custom shell's static/ if framework_m.shell is registered, else serve bundled Desk
  • Rebuild Desk shell as MFE host (empty remotes: {})
  • Implement federation-loader.ts in 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) with build-mfe-remote job

References


Status: This ADR is accepted and fully implemented as of 2026-03-11.