added examples
This commit is contained in:
110
dist/PluginLoader.d.ts
vendored
Normal file
110
dist/PluginLoader.d.ts
vendored
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { NotificationOptions } from './PluginInterfaces.js';
|
||||
import type { IPluginEventClient } from './interfaces.js';
|
||||
import type { PluginRegistry } from './PluginRegistry.js';
|
||||
/**
|
||||
* Configuration options for constructing a {@link PluginLoader}.
|
||||
*
|
||||
* @remarks Node.js only — this class uses `fs`, `path`, and `module` from the Node standard library.
|
||||
*/
|
||||
export interface PluginLoaderOptions {
|
||||
/** The registry to register and wire loaded plugins into. */
|
||||
registry: PluginRegistry;
|
||||
/**
|
||||
* Optional event client forwarded to each plugin's context.
|
||||
* Any object with `on(event, handler)` / `off(event, handler)` works.
|
||||
*/
|
||||
eventClient?: IPluginEventClient;
|
||||
/**
|
||||
* Called when a plugin invokes `context.notify(...)`.
|
||||
* The host is responsible for displaying the notification.
|
||||
*/
|
||||
onNotify?: (options: NotificationOptions) => void | Promise<void>;
|
||||
}
|
||||
/**
|
||||
* 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 declare class PluginLoader {
|
||||
private readonly registry;
|
||||
private readonly eventClient?;
|
||||
private readonly onNotify?;
|
||||
constructor(options: PluginLoaderOptions);
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
loadFromDirectory(dir: string): Promise<string[]>;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
loadPlugin(pluginDir: string): Promise<string>;
|
||||
/**
|
||||
* Reads and parses `plugin-metadata.json` from the plugin directory.
|
||||
* Returns `null` if the file is absent or malformed.
|
||||
*
|
||||
* @param pluginDir - Plugin root directory.
|
||||
*/
|
||||
private _readMetadata;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
private _resolveEntry;
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
private _importModule;
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
private _normalise;
|
||||
}
|
||||
//# sourceMappingURL=PluginLoader.d.ts.map
|
||||
1
dist/PluginLoader.d.ts.map
vendored
Normal file
1
dist/PluginLoader.d.ts.map
vendored
Normal file
@@ -0,0 +1 @@
|
||||
{"version":3,"file":"PluginLoader.d.ts","sourceRoot":"","sources":["../src/PluginLoader.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAU,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACzE,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAC1D,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAY1D;;;;GAIG;AACH,MAAM,WAAW,mBAAmB;IAClC,6DAA6D;IAC7D,QAAQ,EAAE,cAAc,CAAC;IAEzB;;;OAGG;IACH,WAAW,CAAC,EAAE,kBAAkB,CAAC;IAEjC;;;OAGG;IACH,QAAQ,CAAC,EAAE,CAAC,OAAO,EAAE,mBAAmB,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CACnE;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,qBAAa,YAAY;IACvB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAiB;IAC1C,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAC,CAAqB;IAClD,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAyD;gBAEvE,OAAO,EAAE,mBAAmB;IAUxC;;;;;;;OAOG;IACG,iBAAiB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,EAAE,CAAC;IAoBvD;;;;;;;;;;;;;;OAcG;IACG,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;IA2BpD;;;;;OAKG;IACH,OAAO,CAAC,aAAa;IAYrB;;;;;;OAMG;IACH,OAAO,CAAC,aAAa;IAmBrB;;;;;;;;OAQG;YACW,aAAa;IA+B3B;;;;;;;;;OASG;IACH,OAAO,CAAC,UAAU;CA8BnB"}
|
||||
200
dist/PluginLoader.js
vendored
Normal file
200
dist/PluginLoader.js
vendored
Normal file
@@ -0,0 +1,200 @@
|
||||
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
|
||||
1
dist/PluginLoader.js.map
vendored
Normal file
1
dist/PluginLoader.js.map
vendored
Normal file
File diff suppressed because one or more lines are too long
2
dist/index.d.ts
vendored
2
dist/index.d.ts
vendored
@@ -8,4 +8,6 @@ export { PluginRegistry } from './PluginRegistry.js';
|
||||
export type { PluginRegistryOptions } from './PluginRegistry.js';
|
||||
export { createPluginContext } from './PluginContext.js';
|
||||
export type { PluginContextOptions } from './PluginContext.js';
|
||||
export { PluginLoader } from './PluginLoader.js';
|
||||
export type { PluginLoaderOptions } from './PluginLoader.js';
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
2
dist/index.d.ts.map
vendored
2
dist/index.d.ts.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,YAAY,EACV,cAAc,EACd,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,YAAY,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC"}
|
||||
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,YAAY,EACV,cAAc,EACd,kBAAkB,GACnB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAEhD,YAAY,EACV,cAAc,EACd,WAAW,EACX,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAEvC,YAAY,EACV,eAAe,EACf,iBAAiB,EACjB,iBAAiB,EACjB,WAAW,EACX,UAAU,EACV,aAAa,EACb,kBAAkB,EAClB,cAAc,EACd,cAAc,EACd,iBAAiB,EACjB,cAAc,EACd,mBAAmB,EACnB,aAAa,EACb,qBAAqB,EACrB,UAAU,EACV,MAAM,EACN,cAAc,GACf,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AACrD,YAAY,EAAE,qBAAqB,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AACzD,YAAY,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAE/D,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjD,YAAY,EAAE,mBAAmB,EAAE,MAAM,mBAAmB,CAAC"}
|
||||
1
dist/index.js
vendored
1
dist/index.js
vendored
@@ -3,4 +3,5 @@ export { PluginTab } from './types.js';
|
||||
export { generateThemeCSS } from './theme-css.js';
|
||||
export { PluginRegistry } from './PluginRegistry.js';
|
||||
export { createPluginContext } from './PluginContext.js';
|
||||
export { PluginLoader } from './PluginLoader.js';
|
||||
//# sourceMappingURL=index.js.map
|
||||
2
dist/index.js.map
vendored
2
dist/index.js.map
vendored
@@ -1 +1 @@
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAOhD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsBvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC"}
|
||||
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAOhD,OAAO,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAsBvC,OAAO,EAAE,gBAAgB,EAAE,MAAM,gBAAgB,CAAC;AAElD,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAC;AAGrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAGzD,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC"}
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@@ -8,6 +8,7 @@
|
||||
"name": "@paarrot/plugin-manager",
|
||||
"version": "1.0.0",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
@@ -41,6 +42,16 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
|
||||
@@ -442,6 +453,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"clean": "rimraf dist"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.4.0",
|
||||
"rimraf": "^5.0.0"
|
||||
"@types/node": "^25.6.0",
|
||||
"rimraf": "^5.0.0",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
270
src/PluginLoader.ts
Normal file
270
src/PluginLoader.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { createRequire } from 'module';
|
||||
import type { Plugin, NotificationOptions } from './PluginInterfaces.js';
|
||||
import type { IPluginEventClient } from './interfaces.js';
|
||||
import type { PluginRegistry } from './PluginRegistry.js';
|
||||
import { createPluginContext } from './PluginContext.js';
|
||||
|
||||
/**
|
||||
* Minimal shape expected from plugin-metadata.json.
|
||||
*/
|
||||
interface RawPluginMetadata {
|
||||
id: string;
|
||||
name?: string;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for constructing a {@link PluginLoader}.
|
||||
*
|
||||
* @remarks Node.js only — this class uses `fs`, `path`, and `module` from the Node standard library.
|
||||
*/
|
||||
export interface PluginLoaderOptions {
|
||||
/** The registry to register and wire loaded plugins into. */
|
||||
registry: PluginRegistry;
|
||||
|
||||
/**
|
||||
* Optional event client forwarded to each plugin's context.
|
||||
* Any object with `on(event, handler)` / `off(event, handler)` works.
|
||||
*/
|
||||
eventClient?: IPluginEventClient;
|
||||
|
||||
/**
|
||||
* Called when a plugin invokes `context.notify(...)`.
|
||||
* The host is responsible for displaying the notification.
|
||||
*/
|
||||
onNotify?: (options: NotificationOptions) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
private readonly registry: PluginRegistry;
|
||||
private readonly eventClient?: IPluginEventClient;
|
||||
private readonly onNotify?: (options: NotificationOptions) => void | Promise<void>;
|
||||
|
||||
constructor(options: PluginLoaderOptions) {
|
||||
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: string): Promise<string[]> {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
const loaded: string[] = [];
|
||||
|
||||
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: string): Promise<string> {
|
||||
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.
|
||||
*/
|
||||
private _readMetadata(pluginDir: string): RawPluginMetadata | null {
|
||||
const metaPath = path.join(pluginDir, 'plugin-metadata.json');
|
||||
|
||||
if (!fs.existsSync(metaPath)) return null;
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(metaPath, 'utf8')) as RawPluginMetadata;
|
||||
} 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.
|
||||
*/
|
||||
private _resolveEntry(pluginDir: string): string {
|
||||
const pkgPath = path.join(pluginDir, 'package.json');
|
||||
|
||||
if (fs.existsSync(pkgPath)) {
|
||||
try {
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { main?: string };
|
||||
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`.
|
||||
*/
|
||||
private async _importModule(filePath: string): Promise<Record<string, unknown>> {
|
||||
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: Record<string, unknown> } = { 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 as Record<string, unknown>;
|
||||
}
|
||||
} 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) as Record<string, unknown>;
|
||||
return (mod['default'] ?? mod) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`.
|
||||
*/
|
||||
private _normalise(raw: Record<string, unknown>): Plugin {
|
||||
if (typeof raw['onLoad'] === 'function') return raw as unknown as Plugin;
|
||||
|
||||
if (typeof raw['activate'] === 'function') {
|
||||
const activate = raw['activate'] as (ctx: unknown) => void | Promise<void>;
|
||||
const deactivate = raw['deactivate'] as (() => void | Promise<void>) | undefined;
|
||||
|
||||
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 as object), {
|
||||
registerHook: (name: string) =>
|
||||
ctx.log(`[compat] registerHook("${name}") — not supported`),
|
||||
runHook: (name: string) => {
|
||||
ctx.log(`[compat] runHook("${name}")`);
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
getConfig: (key: string) => ctx.settings.get(key),
|
||||
on: (event: string, handler: (...args: unknown[]) => void) =>
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -38,3 +38,6 @@ export type { PluginRegistryOptions } from './PluginRegistry.js';
|
||||
|
||||
export { createPluginContext } from './PluginContext.js';
|
||||
export type { PluginContextOptions } from './PluginContext.js';
|
||||
|
||||
export { PluginLoader } from './PluginLoader.js';
|
||||
export type { PluginLoaderOptions } from './PluginLoader.js';
|
||||
|
||||
Reference in New Issue
Block a user