CoolFace
Apppublic

Ditzzy/api

sourceHugging Faceupdated 8mo agoView on Hugging Face
0likes
plugin-loader.ts328 linesDownload Raw Back to server
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}