Zum Inhalt springen

Eine Plugin-Architektur in TypeScript aufbauen

So entwirfst du ein Plugin-System, das deine Anwendung ohne Kernänderungen erweitert: Schnittstellen, Lifecycle-Hooks, Abhängigkeiten und Sandboxing.

5 Min. Lesezeit
Diagramm einer Plugin-Architektur mit einer Kernanwendung, Erweiterungspunkten und dynamisch geladenen Plugins

Eine Plugin-Architektur ermöglicht es Nutzern, das Verhalten deiner Anwendung zu erweitern, ohne ihren Quellcode zu verändern. Denk an VS-Code-Erweiterungen, Webpack-Plugins, Babel-Transforms oder ESLint-Regeln. Die Kernanwendung definiert Erweiterungspunkte — klar definierte Schnittstellen, an denen sich Plugins einklinken können — und Plugins implementieren diese Schnittstellen, um Funktionalität hinzuzufügen.

Die Designherausforderung besteht darin, die richtige Balance zu finden: flexibel genug für vielfältige Anwendungsfälle, aber eingeschränkt genug, um zu verhindern, dass Plugins die Host-Anwendung beschädigen.

Die Plugin-Schnittstelle definieren

Die Plugin-Schnittstelle ist ein Vertrag zwischen der Kernanwendung und ihren Plugins. Sie legt fest, was Plugins tun können, welche Daten sie erhalten und welche Lifecycle-Methoden sie implementieren können.

tstypescript
// The core plugin interface — every plugin must implement this
interface Plugin {
  name: string;
  version: string;
  dependencies?: string[]; // Other plugins this one requires
 
  // Lifecycle hooks
  onInit?(context: PluginContext): Promise<void> | void;
  onBeforeProcess?(data: ProcessInput): Promise<ProcessInput> | ProcessInput;
  onAfterProcess?(data: ProcessOutput): Promise<ProcessOutput> | ProcessOutput;
  onDestroy?(): Promise<void> | void;
}
 
// Context provided to plugins — their window into the host application
interface PluginContext {
  config: Readonly<Record<string, unknown>>;
  logger: PluginLogger;
  storage: PluginStorage;
  events: PluginEventEmitter;
}
 
interface PluginLogger {
  info(message: string, data?: Record<string, unknown>): void;
  warn(message: string, data?: Record<string, unknown>): void;
  error(message: string, data?: Record<string, unknown>): void;
}
 
interface PluginStorage {
  get<T>(key: string): Promise<T | undefined>;
  set<T>(key: string, value: T): Promise<void>;
  delete(key: string): Promise<void>;
}
 
interface PluginEventEmitter {
  on(event: string, handler: (...args: unknown[]) => void): void;
  emit(event: string, ...args: unknown[]): void;
}
tstypescript
// ❌ Giving plugins direct access to application internals
interface BadPluginContext {
  database: DatabaseConnection;     // Can drop tables
  httpServer: HttpServer;           // Can add uncontrolled routes
  processEnv: NodeJS.ProcessEnv;    // Can read secrets
}
 
// ✅ Providing a scoped, sandboxed API surface
// Plugins get a logger (namespaced), a key-value store (isolated per plugin),
// and an event bus (filtered to allowed events). Nothing else.

Der Plugin-Manager

Der Plugin-Manager übernimmt Registrierung, Lifecycle-Management, Abhängigkeitsauflösung und Hook-Ausführung. Er ist der Orchestrator zwischen der Kernanwendung und ihren Plugins.

tstypescript
class PluginManager {
  private plugins: Map<string, Plugin> = new Map();
  private initialized: Set<string> = new Set();
  private contexts: Map<string, PluginContext> = new Map();
 
  register(plugin: Plugin): void {
    if (this.plugins.has(plugin.name)) {
      throw new Error(`Plugin "${plugin.name}" is already registered`);
    }
    this.plugins.set(plugin.name, plugin);
  }
 
  async initializeAll(): Promise<void> {
    // Resolve initialization order based on dependencies
    const order = this.resolveDependencyOrder();
 
    for (const pluginName of order) {
      await this.initializePlugin(pluginName);
    }
  }
 
