| import { Express, Router } from "express"; |
| import { readdirSync, statSync, existsSync } from "fs"; |
| import { join, extname, relative } from "path"; |
| import { watch } from "chokidar"; |
| import { pathToFileURL } from "url"; |
| import { ApiPluginHandler, PluginMetadata, PluginRegistry } from "./types/plugin"; |
|
|
| export class PluginLoader { |
| private pluginRegistry: PluginRegistry = {}; |
| private pluginsDir: string; |
| private router: Router | null = null; |
| private app: Express | null = null; |
| private watcher: any = null; |
|
|
| constructor(pluginsDir: string) { |
| this.pluginsDir = pluginsDir; |
| } |
|
|
| async loadPlugins(app: Express, enableHotReload = false) { |
| this.app = app; |
| this.router = Router(); |
|
|
| await this.scanDirectory(this.pluginsDir, this.router); |
| app.use("/api", this.router); |
|
|
| console.log(`β
Loaded ${Object.keys(this.pluginRegistry).length} plugins`); |
|
|
| if (enableHotReload) { |
| this.enableHotReload(); |
| } |
|
|
| return this.pluginRegistry; |
| } |
|
|
| private enableHotReload() { |
| if (this.watcher) { |
| console.log("Hot reload already enabled"); |
| return; |
| } |
|
|
| console.log("π₯ Hot reload enabled for plugins"); |
|
|
| let reloadTimeout: NodeJS.Timeout | null = null; |
|
|
| this.watcher = watch(this.pluginsDir, { |
| ignored: /(^|[\/\\])\../, |
| persistent: true, |
| ignoreInitial: true, |
| awaitWriteFinish: { |
| stabilityThreshold: 500, |
| pollInterval: 100, |
| }, |
| }); |
|
|
| const handleChange = (eventType: string, path: string) => { |
| console.log(`π Plugin ${eventType}: ${relative(this.pluginsDir, path)}`); |
|
|
| if (reloadTimeout) { |
| clearTimeout(reloadTimeout); |
| } |
|
|
| reloadTimeout = setTimeout(() => { |
| this.reloadPlugins(); |
| }, 200); |
| }; |
|
|
| this.watcher |
| .on("add", (path: string) => handleChange("added", path)) |
| .on("change", (path: string) => handleChange("changed", path)) |
| .on("unlink", (path: string) => { |
| console.log(`ποΈ Plugin removed: ${relative(this.pluginsDir, path)}`); |
| this.reloadPlugins(); |
| }); |
| } |
|
|
| private async reloadPlugins() { |
| if (!this.app || !this.router) return; |
|
|
| try { |
| console.log("π Reloading plugins..."); |
| const oldRegistry = { ...this.pluginRegistry }; |
| const oldRouter = this.router; |
| this.pluginRegistry = {}; |
| const newRouter = Router(); |
| this.clearModuleCache(this.pluginsDir); |
|
|
| try { |
| await this.scanDirectory(this.pluginsDir, newRouter); |
|
|
| this.removeOldRouter(); |
| this.router = newRouter; |
| this.app.use("/api", this.router); |
|
|
| console.log(`β
Successfully reloaded ${Object.keys(this.pluginRegistry).length} plugins`); |
| } catch (scanError) { |
| console.error("β Error scanning plugins, rolling back..."); |
| this.pluginRegistry = oldRegistry; |
| this.router = oldRouter; |
| throw scanError; |
| } |
| } catch (error) { |
| console.error("β Error reloading plugins:", error); |
| console.log("β οΈ Keeping previous plugin configuration"); |
| } |
| } |
|
|
| private removeOldRouter() { |
| if (!this.app) return; |
|
|
| try { |
| const stack = (this.app as any)._router?.stack || []; |
|
|
| for (let i = stack.length - 1; i >= 0; i--) { |
| const layer = stack[i]; |
| if (layer.name === 'router' && layer.regexp.test('/api')) { |
| stack.splice(i, 1); |
| } |
| } |
| } catch (error) { |
| console.warn("β οΈ Could not remove old router, continuing anyway..."); |
| } |
| } |
|
|
| private clearModuleCache(dirPath: string) { |
| if (!existsSync(dirPath)) return; |
|
|
| const items = readdirSync(dirPath); |
|
|
| for (const item of items) { |
| const fullPath = join(dirPath, item); |
| const stat = statSync(fullPath); |
|
|
| if (stat.isDirectory()) { |
| this.clearModuleCache(fullPath); |
| } else if (stat.isFile() && (extname(item) === ".ts" || extname(item) === ".js")) { |
| const relativePath = relative(process.cwd(), fullPath); |
| console.log(`β»οΈ Marked for reload: ${relativePath}`); |
| } |
| } |
| } |
|
|
| private async scanDirectory(dir: string, router: Router, categoryPath: string[] = []) { |
| try { |
| const items = readdirSync(dir); |
|
|
| for (const item of items) { |
| const fullPath = join(dir, item); |
| const stat = statSync(fullPath); |
|
|
| if (stat.isDirectory()) { |
| await this.scanDirectory(fullPath, router, [...categoryPath, item]); |
| } else if (stat.isFile() && (extname(item) === ".ts" || extname(item) === ".js")) { |
| await this.loadPlugin(fullPath, router, categoryPath); |
| } |
| } |
| } catch (error) { |
| console.error(`β Error scanning directory ${dir}:`, error); |
| } |
| } |
|
|
| private isValidPluginMetadata(handler: ApiPluginHandler, fileName: string): { valid: boolean; reason?: string } { |
| if (!handler.category || !Array.isArray(handler.category) || handler.category.length === 0) { |
| return { valid: false, reason: 'category is missing or empty' }; |
| } |
|
|
| if (!handler.name || typeof handler.name !== 'string' || handler.name.trim() === '') { |
| return { valid: false, reason: 'name is missing or empty' }; |
| } |
|
|
| return { valid: true }; |
| } |
|
|
| private async loadPlugin(filePath: string, router: Router, categoryPath: string[]) { |
| const fileName = relative(this.pluginsDir, filePath); |
| |
| try { |
| const fileUrl = pathToFileURL(filePath).href; |
| const cacheBuster = `?update=${Date.now()}`; |
| const module = await import(fileUrl + cacheBuster); |
|
|
| const handler: ApiPluginHandler = module.default; |
|
|
| if (!handler || !handler.exec) { |
| console.warn(`β οΈ Skipping plugin '${fileName}': missing handler or exec function`); |
| return; |
| } |
|
|
| if (!handler.method) { |
| console.warn(`β οΈ Skipping plugin '${fileName}': missing 'method' field`); |
| return; |
| } |
|
|
| if (!handler.alias || handler.alias.length === 0) { |
| console.warn(`β οΈ Skipping plugin '${fileName}': missing 'alias' array`); |
| return; |
| } |
|
|
| if (typeof handler.exec !== 'function') { |
| console.warn(`β οΈ Skipping plugin '${fileName}': 'exec' must be a function`); |
| return; |
| } |
|
|
| if (handler.disabled) { |
| const reason = handler.disabledReason || "This plugin has been disabled"; |
| console.log(`π« Plugin '${handler.name}' is disabled: ${reason}`); |
| |
| } |
|
|
| if (handler.deprecated) { |
| const reason = handler.deprecatedReason || "This plugin is deprecated and may be removed in future versions"; |
| console.warn(`β οΈ Plugin '${handler.name}' is deprecated: ${reason}`); |
| } |
|
|
| const metadataValidation = this.isValidPluginMetadata(handler, fileName); |
| const shouldShowInDocs = metadataValidation.valid; |
|
|
| if (!shouldShowInDocs) { |
| console.warn(`β οΈ Plugin '${fileName}' will be hidden from docs: ${metadataValidation.reason}`); |
| } |
|
|
| const basePath = handler.category && handler.category.length > 0 |
| ? `/${handler.category.join("/")}` |
| : ""; |
|
|
| const primaryAlias = handler.alias[0]; |
| const primaryEndpoint = basePath ? `${basePath}/${primaryAlias}` : `/${primaryAlias}`; |
| const method = handler.method.toLowerCase() as "get" | "post" | "put" | "delete" | "patch"; |
|
|
| |
| const wrappedExec = async (req: any, res: any, next: any) => { |
| |
| if (handler.disabled) { |
| const reason = handler.disabledReason || "This plugin has been disabled"; |
| return res.status(403).json({ |
| success: false, |
| message: "Plugin is disabled", |
| reason: reason, |
| plugin: handler.name || 'unknown', |
| }); |
| } |
|
|
| |
| if (handler.deprecated) { |
| const reason = handler.deprecatedReason || "This plugin is deprecated and may be removed in future versions"; |
| res.setHeader('X-Plugin-Deprecated', 'true'); |
| res.setHeader('X-Deprecation-Reason', reason); |
| } |
|
|
| try { |
| await handler.exec(req, res, next); |
| } catch (error) { |
| console.error(`β Error in plugin ${handler.name || 'unknown'}:`, error); |
| if (!res.headersSent) { |
| res.status(500).json({ |
| success: false, |
| message: "Plugin execution error", |
| plugin: handler.name || 'unknown', |
| error: error instanceof Error ? error.message : "Unknown error", |
| }); |
| } |
| } |
| }; |
|
|
| for (const alias of handler.alias) { |
| const endpoint = basePath ? `${basePath}/${alias}` : `/${alias}`; |
| router[method](endpoint, wrappedExec); |
| |
| const statusIcon = handler.disabled ? 'π«' : handler.deprecated ? 'β οΈ' : 'β'; |
| console.log(`${statusIcon} [${handler.method}] ${endpoint} -> ${handler.name || 'unnamed'}`); |
| } |
|
|
| if (shouldShowInDocs) { |
| const metadata: PluginMetadata = { |
| name: handler.name, |
| description: handler.description, |
| version: handler.version || "1.0.0", |
| category: handler.category, |
| method: handler.method, |
| endpoint: primaryEndpoint, |
| aliases: handler.alias, |
| tags: handler.tags || [], |
| parameters: handler.parameters || { |
| query: [], |
| body: [], |
| headers: [], |
| path: [] |
| }, |
| responses: handler.responses || {}, |
| disabled: handler.disabled, |
| deprecated: handler.deprecated, |
| disabledReason: handler.disabledReason, |
| deprecatedReason: handler.deprecatedReason |
| }; |
|
|
| this.pluginRegistry[primaryEndpoint] = { handler, metadata }; |
| } |
| } catch (error) { |
| console.error(`β Failed to load plugin '${fileName}':`, error instanceof Error ? error.message : error); |
| } |
| } |
|
|
| getPluginMetadata(): PluginMetadata[] { |
| return Object.values(this.pluginRegistry).map(p => p.metadata); |
| } |
|
|
| getPluginRegistry(): PluginRegistry { |
| return this.pluginRegistry; |
| } |
|
|
| stopHotReload() { |
| if (this.watcher) { |
| this.watcher.close(); |
| this.watcher = null; |
| console.log("π Hot reload stopped"); |
| } |
| } |
| } |
|
|
| let pluginLoader: PluginLoader; |
|
|
| export function initPluginLoader(pluginsDir: string) { |
| pluginLoader = new PluginLoader(pluginsDir); |
| return pluginLoader; |
| } |
|
|
| export function getPluginLoader() { |
| return pluginLoader; |
| } |