api / src /server /plugin-loader.ts
OhMyDitzzy
status code on disabled
e8e03dd
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}`);
// Still register it but with disabled flag
}
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";
// Wrap exec function to handle disabled/deprecated plugins
const wrappedExec = async (req: any, res: any, next: any) => {
// If plugin is disabled, return error response
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 plugin is deprecated, add warning header
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;
}