diff --git a/package.json b/package.json index 99f8c63d..f17853ec 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,10 @@ "main": "index.ts", "scripts": { "start": "node --enable-source-maps --import ./build/src/pathman.js build/src/index.js", - "build": "tsgo --sourceMap && ncp static/webui build/static/webui", - "build:tsc": "tsc --incremental --sourceMap && ncp static/webui build/static/webui", - "build:dev": "tsgo --sourceMap", - "build:dev:tsc": "tsc --incremental --sourceMap", + "build": "node scripts/plugin-discovery.js && tsgo --sourceMap && ncp static/webui build/static/webui && ncp plugins build/plugins", + "build:tsc": "node scripts/plugin-discovery.js && tsc --incremental --sourceMap && ncp static/webui build/static/webui && ncp plugins build/plugins", + "build:dev": "node scripts/plugin-discovery.js && tsgo --sourceMap", + "build:dev:tsc": "node scripts/plugin-discovery.js && tsc --incremental --sourceMap", "build-and-start": "npm run build && npm run start", "build-and-start:bun": "npm run verify && npm run bun-run", "dev": "node scripts/dev.js", diff --git a/plugins/.gitignore b/plugins/.gitignore new file mode 100644 index 00000000..b4b8043e --- /dev/null +++ b/plugins/.gitignore @@ -0,0 +1,5 @@ +* + +!.gitignore +!ExamplePlugin/ +!ExamplePlugin/** \ No newline at end of file diff --git a/plugins/ExamplePlugin/index.ts b/plugins/ExamplePlugin/index.ts new file mode 100644 index 00000000..184934db --- /dev/null +++ b/plugins/ExamplePlugin/index.ts @@ -0,0 +1,30 @@ +import { IPlugin } from "@/src/types/pluginTypes"; +import { logger } from "@/src/utils/logger"; + +export default class ExamplePlugin implements IPlugin { + public name = "ExamplePlugin"; + public version = "1.0.0"; + public description = "Example plugin for the server"; + public author = "Your Name"; + + async initialize(): Promise { + logger.info(`[${this.name}] Plugin initialized successfully!`); + + // Add your plugin initialization logic here + // For example: + // - Register new routes + // - Add new API endpoints + // - Set up event listeners + // - Connect to external services + } + + async cleanup(): Promise { + logger.info(`[${this.name}] Plugin cleanup completed`); + + // Add your cleanup logic here + // For example: + // - Close database connections + // - Clear timers/intervals + // - Remove event listeners + } +} diff --git a/plugins/ExamplePlugin/plugin.json b/plugins/ExamplePlugin/plugin.json new file mode 100644 index 00000000..8b3450da --- /dev/null +++ b/plugins/ExamplePlugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "ExamplePlugin", + "version": "1.0.0", + "description": "Example plugin for the server", + "author": "Your Name", + "main": "index.js", + "dependencies": [], + "config": { + "enabled": false + } +} diff --git a/scripts/plugin-discovery.js b/scripts/plugin-discovery.js new file mode 100644 index 00000000..45f3c174 --- /dev/null +++ b/scripts/plugin-discovery.js @@ -0,0 +1,127 @@ +/** + * Plugin Discovery Script + * This script runs during build time to discover all plugins and generate a registry + */ + +const fs = require('fs'); +const path = require('path'); + +class PluginDiscovery { + constructor(pluginsDir = 'plugins', outputPath = 'build/plugin-registry.json') { + this.pluginsDir = path.resolve(pluginsDir); + this.outputPath = path.resolve(outputPath); + } + + /** + * Discover all plugins in the plugins directory + */ + discoverPlugins() { + const registry = { + plugins: [], + manifest: {}, + buildTime: new Date().toISOString() + }; + + console.log(`🔍 Discovering plugins in: ${this.pluginsDir}`); + + if (!fs.existsSync(this.pluginsDir)) { + console.log('⚠️ Plugins directory not found, creating empty registry'); + return registry; + } + + const pluginDirs = fs.readdirSync(this.pluginsDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + + for (const pluginDir of pluginDirs) { + const pluginPath = path.join(this.pluginsDir, pluginDir); + const manifest = this.loadPluginManifest(pluginPath); + + if (manifest) { + registry.plugins.push(pluginPath); + registry.manifest[manifest.name] = manifest; + console.log(`✅ Found plugin: ${manifest.name} v${manifest.version}`); + } else { + console.log(`❌ Invalid plugin: ${pluginDir} (missing or invalid plugin.json)`); + } + } + + console.log(`📦 Discovered ${registry.plugins.length} plugins`); + return registry; + } + + /** + * Load and validate plugin manifest + */ + loadPluginManifest(pluginPath) { + try { + const manifestPath = path.join(pluginPath, 'plugin.json'); + + if (!fs.existsSync(manifestPath)) { + return null; + } + + const manifestContent = fs.readFileSync(manifestPath, 'utf-8'); + const manifest = JSON.parse(manifestContent); + + // Validate required fields + if (!manifest.name || !manifest.version || !manifest.main) { + console.log(`⚠️ Invalid manifest in ${pluginPath}: missing required fields (name, version, main)`); + return null; + } + + // Check if main file exists + const mainFile = path.join(pluginPath, manifest.main); + const mainTsFile = mainFile.replace(/\.js$/, '.ts'); + + if (!fs.existsSync(mainFile) && !fs.existsSync(mainTsFile)) { + console.log(`⚠️ Main file not found: ${mainFile} or ${mainTsFile}`); + return null; + } + + return manifest; + } catch (error) { + console.log(`❌ Failed to load manifest from ${pluginPath}:`, error.message); + return null; + } + } + + /** + * Save the plugin registry to file + */ + saveRegistry(registry) { + try { + // Ensure output directory exists + const outputDir = path.dirname(this.outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync(this.outputPath, JSON.stringify(registry, null, 2)); + console.log(`💾 Plugin registry saved to: ${this.outputPath}`); + } catch (error) { + console.error(`❌ Failed to save plugin registry:`, error.message); + process.exit(1); + } + } + + /** + * Main execution function + */ + run() { + console.log('🚀 Starting plugin discovery...'); + + const registry = this.discoverPlugins(); + this.saveRegistry(registry); + + console.log('✨ Plugin discovery completed!'); + } +} + +// Run the plugin discovery if this script is executed directly +if (require.main === module) { + const discovery = new PluginDiscovery(); + discovery.run(); +} + +module.exports = { PluginDiscovery }; diff --git a/src/index.ts b/src/index.ts index 53dfcd90..b0fa08ae 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,18 +23,36 @@ import { startWebServer } from "./services/webService"; import { syncConfigWithDatabase, validateConfig } from "@/src/services/configWatcherService"; import { updateWorldStateCollections } from "./services/worldStateService"; +import { pluginManager } from "@/src/managers/pluginManager"; // Patch JSON.stringify to work flawlessly with Bigints. JSON.stringify = JSONStringify; validateConfig(); +// Handle graceful shutdown +process.on('SIGINT', async () => { + logger.info('Received SIGINT, starting graceful shutdown...'); + await pluginManager.cleanup(); + process.exit(0); +}); + +process.on('SIGTERM', async () => { + logger.info('Received SIGTERM, starting graceful shutdown...'); + await pluginManager.cleanup(); + process.exit(0); +}); + mongoose .connect(config.mongodbUrl) - .then(() => { + .then(async () => { logger.info("Connected to MongoDB"); syncConfigWithDatabase(); + // Initialize plugins before starting the web server + logger.info("Loading plugins..."); + await pluginManager.loadPlugins(); + startWebServer(); void updateWorldStateCollections(); diff --git a/src/managers/pluginManager.ts b/src/managers/pluginManager.ts new file mode 100644 index 00000000..4d6f4ab3 --- /dev/null +++ b/src/managers/pluginManager.ts @@ -0,0 +1,161 @@ +import { logger } from "@/src/utils/logger"; +import { IPlugin, PluginInfo, PluginManifest } from "@/src/types/pluginTypes"; +import fs from "fs"; +import path from "path"; +import { pathToFileURL } from "url"; + +export class PluginManager { + private plugins: Map = new Map(); + private pluginInfos: Map = new Map(); + private pluginsDir: string; + + constructor(pluginsDir: string = "plugins") { + this.pluginsDir = path.resolve(pluginsDir); + } + + /** + * Load all plugins from the plugins directory + */ + async loadPlugins(): Promise { + try { + // Load plugin registry generated at build time + const pluginRegistryPath = path.resolve("build/plugin-registry.json"); + if (!fs.existsSync(pluginRegistryPath)) { + logger.info("No plugin registry found, skipping plugin loading"); + return; + } + + const pluginRegistry = JSON.parse(fs.readFileSync(pluginRegistryPath, "utf-8")); + logger.info(`Found ${pluginRegistry.plugins.length} plugins in registry`); + + for (const pluginPath of pluginRegistry.plugins) { + await this.loadPlugin(pluginPath); + } + + logger.info(`Successfully loaded ${this.plugins.size} plugins`); + } catch (error) { + logger.error("Failed to load plugins:", error); + } + } + + /** + * Load a single plugin + */ + private async loadPlugin(pluginPath: string): Promise { + try { + const absolutePath = path.resolve(pluginPath); + const manifestPath = path.join(absolutePath, "plugin.json"); + + if (!fs.existsSync(manifestPath)) { + logger.warn(`Plugin manifest not found: ${manifestPath}`); + return; + } + + const manifest: PluginManifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")); + + // Check if plugin is enabled from config + const pluginInfo: PluginInfo = { + manifest, + path: absolutePath, + enabled: manifest.config?.enabled ?? true + }; + + if (!pluginInfo.enabled) { + logger.info(`Plugin ${manifest.name} is disabled, skipping`); + return; + } + + // Load the plugin module from build directory + const pluginName = path.basename(absolutePath); + const buildPluginPath = path.resolve("build/plugins", pluginName); + const mainFile = path.join(buildPluginPath, manifest.main || "index.js"); + if (!fs.existsSync(mainFile)) { + logger.error(`Plugin main file not found: ${mainFile}`); + return; + } + + const pluginModule = await import(pathToFileURL(mainFile).href); + const PluginClass = pluginModule.default?.default || pluginModule.default || pluginModule[manifest.name]; + + if (!PluginClass) { + logger.error(`Plugin class not found in ${mainFile}`); + return; + } + + const plugin: IPlugin = new PluginClass(); + + // Validate plugin interface + if (!this.validatePlugin(plugin)) { + logger.error(`Plugin ${manifest.name} does not implement required interface`); + return; + } + + // Initialize the plugin + await plugin.initialize(); + + this.plugins.set(manifest.name, plugin); + this.pluginInfos.set(manifest.name, pluginInfo); + + logger.info(`Loaded plugin: ${manifest.name} v${manifest.version}`); + } catch (error) { + logger.error(`Failed to load plugin from ${pluginPath}:`, error); + } + } + + /** + * Validate that a plugin implements the required interface + */ + private validatePlugin(plugin: any): plugin is IPlugin { + return ( + plugin && + typeof plugin.name === "string" && + typeof plugin.version === "string" && + typeof plugin.initialize === "function" + ); + } + + /** + * Get a plugin by name + */ + getPlugin(name: string): IPlugin | undefined { + return this.plugins.get(name); + } + + /** + * Get all loaded plugins + */ + getAllPlugins(): Map { + return new Map(this.plugins); + } + + /** + * Get plugin info + */ + getPluginInfo(name: string): PluginInfo | undefined { + return this.pluginInfos.get(name); + } + + /** + * Cleanup all plugins + */ + async cleanup(): Promise { + logger.info("Cleaning up plugins..."); + + for (const [name, plugin] of this.plugins) { + try { + if (plugin.cleanup) { + await plugin.cleanup(); + } + logger.info(`Cleaned up plugin: ${name}`); + } catch (error) { + logger.error(`Failed to cleanup plugin ${name}:`, error); + } + } + + this.plugins.clear(); + this.pluginInfos.clear(); + } +} + +// Global plugin manager instance +export const pluginManager = new PluginManager(); diff --git a/src/types/pluginTypes.ts b/src/types/pluginTypes.ts new file mode 100644 index 00000000..e95836f6 --- /dev/null +++ b/src/types/pluginTypes.ts @@ -0,0 +1,52 @@ +export interface IPlugin { + /** + * Plugin name + */ + name: string; + + /** + * Plugin version + */ + version: string; + + /** + * Plugin description + */ + description?: string; + + /** + * Plugin author + */ + author?: string; + + /** + * Initialize the plugin + */ + initialize(): Promise | void; + + /** + * Cleanup plugin resources + */ + cleanup?(): Promise | void; + + /** + * Plugin configuration + */ + config?: any; +} + +export interface PluginManifest { + name: string; + version: string; + description?: string; + author?: string; + main: string; + dependencies?: string[]; + config?: any; +} + +export interface PluginInfo { + manifest: PluginManifest; + path: string; + enabled: boolean; +}