  private async initializePlugin(name: string): Promise<void> {
    if (this.initialized.has(name)) return;
 
    const plugin = this.plugins.get(name);
    if (!plugin) throw new Error(`Plugin "${name}" not found`);
 
    // Verify dependencies are initialized
    for (const dep of plugin.dependencies ?? []) {
      if (!this.initialized.has(dep)) {
        throw new Error(
          `Plugin "${name}" depends on "${dep}" which is not initialized`
        );
      }
    }
 
    // Create sandboxed context for this plugin
    const context = this.createContext(name);
    this.contexts.set(name, context);
 
    if (plugin.onInit) {
      await plugin.onInit(context);
    }
 
    this.initialized.add(name);
    console.log(`Plugin "${name}" v${plugin.version} initialized`);
  }
 
  private createContext(pluginName: string): PluginContext {
    return {
      config: Object.freeze(this.getPluginConfig(pluginName)),
      logger: this.createScopedLogger(pluginName),
      storage: this.createScopedStorage(pluginName),
      events: this.createScopedEventEmitter(pluginName),
    };
  }
 
  private getPluginConfig(name: string): Record<string, unknown> {
    return {}; // Load from configuration source
  }
 
  private createScopedLogger(name: string): PluginLogger {
    return {
      info: (msg, data) => console.log(`[${name}] INFO: ${msg}`, data ?? ''),
      warn: (msg, data) => console.warn(`[${name}] WARN: ${msg}`, data ?? ''),
      error: (msg, data) => console.error(`[${name}] ERROR: ${msg}`, data ?? ''),
    };
  }
 
  private createScopedStorage(name: string): PluginStorage {
    const store = new Map<string, unknown>();
    return {
      get: async <T>(key: string) => store.get(`${name}:${key}`) as T | undefined,
      set: async <T>(key: string, value: T) => { store.set(`${name}:${key}`, value); },
      delete: async (key: string) => { store.delete(`${name}:${key}`); },
    };
  }
 
  private createScopedEventEmitter(name: string): PluginEventEmitter {
    const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
    return {
      on: (event, handler) => {
        if (!handlers.has(event)) handlers.set(event, []);
        handlers.get(event)!.push(handler);
      },
      emit: (event, ...args) => {
        for (const handler of handlers.get(event) ?? []) {
          handler(...args);
        }
      },
    };
  }
 
  // Topological sort for dependency resolution
  private resolveDependencyOrder(): string[] {
    const visited = new Set<string>();
    const order: string[] = [];
 
    const visit = (name: string, stack: Set<string>) => {
      if (stack.has(name)) {
        throw new Error(`Circular dependency detected involving "${name}"`);
      }
      if (visited.has(name)) return;
 
      stack.add(name);
      const plugin = this.plugins.get(name);
      for (const dep of plugin?.dependencies ?? []) {
        visit(dep, stack);
      }
      stack.delete(name);
      visited.add(name);
      order.push(name);
    };
 
    for (const name of this.plugins.keys()) {
      visit(name, new Set());
    }
 
    return order;
  }
}

Hook-Ausführungspipeline

Plugins erweitern das Verhalten über Hooks — Funktionen, die an bestimmten Stellen der Verarbeitungspipeline der Anwendung aufgerufen werden. Hooks können synchron oder asynchron sein und die Daten beim Durchlauf transformieren.

tstypescript
// Waterfall hooks: each plugin transforms the data, passing it to the next
async function executeWaterfallHook<T>(
  plugins: Plugin[],
  hookName: 'onBeforeProcess' | 'onAfterProcess',
  initialData: T
): Promise<T> {
  let data = initialData;
 
  for (const plugin of plugins) {
    const hook = plugin[hookName] as
      | ((data: T) => Promise<T> | T)
      | undefined;
 
    if (hook) {
      try {
        data = await hook(data);
      } catch (error) {
        console.error(
          `Plugin "${plugin.name}" threw in ${hookName}:`,
          error
        );
        // Decision: skip this plugin and continue, or abort?
        // Typically: log and continue for non-critical hooks
      }
    }
  }
 
  return data;
}
 
