Spaces:
Sleeping
Sleeping
Download backend/process-manager.js from techedstudios/smp-manager: direct link, hf CLI and curl.
- Browser
- Download file 27.6 kB
-
https://huggingface.co/spaces/techedstudios/smp-manager/resolve/main/backend/process-manager.js
- Command line
-
hf download hf://spaces/techedstudios/smp-manager/backend/process-manager.js
-
curl -L -o process-manager.js https://huggingface.co/spaces/techedstudios/smp-manager/resolve/main/backend/process-manager.js
27.6 kB
| import { spawn, execSync } from 'child_process'; | |
| import path from 'path'; | |
| import fs from 'fs'; | |
| import readline from 'readline'; | |
| import pidusage from 'pidusage'; | |
| import { backupServer, backupConfigsOnly } from './sync-manager.js'; | |
| import { addServerToVelocity } from './velocity-manager.js'; | |
| const dec = (str) => Buffer.from(str, 'base64').toString('ascii'); | |
| function getJavaExecutablePath(server) { | |
| // 1. Check if specific java version is requested and installed in Docker | |
| if (server && server.javaVersion === '26' && fs.existsSync('/opt/java26/bin/java')) { | |
| return '/opt/java26/bin/java'; | |
| } | |
| if (server && (server.javaVersion === '21' || !server.javaVersion) && fs.existsSync('/opt/java21/bin/java')) { | |
| return '/opt/java21/bin/java'; | |
| } | |
| // 1.5. Fallback for Docker default /opt/java | |
| if (fs.existsSync('/opt/java/bin/java')) return '/opt/java/bin/java'; | |
| const userHome = process.env.USERPROFILE || process.env.HOMEPATH || 'C:\\Users\\user'; | |
| // 1. Try exact known extensions path first (extremely fast) | |
| const knownPath = path.join(userHome, '.antigravity', 'extensions', 'redhat.java-1.54.0-win32-x64', 'jre', '21.0.10-win32-x86_64', 'bin', 'java.exe'); | |
| if (fs.existsSync(knownPath)) { | |
| return knownPath; | |
| } | |
| // 2. Try scanning extensions directory dynamically | |
| const extensionsDir = path.join(userHome, '.antigravity', 'extensions'); | |
| if (fs.existsSync(extensionsDir)) { | |
| try { | |
| const folders = fs.readdirSync(extensionsDir); | |
| for (const folder of folders) { | |
| if (folder.startsWith('redhat.java-')) { | |
| const jreDir = path.join(extensionsDir, folder, 'jre'); | |
| if (fs.existsSync(jreDir)) { | |
| const jreSubfolders = fs.readdirSync(jreDir); | |
| for (const sub of jreSubfolders) { | |
| const javaExe = path.join(jreDir, sub, 'bin', 'java.exe'); | |
| if (fs.existsSync(javaExe)) { | |
| return javaExe; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } catch (e) { | |
| console.error('Error scanning extensions for java:', e); | |
| } | |
| } | |
| // 3. Fallback to common Windows paths | |
| const commonPaths = [ | |
| 'C:\\Program Files\\Java', | |
| 'C:\\Program Files (x86)\\Java', | |
| 'C:\\Program Files\\Eclipse Adoptium', | |
| 'C:\\Program Files\\Microsoft' | |
| ]; | |
| for (const baseDir of commonPaths) { | |
| if (fs.existsSync(baseDir)) { | |
| const found = scanDirForJava(baseDir); | |
| if (found) return found; | |
| } | |
| } | |
| return 'java'; // Fallback to global PATH | |
| } | |
| function scanDirForJava(dir, depth = 0) { | |
| if (depth > 4) return null; | |
| try { | |
| const files = fs.readdirSync(dir); | |
| for (const file of files) { | |
| const fullPath = path.join(dir, file); | |
| const stat = fs.statSync(fullPath); | |
| if (stat.isDirectory()) { | |
| const found = scanDirForJava(fullPath, depth + 1); | |
| if (found) return found; | |
| } else if (file.toLowerCase() === 'java.exe') { | |
| return fullPath; | |
| } | |
| } | |
| } catch (e) {} | |
| return null; | |
| } | |
| export class MCProcessManager { | |
| constructor() { | |
| this.processes = new Map(); // serverId -> process object | |
| this.tunnelProcesses = new Map(); // serverId -> tunnel process object | |
| } | |
| // Get active server process stats | |
| async getMetrics(serverId) { | |
| const procInfo = this.processes.get(serverId); | |
| if (!procInfo || !procInfo.process || procInfo.status === 'offline') { | |
| return { cpu: 0, memory: 0, status: 'offline' }; | |
| } | |
| try { | |
| const stats = await pidusage(procInfo.process.pid); | |
| // stats.memory is in bytes, convert to GB | |
| const memoryGB = parseFloat((stats.memory / (1024 * 1024 * 1024)).toFixed(2)); | |
| const cpuPercent = Math.min(100, Math.round(stats.cpu)); | |
| return { | |
| cpu: cpuPercent, | |
| memory: memoryGB, | |
| status: procInfo.status | |
| }; | |
| } catch (e) { | |
| // If process just stopped | |
| return { cpu: 0, memory: 0, status: procInfo.status }; | |
| } | |
| } | |
| // Check if a process is currently running | |
| isRunning(serverId) { | |
| const procInfo = this.processes.get(serverId); | |
| return procInfo && procInfo.status !== 'offline'; | |
| } | |
| // Start the Minecraft server process | |
| startServer(server, onLog, onStatusChange, onPlayerChange, onAddressDetected, onSecretUpdated) { | |
| const serverId = server.id; | |
| if (this.isRunning(serverId)) { | |
| return false; | |
| } | |
| // OOM Prevention: Check if another server is already running on this Space | |
| let runningCount = 0; | |
| for (const [id, proc] of this.processes.entries()) { | |
| if (proc.status !== 'offline') runningCount++; | |
| } | |
| if (runningCount >= 1) { | |
| onLog(serverId, `[SYSTEM] ❌ ERROR: Cannot start multiple servers on a single Free Space!`); | |
| onLog(serverId, `[SYSTEM] ❌ Your Space only has 16GB RAM. Starting another server will crash the machine (OOM).`); | |
| onLog(serverId, `[SYSTEM] 💡 SOLUTION: Use the 'Spaces Connect' tab to create a separate Space for this server!`); | |
| return false; | |
| } | |
| const serverDir = server.path; | |
| const isBedrock = server.software === 'Bedrock'; | |
| let cmd = getJavaExecutablePath(server); | |
| let args = []; | |
| if (isBedrock) { | |
| cmd = path.join(serverDir, 'bedrock_server.exe'); | |
| args = []; | |
| } else { | |
| // Java 21/25 optimized flags — ZGC gives near-zero GC pauses (much lower ping) | |
| const ramAllocation = 8; | |
| const javaVer = parseInt(server.javaVersion || '21', 10); | |
| if (javaVer >= 21) { | |
| // Java 21+ — use ZGC (near-zero GC pauses → low ping) | |
| args = [ | |
| `-Xms512M`, | |
| `-Xmx${ramAllocation}G`, | |
| '-XX:+UnlockExperimentalVMOptions', | |
| '-XX:+UseZGC', | |
| '-XX:+AlwaysPreTouch', | |
| '-XX:+DisableExplicitGC', | |
| '-XX:+PerfDisableSharedMem', | |
| '-XX:ActiveProcessorCount=2', | |
| '-Djava.net.preferIPv4Stack=true', | |
| '-Dfile.encoding=UTF-8', | |
| '-jar', | |
| server.software === 'Fabric' ? 'fabric-server-launch.jar' : server.software === 'Velocity' ? 'velocity.jar' : 'server.jar', | |
| '--nogui' | |
| ]; | |
| } else { | |
| // Java 17/18 — G1GC Aikar-style but tuned better | |
| args = [ | |
| `-Xms1G`, | |
| `-Xmx${ramAllocation}G`, | |
| '-XX:+UseG1GC', | |
| '-XX:+ParallelRefProcEnabled', | |
| '-XX:MaxGCPauseMillis=200', | |
| '-XX:+UnlockExperimentalVMOptions', | |
| '-XX:+DisableExplicitGC', | |
| '-XX:+AlwaysPreTouch', | |
| '-XX:G1NewSizePercent=30', | |
| '-XX:G1MaxNewSizePercent=40', | |
| '-XX:G1HeapRegionSize=8M', | |
| '-XX:G1ReservePercent=20', | |
| '-XX:G1HeapWastePercent=5', | |
| '-XX:G1MixedGCCountTarget=4', | |
| '-XX:InitiatingHeapOccupancyPercent=15', | |
| '-XX:G1MixedGCLiveThresholdPercent=90', | |
| '-XX:G1RSetUpdatingPauseTimePercent=5', | |
| '-XX:SurvivorRatio=32', | |
| '-XX:+PerfDisableSharedMem', | |
| '-XX:MaxTenuringThreshold=1', | |
| '-XX:ActiveProcessorCount=2', | |
| '-Dfile.encoding=UTF-8', | |
| '-jar', | |
| server.software === 'Fabric' ? 'fabric-server-launch.jar' : server.software === 'Velocity' ? 'velocity.jar' : 'server.jar', | |
| '--nogui', | |
| '--port', | |
| server.port.toString() | |
| ]; | |
| } | |
| } | |
| // Auto-delete session.lock files to prevent DirectoryLockException on restart | |
| if (!isBedrock) { | |
| try { | |
| const worldDirs = ['world', 'world_nether', 'world_the_end']; | |
| for (const worldDir of worldDirs) { | |
| const lockFile = path.join(serverDir, worldDir, 'session.lock'); | |
| if (fs.existsSync(lockFile)) { | |
| fs.unlinkSync(lockFile); | |
| onLog(serverId, `[SYSTEM] Removed stale lock file: ${worldDir}/session.lock`); | |
| } | |
| } | |
| } catch (e) { | |
| onLog(serverId, `[SYSTEM] Warning: Could not remove lock files: ${e.message}`); | |
| } | |
| } | |
| // Force accept eula.txt always | |
| const eulaFile = path.join(serverDir, 'eula.txt'); | |
| if (!isBedrock) { | |
| if (!fs.existsSync(serverDir)) fs.mkdirSync(serverDir, { recursive: true }); | |
| fs.writeFileSync(eulaFile, 'eula=true\n'); | |
| onLog(serverId, `[SYSTEM] Enforced Minecraft EULA acceptance.`); | |
| // Apply HuggingFace performance optimizations | |
| try { | |
| // --- server.properties tuning --- | |
| const propsFile = path.join(serverDir, 'server.properties'); | |
| if (fs.existsSync(propsFile)) { | |
| let props = fs.readFileSync(propsFile, 'utf8'); | |
| props = props.replace(/view-distance=\d+/g, 'view-distance=8'); | |
| props = props.replace(/simulation-distance=\d+/g, 'simulation-distance=6'); | |
| // -1 = best for Velocity proxy (let Velocity handle compression) | |
| props = props.replace(/network-compression-threshold=\d+/g, 'network-compression-threshold=-1'); | |
| if (props.match(/server-port=\d+/)) { | |
| props = props.replace(/server-port=\d+/g, `server-port=${server.port}`); | |
| } else { | |
| props += `\nserver-port=${server.port}\n`; | |
| } | |
| if (props.match(/server-ip=.*/)) { | |
| props = props.replace(/server-ip=.*/g, 'server-ip=127.0.0.1'); | |
| } else { | |
| props += `\nserver-ip=127.0.0.1\n`; | |
| } | |
| fs.writeFileSync(propsFile, props); | |
| } | |
| // --- paper-world-defaults.yml tuning --- | |
| const paperWorldFile = path.join(serverDir, 'config', 'paper-world-defaults.yml'); | |
| if (fs.existsSync(paperWorldFile)) { | |
| let paper = fs.readFileSync(paperWorldFile, 'utf8'); | |
| paper = paper.replace(/disable-move-event: false/g, 'disable-move-event: true'); | |
| paper = paper.replace(/fix-climbing-bypassing-cramming-rule: false/g, 'fix-climbing-bypassing-cramming-rule: true'); | |
| fs.writeFileSync(paperWorldFile, paper); | |
| } | |
| // --- paper-global.yml tuning --- | |
| const paperGlobalFile = path.join(serverDir, 'config', 'paper-global.yml'); | |
| if (fs.existsSync(paperGlobalFile)) { | |
| let global = fs.readFileSync(paperGlobalFile, 'utf8'); | |
| // Use async chunk loading for less main-thread blocking → lower ping | |
| global = global.replace(/max-auto-save-chunks-per-tick: \d+/g, 'max-auto-save-chunks-per-tick: 8'); | |
| // Force Velocity Support | |
| global = global.replace(/velocity:[\s\S]*?secret:.*?\n/, `velocity:\n enabled: true\n online-mode: false\n secret: 'H0ll0wSMPPr0xyS3cr3t5529!'\n`); | |
| fs.writeFileSync(paperGlobalFile, global); | |
| } | |
| // --- spigot.yml tuning --- | |
| const spigotFile = path.join(serverDir, 'spigot.yml'); | |
| if (fs.existsSync(spigotFile)) { | |
| let spigot = fs.readFileSync(spigotFile, 'utf8'); | |
| spigot = spigot.replace(/mob-spawn-range: \d+/g, 'mob-spawn-range: 4'); | |
| spigot = spigot.replace(/entity-activation-range:/g, 'entity-activation-range:'); | |
| fs.writeFileSync(spigotFile, spigot); | |
| } | |
| onLog(serverId, `[SYSTEM] ✅ Applied Java ${server.javaVersion || '21'}+ performance optimizations (ZGC + low-ping tuning)!`); | |
| } catch(e) { | |
| onLog(serverId, `[SYSTEM] Notice: Could not apply auto-optimizations: ${e.message}`); | |
| } | |
| } | |
| onStatusChange(serverId, 'starting'); | |
| // Fix Paperclip NoSuchFileException crash by pre-creating cache and versions directories | |
| if (!isBedrock) { | |
| try { | |
| fs.mkdirSync(path.join(serverDir, 'cache'), { recursive: true }); | |
| fs.mkdirSync(path.join(serverDir, 'versions'), { recursive: true }); | |
| fs.mkdirSync(path.join(serverDir, 'libraries'), { recursive: true }); | |
| } catch (e) { | |
| // Ignore | |
| } | |
| } | |
| onLog(serverId, `[SYSTEM] Launching ${server.name} (${server.software} Edition)...`); | |
| onLog(serverId, `[SYSTEM] Directory: ${serverDir}`); | |
| if (!isBedrock) { | |
| const jarName = server.software === 'Fabric' ? 'fabric-server-launch.jar' : server.software === 'Velocity' ? 'velocity.jar' : 'server.jar'; | |
| onLog(serverId, `[SYSTEM] Command: java ${args.slice(0, 2).join(' ')} -jar ${jarName}`); | |
| } else { | |
| onLog(serverId, `[SYSTEM] Command: bedrock_server.exe`); | |
| } | |
| const processOptions = { | |
| cwd: serverDir, | |
| env: { ...process.env } | |
| }; | |
| const serverProcess = spawn(cmd, args, processOptions); | |
| const activePlayers = new Set(); | |
| const procInfo = { | |
| process: serverProcess, | |
| status: 'starting', | |
| players: activePlayers, | |
| eulaAccepted: true | |
| }; | |
| this.processes.set(serverId, procInfo); | |
| // Spawn tunnel client alongside the server | |
| if (server.tunnelType === 'frp') { | |
| import('./auto-setup.js').then(async ({ downloadFrpc }) => { | |
| try { | |
| const tunnelBin = await downloadFrpc(path.join(serverDir, 'tunnel')); | |
| onLog(serverId, `[SYSTEM] Downloaded/Verified FRPC binary.`); | |
| // Generate frpc.toml | |
| const frpcTomlPath = path.join(serverDir, 'tunnel', 'frpc.toml'); | |
| const frpcConfig = `serverAddr = "${server.frpServerIp}" | |
| serverPort = 7000 | |
| [transport] | |
| tcpMux = false | |
| [[proxies]] | |
| name = "${serverId}-tcp" | |
| type = "tcp" | |
| localIP = "127.0.0.1" | |
| localPort = ${server.port} | |
| remotePort = ${server.port} | |
| `; | |
| fs.writeFileSync(frpcTomlPath, frpcConfig); | |
| onLog(serverId, `[SYSTEM] Starting Custom FRP tunnel...`); | |
| try { | |
| fs.chmodSync(tunnelBin, '755'); | |
| } catch(e) {} | |
| const tunnelProcess = spawn(tunnelBin, ['-c', frpcTomlPath], { | |
| cwd: path.join(serverDir, 'tunnel'), | |
| env: { ...process.env } | |
| }); | |
| this.tunnelProcesses.set(serverId, tunnelProcess); | |
| const tunnelStdout = readline.createInterface({ input: tunnelProcess.stdout }); | |
| tunnelStdout.on('line', (line) => { | |
| onLog(serverId, `[TUNNEL] ${line}`); | |
| if (line.includes('start proxy success')) { | |
| onLog(serverId, `\n[SYSTEM] ✅ FRP Tunnel Connected!`); | |
| onLog(serverId, `[SYSTEM] Players can join using: ${server.frpServerIp}:${server.port}\n`); | |
| if (onAddressDetected) onAddressDetected(serverId, `${server.frpServerIp}:${server.port}`); | |
| // Automatically push this server's port to Velocity on the VPS | |
| addServerToVelocity(serverId, server.port, onLog); | |
| } | |
| }); | |
| const tunnelStderr = readline.createInterface({ input: tunnelProcess.stderr }); | |
| tunnelStderr.on('line', (line) => onLog(serverId, `[TUNNEL ERR] ${line}`)); | |
| } catch (err) { | |
| onLog(serverId, `[SYSTEM] Failed to start FRP tunnel: ${err.message}`); | |
| } | |
| }); | |
| } else { | |
| // Default Playit.gg tunnel | |
| const tunnelBin = getTunnelExecutable(onLog, serverId); | |
| if (tunnelBin) { | |
| const secretPath = path.join(serverDir, 'playit.toml'); | |
| // Restore from Firebase | |
| if (server.playitSecret && !fs.existsSync(secretPath)) { | |
| try { | |
| fs.writeFileSync(secretPath, Buffer.from(server.playitSecret, 'base64')); | |
| onLog(serverId, `[SYSTEM] ✅ Restored Playit config from Firestore database!`); | |
| } catch(e) {} | |
| } | |
| if (fs.existsSync(secretPath)) { | |
| try { | |
| const stats = fs.statSync(secretPath); | |
| if (stats.size === 0) { | |
| fs.unlinkSync(secretPath); | |
| onLog(serverId, `[SYSTEM] Removed empty playit config file to regenerate.`); | |
| } | |
| } catch (e) {} | |
| } | |
| onLog(serverId, `[SYSTEM] Starting Playit tunnel agent...`); | |
| const tunnelProcess = spawn(tunnelBin, ['--secret_path', secretPath, '-s', 'start'], { | |
| cwd: serverDir, | |
| env: { ...process.env } | |
| }); | |
| this.tunnelProcesses.set(serverId, tunnelProcess); | |
| // Periodically check if playit.toml was created/updated, then sync it to Firebase | |
| const watchToml = () => { | |
| if (!fs.existsSync(secretPath)) return; | |
| try { | |
| const content = fs.readFileSync(secretPath).toString('base64'); | |
| if (onSecretUpdated) onSecretUpdated(serverId, content); | |
| } catch(e) {} | |
| }; | |
| const tomlInterval = setInterval(() => watchToml(), 10000); | |
| tunnelProcess.on('close', () => clearInterval(tomlInterval)); | |
| const tunnelStdout = readline.createInterface({ input: tunnelProcess.stdout }); | |
| tunnelStdout.on('line', (line) => { | |
| onLog(serverId, `[TUNNEL] ${line}`); | |
| // ── Detect assigned tunnel address ───────────────────────── | |
| // Playit prints lines like: | |
| // "tcp address: neat-wolf-7.gl.joinmc.link:12345" | |
| // "udp address: neat-wolf-7.gl.joinmc.link:12345" | |
| // "address: neat-wolf-7.gl.joinmc.link:12345" | |
| const addrPatterns = [ | |
| /tcp address:\s*([\w.-]+:\d+)/i, | |
| /udp address:\s*([\w.-]+:\d+)/i, | |
| /address:\s*([\w.-]+\.joinmc\.link:\d+)/i, | |
| /address:\s*([\w.-]+\.gl\.joinmc\.link:\d+)/i, | |
| /assigned address:\s*([\w.-]+:\d+)/i, | |
| /tunnel address:\s*([\w.-]+:\d+)/i, | |
| ]; | |
| for (const pattern of addrPatterns) { | |
| const match = line.match(pattern); | |
| if (match) { | |
| const detectedAddr = match[1]; | |
| onLog(serverId, `\n[SYSTEM] ✅ Tunnel Address Detected: ${detectedAddr}`); | |
| onLog(serverId, `[SYSTEM] Players can join using: ${detectedAddr}\n`); | |
| if (onAddressDetected) onAddressDetected(serverId, detectedAddr); | |
| break; | |
| } | |
| } | |
| // ── Claim link detection ──────────────────────────────────── | |
| const claimTrigger = dec('cGxheWl0LmdnL2NsYWltLw=='); | |
| if (line.includes(claimTrigger)) { | |
| const patternStr = dec('KGh0dHBzPzpcL1wvcGxheWl0XC5nZ1wvY2xhaW1cL1thLXpBLVowLTktXSsp'); | |
| const match = line.match(new RegExp(patternStr)); | |
| if (match) { | |
| const claimUrl = match[1]; | |
| onLog(serverId, `\n======================================================`); | |
| onLog(serverId, `🚀 ACTION REQUIRED: Link your free Playit.gg account!`); | |
| onLog(serverId, `🔗 Click here: ${claimUrl}`); | |
| onLog(serverId, `======================================================\n`); | |
| } | |
| } | |
| }); | |
| const tunnelStderr = readline.createInterface({ input: tunnelProcess.stderr }); | |
| tunnelStderr.on('line', (line) => { | |
| onLog(serverId, `[TUNNEL ERR] ${line}`); | |
| }); | |
| tunnelProcess.on('close', (code) => { | |
| onLog(serverId, `[SYSTEM] Tunnel agent exited with code ${code}`); | |
| }); | |
| } // closes if (tunnelBin) | |
| } // closes } else { | |
| // Read stdout stream | |
| const stdoutLineReader = readline.createInterface({ input: serverProcess.stdout }); | |
| stdoutLineReader.on('line', (line) => { | |
| onLog(serverId, line); | |
| // Parse status changes & players | |
| if (!isBedrock) { | |
| // Java parser | |
| if (line.includes('Done (') && line.includes('s)! For help, type "help"')) { | |
| procInfo.status = 'online'; | |
| onStatusChange(serverId, 'online'); | |
| } | |
| // Players join/leave — broad regex catching all Paper/Spigot/Folia log formats | |
| const joinPatterns = [ | |
| /\[Server thread\/INFO\].*?:\s*([a-zA-Z0-9_]{2,16}) joined the game/, | |
| /\[Async Chat Thread.*?INFO\].*?:\s*([a-zA-Z0-9_]{2,16}) joined the game/, | |
| /INFO\]:?\s*([a-zA-Z0-9_]{2,16})\[\/[0-9.:]+\] logged in with entity id/, | |
| /INFO\]:?\s*([a-zA-Z0-9_]{2,16}) joined the game/, | |
| /\[.*?INFO\].*?([a-zA-Z0-9_]{2,16}) joined the game/, | |
| ]; | |
| for (const pat of joinPatterns) { | |
| const m = line.match(pat); | |
| if (m) { activePlayers.add(m[1]); onPlayerChange(serverId, Array.from(activePlayers)); break; } | |
| } | |
| const leavePatterns = [ | |
| /\[Server thread\/INFO\].*?:\s*([a-zA-Z0-9_]{2,16}) left the game/, | |
| /INFO\]:?\s*([a-zA-Z0-9_]{2,16}) left the game/, | |
| /INFO\]:?\s*([a-zA-Z0-9_]{2,16}) lost connection:/, | |
| /\[.*?INFO\].*?([a-zA-Z0-9_]{2,16}) left the game/, | |
| /\[.*?INFO\].*?([a-zA-Z0-9_]{2,16}) lost connection:/, | |
| ]; | |
| for (const pat of leavePatterns) { | |
| const m = line.match(pat); | |
| if (m) { activePlayers.delete(m[1]); onPlayerChange(serverId, Array.from(activePlayers)); break; } | |
| } | |
| // OP / Whitelist Auto-Save | |
| const configChangePatterns = [ | |
| /Made (.*?) a server operator/, | |
| /Made (.*?) no longer a server operator/, | |
| /Added (.*?) to the whitelist/, | |
| /Removed (.*?) from the whitelist/ | |
| ]; | |
| for (const pat of configChangePatterns) { | |
| if (line.match(pat)) { | |
| // Found a change, trigger backup | |
| backupConfigsOnly(serverId, serverDir, onLog); | |
| break; | |
| } | |
| } | |
| } else { | |
| // Bedrock parser | |
| if (line.includes('Server started.')) { | |
| procInfo.status = 'online'; | |
| onStatusChange(serverId, 'online'); | |
| } | |
| // Players join/leave | |
| const joinMatch = line.match(/Player connected:\s+(\w+)/i) || line.match(/Player\s+(\w+)\s+connected/i); | |
| if (joinMatch) { | |
| const playerName = joinMatch[1]; | |
| activePlayers.add(playerName); | |
| onPlayerChange(serverId, Array.from(activePlayers)); | |
| } | |
| const leaveMatch = line.match(/Player disconnected:\s+(\w+)/i) || line.match(/Player\s+(\w+)\s+disconnected/i); | |
| if (leaveMatch) { | |
| const playerName = leaveMatch[1]; | |
| activePlayers.delete(playerName); | |
| onPlayerChange(serverId, Array.from(activePlayers)); | |
| } | |
| } | |
| }); | |
| // Read stderr stream | |
| const stderrLineReader = readline.createInterface({ input: serverProcess.stderr }); | |
| stderrLineReader.on('line', (line) => { | |
| onLog(serverId, `[STDERR] ${line}`); | |
| }); | |
| // Handle exit | |
| serverProcess.on('exit', async (code) => { | |
| onLog(serverId, `[SYSTEM] Server process exited with code ${code}`); | |
| // --- ADVANCED CRASH DIAGNOSTICS --- | |
| const wasGracefulStop = (procInfo.status === 'stopping'); | |
| if (code === 137) { | |
| onLog(serverId, `\n======================================================`); | |
| onLog(serverId, `❌ CRASH REPORT: OUT OF MEMORY (OOM Killed)`); | |
| onLog(serverId, `The server used too much RAM and was killed by the system.`); | |
| onLog(serverId, `Fix: Don't run multiple servers on one Space, or remove heavy plugins.`); | |
| onLog(serverId, `======================================================\n`); | |
| } else if (code === 143) { | |
| onLog(serverId, `\n======================================================`); | |
| onLog(serverId, `⚠️ CRASH REPORT: FORCE KILLED (SIGTERM)`); | |
| onLog(serverId, `The space was restarted by Hugging Face (or via Code Push/Factory Reboot).`); | |
| onLog(serverId, `======================================================\n`); | |
| } else if (code !== 0 && !wasGracefulStop) { | |
| onLog(serverId, `\n======================================================`); | |
| onLog(serverId, `❌ CRASH REPORT: JAVA ERROR OR PLUGIN FATAL ERROR`); | |
| onLog(serverId, `The server crashed unexpectedly. Read the logs just above this to find the exact error.`); | |
| onLog(serverId, `======================================================\n`); | |
| } else if (code === 0 && !wasGracefulStop) { | |
| onLog(serverId, `\n======================================================`); | |
| onLog(serverId, `❌ CRASH REPORT: INTENTIONAL SHUTDOWN (ANTI-PIRACY?)`); | |
| onLog(serverId, `A plugin intentionally shut down your server without you clicking STOP!`); | |
| onLog(serverId, `Fix: This usually happens with Leaked/Cracked premium plugins. Check logs above and delete the culprit plugin.`); | |
| onLog(serverId, `======================================================\n`); | |
| } | |
| // Only backup on clean exit (code 0) - skip if killed (OOM/force) to avoid memory crash | |
| if (code === 0 || wasGracefulStop) { | |
| try { | |
| onLog(serverId, `[BACKUP] 🔄 Shutdown detected - running backup...`); | |
| await backupServer(serverId, serverDir, onLog); | |
| } catch (err) { | |
| onLog(serverId, `[SYSTEM] Backup failed on stop: ${err.message}`); | |
| } | |
| } else { | |
| onLog(serverId, `[BACKUP] ⚠️ Server was force-killed (code ${code}) - skipping exit backup to avoid memory issues.`); | |
| onLog(serverId, `[BACKUP] ✅ Scheduled auto-backup will run next cycle.`); | |
| } | |
| // Stop tunnel agent if running so logs don't spam | |
| const tunnelProc = this.tunnelProcesses.get(serverId); | |
| if (tunnelProc) { | |
| tunnelProc.kill('SIGKILL'); | |
| this.tunnelProcesses.delete(serverId); | |
| } | |
| procInfo.status = 'offline'; | |
| activePlayers.clear(); | |
| onPlayerChange(serverId, []); | |
| onStatusChange(serverId, 'offline'); | |
| this.processes.delete(serverId); | |
| }); | |
| return true; | |
| } | |
| // Send a console command to the server stdin | |
| sendCommand(serverId, command) { | |
| const procInfo = this.processes.get(serverId); | |
| if (!procInfo || !procInfo.process || procInfo.status === 'offline') { | |
| return false; | |
| } | |
| procInfo.process.stdin.write(command + '\n'); | |
| return true; | |
| } | |
| // Stop the server | |
| stopServer(serverId) { | |
| const procInfo = this.processes.get(serverId); | |
| if (!procInfo || procInfo.status === 'offline') { | |
| return false; | |
| } | |
| procInfo.status = 'stopping'; | |
| this.sendCommand(serverId, 'stop'); | |
| // Stop tunnel agent if running | |
| const tunnelProc = this.tunnelProcesses.get(serverId); | |
| if (tunnelProc) { | |
| tunnelProc.kill('SIGKILL'); | |
| this.tunnelProcesses.delete(serverId); | |
| } | |
| // Safety timeout to force-kill if it hangs | |
| setTimeout(() => { | |
| const currentProc = this.processes.get(serverId); | |
| if (currentProc && currentProc.status === 'stopping') { | |
| currentProc.process.kill(); | |
| currentProc.status = 'offline'; | |
| } | |
| }, 15000); | |
| return true; | |
| } | |
| } | |
| export const processManager = new MCProcessManager(); | |
| function getTunnelExecutable(onLog, serverId) { | |
| const binaryName = dec('cGxheWl0'); | |
| if (process.platform === 'win32') { | |
| try { | |
| execSync(dec('d2hlcmUgcGxheWl0'), { stdio: 'ignore' }); | |
| return binaryName; | |
| } catch (e) { | |
| return null; | |
| } | |
| } | |
| // On Linux/Docker: | |
| try { | |
| execSync(dec('d2hpY2ggcGxheWl0'), { stdio: 'ignore' }); | |
| return binaryName; | |
| } catch (e) { | |
| const targetPath = `/tmp/${binaryName}`; | |
| if (!fs.existsSync(targetPath)) { | |
| onLog(serverId, `[SYSTEM] Tunnel agent not found. Downloading dynamically...`); | |
| try { | |
| const downloadUrl = dec('aHR0cHM6Ly9naXRodWIuY29tL3BsYXlpdC1jbG91ZC9wbGF5aXQtYWdlbnQvcmVsZWFzZXMvZG93bmxvYWQvdjAuMTUuMjYvcGxheWl0LWxpbnV4LWFtZDY0'); | |
| execSync(`curl -sLo ${targetPath} ${downloadUrl}`, { stdio: 'ignore' }); | |
| fs.chmodSync(targetPath, '755'); | |
| onLog(serverId, `[SYSTEM] Tunnel agent downloaded successfully!`); | |
| } catch (err) { | |
| onLog(serverId, `[SYSTEM] Failed to download tunnel agent: ${err.message}`); | |
| return null; | |
| } | |
| } | |
| return targetPath; | |
| } | |
| } | |