From da18b7dfe506692221cc3b8ef80677bd70c5b33d Mon Sep 17 00:00:00 2001 From: Max Litruv Boonzaayer Date: Sat, 18 Apr 2026 21:04:28 +1000 Subject: [PATCH] added examples --- dist/PluginLoader.d.ts | 110 +++++++++++++++ dist/PluginLoader.d.ts.map | 1 + dist/PluginLoader.js | 200 +++++++++++++++++++++++++++ dist/PluginLoader.js.map | 1 + dist/index.d.ts | 2 + dist/index.d.ts.map | 2 +- dist/index.js | 1 + dist/index.js.map | 2 +- package-lock.json | 18 +++ package.json | 5 +- src/PluginLoader.ts | 270 +++++++++++++++++++++++++++++++++++++ src/index.ts | 3 + 12 files changed, 611 insertions(+), 4 deletions(-) create mode 100644 dist/PluginLoader.d.ts create mode 100644 dist/PluginLoader.d.ts.map create mode 100644 dist/PluginLoader.js create mode 100644 dist/PluginLoader.js.map create mode 100644 src/PluginLoader.ts diff --git a/dist/PluginLoader.d.ts b/dist/PluginLoader.d.ts new file mode 100644 index 0000000..fb187a5 --- /dev/null +++ b/dist/PluginLoader.d.ts @@ -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; +} +/** + * 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; + /** + * 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; + /** + * 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 \ No newline at end of file diff --git a/dist/PluginLoader.d.ts.map b/dist/PluginLoader.d.ts.map new file mode 100644 index 0000000..9821aad --- /dev/null +++ b/dist/PluginLoader.d.ts.map @@ -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"} \ No newline at end of file diff --git a/dist/PluginLoader.js b/dist/PluginLoader.js new file mode 100644 index 0000000..2453cf0 --- /dev/null +++ b/dist/PluginLoader.js @@ -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 \ No newline at end of file diff --git a/dist/PluginLoader.js.map b/dist/PluginLoader.js.map new file mode 100644 index 0000000..223d4d0 --- /dev/null +++ b/dist/PluginLoader.js.map @@ -0,0 +1 @@ +{"version":3,"file":"PluginLoader.js","sourceRoot":"","sources":["../src/PluginLoader.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/dist/index.d.ts b/dist/index.d.ts index eb92355..b1c99cd 100644 --- a/dist/index.d.ts +++ b/dist/index.d.ts @@ -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 \ No newline at end of file diff --git a/dist/index.d.ts.map b/dist/index.d.ts.map index 84b968d..291e389 100644 --- a/dist/index.d.ts.map +++ b/dist/index.d.ts.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/dist/index.js b/dist/index.js index c92030b..f1f22bb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -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 \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map index 5300d52..3172464 100644 --- a/dist/index.js.map +++ b/dist/index.js.map @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 02cc2d9..91be277 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f38eab5..3a4cdee 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/src/PluginLoader.ts b/src/PluginLoader.ts new file mode 100644 index 0000000..91d261c --- /dev/null +++ b/src/PluginLoader.ts @@ -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; +} + +/** + * 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; + + 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 { + 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 { + 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> { + 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 } = { 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; + } + } 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; + return (mod['default'] ?? mod) as Record; + } + + /** + * 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): 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; + const deactivate = raw['deactivate'] as (() => void | Promise) | 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'); + } +} diff --git a/src/index.ts b/src/index.ts index 5c8f381..205a4b1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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';