import type { Plugin, PluginContext, PluginCommand, MessageInterceptor, MessageContext, CustomRenderer, SettingsSchema, PluginTheme, PluginLogEntry, } from './PluginInterfaces.js'; import type { IPluginStorage, IPluginEventClient } from './interfaces.js'; import { MemoryStorage } from './interfaces.js'; import { generateThemeCSS } from './theme-css.js'; /** * Configuration options for the PluginRegistry. */ export interface PluginRegistryOptions { /** * Storage adapter for persisting plugin settings. * Defaults to in-memory storage if not provided. * Pass `localStorage` (or a wrapper) for browser environments. */ storage?: IPluginStorage; /** * Called when a plugin registers a theme. * The host is responsible for injecting the CSS into the document. * Receives the full theme ID (used as the CSS class name) and the generated CSS string. */ onThemeRegistered?: (themeId: string, className: string, css: string) => void; /** * Called when a plugin unregisters a theme. * The host is responsible for removing the injected CSS. */ onThemeUnregistered?: (themeId: string) => void; } /** * Central registry that manages all loaded plugins and their side-effects. * Create a single instance and share it across your application. */ export class PluginRegistry { private readonly plugins = new Map(); private readonly commands = new Map(); private beforeSendInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; private receiveInterceptors: Array<{ pluginId: string; interceptor: MessageInterceptor }> = []; private readonly renderers = new Map(); private readonly settingsSchemas = new Map(); private readonly pluginSettings = new Map>(); private readonly matrixHandlers = new Map< string, Array<{ pluginId: string; handler: (...args: any[]) => void }> >(); private readonly timers = new Map(); private readonly themes = new Map< string, { pluginId: string; theme: PluginTheme } >(); private logs: PluginLogEntry[] = []; private readonly maxLogs = 1000; private readonly storage: IPluginStorage; private readonly onThemeRegistered?: PluginRegistryOptions['onThemeRegistered']; private readonly onThemeUnregistered?: PluginRegistryOptions['onThemeUnregistered']; constructor(options: PluginRegistryOptions = {}) { this.storage = options.storage ?? new MemoryStorage(); this.onThemeRegistered = options.onThemeRegistered; this.onThemeUnregistered = options.onThemeUnregistered; } // --------------------------------------------------------------------------- // Plugin lifecycle // --------------------------------------------------------------------------- /** Register a plugin and its context. Called after the host has loaded the module. */ registerPlugin(id: string, plugin: Plugin, context: PluginContext): void { if (this.plugins.has(id)) { console.warn(`[PluginRegistry] Plugin ${id} is already registered`); return; } this.plugins.set(id, { plugin, context }); this.timers.set(id, []); } /** Unregister a plugin, calling its onUnload hook and cleaning up all side-effects. */ async unregisterPlugin(id: string): Promise { const entry = this.plugins.get(id); if (!entry) return; if (entry.plugin.onUnload) { try { await entry.plugin.onUnload(); } catch (err) { console.error(`[PluginRegistry] Error unloading plugin ${id}:`, err); } } for (const [cmdName, cmd] of this.commands.entries()) { if (cmd.pluginId === id) this.commands.delete(cmdName); } this.beforeSendInterceptors = this.beforeSendInterceptors.filter(i => i.pluginId !== id); this.receiveInterceptors = this.receiveInterceptors.filter(i => i.pluginId !== id); for (const [type, renderer] of this.renderers.entries()) { if (renderer.pluginId === id) this.renderers.delete(type); } for (const [themeId, theme] of this.themes.entries()) { if (theme.pluginId === id) { this.onThemeUnregistered?.(themeId); this.themes.delete(themeId); } } const timerIds = this.timers.get(id) ?? []; timerIds.forEach(timerId => clearInterval(timerId)); this.timers.delete(id); for (const [eventType, handlers] of this.matrixHandlers.entries()) { const filtered = handlers.filter(h => h.pluginId !== id); if (filtered.length === 0) { this.matrixHandlers.delete(eventType); } else { this.matrixHandlers.set(eventType, filtered); } } this.plugins.delete(id); } // --------------------------------------------------------------------------- // Commands // --------------------------------------------------------------------------- /** Register a command contributed by a plugin. */ registerCommand(pluginId: string, command: PluginCommand): void { if (this.commands.has(command.name)) { console.warn(`[PluginRegistry] Command /${command.name} already exists`); return; } this.commands.set(command.name, { pluginId, command }); } /** Remove a registered command by name. */ unregisterCommand(name: string): void { this.commands.delete(name); } /** Execute a command by name, passing raw argument string. */ async executeCommand(name: string, rawArgs: string): Promise { const cmd = this.commands.get(name); if (!cmd) throw new Error(`Command /${name} not found`); const args = this.parseCommandArgs(cmd.command, rawArgs); const entry = this.plugins.get(cmd.pluginId); if (!entry) throw new Error(`Plugin ${cmd.pluginId} not loaded`); return await cmd.command.run(args, entry.context); } private parseCommandArgs(command: PluginCommand, rawArgs: string): Record { if (!command.args || command.args.length === 0) return {}; const parts = rawArgs.trim().split(/\s+/); const result: Record = {}; command.args.forEach((arg, index) => { const argName = typeof arg === 'string' ? arg : arg.name; result[argName] = parts[index]; }); return result; } /** Return all registered commands. */ getCommands(): Array<{ name: string; pluginId: string; command: PluginCommand }> { return Array.from(this.commands.entries()).map(([name, data]) => ({ name, ...data })); } // --------------------------------------------------------------------------- // Message interceptors // --------------------------------------------------------------------------- /** Add a before-send interceptor for a plugin. */ addBeforeSendInterceptor(pluginId: string, interceptor: MessageInterceptor): void { this.beforeSendInterceptors.push({ pluginId, interceptor }); } /** Add a receive interceptor for a plugin. */ addReceiveInterceptor(pluginId: string, interceptor: MessageInterceptor): void { this.receiveInterceptors.push({ pluginId, interceptor }); } /** Run a message through all before-send interceptors in registration order. */ async processBeforeSend(msg: MessageContext): Promise { let processed = { ...msg }; for (const { interceptor } of this.beforeSendInterceptors) { try { await interceptor(processed); } catch (err) { console.error('[PluginRegistry] Error in beforeSend interceptor:', err); } } return processed; } /** Run a message through all receive interceptors in registration order. */ async processReceive(msg: MessageContext): Promise { let processed = { ...msg }; for (const { interceptor } of this.receiveInterceptors) { try { await interceptor(processed); } catch (err) { console.error('[PluginRegistry] Error in receive interceptor:', err); } } return processed; } // --------------------------------------------------------------------------- // Renderers // --------------------------------------------------------------------------- /** Register a custom renderer contributed by a plugin. */ registerRenderer(pluginId: string, type: string, renderer: CustomRenderer): void { this.renderers.set(type, { pluginId, renderer }); } /** Remove a renderer by type. */ unregisterRenderer(type: string): void { this.renderers.delete(type); } /** Get the renderer for a given type, if any. */ getRenderer(type: string): CustomRenderer | undefined { return this.renderers.get(type)?.renderer; } // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- /** Define the settings schema for a plugin and load persisted values. */ defineSettings(pluginId: string, schema: SettingsSchema): void { this.settingsSchemas.set(pluginId, schema); const stored = this.storage.getItem(`plugin:${pluginId}:settings`); if (stored) { try { this.pluginSettings.set(pluginId, JSON.parse(stored)); } catch (err) { console.error(`[PluginRegistry] Failed to load settings for ${pluginId}:`, err); } } else { const defaults: Record = {}; for (const [key, def] of Object.entries(schema)) { if (def.default !== undefined) defaults[key] = def.default; } this.pluginSettings.set(pluginId, defaults); } } /** Get a plugin setting value. */ getPluginSetting(pluginId: string, key: string): T | undefined { return this.pluginSettings.get(pluginId)?.[key] as T | undefined; } /** Set a plugin setting value and persist it. */ setPluginSetting(pluginId: string, key: string, value: unknown): void { const settings = this.pluginSettings.get(pluginId) ?? {}; settings[key] = value; this.pluginSettings.set(pluginId, settings); this.storage.setItem(`plugin:${pluginId}:settings`, JSON.stringify(settings)); } /** Get the settings schema for a plugin. */ getPluginSettingsSchema(pluginId: string): SettingsSchema | undefined { return this.settingsSchemas.get(pluginId); } // --------------------------------------------------------------------------- // Themes // --------------------------------------------------------------------------- /** Register a theme contributed by a plugin. */ registerTheme(pluginId: string, theme: PluginTheme): void { const fullId = `plugin-${pluginId}-${theme.id}`; if (this.themes.has(fullId)) { console.warn(`[PluginRegistry] Theme ${fullId} already registered`); return; } const css = generateThemeCSS(fullId, theme); this.themes.set(fullId, { pluginId, theme: { ...theme, id: fullId } }); this.onThemeRegistered?.(fullId, fullId, css); console.log(`[PluginRegistry] Theme registered: ${fullId}`); } /** Unregister a theme contributed by a plugin. */ unregisterTheme(pluginId: string, themeId: string): void { const fullId = `plugin-${pluginId}-${themeId}`; if (this.themes.has(fullId)) { this.onThemeUnregistered?.(fullId); this.themes.delete(fullId); } } /** Return all registered plugin themes. */ getPluginThemes(): Array<{ id: string; name: string; kind: 'light' | 'dark'; className: string }> { return Array.from(this.themes.values()).map(({ theme }) => ({ id: theme.id, name: theme.name, kind: theme.kind, className: theme.id, })); } // --------------------------------------------------------------------------- // Event handlers (generic — wire to Matrix, socket.io, etc.) // --------------------------------------------------------------------------- /** Track an event handler registered by a plugin. */ addEventHandler(pluginId: string, eventType: string, handler: (...args: any[]) => void): void { if (!this.matrixHandlers.has(eventType)) { this.matrixHandlers.set(eventType, []); } this.matrixHandlers.get(eventType)!.push({ pluginId, handler }); } /** Remove a previously tracked event handler. */ removeEventHandler( pluginId: string, eventType: string, handler: (...args: any[]) => void ): void { const handlers = this.matrixHandlers.get(eventType); if (handlers) { const filtered = handlers.filter(h => h.pluginId !== pluginId || h.handler !== handler); this.matrixHandlers.set(eventType, filtered); } } /** Get all tracked handlers for an event type. */ getEventHandlers(eventType: string): Array<{ pluginId: string; handler: (...args: any[]) => void }> { return this.matrixHandlers.get(eventType) ?? []; } // --------------------------------------------------------------------------- // Timers // --------------------------------------------------------------------------- /** Track a timer ID for a plugin so it can be cleared on unload. */ addTimer(pluginId: string, timerId: number): void { const timers = this.timers.get(pluginId) ?? []; timers.push(timerId); this.timers.set(pluginId, timers); } /** Remove a timer ID from tracking (e.g. after manual clearInterval). */ removeTimer(pluginId: string, timerId: number): void { const timers = this.timers.get(pluginId) ?? []; this.timers.set(pluginId, timers.filter(id => id !== timerId)); } // --------------------------------------------------------------------------- // Logging // --------------------------------------------------------------------------- /** Append a log entry from a plugin. */ addLog(pluginId: string, level: 'log' | 'warn' | 'error', args: unknown[]): void { this.logs.push({ pluginId, level, timestamp: Date.now(), args }); if (this.logs.length > this.maxLogs) { this.logs = this.logs.slice(-this.maxLogs); } } /** Return log entries, optionally filtered to a specific plugin. */ getLogs(pluginId?: string): PluginLogEntry[] { return pluginId ? this.logs.filter(l => l.pluginId === pluginId) : [...this.logs]; } /** Clear log entries, optionally scoped to a specific plugin. */ clearLogs(pluginId?: string): void { this.logs = pluginId ? this.logs.filter(l => l.pluginId !== pluginId) : []; } // --------------------------------------------------------------------------- // Plugin exports (require()) // --------------------------------------------------------------------------- /** Get the public exports of a loaded plugin. */ getPluginExports(pluginId: string): unknown { return this.plugins.get(pluginId)?.plugin.exports; } // --------------------------------------------------------------------------- // Hot reload // --------------------------------------------------------------------------- /** Unload the current plugin instance and load a new one in-place. */ async reloadPlugin(pluginId: string, newPlugin: Plugin, newContext: PluginContext): Promise { await this.unregisterPlugin(pluginId); this.registerPlugin(pluginId, newPlugin, newContext); await newPlugin.onLoad(newContext); } // --------------------------------------------------------------------------- // Teardown // --------------------------------------------------------------------------- /** Unload all plugins and clear all registry state. */ clear(): void { for (const pluginId of this.plugins.keys()) { this.unregisterPlugin(pluginId).catch(err => { console.error(`[PluginRegistry] Error clearing plugin ${pluginId}:`, err); }); } this.plugins.clear(); this.commands.clear(); this.beforeSendInterceptors = []; this.receiveInterceptors = []; this.renderers.clear(); this.settingsSchemas.clear(); this.matrixHandlers.clear(); this.timers.clear(); this.logs = []; } }