200 lines
8.0 KiB
JavaScript
200 lines
8.0 KiB
JavaScript
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
|