Plugin Architecture Implementation Guide
Related Documents:
- ADR-0008: Frontend Plugin Architecture - High-level design decision
- Framework M Sidebar Architecture - Sidebar implementation that consumes plugin menus
Table of Contents
- Why Do We Need a Plugin SDK?
- Package Roles & Responsibilities
- Plugin Menu Implementation
- Step-by-Step Implementation Plan
- Developer Workflow Examples
Why Do We Need a Plugin SDK?
The Problem Without an SDK
Currently, if you want to build a WMS (Warehouse Management) app with custom frontend:
# Without SDK - Manual wiring required
m new:app wms
cd frontend/
# Now what? How do I add WMS routes/menus without forking the entire frontend?
# Answer: You can't. You have to modify frontend/src/ directly.
Problems:
- ❌ No separation - WMS code mixed with core framework code
- ❌ No reusability - Can't share WMS plugin across projects
- ❌ Merge conflicts - Multiple apps editing same files
- ❌ No discovery - Manual imports everywhere
- ❌ Tight coupling - Changes to one app affect others
The Solution With an SDK
# With SDK - Plugin auto-discovery
m new:app wms --with-frontend
cd frontend/
pnpm dev # WMS plugin automatically loaded, routes/menus registered
# Later, add another app
m new:app personnel --with-frontend
pnpm dev # Both WMS + Personnel auto-discovered and composed
Benefits:
- ✅ Clean separation - Each app is self-contained plugin
- ✅ Reusable - Share plugins across projects
- ✅ No conflicts - Each app in its own directory
- ✅ Auto-discovery - Vite plugin finds all workspace plugins
- ✅ Loose coupling - Apps don't know about each other
What the SDK Provides
The SDK is the contract and tooling that makes plugins possible:
// Without SDK - You write this manually everywhere:
import WMSRoutes from "../apps/wms/routes";
import PersonnelRoutes from "../apps/personnel/routes";
import FinanceRoutes from "../apps/finance/routes";
const routes = [
...WMSRoutes,
...PersonnelRoutes,
...FinanceRoutes,
// Every new app = manual import + spread
];
// With SDK - Framework does this automatically:
const registry = new PluginRegistry();
await registry.discoverAndRegister(); // Finds all plugins
const routes = registry.getRoutes(); // Merged automatically
const menus = registry.getMenu(); // Merged automatically
The SDK is the "glue" that:
- Defines plugin structure (types, interfaces)
- Discovers plugins at build time
- Registers plugins into a central registry
- Provides React hooks for accessing plugin data
- Manages plugin lifecycle
Package Roles & Responsibilities
Current Package Landscape
┌─────────────────────────────────────────────────────────────┐
│ External Packages (npm) │
│ - @refinedev/core, @refinedev/react-router, etc. │
│ - react, react-dom, react-router-dom │
│ - @tanstack/react-query, lucide-react, etc. │
└─────────────────────────────────────────────────────────────┘
│ used by
▼
┌─────────────────────────────────────────────────────────────┐
│ @framework-m/desk (EXISTING) │
│ Location: libs/framework-m-desk/ │
│ │
│ Purpose: Core UI library for Framework M applications │
│ │
│ Responsibilities: │
│ • Refine.dev data provider (REST API integration) │
│ • Auth provider (JWT, session management) │
│ • Live provider (WebSocket notifications) │
│ • Base DocType components (List, Form, Show) │
│ • Hooks (useDocType, useMetadata, useWorkflow) │
│ • Utilities (date formatting, validation, etc.) │
│ │
│ Consumers: Both single-app and multi-app projects │
│ Published to: GitLab npm registry (@framework-m/desk) │
└─────────────────────────────────────────────────────────────┘
New Packages for Plugin System
┌─────────────────────────────────────────────────────────────┐
│ @framework-m/plugin-sdk (NEW - Phase 1) │
│ Location: libs/framework-m-plugin-sdk/ │
│ │
│ Purpose: Plugin infrastructure and runtime │
│ │
│ Responsibilities: │
│ • PluginRegistry - Central registry for all plugins │
│ • ServiceContainer - Dependency injection for services │
│ • React hooks - usePlugin(), useService(), usePluginMenu() │
│ • Types - FrameworkMPlugin interface, MenuItem, etc. │
│ • Discovery - discoverPlugins() utility │
│ │
│ Dependencies: React (peer), @framework-m/desk (peer) │
│ Consumers: Shell app, all plugin packages │
│ Published to: GitLab npm registry │
└─────────────────────────────────────────────────────────────┘
│ used by
▼
┌─────────────────────────────────────────────────────────────┐
│ @framework-m/vite-plugin (NEW - Phase 2) │
│ Location: libs/framework-m-vite-plugin/ │
│ │
│ Purpose: Build-time plugin discovery and bundling │
│ │
│ Responsibilities: │
│ • Scan workspace for packages with "framework-m" metadata │
│ • Generate virtual entry point with all plugins │
│ • Configure code-splitting (one chunk per plugin) │
│ • Setup HMR for plugin hot reload │
│ • Type generation for discovered plugins │
│ │
│ Dependencies: vite, @framework-m/plugin-sdk │
│ Consumers: Shell app vite.config.ts │
│ Published to: GitLab npm registry │
└─────────────────────────────────────────────────────────────┘
│ used by
▼
┌─────────────────────────────────────────────────────────────┐
│ frontend/ (Shell Application) │
│ Location: frontend/ (root of project) │
│ │
│ Purpose: Composition root - assembles all plugins │
│ │
│ Responsibilities: │
│ • Bootstrap PluginRegistry │
│ • Render composed UI (routes, menus, providers) │
│ • Provide base layout (Sidebar, Header, etc.) │
│ • Handle authentication flow │
│ • Error boundaries and loading states │
│ │
│ Dependencies: │
│ • @framework-m/desk - Core UI components │
│ • @framework-m/plugin-sdk - Plugin runtime │
│ • @framework-m/vite-plugin - Build tooling │
│ │
│ NOT published - Static build output (dist/) │
└─────────────────────────────────────────────────────────────┘
│ uses
▼
┌─────────────────────────────────────────────────────────────┐
│ App Plugins (Workspace Packages) │
│ Locations: │
│ • apps/wms/frontend/ │
│ • apps/personnel/frontend/ │
│ • apps/finance/frontend/ │
│ │
│ Purpose: Domain-specific UI modules │
│ │
│ Example: @my-company/wms │
│ │
│ Responsibilities: │
│ • Define routes for WMS screens │
│ • Define menu items for WMS navigation │
│ • Provide WMS-specific components │
│ • Register WMS services (WarehouseService, etc.) │
│ • Extend base DocTypes (add WMS-specific fields) │
│ │
│ Dependencies: │
│ • @framework-m/desk - Use base components │
│ • @framework-m/plugin-sdk - Plugin contract │
│ │
│ NOT published to npm - Workspace-local only │
│ Bundled into frontend/dist/ at build time │
└─────────────────────────────────────────────────────────────┘
Package Dependency Matrix
| Package | Depends On | Used By | Published? |
|---|---|---|---|
@framework-m/desk | @refinedev/*, react | Shell, All Plugins | ✅ GitLab npm |
@framework-m/plugin-sdk | react, @framework-m/desk | Shell, Plugins, Vite Plugin | ✅ GitLab npm |
@framework-m/vite-plugin | vite, plugin-sdk | Shell (vite.config.ts) | ✅ GitLab npm |
Shell App (frontend/) | All framework packages | - | ❌ Static build only |
| App Plugins | desk, plugin-sdk | Shell (via auto-discovery) | ❌ Workspace-local |
Key Design Principles
- @framework-m/desk remains unchanged - No breaking changes for existing users
- Plugin SDK is opt-in - Single-app users don't need it
- Workspace-local plugins - No need to publish app-specific code
- Build-time composition - Plugins bundled into single artifact
- Runtime flexibility - Can switch to Module Federation later if needed
Plugin Menu Implementation
Step 1: Define Plugin Contract (in SDK)
// libs/framework-m-plugin-sdk/src/types/plugin.ts
export interface MenuItem {
name: string; // Unique ID: "sales.invoice"
label: string; // Display: "Sales Invoice"
route: string; // Path: "/doctypes/sales.invoice"
icon?: string; // Icon name: "file-text"
module?: string; // Group: "Sales"
category?: string; // Subgroup: "Transactions"
order?: number; // Sort order
children?: MenuItem[]; // Nested items
}
export interface FrameworkMPlugin {
name: string;
version: string;
// Menu contribution
menu?: MenuItem[];
// Other plugin features
routes?: RouteObject[];
services?: Record<string, ServiceFactory>;
providers?: Provider[];
doctypes?: DocTypeExtension[];
widgets?: Widget[];
}
Step 2: Create Plugin Registry (in SDK)
// libs/framework-m-plugin-sdk/src/core/PluginRegistry.ts
import { FrameworkMPlugin, MenuItem } from "../types/plugin";
export class PluginRegistry {
private plugins = new Map<string, FrameworkMPlugin>();
private menuCache: MenuItem[] | null = null;
/**
* Register a plugin with the registry
*/
async register(plugin: FrameworkMPlugin): Promise<void> {
// Validate plugin structure
if (!plugin.name || !plugin.version) {
throw new Error("Plugin must have name and version");
}
// Check for duplicates
if (this.plugins.has(plugin.name)) {
console.warn(`Plugin ${plugin.name} already registered, overwriting`);
}
this.plugins.set(plugin.name, plugin);
this.menuCache = null; // Invalidate cache
}
/**
* Get merged menu tree from all plugins
*/
getMenu(): MenuItem[] {
if (this.menuCache) return this.menuCache;
const allMenus: MenuItem[] = [];
// Collect menu items from all plugins
for (const plugin of this.plugins.values()) {
if (plugin.menu) {
allMenus.push(...plugin.menu);
}
}
// Merge and sort menu items
this.menuCache = this.mergeMenus(allMenus);
return this.menuCache;
}
/**
* Merge menu items by module and category
*/
private mergeMenus(items: MenuItem[]): MenuItem[] {
const moduleMap = new Map<string, MenuItem>();
for (const item of items) {
const moduleName = item.module || "Other";
if (!moduleMap.has(moduleName)) {
// Create module group
moduleMap.set(moduleName, {
name: moduleName.toLowerCase(),
label: moduleName,
route: `/${moduleName.toLowerCase()}`,
icon: this.getModuleIcon(moduleName),
children: [],
});
}
const moduleGroup = moduleMap.get(moduleName)!;
if (item.category) {
// Find or create category subgroup
let categoryGroup = moduleGroup.children?.find(
c => c.name === item.category,
);
if (!categoryGroup) {
categoryGroup = {
name: item.category!.toLowerCase(),
label: item.category!,
route: `/${moduleName.toLowerCase()}/${item.category!.toLowerCase()}`,
children: [],
};
moduleGroup.children!.push(categoryGroup);
}
categoryGroup.children!.push(item);
} else {
// No category, add directly to module
moduleGroup.children!.push(item);
}
}
// Convert map to array and sort
return Array.from(moduleMap.values()).sort(
(a, b) => (a.order || 999) - (b.order || 999),
);
}
private getModuleIcon(moduleName: string): string {
const icons: Record<string, string> = {
Sales: "shopping-cart",
Inventory: "package",
HR: "users",
Finance: "dollar-sign",
Core: "settings",
};
return icons[moduleName] || "folder";
}
/**
* Get all registered routes
*/
getRoutes(): RouteObject[] {
const routes: RouteObject[] = [];
for (const plugin of this.plugins.values()) {
if (plugin.routes) {
routes.push(...plugin.routes);
}
}
return routes;
}
/**
* Get all registered plugins
*/
getAllPlugins(): FrameworkMPlugin[] {
return Array.from(this.plugins.values());
}
}
Step 3: Create React Hook (in SDK)
// libs/framework-m-plugin-sdk/src/hooks/usePluginMenu.ts
import { useContext, useMemo } from "react";
import { PluginRegistryContext } from "../context/PluginRegistryContext";
import { MenuItem } from "../types/plugin";
/**
* Hook to access the merged menu tree from all plugins
*/
export function usePluginMenu(): MenuItem[] {
const registry = useContext(PluginRegistryContext);
if (!registry) {
throw new Error("usePluginMenu must be used within PluginRegistryProvider");
}
return useMemo(() => registry.getMenu(), [registry]);
}
/**
* Hook to access a specific plugin's data
*/
export function usePlugin(name: string) {
const registry = useContext(PluginRegistryContext);
if (!registry) {
throw new Error("usePlugin must be used within PluginRegistryProvider");
}
return useMemo(
() => registry.getAllPlugins().find(p => p.name === name),
[registry, name],
);
}
Step 4: Create Context Provider (in SDK)
// libs/framework-m-plugin-sdk/src/context/PluginRegistryContext.tsx
import { createContext, ReactNode, useEffect, useState } from 'react';
import { PluginRegistry } from '../core/PluginRegistry';
export const PluginRegistryContext = createContext<PluginRegistry | null>(null);
interface PluginRegistryProviderProps {
children: ReactNode;
registry?: PluginRegistry;
}
export function PluginRegistryProvider({
children,
registry: externalRegistry,
}: PluginRegistryProviderProps) {
const [registry] = useState(() => externalRegistry || new PluginRegistry());
const [isReady, setIsReady] = useState(false);
useEffect(() => {
// Mark as ready after initial mount
setIsReady(true);
}, []);
if (!isReady) {
return <div>Loading plugins...</div>;
}
return (
<PluginRegistryContext.Provider value={registry}>
{children}
</PluginRegistryContext.Provider>
);
}
Step 5: Update Shell App to Use Plugin Menu
// frontend/src/layout/Sidebar.tsx
import { usePluginMenu } from '@framework-m/plugin-sdk';
import { useMemo } from 'react';
export function Sidebar() {
// Get merged menu from all plugins
const pluginMenu = usePluginMenu();
// Load user preferences (favorites, recent)
const favorites = useFavorites();
const recent = useRecent();
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
// Group plugin menu by module
const groupedMenu = useMemo(() => {
const groups: Record<string, MenuItem[]> = {};
for (const moduleGroup of pluginMenu) {
if (moduleGroup.children) {
groups[moduleGroup.label] = moduleGroup.children;
}
}
return groups;
}, [pluginMenu]);
return (
<nav>
{/* Favorites Section */}
<FavoritesSection items={favorites} />
{/* Recent Section */}
<RecentSection items={recent} />
{/* Plugin Menus */}
{Object.entries(groupedMenu).map(([moduleName, items]) => (
<ModuleSection
key={moduleName}
name={moduleName}
items={items}
isCollapsed={collapsedGroups[moduleName]}
onToggle={() => toggleGroup(moduleName)}
/>
))}
</nav>
);
}
Step 6: Create WMS Plugin
// apps/wms/frontend/plugin.config.ts
import { FrameworkMPlugin } from "@framework-m/plugin-sdk";
export default {
name: "wms",
version: "1.0.0",
// Define WMS menu items
menu: [
{
name: "wms.warehouse",
label: "Warehouse",
route: "/doctypes/wms.warehouse",
icon: "warehouse",
module: "Inventory",
category: "Masters",
order: 1,
},
{
name: "wms.stock_entry",
label: "Stock Entry",
route: "/doctypes/wms.stock_entry",
icon: "package",
module: "Inventory",
category: "Transactions",
order: 2,
},
{
name: "wms.bin_location",
label: "Bin Location",
route: "/doctypes/wms.bin_location",
icon: "map-pin",
module: "Inventory",
category: "Masters",
order: 3,
},
],
// Define WMS routes
routes: [
{
path: "/wms/dashboard",
element: () => import("./pages/Dashboard"),
},
{
path: "/wms/receiving",
element: () => import("./pages/Receiving"),
},
],
// Register WMS services
services: {
warehouseService: () => import("./services/WarehouseService"),
},
} satisfies FrameworkMPlugin;
// apps/wms/frontend/package.json
{
"name": "@my-company/wms",
"version": "1.0.0",
"private": true,
"framework-m": {
"plugin": "./dist/plugin.config.js",
"type": "frontend-module"
},
"dependencies": {
"@framework-m/desk": "^0.1.0",
"@framework-m/plugin-sdk": "^0.1.0"
}
}
Step 7: Vite Plugin Auto-Discovery
// libs/framework-m-vite-plugin/src/index.ts
import { Plugin } from "vite";
import { globSync } from "glob";
import path from "path";
import fs from "fs";
export function frameworkMPlugin(): Plugin {
let pluginPaths: string[] = [];
return {
name: "framework-m-plugin",
// Discover plugins before build starts
async buildStart() {
// Find all workspace packages with framework-m metadata
const packageJsonFiles = globSync("**/package.json", {
ignore: ["**/node_modules/**", "**/dist/**"],
});
pluginPaths = [];
for (const pkgPath of packageJsonFiles) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
// Check for framework-m plugin metadata
if (pkg["framework-m"]?.plugin) {
const pluginConfigPath = path.resolve(
path.dirname(pkgPath),
pkg["framework-m"].plugin,
);
pluginPaths.push(pluginConfigPath);
}
}
console.log(`✓ Discovered ${pluginPaths.length} Framework M plugins`);
},
// Generate virtual module with all plugins
resolveId(id) {
if (id === "virtual:framework-m-plugins") {
return "\0virtual:framework-m-plugins";
}
},
load(id) {
if (id === "\0virtual:framework-m-plugins") {
// Generate import statements for all plugins
const imports = pluginPaths
.map((p, i) => `import plugin${i} from '${p}';`)
.join("\n");
const exports = `export default [${pluginPaths.map((_, i) => `plugin${i}`).join(", ")}];`;
return `${imports}\n${exports}`;
}
},
};
}
Step 8: Shell App Bootstrap
// frontend/src/App.tsx
import { PluginRegistry, PluginRegistryProvider } from '@framework-m/plugin-sdk';
import { useEffect, useState } from 'react';
// @ts-ignore - Virtual module generated by Vite plugin
import discoveredPlugins from 'virtual:framework-m-plugins';
async function bootstrap() {
const registry = new PluginRegistry();
// Register all discovered plugins
for (const plugin of discoveredPlugins) {
await registry.register(plugin);
}
return registry;
}
export function App() {
const [registry, setRegistry] = useState<PluginRegistry | null>(null);
useEffect(() => {
bootstrap().then(setRegistry);
}, []);
if (!registry) {
return <LoadingScreen />;
}
return (
<PluginRegistryProvider registry={registry}>
<Refine
dataProvider={dataProvider}
authProvider={authProvider}
routerProvider={routerProvider}
>
<Routes>
<Route path="/" element={<Layout />}>
{/* Routes from plugins */}
{registry.getRoutes().map((route, i) => (
<Route key={i} {...route} />
))}
</Route>
</Routes>
</Refine>
</PluginRegistryProvider>
);
}
Step-by-Step Implementation Plan
Phase 1: Create @framework-m/plugin-sdk (Week 1-2)
Tasks:
-
✅ Create package structure
mkdir -p libs/framework-m-plugin-sdk/src/{types,core,hooks,context}
cd libs/framework-m-plugin-sdk
pnpm init -
✅ Define types (
src/types/plugin.ts)MenuIteminterfaceFrameworkMPlugininterfaceServiceFactorytypeRouteObjectextensions
-
✅ Implement
PluginRegistryclass (src/core/PluginRegistry.ts)register()methodgetMenu()with merging logicgetRoutes()aggregationgetAllPlugins()accessor
-
✅ Create React hooks (
src/hooks/)usePluginMenu()usePlugin(name)useService(name)
-
✅ Create context provider (
src/context/PluginRegistryContext.tsx) -
✅ Write tests (100% coverage target)
-
✅ Setup build (TypeScript + Vite)
-
✅ Publish to GitLab npm registry
Deliverables:
@framework-m/plugin-sdkpackage published- Full API documentation
- Unit tests passing
Phase 2: Create @framework-m/vite-plugin (Week 2-3)
Tasks:
- ✅ Create package structure
- ✅ Implement plugin discovery logic
- ✅ Generate virtual module
- ✅ Configure code-splitting
- ✅ Setup HMR support
- ✅ Write integration tests
- ✅ Publish to GitLab npm registry
Deliverables:
@framework-m/vite-pluginpackage published- Working auto-discovery
- Documentation with examples
Phase 3: Update Shell App (Week 3-4)
Tasks:
-
✅ Install plugin-sdk and vite-plugin
cd frontend
pnpm add @framework-m/plugin-sdk @framework-m/vite-plugin -
✅ Update
vite.config.tsimport { frameworkMPlugin } from "@framework-m/vite-plugin";
export default defineConfig({
plugins: [react(), frameworkMPlugin()],
}); -
✅ Bootstrap in
App.tsx -
✅ Update
Sidebar.tsxto useusePluginMenu() -
✅ Test with existing menu data
-
✅ Add loading states and error boundaries
Deliverables:
- Shell app using plugin system
- Sidebar consuming plugin menus
- No breaking changes to existing functionality
Phase 4: Create Example Plugin (Week 4-5)
Tasks:
-
✅ Scaffold WMS plugin structure
m new:app wms --with-frontend -
✅ Define
plugin.config.ts -
✅ Add menu items
-
✅ Create sample routes
-
✅ Test auto-discovery
-
✅ Document plugin development workflow
Deliverables:
- Working WMS plugin example
- Plugin development guide
- Tutorial documentation
Phase 5: CLI Enhancements (Week 5-6)
Tasks:
- ✅ Add
--with-frontendflag tom new:app - ✅ Create plugin template
- ✅ Auto-configure workspace
- ✅ Update CLI documentation
Deliverables:
- Enhanced CLI command
- Plugin scaffolding template
- Developer workflow documentation
Developer Workflow Examples
Single App (No Plugins) - Current Workflow
# Create project
m new:project erp
cd erp
# Work in monolithic frontend
cd frontend/
pnpm dev
# Add custom route manually
# Edit: frontend/src/App.tsx
# Edit: frontend/src/pages/CustomPage.tsx
No changes needed - Existing workflow still works!
Multi-App (With Plugins) - New Workflow
# Create project
m new:project erp
cd erp
# Create first app with frontend
m new:app wms --with-frontend
# Plugin structure auto-generated:
# apps/wms/frontend/
# ├── plugin.config.ts
# ├── package.json (with framework-m metadata)
# └── src/
# ├── pages/
# ├── components/
# └── services/
# Develop WMS plugin
cd apps/wms/frontend
# Edit: plugin.config.ts (add menu items, routes)
# Edit: src/pages/Dashboard.tsx (create pages)
# Run from shell
cd ../../../frontend
pnpm dev
# ✓ WMS plugin auto-discovered
# ✓ WMS routes registered
# ✓ WMS menu items in sidebar
# Add second app
m new:app personnel --with-frontend
cd frontend
pnpm dev
# ✓ Both WMS and Personnel plugins loaded
# ✓ Menus merged automatically
Production Build
cd frontend
pnpm build
# Output: frontend/dist/
# index.html
# assets/
# main-abc123.js (Shell + framework)
# wms-def456.js (WMS plugin - lazy loaded)
# personnel-ghi789.js (Personnel plugin - lazy loaded)
# Deploy
m build # Bundles dist/ into Python package
pip install dist/framework_m-1.0.0-py3-none-any.whl
m prod # Serves bundled frontend
Summary
Why SDK?
| Without SDK | With SDK |
|---|---|
| Manual imports everywhere | Auto-discovery |
| Code duplication | Reusable plugins |
| Merge conflicts | Clean separation |
| Tight coupling | Loose coupling |
| Hard to scale | Scales easily |
Package Roles Quick Reference
| Package | Role | Published? | Used By |
|---|---|---|---|
@framework-m/desk | Core UI library | ✅ npm | Everyone |
@framework-m/plugin-sdk | Plugin runtime | ✅ npm | Shell + Plugins |
@framework-m/vite-plugin | Build tooling | ✅ npm | Shell only |
Shell (frontend/) | Composition root | ❌ Build output | - |
| App Plugins | Domain modules | ❌ Workspace-local | Shell (bundled) |
Implementation Path
- Phase 1 - Build
@framework-m/plugin-sdk← START HERE - Phase 2 - Build
@framework-m/vite-plugin - Phase 3 - Update shell app to use plugins
- Phase 4 - Create example WMS plugin
- Phase 5 - Enhance CLI for plugin scaffolding
Next Step: Start implementing Phase 1 (plugin-sdk package)