Skip to main content

Plugin Architecture Implementation Guide

Related Documents:

Table of Contents

  1. Why Do We Need a Plugin SDK?
  2. Package Roles & Responsibilities
  3. Plugin Menu Implementation
  4. Step-by-Step Implementation Plan
  5. 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:

  1. No separation - WMS code mixed with core framework code
  2. No reusability - Can't share WMS plugin across projects
  3. Merge conflicts - Multiple apps editing same files
  4. No discovery - Manual imports everywhere
  5. 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:

  1. Clean separation - Each app is self-contained plugin
  2. Reusable - Share plugins across projects
  3. No conflicts - Each app in its own directory
  4. Auto-discovery - Vite plugin finds all workspace plugins
  5. 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

PackageDepends OnUsed ByPublished?
@framework-m/desk@refinedev/*, reactShell, All Plugins✅ GitLab npm
@framework-m/plugin-sdkreact, @framework-m/deskShell, Plugins, Vite Plugin✅ GitLab npm
@framework-m/vite-pluginvite, plugin-sdkShell (vite.config.ts)✅ GitLab npm
Shell App (frontend/)All framework packages-❌ Static build only
App Pluginsdesk, plugin-sdkShell (via auto-discovery)❌ Workspace-local

Key Design Principles

  1. @framework-m/desk remains unchanged - No breaking changes for existing users
  2. Plugin SDK is opt-in - Single-app users don't need it
  3. Workspace-local plugins - No need to publish app-specific code
  4. Build-time composition - Plugins bundled into single artifact
  5. 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:

  1. ✅ Create package structure

    mkdir -p libs/framework-m-plugin-sdk/src/{types,core,hooks,context}
    cd libs/framework-m-plugin-sdk
    pnpm init
  2. ✅ Define types (src/types/plugin.ts)

    • MenuItem interface
    • FrameworkMPlugin interface
    • ServiceFactory type
    • RouteObject extensions
  3. ✅ Implement PluginRegistry class (src/core/PluginRegistry.ts)

    • register() method
    • getMenu() with merging logic
    • getRoutes() aggregation
    • getAllPlugins() accessor
  4. ✅ Create React hooks (src/hooks/)

    • usePluginMenu()
    • usePlugin(name)
    • useService(name)
  5. ✅ Create context provider (src/context/PluginRegistryContext.tsx)

  6. ✅ Write tests (100% coverage target)

  7. ✅ Setup build (TypeScript + Vite)

  8. ✅ Publish to GitLab npm registry

Deliverables:

  • @framework-m/plugin-sdk package published
  • Full API documentation
  • Unit tests passing

Phase 2: Create @framework-m/vite-plugin (Week 2-3)

Tasks:

  1. ✅ Create package structure
  2. ✅ Implement plugin discovery logic
  3. ✅ Generate virtual module
  4. ✅ Configure code-splitting
  5. ✅ Setup HMR support
  6. ✅ Write integration tests
  7. ✅ Publish to GitLab npm registry

Deliverables:

  • @framework-m/vite-plugin package published
  • Working auto-discovery
  • Documentation with examples

Phase 3: Update Shell App (Week 3-4)

Tasks:

  1. ✅ Install plugin-sdk and vite-plugin

    cd frontend
    pnpm add @framework-m/plugin-sdk @framework-m/vite-plugin
  2. ✅ Update vite.config.ts

    import { frameworkMPlugin } from "@framework-m/vite-plugin";

    export default defineConfig({
    plugins: [react(), frameworkMPlugin()],
    });
  3. ✅ Bootstrap in App.tsx

  4. ✅ Update Sidebar.tsx to use usePluginMenu()

  5. ✅ Test with existing menu data

  6. ✅ 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:

  1. ✅ Scaffold WMS plugin structure

    m new:app wms --with-frontend
  2. ✅ Define plugin.config.ts

  3. ✅ Add menu items

  4. ✅ Create sample routes

  5. ✅ Test auto-discovery

  6. ✅ Document plugin development workflow

Deliverables:

  • Working WMS plugin example
  • Plugin development guide
  • Tutorial documentation

Phase 5: CLI Enhancements (Week 5-6)

Tasks:

  1. ✅ Add --with-frontend flag to m new:app
  2. ✅ Create plugin template
  3. ✅ Auto-configure workspace
  4. ✅ 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 SDKWith SDK
Manual imports everywhereAuto-discovery
Code duplicationReusable plugins
Merge conflictsClean separation
Tight couplingLoose coupling
Hard to scaleScales easily

Package Roles Quick Reference

PackageRolePublished?Used By
@framework-m/deskCore UI library✅ npmEveryone
@framework-m/plugin-sdkPlugin runtime✅ npmShell + Plugins
@framework-m/vite-pluginBuild tooling✅ npmShell only
Shell (frontend/)Composition root❌ Build output-
App PluginsDomain modules❌ Workspace-localShell (bundled)

Implementation Path

  1. Phase 1 - Build @framework-m/plugin-sdkSTART HERE
  2. Phase 2 - Build @framework-m/vite-plugin
  3. Phase 3 - Update shell app to use plugins
  4. Phase 4 - Create example WMS plugin
  5. Phase 5 - Enhance CLI for plugin scaffolding

Next Step: Start implementing Phase 1 (plugin-sdk package)