mcp2mqtt

  • src
import { spawn, ChildProcess } from 'child_process'; import { EventEmitter } from 'events'; import { MinecraftServerConfig } from './types/config.js'; import * as fs from 'fs'; import path from 'path'; import * as os from 'os'; export class ServerManager extends EventEmitter { private process: ChildProcess | null = null; private config: MinecraftServerConfig; private isRunning: boolean = false; constructor(config: MinecraftServerConfig) { super(); this.config = this.validateConfig(config); process.on('exit', () => { this.killProcess(); }); process.on('SIGTERM', () => { this.killProcess(); }); process.on('SIGINT', () => { this.killProcess(); }); } private killProcess(): void { if (this.process) { try { process.kill(-this.process.pid!, 'SIGKILL'); } catch (error) { // Ignore errors during force kill } this.process = null; this.isRunning = false; } } private validateConfig(config: MinecraftServerConfig): MinecraftServerConfig { if (!config.serverJarPath) { throw new Error('Server JAR path is required'); } if (!config.port || config.port < 1 || config.port > 65535) { throw new Error('Invalid port number'); } return { maxPlayers: config.maxPlayers || 20, port: config.port, serverJarPath: config.serverJarPath, memoryAllocation: config.memoryAllocation || '2G', username: config.username || 'MCPBot', version: config.version || '1.21' }; } private ensureEulaAccepted(): void { // Get the directory containing the server JAR const serverDir = path.dirname(this.config.serverJarPath); const eulaPath = path.join(serverDir, 'eula.txt'); // Create or update eula.txt fs.writeFileSync(eulaPath, 'eula=true', 'utf8'); } private ensureServerProperties(): void { const serverDir = path.dirname(this.config.serverJarPath); const propsPath = path.join(serverDir, 'server.properties'); let properties = ''; if (fs.existsSync(propsPath)) { properties = fs.readFileSync(propsPath, 'utf8'); } // Define our server properties for a simple plains world const serverProperties = { 'online-mode': 'false', 'level-type': 'flat', 'spawn-protection': '0', 'difficulty': 'peaceful', // No hostile mobs 'spawn-monsters': 'false', // Disable monster spawning 'spawn-animals': 'true', // Enable animal spawning 'spawn-npcs': 'false', // Disable villagers 'generate-structures': 'false', // Disable structures (villages, temples, etc) 'allow-nether': 'false', // Disable nether 'gamemode': 'creative', // Set creative mode for easier building 'do-daylight-cycle': 'false', 'max-players': this.config.maxPlayers.toString(), 'server-port': this.config.port.toString(), 'motd': 'Peaceful Plains Server' }; // Update or create each property for (const [key, value] of Object.entries(serverProperties)) { const regex = new RegExp(`^${key}=.*$`, 'm'); if (properties.match(regex)) { properties = properties.replace(regex, `${key}=${value}`); } else { properties += `\n${key}=${value}`; } } fs.writeFileSync(propsPath, properties.trim(), 'utf8'); } private normalizePath(p: string): string { return path.normalize(p).toLowerCase(); } private expandHome(filepath: string): string { if (filepath.startsWith("~/") || filepath === "~") { return path.join(os.homedir(), filepath.slice(1)); } return filepath; } private validateServerPath(): string { const expandedPath = this.expandHome(this.config.serverJarPath); const absolutePath = path.isAbsolute(expandedPath) ? path.resolve(expandedPath) : path.resolve(process.cwd(), expandedPath); if (!fs.existsSync(absolutePath)) { throw new Error(`Server JAR not found at path: ${absolutePath}`); } return absolutePath; } public async start(): Promise<void> { if (this.isRunning) { throw new Error('Server is already running'); } return new Promise((resolve, reject) => { try { const serverJarPath = this.validateServerPath(); const serverDir = path.dirname(serverJarPath); this.ensureEulaAccepted(); this.ensureServerProperties(); this.process = spawn('java', [ `-Xmx${this.config.memoryAllocation}`, `-Xms${this.config.memoryAllocation}`, '-jar', serverJarPath, 'nogui' ], { cwd: serverDir, stdio: ['pipe', 'pipe', 'pipe'], detached: true, ...(process.platform !== 'win32' && { pid: true }) }); const timeout = setTimeout(() => { reject(new Error('Server startup timed out')); this.stop(); }, 60000); this.process.stdout?.on('data', (data: Buffer) => { const message = data.toString(); if (message.includes('Done')) { clearTimeout(timeout); this.isRunning = true; resolve(); } }); this.process.stderr?.on('data', (data: Buffer) => { const error = data.toString(); if (error.includes('Error')) { reject(new Error(error)); } }); this.process.on('close', (code) => { this.isRunning = false; }); this.process.on('error', (err) => { this.isRunning = false; reject(err); }); } catch (error) { reject(error); } }); } public async stop(): Promise<void> { if (!this.isRunning || !this.process) { return; } return new Promise((resolve) => { const forceKillTimeout = setTimeout(() => { this.killProcess(); resolve(); }, 10000); this.process?.once('close', () => { clearTimeout(forceKillTimeout); this.isRunning = false; this.process = null; resolve(); }); if (this.process?.stdin) { this.process.stdin.write('stop\n'); } else { this.killProcess(); resolve(); } }); } public isServerRunning(): boolean { return this.isRunning; } }