import { MemoryStorage } from './interfaces.js'; import { generateThemeCSS } from './theme-css.js'; /** * Central registry that manages all loaded plugins and their side-effects. * Create a single instance and share it across your application. */ export class PluginRegistry { constructor(options = {}) { this.plugins = new Map(); this.commands = new Map(); this.beforeSendInterceptors = []; this.receiveInterceptors = []; this.renderers = new Map(); this.buttons = new Map(); this.settingsSchemas = new Map(); this.pluginSettings = new Map(); this.matrixHandlers = new Map(); this.timers = new Map(); this.themes = new Map(); this.logs = []; this.maxLogs = 1000; 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, plugin, context) { 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) { 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 [buttonId, btn] of this.buttons.entries()) { if (btn.pluginId === id) this.buttons.delete(buttonId); } 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, command) { 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) { this.commands.delete(name); } /** Execute a command by name, passing raw argument string. */ async executeCommand(name, rawArgs, roomId) { const cmd = this.commands.get(name); if (!cmd) throw new Error(`Command /${name} not found`); const args = this.parseCommandArgs(cmd.command, rawArgs); if (roomId) { args.roomId = roomId; } 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); } parseCommandArgs(command, rawArgs) { if (!command.args || command.args.length === 0) return {}; const trimmedArgs = rawArgs.trim(); if (!trimmedArgs) return {}; const argNames = command.args.map(arg => typeof arg === 'string' ? arg : arg.name); // If only one arg (that's not roomId), give it all the text const nonRoomIdArgs = argNames.filter(name => name !== 'roomId'); if (nonRoomIdArgs.length === 1) { const result = {}; result[nonRoomIdArgs[0]] = trimmedArgs; return result; } // Otherwise, split by whitespace const parts = trimmedArgs.split(/\s+/); const result = {}; command.args.forEach((arg, index) => { const argName = typeof arg === 'string' ? arg : arg.name; if (argName !== 'roomId') { result[argName] = parts[index]; } }); return result; } /** Return all registered commands. */ getCommands() { return Array.from(this.commands.entries()).map(([name, data]) => ({ name, ...data })); } // --------------------------------------------------------------------------- // Message interceptors // --------------------------------------------------------------------------- /** Add a before-send interceptor for a plugin. */ addBeforeSendInterceptor(pluginId, interceptor) { this.beforeSendInterceptors.push({ pluginId, interceptor }); } /** Add a receive interceptor for a plugin. */ addReceiveInterceptor(pluginId, interceptor) { this.receiveInterceptors.push({ pluginId, interceptor }); } /** Run a message through all before-send interceptors in registration order. */ async processBeforeSend(msg) { 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) { 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, type, renderer) { this.renderers.set(type, { pluginId, renderer }); } /** Remove a renderer by type. */ unregisterRenderer(type) { this.renderers.delete(type); } /** Get the renderer for a given type, if any. */ getRenderer(type) { return this.renderers.get(type)?.renderer; } // --------------------------------------------------------------------------- // UI Buttons // --------------------------------------------------------------------------- /** Register a UI button contributed by a plugin. */ registerButton(pluginId, button) { const fullId = `${pluginId}:${button.id}`; if (this.buttons.has(fullId)) { console.warn(`[PluginRegistry] Button ${fullId} already registered`); return; } this.buttons.set(fullId, { pluginId, button: { ...button, id: fullId } }); } /** Remove a registered button by ID. */ unregisterButton(pluginId, buttonId) { const fullId = `${pluginId}:${buttonId}`; this.buttons.delete(fullId); } /** * Get all buttons for a specific UI location, sorted by position configuration. * Returns buttons in the order they should be rendered. */ getButtonsForLocation(location) { const buttons = Array.from(this.buttons.values()) .filter(({ button }) => button.location === location) .map(({ button }) => button); return this.sortButtonsByPosition(buttons); } /** * Sort buttons according to their position configuration. * - Buttons with position.before/after are positioned relative to existing buttons * - Buttons in groups are kept together * - Within groups, buttons are sorted by order property */ sortButtonsByPosition(buttons) { if (buttons.length === 0) return []; const grouped = new Map(); const ungrouped = []; const positionedButtons = new Map(); for (const button of buttons) { positionedButtons.set(button.id, button); if (button.position?.group) { if (!grouped.has(button.position.group)) { grouped.set(button.position.group, []); } grouped.get(button.position.group).push(button); } else { ungrouped.push(button); } } for (const [_group, groupButtons] of grouped) { groupButtons.sort((a, b) => { const orderA = a.position?.order ?? 0; const orderB = b.position?.order ?? 0; return orderA - orderB; }); } const result = []; const inserted = new Set(); const insertButton = (button) => { if (inserted.has(button.id)) return; const group = button.position?.group ? grouped.get(button.position.group) : [button]; if (!group) return; for (const btn of group) { if (!inserted.has(btn.id)) { result.push(btn); inserted.add(btn.id); } } }; for (const button of [...ungrouped, ...Array.from(grouped.values()).flat()]) { if (inserted.has(button.id)) continue; if (button.position?.before) { const beforeButton = positionedButtons.get(button.position.before); if (beforeButton) { const idx = result.findIndex(b => b.id === beforeButton.id); if (idx !== -1) { const group = button.position.group ? grouped.get(button.position.group) : [button]; if (group) { for (let i = group.length - 1; i >= 0; i--) { if (!inserted.has(group[i].id)) { result.splice(idx, 0, group[i]); inserted.add(group[i].id); } } } continue; } } } if (button.position?.after) { const afterButton = positionedButtons.get(button.position.after); if (afterButton) { const idx = result.findIndex(b => b.id === afterButton.id); if (idx !== -1) { insertButton(button); continue; } } } insertButton(button); } for (const button of buttons) { if (!inserted.has(button.id)) { result.push(button); } } return result; } // --------------------------------------------------------------------------- // Settings // --------------------------------------------------------------------------- /** Define the settings schema for a plugin and load persisted values. */ defineSettings(pluginId, schema) { 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 = {}; 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, key) { return this.pluginSettings.get(pluginId)?.[key]; } /** Set a plugin setting value and persist it. */ setPluginSetting(pluginId, key, value) { 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) { return this.settingsSchemas.get(pluginId); } // --------------------------------------------------------------------------- // Themes // --------------------------------------------------------------------------- /** Register a theme contributed by a plugin. */ registerTheme(pluginId, theme) { 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, themeId) { const fullId = `plugin-${pluginId}-${themeId}`; if (this.themes.has(fullId)) { this.onThemeUnregistered?.(fullId); this.themes.delete(fullId); } } /** Return all registered plugin themes. */ getPluginThemes() { 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, eventType, handler) { if (!this.matrixHandlers.has(eventType)) { this.matrixHandlers.set(eventType, []); } this.matrixHandlers.get(eventType).push({ pluginId, handler }); } /** Remove a previously tracked event handler. */ removeEventHandler(pluginId, eventType, handler) { 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) { return this.matrixHandlers.get(eventType) ?? []; } // --------------------------------------------------------------------------- // Timers // --------------------------------------------------------------------------- /** Track a timer ID for a plugin so it can be cleared on unload. */ addTimer(pluginId, timerId) { 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, timerId) { 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, level, args) { 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) { return pluginId ? this.logs.filter(l => l.pluginId === pluginId) : [...this.logs]; } /** Clear log entries, optionally scoped to a specific plugin. */ clearLogs(pluginId) { this.logs = pluginId ? this.logs.filter(l => l.pluginId !== pluginId) : []; } // --------------------------------------------------------------------------- // Plugin exports (require()) // --------------------------------------------------------------------------- /** Get the public exports of a loaded plugin. */ getPluginExports(pluginId) { return this.plugins.get(pluginId)?.plugin.exports; } /** * Returns metadata for all currently registered plugins. * Useful for displaying a plugin directory / manager UI. */ getRegisteredPlugins() { return Array.from(this.plugins.entries()).map(([id, { plugin }]) => ({ id, name: plugin.name ?? id, version: plugin.version ?? '—', commandCount: [...this.commands.values()].filter(c => c.pluginId === id).length, interceptorCount: this.beforeSendInterceptors.filter(i => i.pluginId === id).length + this.receiveInterceptors.filter(i => i.pluginId === id).length, themeCount: [...this.themes.values()].filter(t => t.pluginId === id).length, hasSettings: (this.settingsSchemas.get(id)?.constructor === Object ? Object.keys(this.settingsSchemas.get(id)).length > 0 : !!this.settingsSchemas.get(id)), })); } // --------------------------------------------------------------------------- // Hot reload // --------------------------------------------------------------------------- /** Unload the current plugin instance and load a new one in-place. */ async reloadPlugin(pluginId, newPlugin, newContext) { await this.unregisterPlugin(pluginId); this.registerPlugin(pluginId, newPlugin, newContext); await newPlugin.onLoad(newContext); } // --------------------------------------------------------------------------- // Teardown // --------------------------------------------------------------------------- /** Unload all plugins and clear all registry state. */ clear() { 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 = []; } } //# sourceMappingURL=PluginRegistry.js.map