Ditzzy/api
0
1import { Express, Router } from "express";2import { readdirSync, statSync, existsSync } from "fs";3import { join, extname, relative } from "path";4import { watch } from "chokidar";5import { pathToFileURL } from "url";6import { ApiPluginHandler, PluginMetadata, PluginRegistry } from "./types/plugin";7 8export class PluginLoader {9 private pluginRegistry: PluginRegistry = {};10 private pluginsDir: string;11 private router: Router | null = null;12 private app: Express | null = null;13 private watcher: any = null;14 15 constructor(pluginsDir: string) {16 this.pluginsDir = pluginsDir;17 }18 19 async loadPlugins(app: Express, enableHotReload = false) {20 this.app = app;21 this.router = Router();22 23 await this.scanDirectory(this.pluginsDir, this.router);24 app.use("/api", this.router);25 26 console.log(`โ
Loaded ${Object.keys(this.pluginRegistry).length} plugins`);27 28 if (enableHotReload) {29 this.enableHotReload();30 }31 32 return this.pluginRegistry;33 }34 35 private enableHotReload() {36 if (this.watcher) {37 console.log("Hot reload already enabled");38 return;39 }40 41 console.log("๐ฅ Hot reload enabled for plugins");42 43 let reloadTimeout: NodeJS.Timeout | null = null;44 45 this.watcher = watch(this.pluginsDir, {46 ignored: /(^|[\/\\])\../, 47 persistent: true,48 ignoreInitial: true,49 awaitWriteFinish: {50 stabilityThreshold: 500,51 pollInterval: 100,52 },53 });54 55 const handleChange = (eventType: string, path: string) => {56 console.log(`๐ Plugin ${eventType}: ${relative(this.pluginsDir, path)}`);57 58 if (reloadTimeout) {59 clearTimeout(reloadTimeout);60 }61 62 reloadTimeout = setTimeout(() => {63 this.reloadPlugins();64 }, 200);65 };66 67 this.watcher68 .on("add", (path: string) => handleChange("added", path))69 .on("change", (path: string) => handleChange("changed", path))70 .on("unlink", (path: string) => {71 console.log(`๐๏ธ Plugin removed: ${relative(this.pluginsDir, path)}`);72 this.reloadPlugins();73 });74 }75 76 private async reloadPlugins() {77 if (!this.app || !this.router) return;78 79 try {80 console.log("๐ Reloading plugins...");81 const oldRegistry = { ...this.pluginRegistry };82 const oldRouter = this.router;83 this.pluginRegistry = {};84 const newRouter = Router();85 this.clearModuleCache(this.pluginsDir);86 87 try {88 await this.scanDirectory(this.pluginsDir, newRouter);89 90 this.removeOldRouter();91 this.router = newRouter;92 this.app.use("/api", this.router);93 94 console.log(`โ
Successfully reloaded ${Object.keys(this.pluginRegistry).length} plugins`);95 } catch (scanError) {96 console.error("โ Error scanning plugins, rolling back...");97 this.pluginRegistry = oldRegistry;98 this.router = oldRouter;99 throw scanError;100 }101 } catch (error) {102 console.error("โ Error reloading plugins:", error);103 console.log("โ ๏ธ Keeping previous plugin configuration");104 }105 }106 107 private removeOldRouter() {108 if (!this.app) return;109 110 try {111 const stack = (this.app as any)._router?.stack || [];112 113 for (let i = stack.length - 1; i >= 0; i--) {114 const layer = stack[i];115 if (layer.name === 'router' && layer.regexp.test('/api')) {116 stack.splice(i, 1);117 }118 }119 } catch (error) {120 console.warn("โ ๏ธ Could not remove old router, continuing anyway...");121 }122 }123 124 private clearModuleCache(dirPath: string) {125 if (!existsSync(dirPath)) return;126 127 const items = readdirSync(dirPath);128 129 for (const item of items) {130 const fullPath = join(dirPath, item);131 const stat = statSync(fullPath);132 133 if (stat.isDirectory()) {134 this.clearModuleCache(fullPath);135 } else if (stat.isFile() && (extname(item) === ".ts" || extname(item) === ".js")) {136 const relativePath = relative(process.cwd(), fullPath);137 console.log(`โป๏ธ Marked for reload: ${relativePath}`);138 }139 }140 }141 142 private async scanDirectory(dir: string, router: Router, categoryPath: string[] = []) {143 try {144 const items = readdirSync(dir);145 146 for (const item of items) {147 const fullPath = join(dir, item);148 const stat = statSync(fullPath);149 150 if (stat.isDirectory()) {151 await this.scanDirectory(fullPath, router, [...categoryPath, item]);152 } else if (stat.isFile() && (extname(item) === ".ts" || extname(item) === ".js")) {153 await this.loadPlugin(fullPath, router, categoryPath);154 }155 }156 } catch (error) {157 console.error(`โ Error scanning directory ${dir}:`, error);158 }159 }160 161 private isValidPluginMetadata(handler: ApiPluginHandler, fileName: string): { valid: boolean; reason?: string } {162 if (!handler.category || !Array.isArray(handler.category) || handler.category.length === 0) {163 return { valid: false, reason: 'category is missing or empty' };164 }165 166 if (!handler.name || typeof handler.name !== 'string' || handler.name.trim() === '') {167 return { valid: false, reason: 'name is missing or empty' };168 }169 170 return { valid: true };171 }172 173 private async loadPlugin(filePath: string, router: Router, categoryPath: string[]) {174 const fileName = relative(this.pluginsDir, filePath);175 176 try {177 const fileUrl = pathToFileURL(filePath).href;178 const cacheBuster = `?update=${Date.now()}`;179 const module = await import(fileUrl + cacheBuster);180 181 const handler: ApiPluginHandler = module.default;182 183 if (!handler || !handler.exec) {184 console.warn(`โ ๏ธ Skipping plugin '${fileName}': missing handler or exec function`);185 return;186 }187 188 if (!handler.method) {189 console.warn(`โ ๏ธ Skipping plugin '${fileName}': missing 'method' field`);190 return;191 }192 193 if (!handler.alias || handler.alias.length === 0) {194 console.warn(`โ ๏ธ Skipping plugin '${fileName}': missing 'alias' array`);195 return;196 }197 198 if (typeof handler.exec !== 'function') {199 console.warn(`โ ๏ธ Skipping plugin '${fileName}': 'exec' must be a function`);200 return;201 }202 203 if (handler.disabled) {204 const reason = handler.disabledReason || "This plugin has been disabled";205 console.log(`๐ซ Plugin '${handler.name}' is disabled: ${reason}`);206 // Still register it but with disabled flag207 }208 209 if (handler.deprecated) {210 const reason = handler.deprecatedReason || "This plugin is deprecated and may be removed in future versions";211 console.warn(`โ ๏ธ Plugin '${handler.name}' is deprecated: ${reason}`);212 }213 214 const metadataValidation = this.isValidPluginMetadata(handler, fileName);215 const shouldShowInDocs = metadataValidation.valid;216 217 if (!shouldShowInDocs) {218 console.warn(`โ ๏ธ Plugin '${fileName}' will be hidden from docs: ${metadataValidation.reason}`);219 }220 221 const basePath = handler.category && handler.category.length > 0 222 ? `/${handler.category.join("/")}` 223 : "";224 225 const primaryAlias = handler.alias[0];226 const primaryEndpoint = basePath ? `${basePath}/${primaryAlias}` : `/${primaryAlias}`;227 const method = handler.method.toLowerCase() as "get" | "post" | "put" | "delete" | "patch";228 229 // Wrap exec function to handle disabled/deprecated plugins230 const wrappedExec = async (req: any, res: any, next: any) => {231 // If plugin is disabled, return error response232 if (handler.disabled) {233 const reason = handler.disabledReason || "This plugin has been disabled";234 return res.status(403).json({235 success: false,236 message: "Plugin is disabled",237 reason: reason,238 plugin: handler.name || 'unknown',239 });240 }241 242 // If plugin is deprecated, add warning header243 if (handler.deprecated) {244 const reason = handler.deprecatedReason || "This plugin is deprecated and may be removed in future versions";245 res.setHeader('X-Plugin-Deprecated', 'true');246 res.setHeader('X-Deprecation-Reason', reason);247 }248 249 try {250 await handler.exec(req, res, next);251 } catch (error) {252 console.error(`โ Error in plugin ${handler.name || 'unknown'}:`, error);253 if (!res.headersSent) {254 res.status(500).json({255 success: false,256 message: "Plugin execution error",257 plugin: handler.name || 'unknown',258 error: error instanceof Error ? error.message : "Unknown error",259 });260 }261 }262 };263 264 for (const alias of handler.alias) {265 const endpoint = basePath ? `${basePath}/${alias}` : `/${alias}`;266 router[method](endpoint, wrappedExec);267 268 const statusIcon = handler.disabled ? '๐ซ' : handler.deprecated ? 'โ ๏ธ' : 'โ';269 console.log(`${statusIcon} [${handler.method}] ${endpoint} -> ${handler.name || 'unnamed'}`);270 }271 272 if (shouldShowInDocs) {273 const metadata: PluginMetadata = {274 name: handler.name,275 description: handler.description,276 version: handler.version || "1.0.0",277 category: handler.category,278 method: handler.method,279 endpoint: primaryEndpoint,280 aliases: handler.alias,281 tags: handler.tags || [],282 parameters: handler.parameters || {283 query: [],284 body: [],285 headers: [],286 path: []287 },288 responses: handler.responses || {},289 disabled: handler.disabled,290 deprecated: handler.deprecated,291 disabledReason: handler.disabledReason,292 deprecatedReason: handler.deprecatedReason293 };294 295 this.pluginRegistry[primaryEndpoint] = { handler, metadata };296 }297 } catch (error) {298 console.error(`โ Failed to load plugin '${fileName}':`, error instanceof Error ? error.message : error);299 }300 }301 302 getPluginMetadata(): PluginMetadata[] {303 return Object.values(this.pluginRegistry).map(p => p.metadata);304 }305 306 getPluginRegistry(): PluginRegistry {307 return this.pluginRegistry;308 }309 310 stopHotReload() {311 if (this.watcher) {312 this.watcher.close();313 this.watcher = null;314 console.log("๐ Hot reload stopped");315 }316 }317}318 319let pluginLoader: PluginLoader;320 321export function initPluginLoader(pluginsDir: string) {322 pluginLoader = new PluginLoader(pluginsDir);323 return pluginLoader;324}325 326export function getPluginLoader() {327 return pluginLoader;328}