Chapter 20: Plugin Architecture and Extensibility
Modern applications increasingly need to be extensible—allowing third-party developers to add features without modifying the core codebase. Whether you're building a developer tool, a content management system, or an enterprise application, a well-designed plugin architecture enables runtime extensibility while maintaining architectural integrity.
This chapter explores plugin architecture patterns using the Web Loom monorepo's plugin-core library and plugin-react application as concrete examples. We'll examine how to build framework-agnostic plugin systems that integrate seamlessly with MVVM architecture, demonstrating the PluginRegistry, FrameworkAdapter, PluginManifest, and PluginSDK patterns.
Why Plugin Architecture Matters for MVVM
Plugin architectures solve a fundamental problem: how do you allow external code to extend your application without breaking architectural boundaries?
In MVVM applications, this challenge is particularly interesting because:
- Framework Independence: Plugins should work regardless of whether your host uses React, Vue, Angular, or another framework
- Lifecycle Management: Plugins need controlled initialization, mounting, and cleanup
- Isolation: Plugins shouldn't directly access host internals or break encapsulation
- Validation: Plugin configurations must be validated before loading
- Dependencies: Plugins may depend on other plugins, requiring load order resolution
A well-designed plugin system provides these capabilities while maintaining the separation of concerns that makes MVVM valuable.
The Plugin Architecture Pattern
At its core, a plugin architecture consists of four key components:
- PluginRegistry: Manages plugin registration, lifecycle states, and dependency resolution
- PluginManifest: Declarative configuration describing what a plugin provides
- FrameworkAdapter: Abstraction for mounting plugins in specific UI frameworks
- PluginSDK: Controlled API for plugin-host communication
Let's examine each component using real implementations from the Web Loom monorepo.
PluginRegistry: Framework-Agnostic Plugin Management
The PluginRegistry is the heart of the plugin system. It maintains the registry of all plugins, tracks their lifecycle states, and resolves dependencies.
Here's the actual implementation from packages/plugin-core/src/registry/PluginRegistry.ts:
/**
* The lifecycle state of a plugin.
*/
export type PluginState =
| 'registered' // Manifest has been loaded and validated
| 'loading' // Plugin code is being fetched
| 'loaded' // Plugin code has been loaded, `init` called
| 'mounted' // `mount` has been called
| 'unmounted' // `unmount` has been called
| 'error'; // An error occurred
/**
* Internal representation of a plugin within the registry.
*/
export interface PluginDefinition<T extends TComponent = TComponent> {
manifest: PluginManifest<T>;
state: PluginState;
instance?: any; // This would hold the loaded plugin module
}
export class PluginRegistry<T extends TComponent = TComponent> {
public readonly plugins = new Map<string, PluginDefinition<T>>();
constructor(public readonly adapter?: FrameworkAdapter<T>) {}
/**
* Registers a plugin with the registry.
*/
register(manifest: PluginManifest<T>): void {
// 1. Validate the manifest
try {
PluginManifestSchema.parse(manifest);
} catch (error) {
const e = error as Error;
throw new Error(`Invalid plugin manifest for "${manifest.id}": ${e.message}`);
}
// 2. Check for duplicates
if (this.plugins.has(manifest.id)) {
throw new Error(`Plugin with ID "${manifest.id}" is already registered.`);
}
// 3. Store the plugin definition
const definition: PluginDefinition<T> = {
manifest,
state: 'registered',
};
this.plugins.set(manifest.id, definition);
}
}Notice several key design decisions:
- Generic Type Parameter:
T extends TComponentallows the registry to work with any framework's component type - Lifecycle States: Explicit states track plugin progression from registration to mounting
- Validation First: Manifests are validated using Zod before registration
- Duplicate Prevention: The registry prevents ID collisions
Plugin Lifecycle Management
The lifecycle states enable controlled plugin initialization:
- registered: Manifest validated and stored
- loading: Plugin code being fetched (for remote plugins)
- loaded: Plugin module loaded,
init()called - mounted: Plugin's
mount()called, UI rendered - unmounted: Plugin's
unmount()called, cleanup performed - error: An error occurred during any lifecycle transition
This explicit state machine prevents plugins from being used before they're ready and ensures proper cleanup.
Dependency Resolution with Topological Sort
One of the most sophisticated features of the PluginRegistry is automatic dependency resolution. Plugins can declare dependencies on other plugins, and the registry calculates the correct load order:
/**
* Resolves the plugin load order based on dependencies using topological sort.
*/
resolveLoadOrder(): string[] {
const pluginIds = Array.from(this.plugins.keys());
const adj = new Map<string, string[]>();
const inDegree = new Map<string, number>();
// Initialize in-degrees and adjacency list
for (const id of pluginIds) {
inDegree.set(id, 0);
adj.set(id, []);
}
// Build the graph
for (const [id, plugin] of this.plugins) {
const dependencies = plugin.manifest.dependencies || {};
for (const depId of Object.keys(dependencies)) {
if (!this.plugins.has(depId)) {
throw new Error(
`Plugin "${id}" has an unresolved dependency: "${depId}" is not registered.`
);
}
// Edge from dependency to the plugin that depends on it
adj.get(depId)!.push(id);
inDegree.set(id, (inDegree.get(id) || 0) + 1);
}
}
// Kahn's algorithm for topological sort
const queue = pluginIds.filter((id) => inDegree.get(id) === 0);
const sorted: string[] = [];
while (queue.length > 0) {
const id = queue.shift()!;
sorted.push(id);
for (const dependentId of adj.get(id)!) {
inDegree.set(dependentId, (inDegree.get(dependentId) || 0) - 1);
if (inDegree.get(dependentId) === 0) {
queue.push(dependentId);
}
}
}
// Detect circular dependencies
if (sorted.length !== pluginIds.length) {
const cycleNodes = pluginIds.filter((id) => !sorted.includes(id));
throw new Error(
`Circular dependency detected. Check: ${cycleNodes.join(', ')}`
);
}
return sorted;
}This implementation uses Kahn's algorithm for topological sorting, which:
- Builds a dependency graph from plugin manifests
- Identifies plugins with no dependencies (in-degree = 0)
- Processes plugins in order, removing edges as dependencies are satisfied
- Detects circular dependencies if any plugins remain unprocessed
This ensures plugins always load after their dependencies, preventing runtime errors from missing dependencies.
PluginManifest: Declarative Configuration
The PluginManifest is a declarative, JSON-serializable object that describes what a plugin provides. This separation of configuration from implementation is crucial for plugin systems.
Here's the manifest interface from packages/plugin-core/src/contracts/PluginManifest.ts:
/**
* The plugin manifest is a static, declarative JSON object that describes
* the plugin's metadata and its contributions to the host application.
*/
export interface PluginManifest<T extends TComponent = TComponent> {
id: string;
name: string;
version: string;
entry: string;
description?: string;
author?: string;
icon?: string;
routes?: PluginRouteDefinition<T>[];
menuItems?: PluginMenuItem[];
widgets?: PluginWidgetDefinition<T>[];
metadata?: Record<string, unknown>;
dependencies?: Record<string, string>;
}
/**
* Defines a route to be registered by a plugin.
*/
export interface PluginRouteDefinition<T extends TComponent = TComponent> {
path: string;
component: T;
exact?: boolean;
}
/**
* Defines a menu item to be added to the host application's UI.
*/
export interface PluginMenuItem {
label: string;
path: string;
icon?: string;
}
/**
* Defines a widget to be displayed in the host application.
*/
export interface PluginWidgetDefinition<T extends TComponent = TComponent> {
id: string;
title: string;
component: T;
}The manifest enables plugins to contribute:
- Routes: New pages/views in the application
- Menu Items: Navigation entries
- Widgets: Dashboard components
- Metadata: Custom configuration data
- Dependencies: Required plugins
Runtime Validation with Zod
Manifests are validated at runtime using Zod schemas, ensuring type safety even for dynamically loaded plugins:
const PluginRouteDefinitionSchema = z.object({
path: z.string(),
component: z.any(),
exact: z.boolean().optional(),
});
const PluginMenuItemSchema = z.object({
label: z.string(),
path: z.string(),
icon: z.string().optional(),
});
const PluginWidgetDefinitionSchema = z.object({
id: z.string(),
title: z.string(),
component: z.any(),
});
export const PluginManifestSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
version: z.string().min(1),
entry: z.string().min(1),
description: z.string().optional(),
author: z.string().optional(),
icon: z.string().url().optional(),
routes: z.array(PluginRouteDefinitionSchema).optional(),
menuItems: z.array(PluginMenuItemSchema).optional(),
widgets: z.array(PluginWidgetDefinitionSchema).optional(),
metadata: z.record(z.unknown()).optional(),
dependencies: z.record(z.string()).optional(),
});This validation happens in the PluginRegistry.register() method before a plugin is accepted, preventing invalid plugins from entering the system.
Real Plugin Manifest Example
Here's an actual plugin manifest from the GreenWatch application (apps/plugin-react/src/config/plugin.config.ts):
const greenhouseManifest: PluginManifest<ReactPluginComponent> = {
id: 'greenhouse-plugin',
name: 'Greenhouse Plugin',
version: '1.0.0',
entry: '../plugins/greenhouse/index.tsx',
description: 'Greenhouse list and summary widget',
routes: [
{
path: '/greenhouses',
component: GreenhouseList,
},
],
widgets: [
{
id: 'greenhouse-card-widget',
title: 'Greenhouses',
component: GreenhouseWidget,
},
],
};This manifest declares that the greenhouse plugin:
- Provides a route at
/greenhousesrendering theGreenhouseListcomponent - Contributes a dashboard widget showing greenhouse summaries
- Has no dependencies on other plugins
FrameworkAdapter: Bridging Framework Boundaries
The FrameworkAdapter is the abstraction that makes the plugin system framework-agnostic. It defines how to mount and unmount components in a specific UI framework.
Here's the interface from packages/plugin-core/src/adapter/FrameworkAdapter.ts:
/**
* The FrameworkAdapter is responsible for bridging the gap between the
* framework-agnostic plugin core and the specific UI framework used by the host.
*
* @template T The base component type for the target framework
*/
export interface FrameworkAdapter<T extends TComponent = TComponent> {
/**
* Renders a plugin's component into a given DOM element.
*/
mountComponent(component: T, container: HTMLElement): void;
/**
* Unmounts and cleans up a rendered component from a DOM element.
*/
unmountComponent(container: HTMLElement): void;
}This simple interface hides all framework-specific complexity behind two methods. The host application provides an implementation for its framework.
React Framework Adapter Implementation
Here's the React adapter from apps/plugin-react/src/host/PluginHost.tsx:
import { createRoot, type Root } from 'react-dom/client';
const rootMap = new Map<HTMLElement, Root>();
const ReactAdapter: FrameworkAdapter<ReactPluginComponent> = {
mountComponent: (component, container) => {
let root = rootMap.get(container);
if (!root) {
root = createRoot(container);
rootMap.set(container, root);
}
root.render(createElement(component));
},
unmountComponent: (container) => {
const root = rootMap.get(container);
if (root) {
root.unmount();
rootMap.delete(container);
}
},
};Key implementation details:
- Root Management: Maintains a map of containers to React roots for proper cleanup
- Lazy Root Creation: Creates roots only when needed
- Cleanup: Properly unmounts and removes roots to prevent memory leaks
For Vue, Angular, or other frameworks, you'd implement the same interface using framework-specific mounting APIs. The plugin-core library remains completely framework-agnostic.
PluginSDK: Controlled Host Communication
The PluginSDK is the sole interface between plugins and the host application. It provides a controlled API that prevents plugins from accessing host internals directly.
Here's the SDK interface from packages/plugin-core/src/sdk/PluginSDK.ts:
/**
* The Plugin SDK is the sole, controlled interface between a plugin
* and the host application.
*/
export interface PluginSDK {
/**
* Provides context about the plugin itself.
*/
readonly plugin: {
id: string;
manifest: PluginManifest;
};
/**
* Manages registration of application routes.
*/
readonly routes: {
add: <T extends TComponent>(route: PluginRouteDefinition<T>) => void;
remove: (path: string) => void;
};
/**
* Manages registration of navigation menu items.
*/
readonly menus: {
addItem: (item: PluginMenuItem) => void;
removeItem: (label: string) => void;
};
/**
* Manages registration of dashboard widgets.
*/
readonly widgets: {
add: <T extends TComponent>(widget: PluginWidgetDefinition<T>) => void;
remove: (id: string) => void;
};
/**
* A pub/sub event bus for cross-plugin and host-plugin communication.
*/
readonly events: {
on: (event: string, handler: (...args: any[]) => void) => void;
off: (event: string, handler: (...args: any[]) => void) => void;
emit: (event: string, ...args: any[]) => void;
};
/**
* Provides access to shared, host-managed UI components.
*/
readonly ui: {
showModal: <T extends TComponent>(content: T, options?: object) => void;
showToast: (message: string, type?: 'info' | 'success' | 'warning' | 'error') => void;
};
/**
* Provides access to shared, host-provided application services.
*/
readonly services: {
apiClient: {
get: <T>(url: string) => Promise<T>;
post: <T>(url: string, data: any) => Promise<T>;
put: <T>(url: string, data: any) => Promise<T>;
delete: <T>(url: string) => Promise<T>;
};
auth: {
getUser: () => Promise<{ id: string; name: string; email: string } | null>;
hasRole: (role: string) => Promise<boolean>;
};
storage: {
get: <T>(key: string) => Promise<T | null>;
set: (key: string, value: any) => Promise<void>;
remove: (key: string) => Promise<void>;
};
};
}The SDK provides several categories of functionality:
- Plugin Context: Information about the plugin itself
- Contribution APIs: Register routes, menus, and widgets
- Event Bus: Cross-plugin communication
- UI Services: Shared UI components (modals, toasts)
- Application Services: Authenticated API client, auth, storage
This design ensures plugins can only interact with the host through well-defined APIs, maintaining encapsulation and security.
Building a Plugin: Complete Example
Let's examine a complete plugin implementation from the GreenWatch application. This is the greenhouse plugin from apps/plugin-react/src/plugins/greenhouse/index.tsx:
import React, { useEffect } from 'react';
import type { PluginModule, PluginSDK } from '@repo/plugin-core';
import { registerManifestContributions, unregisterManifestContributions } from '../utils/manifestHelpers';
import GreenhouseCard from '../../components/GreenhouseCard';
import { greenHouseViewModel, type GreenhouseListData } from '@repo/view-models/GreenHouseViewModel';
import { useObservable } from '../../hooks/useObservable';
// The widget component that will be rendered
const GreenhouseWidget: React.FC = () => {
const greenHouses = useObservable(greenHouseViewModel.data$, [] as GreenhouseListData);
useEffect(() => {
const fetchData = async () => {
try {
await greenHouseViewModel.fetchCommand.execute();
} catch (error) {
console.error('[greenhouse widget] failed to load data', error);
}
};
fetchData();
}, []);
return <GreenhouseCard greenHouses={greenHouses} />;
};
let activeSdk: PluginSDK | null = null;
// The plugin module with lifecycle methods
const greenhouseModule: PluginModule = {
init: async (sdk) => {
activeSdk = sdk;
console.debug('[greenhouse] initialized');
},
mount: async (sdk) => {
activeSdk = sdk;
registerManifestContributions(sdk);
console.debug('[greenhouse] mounted');
},
unmount: async () => {
if (!activeSdk) return;
unregisterManifestContributions(activeSdk);
console.debug('[greenhouse] unmounted');
activeSdk = null;
},
};
export { GreenhouseWidget };
export default greenhouseModule;Notice how this plugin:
- Uses MVVM: The widget subscribes to
greenHouseViewModel.data$using theuseObservablehook - Implements Lifecycle: Provides
init,mount, andunmountmethods - Registers Contributions: Uses the SDK to register routes and widgets
- Maintains State: Tracks the active SDK for cleanup
- Handles Cleanup: Unregisters contributions on unmount
This demonstrates how plugins integrate seamlessly with MVVM architecture—the plugin uses the same ViewModels as the rest of the application.
The Plugin Host: Bringing It All Together
The plugin host is the application that loads and manages plugins. Here's a simplified version of the React plugin host from apps/plugin-react/src/host/PluginHost.tsx:
export const PluginHostProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [routes, setRoutes] = useState<PluginRouteDefinition<ReactPluginComponent>[]>([]);
const [widgets, setWidgets] = useState<PluginWidgetDefinition<ReactPluginComponent>[]>([]);
const [menuItems, setMenuItems] = useState<PluginMenuItem[]>([]);
const [isReady, setIsReady] = useState(false);
// Create host services that plugins can access via SDK
const hostServices = useMemo<HostServices>(() => {
const addRoute = <T extends TComponent>(route: PluginRouteDefinition<T>) => {
const normalizedRoute: PluginRouteDefinition<ReactPluginComponent> = {
...route,
component: route.component as ReactPluginComponent,
};
setRoutes((prev) =>
prev.some((entry) => entry.path === normalizedRoute.path)
? prev
: [...prev, normalizedRoute]
);
};
const addWidget = <T extends TComponent>(widget: PluginWidgetDefinition<T>) => {
const normalizedWidget: PluginWidgetDefinition<ReactPluginComponent> = {
...widget,
component: widget.component as ReactPluginComponent,
};
setWidgets((prev) =>
prev.some((entry) => entry.id === normalizedWidget.id)
? prev
: [...prev, normalizedWidget]
);
};
const addMenuItem = (item: PluginMenuItem) => {
setMenuItems((prev) =>
prev.some((entry) => entry.label === item.label)
? prev
: [...prev, item]
);
};
// Event bus implementation
const handlers = new Map<string, Set<(...args: any[]) => void>>();
const registerEvent = (event: string, handler: (...args: any[]) => void) => {
const set = handlers.get(event) ?? new Set();
set.add(handler);
handlers.set(event, set);
};
const emitEvent = (event: string, ...args: any[]) => {
handlers.get(event)?.forEach((handler) => handler(...args));
};
return {
routes: { add: addRoute, remove: (path) => setRoutes(prev => prev.filter(r => r.path !== path)) },
menus: { addItem: addMenuItem, removeItem: (label) => setMenuItems(prev => prev.filter(m => m.label !== label)) },
widgets: { add: addWidget, remove: (id) => setWidgets(prev => prev.filter(w => w.id !== id)) },
events: { on: registerEvent, off: () => {}, emit: emitEvent },
ui: { showModal: () => {}, showToast: (msg) => console.info(msg) },
services: { /* API client, auth, storage implementations */ },
};
}, []);
useEffect(() => {
// Register all plugin manifests
pluginManifests.forEach((manifest) => {
try {
if (!pluginRegistry.get(manifest.id)) {
pluginRegistry.register(manifest);
}
} catch (error) {
console.error('Failed to register plugin', manifest.id, error);
}
});
// Load and initialize plugins
const loadPlugins = async () => {
for (const manifest of pluginManifests) {
const module = pluginModules[manifest.id];
if (!module) continue;
const sdk = new PluginSDKImplementation(manifest.id, manifest, hostServices);
try {
await module.init?.(sdk);
await module.mount?.(sdk);
} catch (error) {
console.error(`Failed to initialize plugin ${manifest.id}`, error);
}
}
setIsReady(true);
};
loadPlugins();
// Cleanup on unmount
return () => {
pluginManifests.forEach((manifest) => {
pluginModules[manifest.id]?.unmount?.();
});
};
}, [hostServices]);
return (
<PluginHostContext.Provider value={{ routes, widgets, menuItems, isReady }}>
{children}
</PluginHostContext.Provider>
);
};The host implementation:
- Manages Plugin State: Tracks routes, widgets, and menu items contributed by plugins
- Provides Host Services: Creates the services object passed to plugins via SDK
- Registers Plugins: Validates and registers all plugin manifests
- Initializes Plugins: Calls
init()andmount()on each plugin module - Handles Cleanup: Calls
unmount()on all plugins when the host unmounts
This pattern ensures plugins are loaded in a controlled manner with proper lifecycle management.
Rendering Plugin Contributions
Once plugins are loaded, the host needs to render their contributions. Here's how the GreenWatch application renders plugin widgets and routes:
export const PluginHost: React.FC = () => {
const { routes, widgets, isReady } = usePluginHostContext();
const location = useLocation();
const showWidgets = ['/', '/dashboard'].includes(location.pathname);
if (!isReady) {
return <p>Loading plugins...</p>;
}
return (
<div>
{/* Render plugin widgets on dashboard */}
{showWidgets && widgets.length > 0 && (
<section className="widget-area">
{widgets.map((widget) => (
<article key={widget.id} className="widget-card">
<header>
<h3>{widget.title}</h3>
</header>
<div>
<widget.component />
</div>
</article>
))}
</section>
)}
{/* Render plugin routes */}
<Routes>
{routes.map((route) => (
<Route
key={route.path}
path={route.path}
element={<route.component />}
/>
))}
<Route path="*" element={<p>Select a feature from the navigation menu.</p>} />
</Routes>
</div>
);
};This demonstrates the power of the plugin system: the host doesn't know what widgets or routes exist at build time. Plugins dynamically contribute UI elements that are rendered seamlessly alongside the host application.
Security Considerations
Plugin architectures introduce security concerns that must be addressed:
1. Manifest Validation
Always validate plugin manifests before loading:
register(manifest: PluginManifest<T>): void {
try {
PluginManifestSchema.parse(manifest);
} catch (error) {
throw new Error(`Invalid plugin manifest: ${error.message}`);
}
// ... rest of registration
}This prevents malformed or malicious manifests from entering the system.
2. SDK Encapsulation
The SDK should be the only way plugins interact with the host:
// ❌ Bad: Plugin directly accesses host internals
import { hostDatabase } from '../host/database';
// ✅ Good: Plugin uses SDK services
const data = await sdk.services.storage.get('myData');Never expose host internals to plugins. All communication should go through the SDK.
3. Sandboxing and Permissions
For untrusted plugins, consider implementing a permission system:
interface PluginPermissions {
canAccessNetwork: boolean;
canAccessStorage: boolean;
canRegisterRoutes: boolean;
allowedAPIs: string[];
}
// Check permissions before allowing SDK operations
if (!plugin.permissions.canAccessStorage) {
throw new Error('Plugin does not have storage permission');
}This allows fine-grained control over what each plugin can do.
4. Content Security Policy
For plugins loaded from remote sources, use Content Security Policy headers to restrict what code can execute:
// Only allow scripts from trusted sources
const csp = "script-src 'self' https://trusted-plugin-cdn.com";5. Plugin Isolation
Consider loading plugins in iframes or web workers for stronger isolation:
// Load plugin in isolated context
const worker = new Worker(pluginUrl);
worker.postMessage({ type: 'init', sdk: serializableSDK });This prevents plugins from accessing the main application's memory or DOM directly.
Plugin Architecture and MVVM Integration
The plugin architecture integrates beautifully with MVVM:
Plugins Use ViewModels
Plugins can use the same ViewModels as the host application:
// Plugin component uses shared ViewModel
const GreenhouseWidget: React.FC = () => {
const greenHouses = useObservable(greenHouseViewModel.data$, []);
useEffect(() => {
greenHouseViewModel.fetchCommand.execute();
}, []);
return <GreenhouseCard greenHouses={greenHouses} />;
};This ensures plugins follow the same architectural patterns as the host.
ViewModels Remain Framework-Agnostic
Because ViewModels are framework-agnostic, plugins can use them regardless of the host's framework:
// Same ViewModel works in React plugin
const reactPlugin = () => {
const data = useObservable(sensorViewModel.data$);
return <div>{data}</div>;
};
// And in Vue plugin
const vuePlugin = () => {
const data = ref([]);
watchEffect(() => {
sensorViewModel.data$.subscribe(value => data.value = value);
});
return () => <div>{data.value}</div>;
};Plugins Contribute Views, Not Business Logic
Plugins should contribute Views (UI components) while using shared ViewModels for business logic:
// ✅ Good: Plugin provides View, uses shared ViewModel
const SensorWidget = () => {
const sensors = useObservable(sensorViewModel.data$);
return <SensorList sensors={sensors} />;
};
// ❌ Bad: Plugin duplicates business logic
const SensorWidget = () => {
const [sensors, setSensors] = useState([]);
useEffect(() => {
fetch('/api/sensors').then(r => r.json()).then(setSensors);
}, []);
return <SensorList sensors={sensors} />;
};This maintains the separation of concerns that makes MVVM valuable.
Advanced Plugin Patterns
Plugin Communication via Event Bus
Plugins can communicate with each other through the SDK's event bus:
// Plugin A emits an event
sdk.events.emit('sensor:reading:updated', { sensorId: '123', value: 25.5 });
// Plugin B listens for the event
sdk.events.on('sensor:reading:updated', (data) => {
console.log('Sensor updated:', data);
updateChart(data);
});This enables loose coupling between plugins while maintaining encapsulation.
Lazy Loading Plugins
For better performance, load plugins on demand:
const loadPlugin = async (pluginId: string) => {
const manifest = await fetch(`/plugins/${pluginId}/manifest.json`).then(r => r.json());
pluginRegistry.register(manifest);
const module = await import(`/plugins/${pluginId}/index.js`);
const sdk = new PluginSDKImplementation(pluginId, manifest, hostServices);
await module.default.init?.(sdk);
await module.default.mount?.(sdk);
};
// Load plugin when user navigates to its route
router.beforeEach(async (to) => {
const pluginId = getPluginForRoute(to.path);
if (pluginId && !pluginRegistry.get(pluginId)) {
await loadPlugin(pluginId);
}
});Plugin Hot Reloading
For development, implement hot reloading:
if (import.meta.hot) {
import.meta.hot.accept('./plugins/greenhouse/index.tsx', (newModule) => {
// Unmount old version
greenhouseModule.unmount?.();
// Mount new version
newModule.default.init?.(sdk);
newModule.default.mount?.(sdk);
});
}Plugin Versioning and Compatibility
Handle plugin version compatibility:
interface PluginManifest {
// ... other fields
version: string;
hostVersion: string; // Required host version
dependencies?: Record<string, string>; // Required plugin versions
}
const isCompatible = (plugin: PluginManifest): boolean => {
const hostVersion = '2.1.0';
return semver.satisfies(hostVersion, plugin.hostVersion);
};
if (!isCompatible(manifest)) {
throw new Error(`Plugin requires host version ${manifest.hostVersion}, but running ${hostVersion}`);
}Testing Plugin Systems
Testing the PluginRegistry
describe('PluginRegistry', () => {
it('should register valid plugins', () => {
const registry = new PluginRegistry();
const manifest: PluginManifest = {
id: 'test-plugin',
name: 'Test Plugin',
version: '1.0.0',
entry: './index.js',
};
expect(() => registry.register(manifest)).not.toThrow();
expect(registry.get('test-plugin')).toBeDefined();
});
it('should reject invalid manifests', () => {
const registry = new PluginRegistry();
const invalidManifest = { id: '', name: 'Test' }; // Missing required fields
expect(() => registry.register(invalidManifest as any)).toThrow();
});
it('should resolve plugin dependencies', () => {
const registry = new PluginRegistry();
registry.register({ id: 'plugin-a', name: 'A', version: '1.0.0', entry: './a.js' });
registry.register({
id: 'plugin-b',
name: 'B',
version: '1.0.0',
entry: './b.js',
dependencies: { 'plugin-a': '1.0.0' }
});
const order = registry.resolveLoadOrder();
expect(order.indexOf('plugin-a')).toBeLessThan(order.indexOf('plugin-b'));
});
it('should detect circular dependencies', () => {
const registry = new PluginRegistry();
registry.register({
id: 'plugin-a',
name: 'A',
version: '1.0.0',
entry: './a.js',
dependencies: { 'plugin-b': '1.0.0' }
});
registry.register({
id: 'plugin-b',
name: 'B',
version: '1.0.0',
entry: './b.js',
dependencies: { 'plugin-a': '1.0.0' }
});
expect(() => registry.resolveLoadOrder()).toThrow(/circular dependency/i);
});
});Testing Plugin Modules
describe('GreenhousePlugin', () => {
let mockSDK: jest.Mocked<PluginSDK>;
beforeEach(() => {
mockSDK = {
plugin: { id: 'greenhouse-plugin', manifest: {} as any },
routes: { add: jest.fn(), remove: jest.fn() },
widgets: { add: jest.fn(), remove: jest.fn() },
menus: { addItem: jest.fn(), removeItem: jest.fn() },
events: { on: jest.fn(), off: jest.fn(), emit: jest.fn() },
ui: { showModal: jest.fn(), showToast: jest.fn() },
services: {} as any,
};
});
it('should initialize without errors', async () => {
await expect(greenhouseModule.init(mockSDK)).resolves.not.toThrow();
});
it('should register contributions on mount', async () => {
await greenhouseModule.mount(mockSDK);
expect(mockSDK.routes.add).toHaveBeenCalled();
expect(mockSDK.widgets.add).toHaveBeenCalled();
});
it('should cleanup on unmount', async () => {
await greenhouseModule.mount(mockSDK);
await greenhouseModule.unmount();
expect(mockSDK.routes.remove).toHaveBeenCalled();
expect(mockSDK.widgets.remove).toHaveBeenCalled();
});
});Integration Testing
describe('Plugin System Integration', () => {
it('should load and render plugin widgets', async () => {
const { container } = render(
<PluginHostProvider>
<PluginHost />
</PluginHostProvider>
);
// Wait for plugins to load
await waitFor(() => {
expect(screen.queryByText('Loading plugins...')).not.toBeInTheDocument();
});
// Verify plugin widgets are rendered
expect(screen.getByText('Greenhouses')).toBeInTheDocument();
expect(screen.getByText('Sensors')).toBeInTheDocument();
});
it('should navigate to plugin routes', async () => {
render(
<MemoryRouter initialEntries={['/greenhouses']}>
<PluginHostProvider>
<PluginHost />
</PluginHostProvider>
</MemoryRouter>
);
await waitFor(() => {
expect(screen.getByText('Greenhouse List')).toBeInTheDocument();
});
});
});Real-World Considerations
Performance
- Lazy Loading: Load plugins only when needed
- Code Splitting: Bundle plugins separately from the host
- Caching: Cache plugin manifests and modules
- Tree Shaking: Ensure unused plugin code is eliminated
Monitoring
Track plugin health and performance:
const pluginMetrics = {
loadTime: new Map<string, number>(),
errors: new Map<string, Error[]>(),
trackLoad(pluginId: string, duration: number) {
this.loadTime.set(pluginId, duration);
},
trackError(pluginId: string, error: Error) {
const errors = this.errors.get(pluginId) || [];
errors.push(error);
this.errors.set(pluginId, errors);
},
};
// Use in plugin loading
const start = performance.now();
try {
await loadPlugin(pluginId);
pluginMetrics.trackLoad(pluginId, performance.now() - start);
} catch (error) {
pluginMetrics.trackError(pluginId, error);
}Documentation
Provide clear documentation for plugin developers:
- SDK Reference: Document all SDK methods and types
- Examples: Provide working plugin examples
- Best Practices: Guide developers on plugin architecture
- Migration Guides: Help developers update plugins for new host versions
Key Takeaways
-
Plugin Architecture Enables Extensibility: A well-designed plugin system allows third-party developers to extend your application without modifying core code
-
Four Core Components: PluginRegistry (lifecycle management), PluginManifest (declarative configuration), FrameworkAdapter (framework abstraction), and PluginSDK (controlled communication)
-
Framework Independence: The plugin-core library is completely framework-agnostic, with framework-specific concerns isolated in the FrameworkAdapter
-
Lifecycle Management: Explicit lifecycle states (registered → loading → loaded → mounted → unmounted) ensure controlled plugin initialization and cleanup
-
Dependency Resolution: Topological sorting automatically resolves plugin dependencies and detects circular dependencies
-
Validation First: Zod schemas validate plugin manifests at runtime, preventing invalid plugins from entering the system
-
SDK Encapsulation: The PluginSDK is the sole interface between plugins and the host, maintaining security and encapsulation
-
MVVM Integration: Plugins use shared ViewModels for business logic while contributing framework-specific Views, maintaining architectural consistency
-
Security Matters: Implement manifest validation, SDK encapsulation, permission systems, and consider sandboxing for untrusted plugins
-
Testing is Essential: Test the PluginRegistry, individual plugin modules, and the complete integration to ensure reliability
Looking Ahead
Plugin architecture represents one of the most sophisticated applications of MVVM principles. By maintaining clear boundaries between the plugin system (framework-agnostic core), the host application (framework-specific implementation), and individual plugins (framework-specific Views using shared ViewModels), you create systems that are both extensible and maintainable.
In the next chapter, we'll explore design systems and theming patterns, examining how to build framework-agnostic design token systems that work seamlessly with MVVM architecture. We'll see how the design-core library provides dynamic theming capabilities while maintaining the separation of concerns that makes MVVM valuable.
The patterns we've learned—lifecycle management, dependency resolution, validation, and controlled communication—apply beyond plugin systems to any scenario where you need to integrate external code into your application while maintaining architectural integrity.
Code References:
packages/plugin-core/src/registry/PluginRegistry.ts- Plugin registry implementationpackages/plugin-core/src/adapter/FrameworkAdapter.ts- Framework adapter interfacepackages/plugin-core/src/contracts/PluginManifest.ts- Plugin manifest types and validationpackages/plugin-core/src/sdk/PluginSDK.ts- Plugin SDK interfaceapps/plugin-react/src/host/PluginHost.tsx- React plugin host implementationapps/plugin-react/src/plugins/greenhouse/index.tsx- Example plugin implementationapps/plugin-react/src/config/plugin.config.ts- Plugin configuration