const childProcess = require('child_process') const fs = require('fs-extra') const path = require('path') const ConfigManager = require('./configmanager') const ProfileAssetManager = require('./profileassetmanager') const runtimes = new Map() function getRuntime(profileId){ if(!runtimes.has(profileId)){ runtimes.set(profileId, { serverProcess: null, tunnelProcess: null, logs: [], status: 'stopped', publishedAddress: ConfigManager.getPublishedLibraryServerAddress(profileId) }) } return runtimes.get(profileId) } function appendLog(runtime, line){ runtime.logs.push(line) if(runtime.logs.length > 200){ runtime.logs.shift() } } function interpolateCommand(template, variables){ return Object.entries(variables).reduce((command, [key, value]) => { return command.replaceAll(`\${${key}}`, String(value)) }, template) } function extractPublishedAddress(line, profile){ if(profile.tunnelAddressRegex){ const customRegex = new RegExp(profile.tunnelAddressRegex) const customMatch = line.match(customRegex) if(customMatch){ return customMatch[1] ?? customMatch[0] } } const genericMatch = line.match(/([a-zA-Z0-9.-]+\.[a-zA-Z]{2,}:\d+|[a-zA-Z0-9.-]+:\d{2,5})/) return genericMatch ? genericMatch[1] : null } async function resolveServerLaunchCommand(profile, serverDirectory){ if(profile.serverLaunchCommand){ return profile.serverLaunchCommand } const startScript = process.platform === 'win32' ? 'start.bat' : 'start.sh' const startScriptPath = path.join(serverDirectory, startScript) if(await fs.pathExists(startScriptPath)){ return process.platform === 'win32' ? startScript : `./${startScript}` } const jarPath = path.join(serverDirectory, 'server.jar') if(await fs.pathExists(jarPath)){ return 'java -jar server.jar nogui' } throw new Error('서버 시작 명령을 결정할 수 없습니다. serverLaunchCommand 또는 server.jar/start 스크립트를 준비하세요.') } function resolveWorkingDirectory(profile, serverDirectory){ if(profile.serverWorkingDirectory){ return path.join(serverDirectory, profile.serverWorkingDirectory) } return serverDirectory } async function startTunnelProcess(profile, runtime, serverDirectory){ if(!profile.tunnelCommand){ return null } const command = interpolateCommand(profile.tunnelCommand, { port: profile.serverPort ?? 25565, serverDir: serverDirectory }) const tunnelProcess = childProcess.spawn(command, { cwd: serverDirectory, shell: true, detached: false }) runtime.tunnelProcess = tunnelProcess tunnelProcess.stdout?.on('data', (chunk) => { const text = chunk.toString() text.split(/\r?\n/).filter(Boolean).forEach((line) => { appendLog(runtime, `[tunnel] ${line}`) const address = extractPublishedAddress(line, profile) if(address){ runtime.publishedAddress = address ConfigManager.setPublishedLibraryServerAddress(profile.id, address) ConfigManager.save() } }) }) tunnelProcess.stderr?.on('data', (chunk) => { chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => { appendLog(runtime, `[tunnel:err] ${line}`) }) }) tunnelProcess.on('close', () => { runtime.tunnelProcess = null }) return tunnelProcess } exports.startHostedProfile = async function(profile){ const runtime = getRuntime(profile.id) if(runtime.serverProcess != null){ return runtime } const serverDirectory = await ProfileAssetManager.ensureServerBundleInstalled(profile) const workingDirectory = resolveWorkingDirectory(profile, serverDirectory) const command = await resolveServerLaunchCommand(profile, workingDirectory) runtime.status = 'starting' runtime.publishedAddress = null ConfigManager.setPublishedLibraryServerAddress(profile.id, null) ConfigManager.save() appendLog(runtime, `[launcher] starting server: ${command}`) const serverProcess = childProcess.spawn(command, { cwd: workingDirectory, shell: true, detached: false }) runtime.serverProcess = serverProcess serverProcess.stdout?.on('data', (chunk) => { chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => { appendLog(runtime, `[server] ${line}`) if(runtime.status !== 'running'){ runtime.status = 'running' } }) }) serverProcess.stderr?.on('data', (chunk) => { chunk.toString().split(/\r?\n/).filter(Boolean).forEach((line) => { appendLog(runtime, `[server:err] ${line}`) }) }) serverProcess.on('close', () => { runtime.serverProcess = null runtime.status = 'stopped' runtime.publishedAddress = null ConfigManager.setPublishedLibraryServerAddress(profile.id, null) ConfigManager.save() }) if(profile.tunnelCommand){ await startTunnelProcess(profile, runtime, workingDirectory) } return runtime } exports.stopHostedProfile = function(profileId){ const runtime = getRuntime(profileId) if(runtime.tunnelProcess != null){ runtime.tunnelProcess.kill() runtime.tunnelProcess = null } if(runtime.serverProcess != null){ runtime.serverProcess.kill() runtime.serverProcess = null } runtime.status = 'stopped' runtime.publishedAddress = null ConfigManager.setPublishedLibraryServerAddress(profileId, null) ConfigManager.save() } exports.getHostedProfileState = function(profileId){ const runtime = getRuntime(profileId) return { status: runtime.status, running: runtime.serverProcess != null, tunneling: runtime.tunnelProcess != null, publishedAddress: runtime.publishedAddress ?? ConfigManager.getPublishedLibraryServerAddress(profileId), logs: [...runtime.logs] } } exports.hasRunningProfiles = function(){ for(const runtime of runtimes.values()){ if(runtime.serverProcess != null || runtime.tunnelProcess != null){ return true } } return false }