forked from OpenWF/SpaceNinjaServer
feat(plugins): implement plugin system with discovery and management
This commit is contained in:
parent
1a2d8ab19a
commit
f99f9a945c
@ -5,10 +5,10 @@
|
|||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node --enable-source-maps --import ./build/src/pathman.js build/src/index.js",
|
"start": "node --enable-source-maps --import ./build/src/pathman.js build/src/index.js",
|
||||||
"build": "tsgo --sourceMap && ncp static/webui build/static/webui",
|
"build": "node scripts/plugin-discovery.js && tsgo --sourceMap && ncp static/webui build/static/webui && ncp plugins build/plugins",
|
||||||
"build:tsc": "tsc --incremental --sourceMap && ncp static/webui build/static/webui",
|
"build:tsc": "node scripts/plugin-discovery.js && tsc --incremental --sourceMap && ncp static/webui build/static/webui && ncp plugins build/plugins",
|
||||||
"build:dev": "tsgo --sourceMap",
|
"build:dev": "node scripts/plugin-discovery.js && tsgo --sourceMap",
|
||||||
"build:dev:tsc": "tsc --incremental --sourceMap",
|
"build:dev:tsc": "node scripts/plugin-discovery.js && tsc --incremental --sourceMap",
|
||||||
"build-and-start": "npm run build && npm run start",
|
"build-and-start": "npm run build && npm run start",
|
||||||
"build-and-start:bun": "npm run verify && npm run bun-run",
|
"build-and-start:bun": "npm run verify && npm run bun-run",
|
||||||
"dev": "node scripts/dev.js",
|
"dev": "node scripts/dev.js",
|
||||||
|
5
plugins/.gitignore
vendored
Normal file
5
plugins/.gitignore
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
*
|
||||||
|
|
||||||
|
!.gitignore
|
||||||
|
!ExamplePlugin/
|
||||||
|
!ExamplePlugin/**
|
30
plugins/ExamplePlugin/index.ts
Normal file
30
plugins/ExamplePlugin/index.ts
Normal file
@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
logger.info(`[${this.name}] Plugin cleanup completed`);
|
||||||
|
|
||||||
|
// Add your cleanup logic here
|
||||||
|
// For example:
|
||||||
|
// - Close database connections
|
||||||
|
// - Clear timers/intervals
|
||||||
|
// - Remove event listeners
|
||||||
|
}
|
||||||
|
}
|
11
plugins/ExamplePlugin/plugin.json
Normal file
11
plugins/ExamplePlugin/plugin.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
}
|
127
scripts/plugin-discovery.js
Normal file
127
scripts/plugin-discovery.js
Normal file
@ -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 };
|
20
src/index.ts
20
src/index.ts
@ -23,18 +23,36 @@ import { startWebServer } from "./services/webService";
|
|||||||
|
|
||||||
import { syncConfigWithDatabase, validateConfig } from "@/src/services/configWatcherService";
|
import { syncConfigWithDatabase, validateConfig } from "@/src/services/configWatcherService";
|
||||||
import { updateWorldStateCollections } from "./services/worldStateService";
|
import { updateWorldStateCollections } from "./services/worldStateService";
|
||||||
|
import { pluginManager } from "@/src/managers/pluginManager";
|
||||||
|
|
||||||
// Patch JSON.stringify to work flawlessly with Bigints.
|
// Patch JSON.stringify to work flawlessly with Bigints.
|
||||||
JSON.stringify = JSONStringify;
|
JSON.stringify = JSONStringify;
|
||||||
|
|
||||||
validateConfig();
|
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
|
mongoose
|
||||||
.connect(config.mongodbUrl)
|
.connect(config.mongodbUrl)
|
||||||
.then(() => {
|
.then(async () => {
|
||||||
logger.info("Connected to MongoDB");
|
logger.info("Connected to MongoDB");
|
||||||
syncConfigWithDatabase();
|
syncConfigWithDatabase();
|
||||||
|
|
||||||
|
// Initialize plugins before starting the web server
|
||||||
|
logger.info("Loading plugins...");
|
||||||
|
await pluginManager.loadPlugins();
|
||||||
|
|
||||||
startWebServer();
|
startWebServer();
|
||||||
|
|
||||||
void updateWorldStateCollections();
|
void updateWorldStateCollections();
|
||||||
|
161
src/managers/pluginManager.ts
Normal file
161
src/managers/pluginManager.ts
Normal file
@ -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<string, IPlugin> = new Map();
|
||||||
|
private pluginInfos: Map<string, PluginInfo> = new Map();
|
||||||
|
private pluginsDir: string;
|
||||||
|
|
||||||
|
constructor(pluginsDir: string = "plugins") {
|
||||||
|
this.pluginsDir = path.resolve(pluginsDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all plugins from the plugins directory
|
||||||
|
*/
|
||||||
|
async loadPlugins(): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, IPlugin> {
|
||||||
|
return new Map(this.plugins);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get plugin info
|
||||||
|
*/
|
||||||
|
getPluginInfo(name: string): PluginInfo | undefined {
|
||||||
|
return this.pluginInfos.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup all plugins
|
||||||
|
*/
|
||||||
|
async cleanup(): Promise<void> {
|
||||||
|
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();
|
52
src/types/pluginTypes.ts
Normal file
52
src/types/pluginTypes.ts
Normal file
@ -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> | void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup plugin resources
|
||||||
|
*/
|
||||||
|
cleanup?(): Promise<void> | 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;
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user