| | 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' }; |
| | } |
| |
|
| | if (!handler.description || typeof handler.description !== 'string' || handler.description.trim() === '') { |
| | return { valid: false, reason: 'description 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; |
| | } |
| |
|
| | 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) => { |
| | 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); |
| | console.log(`β [${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 || {} |
| | }; |
| |
|
| | 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; |
| | } |