Refactor launcher profiles and port automation
This commit is contained in:
@@ -3,18 +3,25 @@ const fs = require('fs-extra')
|
||||
const path = require('path')
|
||||
|
||||
const ConfigManager = require('./configmanager')
|
||||
const PortManager = require('./portmanager')
|
||||
const ProfileAssetManager = require('./profileassetmanager')
|
||||
|
||||
const runtimes = new Map()
|
||||
const READY_PATTERNS = [
|
||||
/Done \([0-9.]+s\)!/i,
|
||||
/For help, type "help"/i
|
||||
]
|
||||
|
||||
function getRuntime(profileId){
|
||||
if(!runtimes.has(profileId)){
|
||||
runtimes.set(profileId, {
|
||||
serverProcess: null,
|
||||
tunnelProcess: null,
|
||||
logs: [],
|
||||
status: 'stopped',
|
||||
publishedAddress: ConfigManager.getPublishedLibraryServerAddress(profileId)
|
||||
ready: false,
|
||||
readyPromise: null,
|
||||
readyResolver: null,
|
||||
readyRejecter: null
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,124 +35,124 @@ function appendLog(runtime, line){
|
||||
}
|
||||
}
|
||||
|
||||
function interpolateCommand(template, variables){
|
||||
return Object.entries(variables).reduce((command, [key, value]) => {
|
||||
return command.replaceAll(`\${${key}}`, String(value))
|
||||
}, template)
|
||||
function createReadyPromise(runtime){
|
||||
runtime.ready = false
|
||||
runtime.readyPromise = new Promise((resolve, reject) => {
|
||||
runtime.readyResolver = resolve
|
||||
runtime.readyRejecter = reject
|
||||
})
|
||||
return runtime.readyPromise
|
||||
}
|
||||
|
||||
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]
|
||||
function resolveReady(runtime){
|
||||
if(runtime.ready === true){
|
||||
return
|
||||
}
|
||||
|
||||
runtime.ready = true
|
||||
runtime.status = 'running'
|
||||
if(runtime.readyResolver){
|
||||
runtime.readyResolver(runtime)
|
||||
}
|
||||
runtime.readyResolver = null
|
||||
runtime.readyRejecter = null
|
||||
}
|
||||
|
||||
function rejectReady(runtime, error){
|
||||
runtime.ready = false
|
||||
if(runtime.readyRejecter){
|
||||
runtime.readyRejecter(error)
|
||||
}
|
||||
runtime.readyResolver = null
|
||||
runtime.readyRejecter = null
|
||||
}
|
||||
|
||||
function resolveServerJavaExecutable(){
|
||||
const selectedServerId = ConfigManager.getSelectedServer()
|
||||
if(selectedServerId != null){
|
||||
const configured = ConfigManager.getJavaExecutable(selectedServerId)
|
||||
if(configured){
|
||||
return configured
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
return 'java'
|
||||
}
|
||||
|
||||
async function resolveServerLaunchCommand(profile, serverDirectory){
|
||||
if(profile.serverLaunchCommand){
|
||||
return profile.serverLaunchCommand
|
||||
}
|
||||
async function writeServerFiles(profile, serverDirectory){
|
||||
await fs.writeFile(path.join(serverDirectory, 'eula.txt'), 'eula=true\n', 'utf8')
|
||||
|
||||
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 properties = [
|
||||
['enable-query', 'false'],
|
||||
['max-players', String(profile.serverMaxPlayers ?? 20)],
|
||||
['online-mode', 'true'],
|
||||
['server-port', String(profile.serverPort ?? 25565)],
|
||||
['white-list', profile.serverWhitelistEnabled ? 'true' : 'false'],
|
||||
['level-name', 'world']
|
||||
]
|
||||
|
||||
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 스크립트를 준비하세요.')
|
||||
await fs.writeFile(
|
||||
path.join(serverDirectory, 'server.properties'),
|
||||
properties.map(([key, value]) => `${key}=${value}`).join('\n') + '\n',
|
||||
'utf8'
|
||||
)
|
||||
}
|
||||
|
||||
function resolveWorkingDirectory(profile, serverDirectory){
|
||||
if(profile.serverWorkingDirectory){
|
||||
return path.join(serverDirectory, profile.serverWorkingDirectory)
|
||||
}
|
||||
return serverDirectory
|
||||
}
|
||||
function buildLaunchCommand(profile, javaExecutable){
|
||||
const memoryMb = Number.isFinite(Number(profile.serverMemoryMb))
|
||||
? Math.max(512, Number(profile.serverMemoryMb))
|
||||
: 4096
|
||||
|
||||
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
|
||||
return `"${javaExecutable}" -Xms${memoryMb}M -Xmx${memoryMb}M -jar server.jar nogui`
|
||||
}
|
||||
|
||||
exports.startHostedProfile = async function(profile){
|
||||
if(profile.serverEnabled !== true){
|
||||
throw new Error('서버 사용이 꺼진 프로필입니다.')
|
||||
}
|
||||
|
||||
const runtime = getRuntime(profile.id)
|
||||
if(runtime.serverProcess != null){
|
||||
if(runtime.readyPromise != null){
|
||||
await runtime.readyPromise.catch(() => {})
|
||||
}
|
||||
return runtime
|
||||
}
|
||||
|
||||
const serverDirectory = await ProfileAssetManager.ensureServerBundleInstalled(profile)
|
||||
const workingDirectory = resolveWorkingDirectory(profile, serverDirectory)
|
||||
const command = await resolveServerLaunchCommand(profile, workingDirectory)
|
||||
const installedServer = await ProfileAssetManager.ensureServerJarInstalled(profile)
|
||||
if(installedServer?.serverJarPath == null){
|
||||
throw new Error('로컬 서버를 시작하려면 버킷 JAR 업로드가 필요합니다.')
|
||||
}
|
||||
|
||||
runtime.status = 'starting'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profile.id, null)
|
||||
ConfigManager.save()
|
||||
await ProfileAssetManager.ensureServerWorldInstalled(profile, installedServer.serverDirectory)
|
||||
await writeServerFiles(profile, installedServer.serverDirectory)
|
||||
await PortManager.ensurePortAvailability(profile)
|
||||
|
||||
const command = buildLaunchCommand(profile, resolveServerJavaExecutable())
|
||||
appendLog(runtime, `[launcher] starting server: ${command}`)
|
||||
|
||||
runtime.status = 'starting'
|
||||
createReadyPromise(runtime)
|
||||
|
||||
const serverProcess = childProcess.spawn(command, {
|
||||
cwd: workingDirectory,
|
||||
cwd: installedServer.serverDirectory,
|
||||
shell: true,
|
||||
detached: false
|
||||
})
|
||||
|
||||
runtime.serverProcess = serverProcess
|
||||
|
||||
const readyFallbackTimer = setTimeout(() => {
|
||||
if(runtime.serverProcess != null){
|
||||
resolveReady(runtime)
|
||||
}
|
||||
}, 25000)
|
||||
|
||||
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'
|
||||
if(READY_PATTERNS.some((pattern) => pattern.test(line))){
|
||||
resolveReady(runtime)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -157,37 +164,39 @@ exports.startHostedProfile = async function(profile){
|
||||
})
|
||||
|
||||
serverProcess.on('close', () => {
|
||||
clearTimeout(readyFallbackTimer)
|
||||
runtime.serverProcess = null
|
||||
runtime.status = 'stopped'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profile.id, null)
|
||||
ConfigManager.save()
|
||||
runtime.ready = false
|
||||
rejectReady(runtime, new Error('서버 프로세스가 종료되었습니다.'))
|
||||
PortManager.releaseProfilePort(profile.id).catch(() => {})
|
||||
})
|
||||
|
||||
if(profile.tunnelCommand){
|
||||
await startTunnelProcess(profile, runtime, workingDirectory)
|
||||
}
|
||||
|
||||
await runtime.readyPromise.catch(() => {})
|
||||
return runtime
|
||||
}
|
||||
|
||||
exports.stopHostedProfile = function(profileId){
|
||||
exports.stopHostedProfile = async 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 = 'stopping'
|
||||
try {
|
||||
runtime.serverProcess.stdin?.write('stop\n')
|
||||
} catch (error) {
|
||||
void error
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
if(runtime.serverProcess != null){
|
||||
runtime.serverProcess.kill()
|
||||
}
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
runtime.status = 'stopped'
|
||||
runtime.publishedAddress = null
|
||||
ConfigManager.setPublishedLibraryServerAddress(profileId, null)
|
||||
ConfigManager.save()
|
||||
runtime.ready = false
|
||||
await PortManager.releaseProfilePort(profileId).catch(() => {})
|
||||
}
|
||||
|
||||
exports.getHostedProfileState = function(profileId){
|
||||
@@ -195,15 +204,14 @@ exports.getHostedProfileState = function(profileId){
|
||||
return {
|
||||
status: runtime.status,
|
||||
running: runtime.serverProcess != null,
|
||||
tunneling: runtime.tunnelProcess != null,
|
||||
publishedAddress: runtime.publishedAddress ?? ConfigManager.getPublishedLibraryServerAddress(profileId),
|
||||
ready: runtime.ready,
|
||||
logs: [...runtime.logs]
|
||||
}
|
||||
}
|
||||
|
||||
exports.hasRunningProfiles = function(){
|
||||
for(const runtime of runtimes.values()){
|
||||
if(runtime.serverProcess != null || runtime.tunnelProcess != null){
|
||||
if(runtime.serverProcess != null){
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user