import fs from 'fs'; import path from 'path'; import { pathToFileURL } from 'url'; import { createRequire } from 'module'; import { createPluginContext } from './PluginContext.js'; /** * Node.js-specific loader that discovers and registers plugins from the filesystem. * * Provide a directory; the loader scans each immediate subdirectory, resolves the * entry point, imports the module (CJS or ESM), normalises it to the {@link Plugin} * interface, and wires it into the supplied {@link PluginRegistry}. * * Supports: * - Native plugins exporting `onLoad` / `onUnload` * - Legacy plugins exporting `activate` / `deactivate` (with a compat shim) * - `plugin-metadata.json` for stable plugin IDs * - `package.json` `"main"` for custom entry points (falls back to `index.js`) * * @example * ```ts * const loader = new PluginLoader({ registry, eventClient: myEmitter }); * const ids = await loader.loadFromDirectory('/path/to/plugins'); * console.log('Loaded:', ids); * ``` */ export class PluginLoader { constructor(options) { this.registry = options.registry; this.eventClient = options.eventClient; this.onNotify = options.onNotify; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Scans `dir` for plugin subdirectories and loads each one. * Subdirectories that lack a recognisable entry point are silently skipped; * those that throw during loading emit a console warning and are skipped. * * @param dir - Absolute path to the plugins folder. * @returns IDs of all plugins that loaded successfully. */ async loadFromDirectory(dir) { const entries = fs.readdirSync(dir, { withFileTypes: true }); const loaded = []; for (const entry of entries) { if (!entry.isDirectory()) continue; const pluginDir = path.join(dir, entry.name); try { const id = await this.loadPlugin(pluginDir); loaded.push(id); } catch (err) { console.warn(`[PluginLoader] Skipped "${entry.name}":`, err); } } return loaded; } /** * Loads a single plugin from an absolute directory path. * * Resolution order for the plugin ID: * 1. `plugin-metadata.json` → `id` field * 2. `path.basename(pluginDir)` * * Resolution order for the entry point: * 1. `package.json` → `main` field * 2. `index.js` * * @param pluginDir - Absolute path to the plugin directory. * @returns The plugin ID used for registration. * @throws If no entry point is found or the module cannot be loaded. */ async loadPlugin(pluginDir) { const metadata = this._readMetadata(pluginDir); const entryPath = this._resolveEntry(pluginDir); const pluginId = metadata?.id ?? path.basename(pluginDir); const raw = await this._importModule(entryPath); const plugin = this._normalise(raw); const context = createPluginContext({ pluginId, eventClient: this.eventClient, onNotify: this.onNotify, }, this.registry); this.registry.registerPlugin(pluginId, plugin, context); await plugin.onLoad(context); return pluginId; } // --------------------------------------------------------------------------- // Private helpers // --------------------------------------------------------------------------- /** * Reads and parses `plugin-metadata.json` from the plugin directory. * Returns `null` if the file is absent or malformed. * * @param pluginDir - Plugin root directory. */ _readMetadata(pluginDir) { const metaPath = path.join(pluginDir, 'plugin-metadata.json'); if (!fs.existsSync(metaPath)) return null; try { return JSON.parse(fs.readFileSync(metaPath, 'utf8')); } catch { return null; } } /** * Resolves the JavaScript entry point for a plugin. * Reads `main` from `package.json` when present; falls back to `index.js`. * * @param pluginDir - Plugin root directory. * @throws If no entry file can be found. */ _resolveEntry(pluginDir) { const pkgPath = path.join(pluginDir, 'package.json'); if (fs.existsSync(pkgPath)) { try { const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')); if (pkg.main) return path.resolve(pluginDir, pkg.main); } catch { /* fall through to index.js */ } } const indexPath = path.join(pluginDir, 'index.js'); if (!fs.existsSync(indexPath)) { throw new Error(`No entry point found in "${pluginDir}"`); } return indexPath; } /** * Imports a plugin module from an absolute file path. * * Attempts CJS evaluation first (via `new Function` with a `createRequire`-backed * `require` shim). Falls back to native ESM `import()` on `SyntaxError`. * * @param filePath - Absolute path to the plugin entry file. * @throws On load or evaluation errors that are not `SyntaxError`. */ async _importModule(filePath) { const code = fs.readFileSync(filePath, 'utf8'); const pluginRequire = createRequire(pathToFileURL(filePath).href); // CJS path — eval with a real require shim so the plugin can require deps try { const m = { exports: {} }; new Function('module', 'exports', '__dirname', '__filename', 'require', code)(m, m.exports, path.dirname(filePath), filePath, pluginRequire); const result = m.exports?.['default'] ?? m.exports; if (result && typeof result === 'object' && Object.keys(result).length > 0) { return result; } } catch (err) { if (!(err instanceof SyntaxError)) throw err; // SyntaxError means ESM syntax — fall through below } // ESM path — import via file URL const mod = await import(pathToFileURL(filePath).href); return (mod['default'] ?? mod); } /** * Normalises a raw plugin module export to the {@link Plugin} interface. * * - Native: object with `onLoad` function — used as-is. * - Legacy: object with `activate` function — wrapped into `onLoad` / `onUnload` * with a compatibility shim for removed APIs (`registerHook`, `runHook`, `getConfig`). * * @param raw - Raw module exports. * @throws If the module exports neither `onLoad` nor `activate`. */ _normalise(raw) { if (typeof raw['onLoad'] === 'function') return raw; if (typeof raw['activate'] === 'function') { const activate = raw['activate']; const deactivate = raw['deactivate']; return { name: typeof raw['name'] === 'string' ? raw['name'] : undefined, version: typeof raw['version'] === 'string' ? raw['version'] : undefined, onLoad: async (ctx) => { const legacyCtx = Object.assign(Object.create(ctx), { registerHook: (name) => ctx.log(`[compat] registerHook("${name}") — not supported`), runHook: (name) => { ctx.log(`[compat] runHook("${name}")`); return Promise.resolve([]); }, getConfig: (key) => ctx.settings.get(key), on: (event, handler) => ctx.events.on(event, handler), }); await activate(legacyCtx); }, onUnload: deactivate ? async () => { await deactivate(); } : undefined, }; } throw new Error('Plugin does not export `onLoad` or `activate` — cannot load'); } } //# sourceMappingURL=PluginLoader.js.map