// Usage in the core application
async function processData(input: ProcessInput): Promise<ProcessOutput> {
  // Pre-processing hooks — plugins can modify the input
  const processed = await executeWaterfallHook(
    activePlugins,
    'onBeforeProcess',
    input
  );
 
  // Core processing logic
  const result = await coreProcess(processed);
 
  // Post-processing hooks — plugins can modify the output
  const finalResult = await executeWaterfallHook(
    activePlugins,
    'onAfterProcess',
    result
  );
 
  return finalResult;
}

Beispiel-Plugins

Hier sind konkrete Plugin-Implementierungen, die das Muster in der Praxis zeigen.

tstypescript
// Plugin 1: Adds timing metrics to every processed item
const timingPlugin: Plugin = {
  name: 'timing-metrics',
  version: '1.0.0',
 
  onBeforeProcess(data: ProcessInput): ProcessInput {
    return { ...data, _startTime: Date.now() };
  },
 
  onAfterProcess(data: ProcessOutput): ProcessOutput {
    const startTime = (data as any)._startTime;
    const duration = Date.now() - startTime;
    return { ...data, processingTimeMs: duration };
  },
};
 
// Plugin 2: Validates input data before processing
const validationPlugin: Plugin = {
  name: 'input-validation',
  version: '1.0.0',
  dependencies: [], // No dependencies
 
  onBeforeProcess(data: ProcessInput): ProcessInput {
    if (!data.id) {
      throw new Error('Validation failed: missing required field "id"');
    }
    if (typeof data.payload !== 'object') {
      throw new Error('Validation failed: payload must be an object');
    }
    return data;
  },
};
 
// Plugin 3: Caches results to avoid reprocessing
const cachingPlugin: Plugin = {
  name: 'result-cache',
  version: '1.0.0',
 
  async onInit(context: PluginContext): Promise<void> {
    context.logger.info('Cache plugin initialized');
  },
 
  async onBeforeProcess(data: ProcessInput): Promise<ProcessInput> {
    // Check if result is already cached
    return data; // Pass through — cache check happens via context.storage
  },
 
  async onAfterProcess(data: ProcessOutput): Promise<ProcessOutput> {
    // Store result in cache for future lookups
    return data;
  },
};
 
// Register and initialize
const manager = new PluginManager();
manager.register(validationPlugin);
manager.register(timingPlugin);
manager.register(cachingPlugin);
await manager.initializeAll();
tstypescript
// ❌ Plugin that modifies global state
const dangerousPlugin: Plugin = {
  name: 'dangerous',
  version: '1.0.0',
  onInit() {
    // Modifying prototypes, globals, or shared state
    (Array.prototype as any).myMethod = () => {};  // NO!
    process.env.SECRET = 'exposed';                  // NO!
  },
};
 
// ✅ Plugin that operates only through its provided context
const safePlugin: Plugin = {
  name: 'safe',
  version: '1.0.0',
  async onInit(context: PluginContext) {
    // Uses only the APIs provided through context
    context.logger.info('Initializing');
    await context.storage.set('lastInit', new Date().toISOString());
    context.events.on('data.processed', () => {
      context.logger.info('Processing completed');
    });
  },
};

Die wichtigsten Erkenntnisse

  1. Definiere eine klare Plugin-Schnittstelle — der Vertrag zwischen Kern und Plugins legt Lifecycle-Hooks, verfügbare APIs und Datenstrukturen fest
  2. Schotte den Plugin-Zugriff in einer Sandbox ab — stelle Logger mit eigenem Namespace, isolierten Speicher und gefilterte Events bereit, statt direkten Zugriff auf Anwendungsinterna zu gewähren
  3. Löse Abhängigkeiten per topologischer Sortierung auf — erkenne zirkuläre Abhängigkeiten bereits bei der Registrierung, nicht zur Laufzeit
  4. Nutze Waterfall-Hooks für Datentransformation — jedes Plugin erhält die Ausgabe des vorherigen und baut so eine komponierbare Pipeline
  5. Behandle Plugin-Fehler graceful — ein abstürzendes Plugin sollte die Host-Anwendung nicht mitreißen; loggen, überspringen, weiterlaufen
  6. Friere die Konfiguration ein und grenze den Speicher ab — verhindere, dass Plugins den Zustand anderer Plugins oder die globale Konfiguration verändern
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX