488 lines
20 KiB
JavaScript
488 lines
20 KiB
JavaScript
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
|