Web Loom logo
Chapter 20Advanced Topics

Plugin Architecture and Extensibility

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:

  1. Framework Independence: Plugins should work regardless of whether your host uses React, Vue, Angular, or another framework
  2. Lifecycle Management: Plugins need controlled initialization, mounting, and cleanup
  3. Isolation: Plugins shouldn't directly access host internals or break encapsulation
  4. Validation: Plugin configurations must be validated before loading
  5. 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:

  1. PluginRegistry: Manages plugin registration, lifecycle states, and dependency resolution
  2. PluginManifest: Declarative configuration describing what a plugin provides
  3. FrameworkAdapter: Abstraction for mounting plugins in specific UI frameworks
  4. 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:

  1. Generic Type Parameter: T extends TComponent allows the registry to work with any framework's component type
  2. Lifecycle States: Explicit states track plugin progression from registration to mounting
  3. Validation First: Manifests are validated using Zod before registration
  4. 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:

  1. Builds a dependency graph from plugin manifests
  2. Identifies plugins with no dependencies (in-degree = 0)
  3. Processes plugins in order, removing edges as dependencies are satisfied
  4. 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 /greenhouses rendering the GreenhouseList component
  • 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:

  1. Root Management: Maintains a map of containers to React roots for proper cleanup
  2. Lazy Root Creation: Creates roots only when needed
  3. 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:

  1. Plugin Context: Information about the plugin itself
  2. Contribution APIs: Register routes, menus, and widgets
  3. Event Bus: Cross-plugin communication
  4. UI Services: Shared UI components (modals, toasts)
  5. 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:

  1. Uses MVVM: The widget subscribes to greenHouseViewModel.data$ using the useObservable hook
  2. Implements Lifecycle: Provides init, mount, and unmount methods
  3. Registers Contributions: Uses the SDK to register routes and widgets
  4. Maintains State: Tracks the active SDK for cleanup
  5. 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:

  1. Manages Plugin State: Tracks routes, widgets, and menu items contributed by plugins
  2. Provides Host Services: Creates the services object passed to plugins via SDK
  3. Registers Plugins: Validates and registers all plugin manifests
  4. Initializes Plugins: Calls init() and mount() on each plugin module
  5. 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

  1. Lazy Loading: Load plugins only when needed
  2. Code Splitting: Bundle plugins separately from the host
  3. Caching: Cache plugin manifests and modules
  4. 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:

  1. SDK Reference: Document all SDK methods and types
  2. Examples: Provide working plugin examples
  3. Best Practices: Guide developers on plugin architecture
  4. Migration Guides: Help developers update plugins for new host versions

Key Takeaways

  1. Plugin Architecture Enables Extensibility: A well-designed plugin system allows third-party developers to extend your application without modifying core code

  2. Four Core Components: PluginRegistry (lifecycle management), PluginManifest (declarative configuration), FrameworkAdapter (framework abstraction), and PluginSDK (controlled communication)

  3. Framework Independence: The plugin-core library is completely framework-agnostic, with framework-specific concerns isolated in the FrameworkAdapter

  4. Lifecycle Management: Explicit lifecycle states (registered → loading → loaded → mounted → unmounted) ensure controlled plugin initialization and cleanup

  5. Dependency Resolution: Topological sorting automatically resolves plugin dependencies and detects circular dependencies

  6. Validation First: Zod schemas validate plugin manifests at runtime, preventing invalid plugins from entering the system

  7. SDK Encapsulation: The PluginSDK is the sole interface between plugins and the host, maintaining security and encapsulation

  8. MVVM Integration: Plugins use shared ViewModels for business logic while contributing framework-specific Views, maintaining architectural consistency

  9. Security Matters: Implement manifest validation, SDK encapsulation, permission systems, and consider sandboxing for untrusted plugins

  10. 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 implementation
  • packages/plugin-core/src/adapter/FrameworkAdapter.ts - Framework adapter interface
  • packages/plugin-core/src/contracts/PluginManifest.ts - Plugin manifest types and validation
  • packages/plugin-core/src/sdk/PluginSDK.ts - Plugin SDK interface
  • apps/plugin-react/src/host/PluginHost.tsx - React plugin host implementation
  • apps/plugin-react/src/plugins/greenhouse/index.tsx - Example plugin implementation
  • apps/plugin-react/src/config/plugin.config.ts - Plugin configuration
Web Loom logo
Copyright © Web Loom. All rights reserved.