diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 00000000..345740e8 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,22 @@ +# Build output +build/ + +# Node modules +node_modules/ + +# Logs +logs/ + +# Environment files +.env +.env.local +.env.*.local + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/package.json b/package.json index 99f8c63d..8b771442 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", @@ -19,7 +19,9 @@ "lint:ci": "eslint --ext .ts --rule \"prettier/prettier: off\" .", "lint:fix": "eslint --fix --ext .ts .", "prettier": "prettier --write .", - "update-translations": "cd scripts && node update-translations.js" + "update-translations": "cd scripts && node update-translations.js", + "new-plugin-ts": "node scripts/new-plugin-ts.js", + "new-plugin-js": "node scripts/new-plugin-js.js" }, "license": "GNU", "dependencies": { 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..6024f9c4 --- /dev/null +++ b/plugins/ExamplePlugin/index.ts @@ -0,0 +1,32 @@ +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 + await Promise.resolve(); // Simulate async operation if needed + } + + 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 + await Promise.resolve(); // Simulate async operation if needed + } +} diff --git a/plugins/ExamplePlugin/plugin.json b/plugins/ExamplePlugin/plugin.json new file mode 100644 index 00000000..3d182670 --- /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/create-plugin-core.js b/scripts/create-plugin-core.js new file mode 100644 index 00000000..b9e4f14d --- /dev/null +++ b/scripts/create-plugin-core.js @@ -0,0 +1,238 @@ +/** + * Plugin Generator Script + * Creates new plugin templates for both TypeScript and JavaScript + */ + +const fs = require("fs"); +const path = require("path"); + +class PluginGenerator { + constructor() { + this.pluginsDir = path.resolve("plugins"); + } + + /** + * Create a new plugin + */ + createPlugin(name, language = "ts") { + if (!name) { + console.error("❌ Plugin name is required!"); + console.log("Usage: node create-plugin.js [ts|js]"); + process.exit(1); + } + + if (!["ts", "js"].includes(language)) { + console.error("❌ Language must be 'ts' or 'js'"); + process.exit(1); + } + + const pluginDir = path.join(this.pluginsDir, name); + + // Check if plugin already exists + if (fs.existsSync(pluginDir)) { + console.error(`❌ Plugin '${name}' already exists!`); + process.exit(1); + } + + console.log(`🚀 Creating ${language.toUpperCase()} plugin: ${name}`); + + // Create plugin directory + this.createDirectory(pluginDir); + + // Create plugin files + this.createPluginManifest(pluginDir, name); + + if (language === "ts") { + this.createTypeScriptPlugin(pluginDir, name); + } else { + this.createJavaScriptPlugin(pluginDir, name); + } + + console.log(`✅ Plugin '${name}' created successfully!`); + console.log(`📁 Location: ${pluginDir}`); + console.log(`🔧 Next steps:`); + console.log(` 1. Edit ${path.join(pluginDir, `index.${language}`)} to implement your plugin logic`); + console.log(` 2. Update ${path.join(pluginDir, "plugin.json")} if needed`); + console.log(` 3. Run 'npm run build' to build the plugin`); + } + + /** + * Create directory if it doesn't exist + */ + createDirectory(dirPath) { + if (!fs.existsSync(dirPath)) { + fs.mkdirSync(dirPath, { recursive: true }); + } + } + + /** + * Create plugin manifest file + */ + createPluginManifest(pluginDir, name) { + const manifest = { + name: name, + version: "1.0.0", + description: `${name} plugin for Warframe Emulator`, + main: "index.js", + author: "Your Name", + license: "GNU", + config: { + enabled: true + }, + dependencies: {}, + tags: ["custom"] + }; + + const manifestPath = path.join(pluginDir, "plugin.json"); + fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2)); + console.log(`📝 Created: plugin.json`); + } + + /** + * Create TypeScript plugin template + */ + createTypeScriptPlugin(pluginDir, name) { + const template = `import { IPlugin } from "@/src/types/pluginTypes"; +import { logger } from "@/src/utils/logger"; + +export default class ${name} implements IPlugin { + public name = "${name}"; + public version = "1.0.0"; + public description = "${name} plugin for Warframe Emulator"; + + 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 + + await Promise.resolve(); // Remove this line and add your actual logic + } + + 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 + // - Cleanup resources + + await Promise.resolve(); // Remove this line and add your actual logic + } + + // Add your custom methods here + // Example: + // public async customMethod(): Promise { + // logger.info(\`[\${this.name}] Custom method called\`); + // } +} +`; + + const pluginPath = path.join(pluginDir, "index.ts"); + fs.writeFileSync(pluginPath, template); + console.log(`📝 Created: index.ts`); + } + + /** + * Create JavaScript plugin template + */ + createJavaScriptPlugin(pluginDir, name) { + const template = `/** + * ${name} Plugin + * ${name} plugin for Warframe Emulator + */ +import {logger} from "../../src/utils/logger.js"; + +class ${name} { + constructor() { + this.name = "${name}"; + this.version = "1.0.0"; + this.description = "${name} plugin for Warframe Emulator"; + } + + async initialize() { + 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 + + return Promise.resolve(); // Remove this line and add your actual logic + } + + async cleanup() { + logger.info(\`[\${this.name}] Plugin cleanup completed\`); + + // Add your cleanup logic here + // For example: + // - Close database connections + // - Clear timers/intervals + // - Remove event listeners + // - Cleanup resources + + return Promise.resolve(); // Remove this line and add your actual logic + } + + // Add your custom methods here + // Example: + // async customMethod() { + // console.log(\`[\${this.name}] Custom method called\`); + // } +} + +export default ${name}; +`; + + const pluginPath = path.join(pluginDir, "index.js"); + fs.writeFileSync(pluginPath, template); + console.log(`📝 Created: index.js`); + + // Create package.json for ES module support + const packageJson = { + type: "module" + }; + const packagePath = path.join(pluginDir, "package.json"); + fs.writeFileSync(packagePath, JSON.stringify(packageJson, null, 2)); + console.log(`📝 Created: package.json`); + } +} + +// Parse command line arguments +const args = process.argv.slice(2); +const pluginName = args[0]; +const language = args[1] || "ts"; + +// Validate plugin name +if (!pluginName) { + console.error("❌ Plugin name is required!"); + console.log("Usage:"); + console.log(" node scripts/create-plugin.js [ts|js]"); + console.log(""); + console.log("Examples:"); + console.log(" node scripts/create-plugin.js MyAwesomePlugin ts"); + console.log(" node scripts/create-plugin.js MyJSPlugin js"); + process.exit(1); +} + +// Validate plugin name format +if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(pluginName)) { + console.error("❌ Plugin name must start with a letter and contain only letters, numbers, and underscores!"); + process.exit(1); +} + +// Create the plugin +if (require.main === module) { + const generator = new PluginGenerator(); + generator.createPlugin(pluginName, language); +} + +module.exports = { PluginGenerator }; diff --git a/scripts/new-plugin-js.js b/scripts/new-plugin-js.js new file mode 100644 index 00000000..4263459a --- /dev/null +++ b/scripts/new-plugin-js.js @@ -0,0 +1,19 @@ +/** + * JavaScript Plugin Generator Wrapper + */ + +const { PluginGenerator } = require("./create-plugin-core"); + +// Get plugin name from command line arguments +const pluginName = process.argv[2]; + +if (!pluginName) { + console.error("❌ Plugin name is required!"); + console.log("Usage: npm run new-plugin-js "); + console.log("Example: npm run new-plugin-js MyJSPlugin"); + process.exit(1); +} + +// Create JavaScript plugin +const generator = new PluginGenerator(); +generator.createPlugin(pluginName, "js"); diff --git a/scripts/new-plugin-ts.js b/scripts/new-plugin-ts.js new file mode 100644 index 00000000..37ba0a0b --- /dev/null +++ b/scripts/new-plugin-ts.js @@ -0,0 +1,19 @@ +/** + * TypeScript Plugin Generator Wrapper + */ + +const { PluginGenerator } = require("./create-plugin-core"); + +// Get plugin name from command line arguments +const pluginName = process.argv[2]; + +if (!pluginName) { + console.error("❌ Plugin name is required!"); + console.log("Usage: npm run new-plugin-ts "); + console.log("Example: npm run new-plugin-ts MyAwesomePlugin"); + process.exit(1); +} + +// Create TypeScript plugin +const generator = new PluginGenerator(); +generator.createPlugin(pluginName, "ts"); diff --git a/scripts/plugin-discovery.js b/scripts/plugin-discovery.js new file mode 100644 index 00000000..98b5b15b --- /dev/null +++ b/scripts/plugin-discovery.js @@ -0,0 +1,128 @@ +/** + * 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..d2cde64c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,18 +23,38 @@ 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", () => { + logger.info("Received SIGINT, starting graceful shutdown..."); + void pluginManager.cleanup().then(() => { + process.exit(0); + }); +}); + +process.on("SIGTERM", () => { + logger.info("Received SIGTERM, starting graceful shutdown..."); + void pluginManager.cleanup().then(() => { + 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..ffac0045 --- /dev/null +++ b/src/managers/pluginManager.ts @@ -0,0 +1,193 @@ +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"; + +interface PluginRegistry { + plugins: string[]; +} + +interface PluginModule { + default?: + | (new () => IPlugin) + | { + default?: new () => IPlugin; + }; + [key: string]: unknown; +} + +interface PluginConfig { + enabled?: boolean; + [key: string]: unknown; +} + +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")) as PluginRegistry; + 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 = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as PluginManifest; + + // Check if plugin is enabled from config + const pluginConfig = manifest.config as PluginConfig | undefined; + const pluginInfo: PluginInfo = { + manifest, + path: absolutePath, + enabled: pluginConfig?.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)) as PluginModule; + + let PluginClass: (new () => IPlugin) | undefined; + + if (typeof pluginModule.default === "function") { + PluginClass = pluginModule.default; + } else if (typeof pluginModule.default === "object" && pluginModule.default.default) { + PluginClass = pluginModule.default.default; + } else if (pluginModule[manifest.name]) { + PluginClass = pluginModule[manifest.name] as new () => IPlugin; + } + + if (!PluginClass) { + logger.error(`Plugin class not found in ${mainFile}`); + return; + } + + const plugin = new PluginClass(); + + // Validate plugin interface + if (!this.validatePlugin(plugin)) { + logger.error(`Plugin ${manifest.name} does not implement required interface`); + return; + } + + // Initialize the plugin + await Promise.resolve(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: unknown): plugin is IPlugin { + return ( + plugin != null && + typeof plugin === "object" && + "name" in plugin && + "version" in plugin && + "initialize" in plugin && + typeof (plugin as Record).name === "string" && + typeof (plugin as Record).version === "string" && + typeof (plugin as Record).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 Promise.resolve(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..c7bb7e09 --- /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?: Record; +} + +export interface PluginManifest { + name: string; + version: string; + description?: string; + author?: string; + main: string; + dependencies?: string[]; + config?: Record; +} + +export interface PluginInfo { + manifest: PluginManifest; + path: string; + enabled: boolean; +}