feat:移除了弹窗,服务器添加sls
This commit is contained in:
15
packages/server/src/config/registry.ts
Normal file
15
packages/server/src/config/registry.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import type { ConfigFileDefinition } from './types.ts'
|
||||
|
||||
const registry = new Map<string, ConfigFileDefinition>()
|
||||
|
||||
export function registerConfigFile(definition: ConfigFileDefinition): void {
|
||||
registry.set(definition.filename, definition)
|
||||
}
|
||||
|
||||
export function getConfigFile(filename: string): ConfigFileDefinition | undefined {
|
||||
return registry.get(filename)
|
||||
}
|
||||
|
||||
export function listConfigFiles(): ConfigFileDefinition[] {
|
||||
return [...registry.values()]
|
||||
}
|
||||
37
packages/server/src/config/server-properties.test.ts
Normal file
37
packages/server/src/config/server-properties.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { serverPropertiesDefinition } from './server-properties.ts'
|
||||
import { configFieldLabel, resolveConfigField } from './types.ts'
|
||||
|
||||
test('declared keys keep their configured kind', () => {
|
||||
const difficulty = resolveConfigField(serverPropertiesDefinition, 'difficulty', 'normal')
|
||||
assert.equal(difficulty.kind, 'enum')
|
||||
assert.deepEqual(difficulty.options, ['peaceful', 'easy', 'normal', 'hard'])
|
||||
assert.equal(difficulty.inferred, false)
|
||||
|
||||
const port = resolveConfigField(serverPropertiesDefinition, 'server-port', '25565')
|
||||
assert.equal(port.kind, 'integer')
|
||||
assert.equal(port.min, 1)
|
||||
assert.equal(port.max, 65535)
|
||||
|
||||
const online = resolveConfigField(serverPropertiesDefinition, 'online-mode', 'true')
|
||||
assert.equal(online.kind, 'boolean')
|
||||
})
|
||||
|
||||
test('unknown keys are inferred from their value', () => {
|
||||
const booleanField = resolveConfigField(serverPropertiesDefinition, 'some-flag', 'true')
|
||||
assert.equal(booleanField.kind, 'boolean')
|
||||
assert.equal(booleanField.inferred, true)
|
||||
|
||||
const numericField = resolveConfigField(serverPropertiesDefinition, 'some-count', '12')
|
||||
assert.equal(numericField.kind, 'integer')
|
||||
|
||||
const textField = resolveConfigField(serverPropertiesDefinition, 'some-name', 'hello')
|
||||
assert.equal(textField.kind, 'string')
|
||||
})
|
||||
|
||||
test('labels are humanized from the key', () => {
|
||||
assert.equal(configFieldLabel('server-port'), 'Server port')
|
||||
assert.equal(configFieldLabel('rcon.password'), 'Rcon password')
|
||||
})
|
||||
92
packages/server/src/config/server-properties.ts
Normal file
92
packages/server/src/config/server-properties.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { inferFieldKindFromValue, type ConfigFileDefinition } from './types.ts'
|
||||
import { registerConfigFile } from './registry.ts'
|
||||
|
||||
const boolean = (key: string) => ({ key, kind: 'boolean' as const })
|
||||
const integer = (key: string, min?: number, max?: number) => ({
|
||||
key,
|
||||
kind: 'integer' as const,
|
||||
min,
|
||||
max,
|
||||
})
|
||||
const string = (key: string) => ({ key, kind: 'string' as const })
|
||||
const enumeration = (key: string, options: string[]) => ({ key, kind: 'enum' as const, options })
|
||||
|
||||
/**
|
||||
* Known server.properties fields. Keys not listed here fall back to automatic
|
||||
* type inference from the current value.
|
||||
*/
|
||||
export const serverPropertiesDefinition: ConfigFileDefinition = {
|
||||
id: 'server-properties',
|
||||
filename: 'server.properties',
|
||||
inferFieldKind: (_key, value) => inferFieldKindFromValue(value),
|
||||
fields: [
|
||||
integer('server-port', 1, 65535),
|
||||
enumeration('difficulty', ['peaceful', 'easy', 'normal', 'hard']),
|
||||
enumeration('gamemode', ['survival', 'creative', 'adventure', 'spectator']),
|
||||
enumeration('level-type', [
|
||||
'minecraft:normal',
|
||||
'minecraft:flat',
|
||||
'minecraft:large_biomes',
|
||||
'minecraft:amplified',
|
||||
'minecraft:single_biome',
|
||||
'minecraft:debug',
|
||||
'normal',
|
||||
'flat',
|
||||
'largeBiomes',
|
||||
'amplified',
|
||||
'default',
|
||||
]),
|
||||
integer('max-players', 0, 1000),
|
||||
integer('view-distance', 2, 32),
|
||||
integer('simulation-distance', 2, 32),
|
||||
integer('max-tick-time', -1),
|
||||
integer('max-world-size', 1, 29999984),
|
||||
integer('op-permission-level', 0, 4),
|
||||
integer('function-permission-level', 0, 4),
|
||||
integer('spawn-protection', 0, 256),
|
||||
integer('player-idle-timeout', 0),
|
||||
integer('network-compression-threshold', -1, 1024),
|
||||
integer('rate-limit', 0),
|
||||
integer('query.port', 1, 65535),
|
||||
integer('rcon.port', 1, 65535),
|
||||
string('level-name'),
|
||||
string('level-seed'),
|
||||
string('motd'),
|
||||
string('resource-pack'),
|
||||
string('resource-pack-sha1'),
|
||||
string('resource-pack-prompt'),
|
||||
string('rcon.password'),
|
||||
string('server-ip'),
|
||||
string('text-filtering-config'),
|
||||
string('initial-enabled-packs'),
|
||||
boolean('online-mode'),
|
||||
boolean('white-list'),
|
||||
boolean('enforce-whitelist'),
|
||||
boolean('enforce-secure-profile'),
|
||||
boolean('prevent-proxy-connections'),
|
||||
boolean('allow-flight'),
|
||||
boolean('allow-nether'),
|
||||
boolean('spawn-animals'),
|
||||
boolean('spawn-monsters'),
|
||||
boolean('spawn-npcs'),
|
||||
boolean('pvp'),
|
||||
boolean('enable-command-block'),
|
||||
boolean('enable-status'),
|
||||
boolean('enable-query'),
|
||||
boolean('enable-rcon'),
|
||||
boolean('enable-jmx-monitoring'),
|
||||
boolean('force-gamemode'),
|
||||
boolean('hardcore'),
|
||||
boolean('announce-player-achievements'),
|
||||
boolean('log-ips'),
|
||||
boolean('hide-online-players'),
|
||||
boolean('require-resource-pack'),
|
||||
boolean('sync-chunk-writes'),
|
||||
boolean('use-native-transport'),
|
||||
boolean('allow-end'),
|
||||
boolean('generate-structures'),
|
||||
boolean('enable-lan'),
|
||||
],
|
||||
}
|
||||
|
||||
registerConfigFile(serverPropertiesDefinition)
|
||||
85
packages/server/src/config/types.ts
Normal file
85
packages/server/src/config/types.ts
Normal file
@ -0,0 +1,85 @@
|
||||
import type { PropertiesEntry } from '../properties.ts'
|
||||
|
||||
export type ConfigFieldKind = 'boolean' | 'integer' | 'number' | 'string' | 'enum'
|
||||
|
||||
export interface ConfigFieldDefinition {
|
||||
key: string
|
||||
kind: ConfigFieldKind
|
||||
/** Allowed values for `enum` fields. */
|
||||
options?: string[]
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Describes an editable configuration file. Formats plug in through the
|
||||
* `entries` accessors so new files (bukkit.yml, whitelist.json, ...) only need
|
||||
* a schema definition and, when needed, their own parse/serialize functions.
|
||||
*/
|
||||
export interface ConfigFileDefinition {
|
||||
id: string
|
||||
filename: string
|
||||
fields: ConfigFieldDefinition[]
|
||||
/** Fallback inference for keys not present in `fields`. */
|
||||
inferFieldKind: (key: string, value: string) => ConfigFieldKind
|
||||
}
|
||||
|
||||
export interface ResolvedConfigField extends ConfigFieldDefinition {
|
||||
/** True when the kind was inferred from the value rather than declared. */
|
||||
inferred: boolean
|
||||
}
|
||||
|
||||
const TRUE_VALUES = new Set(['true', 'false'])
|
||||
const INTEGER_RE = /^-?\d+$/
|
||||
const NUMBER_RE = /^-?\d+(\.\d+)?$/
|
||||
|
||||
export function inferFieldKindFromValue(value: string): ConfigFieldKind {
|
||||
if (TRUE_VALUES.has(value.toLowerCase())) return 'boolean'
|
||||
if (INTEGER_RE.test(value)) return 'integer'
|
||||
if (NUMBER_RE.test(value)) return 'number'
|
||||
return 'string'
|
||||
}
|
||||
|
||||
export function resolveConfigField(
|
||||
definition: ConfigFileDefinition,
|
||||
key: string,
|
||||
value: string,
|
||||
): ResolvedConfigField {
|
||||
const declared = definition.fields.find((field) => field.key === key)
|
||||
if (declared) return { ...declared, inferred: false }
|
||||
return { key, kind: definition.inferFieldKind(key, value), inferred: true }
|
||||
}
|
||||
|
||||
/** Humanizes a config key (`server-port` -> `Server port`) as a fallback label. */
|
||||
export function configFieldLabel(key: string): string {
|
||||
const parts = key.split(/[-_.]/)
|
||||
if (parts.length === 0) return key
|
||||
const first = parts[0]
|
||||
return (
|
||||
(first.length > 0 ? first[0].toUpperCase() + first.slice(1) : first) +
|
||||
' ' +
|
||||
parts.slice(1).join(' ')
|
||||
).trimEnd()
|
||||
}
|
||||
|
||||
export interface ConfigFileDocument {
|
||||
definition: ConfigFileDefinition
|
||||
entries: PropertiesEntry[]
|
||||
}
|
||||
|
||||
export function getRawValue(document: ConfigFileDocument, key: string): string | undefined {
|
||||
const entry = document.entries.find(
|
||||
(entry): entry is Extract<PropertiesEntry, { type: 'pair' }> =>
|
||||
entry.type === 'pair' && entry.key === key,
|
||||
)
|
||||
return entry?.value
|
||||
}
|
||||
|
||||
export function setRawValue(document: ConfigFileDocument, key: string, value: string): void {
|
||||
const index = document.entries.findIndex((entry) => entry.type === 'pair' && entry.key === key)
|
||||
if (index === -1) {
|
||||
document.entries.push({ type: 'pair', key, value, separator: '=' })
|
||||
return
|
||||
}
|
||||
document.entries[index] = { type: 'pair', key, value, separator: '=' }
|
||||
}
|
||||
29
packages/server/src/eula.test.ts
Normal file
29
packages/server/src/eula.test.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { parseEula, setEulaAccepted } from './eula.ts'
|
||||
|
||||
const SAMPLE_EULA = [
|
||||
'#By changing the setting below to TRUE you are indicating your agreement to our EULA (https://aka.ms/MinecraftEULA).',
|
||||
'#Wed Jan 01 00:00:00 UTC 2025',
|
||||
'eula=false',
|
||||
].join('\n')
|
||||
|
||||
test('parses eula state', () => {
|
||||
assert.equal(parseEula(SAMPLE_EULA).accepted, false)
|
||||
assert.equal(parseEula(SAMPLE_EULA.replace('eula=false', 'eula=true')).accepted, true)
|
||||
assert.equal(parseEula('').accepted, false)
|
||||
})
|
||||
|
||||
test('accepting the eula preserves the surrounding text', () => {
|
||||
const accepted = setEulaAccepted(SAMPLE_EULA, true)
|
||||
assert.equal(parseEula(accepted).accepted, true)
|
||||
assert.equal(accepted.split('\n')[0], SAMPLE_EULA.split('\n')[0])
|
||||
assert.equal(accepted.split('\n')[1], SAMPLE_EULA.split('\n')[1])
|
||||
})
|
||||
|
||||
test('declining rewrites eula back to false', () => {
|
||||
const accepted = setEulaAccepted(SAMPLE_EULA, true)
|
||||
const declined = setEulaAccepted(accepted, false)
|
||||
assert.equal(parseEula(declined).accepted, false)
|
||||
})
|
||||
32
packages/server/src/eula.ts
Normal file
32
packages/server/src/eula.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { parseProperties, serializeProperties, type PropertiesEntry } from './properties.ts'
|
||||
|
||||
const EULA_KEY = 'eula'
|
||||
|
||||
export interface EulaDocument {
|
||||
entries: PropertiesEntry[]
|
||||
accepted: boolean
|
||||
}
|
||||
|
||||
export function parseEula(text: string): EulaDocument {
|
||||
const entries = parseProperties(text)
|
||||
const value = entries.find(
|
||||
(entry): entry is Extract<PropertiesEntry, { type: 'pair' }> =>
|
||||
entry.type === 'pair' && entry.key === EULA_KEY,
|
||||
)
|
||||
return { entries, accepted: value?.value === 'true' }
|
||||
}
|
||||
|
||||
export function setEulaAccepted(text: string, accepted: boolean): string {
|
||||
const { entries } = parseEula(text)
|
||||
const updated = entries.map((entry) =>
|
||||
entry.type === 'pair' && entry.key === EULA_KEY ? { ...entry, value: String(accepted) } : entry,
|
||||
)
|
||||
const hasEulaKey = updated.some((entry) => entry.type === 'pair' && entry.key === EULA_KEY)
|
||||
const finalEntries = hasEulaKey
|
||||
? updated
|
||||
: [
|
||||
...updated,
|
||||
{ type: 'pair' as const, key: EULA_KEY, value: String(accepted), separator: '=' },
|
||||
]
|
||||
return serializeProperties(finalEntries)
|
||||
}
|
||||
159
packages/server/src/log-parse.test.ts
Normal file
159
packages/server/src/log-parse.test.ts
Normal file
@ -0,0 +1,159 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { classifyServerLogLine, summarizeServerExit } from './log-parse.ts'
|
||||
import { computeServerStatus } from './status.ts'
|
||||
import {
|
||||
pickFabricInstallerVersion,
|
||||
requiredJavaMajorVersion,
|
||||
resolveServerJar,
|
||||
type PaperBuildsResponse,
|
||||
latestStablePaperBuild,
|
||||
} from './server-types.ts'
|
||||
|
||||
test('maps legacy game versions to their required Java major', () => {
|
||||
assert.equal(requiredJavaMajorVersion('1.21.4'), 21)
|
||||
assert.equal(requiredJavaMajorVersion('1.20.5'), 21)
|
||||
assert.equal(requiredJavaMajorVersion('1.20.4'), 17)
|
||||
assert.equal(requiredJavaMajorVersion('1.17.1'), 17)
|
||||
assert.equal(requiredJavaMajorVersion('1.16.5'), 8)
|
||||
assert.equal(requiredJavaMajorVersion('1.12.2'), 8)
|
||||
})
|
||||
|
||||
test('maps year-based game versions to Java 25', () => {
|
||||
assert.equal(requiredJavaMajorVersion('26.2'), 25)
|
||||
assert.equal(requiredJavaMajorVersion('26.1'), 25)
|
||||
assert.equal(requiredJavaMajorVersion('26w14a'), 25)
|
||||
assert.equal(requiredJavaMajorVersion('25w46a'), 21)
|
||||
assert.equal(requiredJavaMajorVersion('unknown'), 25)
|
||||
})
|
||||
|
||||
test('detects the eula notice in server logs', () => {
|
||||
assert.equal(
|
||||
classifyServerLogLine('[ServerMain/ERROR]: Failed to start the minecraft server').eulaRequired,
|
||||
undefined,
|
||||
)
|
||||
assert.equal(
|
||||
classifyServerLogLine(
|
||||
'[ServerMain/ERROR]: You need to agree to the EULA in order to run the server. Go to eula.txt for more info.',
|
||||
).eulaRequired,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('detects a fully started server', () => {
|
||||
assert.equal(
|
||||
classifyServerLogLine('[Server thread/INFO]: Done (3.542s)! For help, type "help"').started,
|
||||
true,
|
||||
)
|
||||
})
|
||||
|
||||
test('first-run eula exit is not a crash', () => {
|
||||
const lines = [
|
||||
'Starting minecraft server version 1.21',
|
||||
'You need to agree to the EULA in order to run the server.',
|
||||
]
|
||||
assert.deepEqual(summarizeServerExit(lines, 1), { crashed: false, eulaRequired: true })
|
||||
assert.deepEqual(summarizeServerExit(['Done (1.0s)!'], 0), {
|
||||
crashed: false,
|
||||
eulaRequired: false,
|
||||
})
|
||||
assert.deepEqual(summarizeServerExit(['Exception in thread "main"'], 1), {
|
||||
crashed: true,
|
||||
eulaRequired: false,
|
||||
})
|
||||
})
|
||||
|
||||
test('resolves vanilla server jar from version info', () => {
|
||||
const jar = resolveServerJar('vanilla', {
|
||||
gameVersion: '1.21.4',
|
||||
vanillaVersionInfo: {
|
||||
downloads: { server: { sha1: 'abc', size: 100, url: 'https://example.com/server.jar' } },
|
||||
},
|
||||
})
|
||||
assert.equal(jar?.url, 'https://example.com/server.jar')
|
||||
assert.equal(jar?.filename, 'server.jar')
|
||||
})
|
||||
|
||||
test('resolves fabric server launcher url', () => {
|
||||
const jar = resolveServerJar('fabric', {
|
||||
gameVersion: '1.21.4',
|
||||
loaderVersion: '0.16.9',
|
||||
installerVersion: '1.0.3',
|
||||
})
|
||||
assert.equal(
|
||||
jar?.url,
|
||||
'https://meta.fabricmc.net/v2/versions/loader/1.21.4/0.16.9/1.0.3/server/jar',
|
||||
)
|
||||
assert.equal(jar?.filename, 'fabric-server.jar')
|
||||
})
|
||||
|
||||
test('fabric server jar requires an installer version', () => {
|
||||
assert.equal(resolveServerJar('fabric', { gameVersion: '1.21.4', loaderVersion: '0.16.9' }), null)
|
||||
})
|
||||
|
||||
test('picks the newest fabric installer version', () => {
|
||||
assert.equal(pickFabricInstallerVersion([{ version: '1.1.2', stable: true }]), '1.1.2')
|
||||
assert.equal(pickFabricInstallerVersion([]), null)
|
||||
})
|
||||
|
||||
test('resolves paper server jar from the newest stable fill build', () => {
|
||||
const builds: PaperBuildsResponse = [
|
||||
{
|
||||
id: 112,
|
||||
channel: 'STABLE',
|
||||
downloads: {
|
||||
'server:default': {
|
||||
name: 'paper-26.2-112.jar',
|
||||
url: 'https://fill-data.papermc.io/v1/objects/abc/paper-26.2-112.jar',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 113,
|
||||
channel: 'EXPERIMENTAL',
|
||||
downloads: {
|
||||
'server:default': { name: 'paper-26.2-113.jar', url: 'https://fill-data.papermc.io/v3/x' },
|
||||
},
|
||||
},
|
||||
]
|
||||
const build = latestStablePaperBuild(builds)
|
||||
assert.equal(build?.id, 112)
|
||||
const jar = resolveServerJar('paper', { gameVersion: '26.2', paperBuild: build ?? undefined })
|
||||
assert.equal(jar?.url, 'https://fill-data.papermc.io/v1/objects/abc/paper-26.2-112.jar')
|
||||
assert.equal(jar?.filename, 'server.jar')
|
||||
|
||||
assert.equal(latestStablePaperBuild([]), null)
|
||||
assert.equal(
|
||||
resolveServerJar('paper', {
|
||||
gameVersion: '26.2',
|
||||
paperBuild: { id: 1, channel: 'STABLE', downloads: {} },
|
||||
}),
|
||||
null,
|
||||
)
|
||||
})
|
||||
|
||||
test('installer-based types resolve to null until implemented', () => {
|
||||
assert.equal(resolveServerJar('forge', { gameVersion: '1.21.4' }), null)
|
||||
assert.equal(resolveServerJar('neoforge', { gameVersion: '1.21.4' }), null)
|
||||
})
|
||||
|
||||
test('computes server status precedence', () => {
|
||||
const base = {
|
||||
manifest: { id: 'a' },
|
||||
isRunning: false,
|
||||
isStarting: false,
|
||||
lastExitWasCrash: false,
|
||||
eulaAccepted: false,
|
||||
eulaFileExists: false,
|
||||
}
|
||||
assert.equal(computeServerStatus(base), 'created')
|
||||
assert.equal(computeServerStatus({ ...base, eulaFileExists: true }), 'eula_pending')
|
||||
assert.equal(computeServerStatus({ ...base, eulaAccepted: true }), 'ready')
|
||||
assert.equal(computeServerStatus({ ...base, isStarting: true }), 'starting')
|
||||
assert.equal(computeServerStatus({ ...base, isRunning: true }), 'running')
|
||||
assert.equal(
|
||||
computeServerStatus({ ...base, lastExitWasCrash: true, eulaAccepted: true }),
|
||||
'crashed',
|
||||
)
|
||||
})
|
||||
36
packages/server/src/log-parse.ts
Normal file
36
packages/server/src/log-parse.ts
Normal file
@ -0,0 +1,36 @@
|
||||
export interface ServerLogSignal {
|
||||
eulaRequired?: boolean
|
||||
started?: boolean
|
||||
stopping?: boolean
|
||||
}
|
||||
|
||||
const EULA_RE = /you need to agree to the eula|eula\.txt/i
|
||||
const DONE_RE = /Done \([\d.]+s\)!/
|
||||
const STOPPING_RE = /Stopping (the )?server|Stopping singleplayer server/i
|
||||
|
||||
/** Classifies a dedicated server log line for status tracking. */
|
||||
export function classifyServerLogLine(line: string): ServerLogSignal {
|
||||
const signals: ServerLogSignal = {}
|
||||
if (EULA_RE.test(line)) signals.eulaRequired = true
|
||||
if (DONE_RE.test(line)) signals.started = true
|
||||
if (STOPPING_RE.test(line)) signals.stopping = true
|
||||
return signals
|
||||
}
|
||||
|
||||
export interface ServerExitSummary {
|
||||
crashed: boolean
|
||||
eulaRequired: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes the outcome of a server process run from its log lines and exit
|
||||
* code. A clean exit right after the EULA notice is the expected first-run
|
||||
* behavior, not a crash.
|
||||
*/
|
||||
export function summarizeServerExit(lines: string[], exitCode: number | null): ServerExitSummary {
|
||||
const eulaRequired = lines.some((line) => classifyServerLogLine(line).eulaRequired)
|
||||
return {
|
||||
crashed: exitCode !== null && exitCode !== 0 && !eulaRequired,
|
||||
eulaRequired,
|
||||
}
|
||||
}
|
||||
59
packages/server/src/properties.test.ts
Normal file
59
packages/server/src/properties.test.ts
Normal file
@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { getProperty, parseProperties, serializeProperties, setProperty } from './properties.ts'
|
||||
|
||||
test('parses simple pairs with = separator', () => {
|
||||
const entries = parseProperties('server-port=25565\nonline-mode=true')
|
||||
assert.deepEqual(
|
||||
entries.map((e) => (e.type === 'pair' ? [e.key, e.value] : e.type)),
|
||||
[
|
||||
['server-port', '25565'],
|
||||
['online-mode', 'true'],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('preserves comments, blank lines and key order on round-trip', () => {
|
||||
const text =
|
||||
'#Minecraft server properties\n#Wed Jan 01 00:00:00 UTC 2025\nserver-port=25565\n\nmotd=A Minecraft Server\nwhite-list=false'
|
||||
const entries = parseProperties(text)
|
||||
const roundTripped = serializeProperties(entries)
|
||||
assert.equal(roundTripped.split('\n')[0], '#Minecraft server properties')
|
||||
assert.equal(roundTripped.split('\n')[3], '')
|
||||
const reparsed = parseProperties(roundTripped)
|
||||
assert.equal(getProperty(reparsed, 'motd'), 'A Minecraft Server')
|
||||
assert.equal(getProperty(reparsed, 'white-list'), 'false')
|
||||
})
|
||||
|
||||
test('supports colon separators and whitespace', () => {
|
||||
const entries = parseProperties('gamemode : creative')
|
||||
const pair = entries[0]
|
||||
assert.equal(pair.type, 'pair')
|
||||
if (pair.type === 'pair') {
|
||||
assert.equal(pair.key, 'gamemode')
|
||||
assert.equal(pair.value, 'creative')
|
||||
assert.equal(pair.separator, ':')
|
||||
}
|
||||
})
|
||||
|
||||
test('escapes and unescapes values', () => {
|
||||
const entries = parseProperties('motd=Line one\\nLine two')
|
||||
assert.equal(getProperty(entries, 'motd'), 'Line one\nLine two')
|
||||
const serialized = serializeProperties(entries)
|
||||
assert.equal(getProperty(parseProperties(serialized), 'motd'), 'Line one\nLine two')
|
||||
})
|
||||
|
||||
test('setProperty updates existing keys and appends new ones', () => {
|
||||
const entries = parseProperties('difficulty=easy')
|
||||
const updated = setProperty(entries, 'difficulty', 'hard')
|
||||
assert.equal(getProperty(updated, 'difficulty'), 'hard')
|
||||
const appended = setProperty(updated, 'new-key', 'value')
|
||||
assert.equal(getProperty(appended, 'new-key'), 'value')
|
||||
assert.equal(appended.length, updated.length + 1)
|
||||
})
|
||||
|
||||
test('handles values containing escaped separators', () => {
|
||||
const entries = parseProperties('level-name=World\\=Two')
|
||||
assert.equal(getProperty(entries, 'level-name'), 'World=Two')
|
||||
})
|
||||
141
packages/server/src/properties.ts
Normal file
141
packages/server/src/properties.ts
Normal file
@ -0,0 +1,141 @@
|
||||
export type PropertiesEntry =
|
||||
| { type: 'comment'; text: string }
|
||||
| { type: 'blank' }
|
||||
| { type: 'pair'; key: string; value: string; separator: string }
|
||||
|
||||
const COMMENT_RE = /^\s*[#!]/
|
||||
const ESCAPE_RE = /\\(.)/g
|
||||
|
||||
function unescapeValue(value: string): string {
|
||||
return value.replace(ESCAPE_RE, (_, char: string) => {
|
||||
switch (char) {
|
||||
case 'n':
|
||||
return '\n'
|
||||
case 't':
|
||||
return '\t'
|
||||
case 'r':
|
||||
return '\r'
|
||||
case 'f':
|
||||
return '\f'
|
||||
default:
|
||||
return char
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function escapeValue(value: string): string {
|
||||
return value
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/\n/g, '\\n')
|
||||
.replace(/\t/g, '\\t')
|
||||
.replace(/\r/g, '\\r')
|
||||
}
|
||||
|
||||
/** Finds the first unescaped `=` or `:` separator, falling back to whitespace. */
|
||||
function findSeparator(line: string): number {
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i]
|
||||
if (char === '\\') {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (char === '=' || char === ':') return i
|
||||
if (/\s/.test(char)) {
|
||||
let j = i
|
||||
while (j < line.length && /\s/.test(line[j])) j++
|
||||
if (line[j] === '=' || line[j] === ':') return j
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function unescapeKey(key: string): string {
|
||||
return unescapeValue(key).replace(/\\([ =:])/g, '$1')
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a Java `.properties` document, preserving comment lines, blank lines,
|
||||
* key order, and the original `=`/`:` separators so it round-trips safely.
|
||||
*/
|
||||
export function parseProperties(text: string): PropertiesEntry[] {
|
||||
const entries: PropertiesEntry[] = []
|
||||
let pendingContinuation: { type: 'pair'; key: string; value: string; separator: string } | null =
|
||||
null
|
||||
|
||||
for (const rawLine of text.split(/\r?\n/)) {
|
||||
const line = pendingContinuation ? rawLine : rawLine.trim()
|
||||
if (pendingContinuation) {
|
||||
const continued = line.replace(/\\\s*$/, '')
|
||||
pendingContinuation.value += continued
|
||||
if (/\\\s*$/.test(rawLine)) continue
|
||||
entries.push(pendingContinuation)
|
||||
pendingContinuation = null
|
||||
continue
|
||||
}
|
||||
if (line === '') {
|
||||
entries.push({ type: 'blank' })
|
||||
continue
|
||||
}
|
||||
if (COMMENT_RE.test(line)) {
|
||||
entries.push({ type: 'comment', text: line })
|
||||
continue
|
||||
}
|
||||
const separatorIndex = findSeparator(line)
|
||||
if (separatorIndex === -1) {
|
||||
entries.push({ type: 'pair', key: unescapeKey(line), value: '', separator: '=' })
|
||||
continue
|
||||
}
|
||||
const key = unescapeKey(line.slice(0, separatorIndex).trimEnd())
|
||||
const separator = line[separatorIndex]
|
||||
const value = line.slice(separatorIndex + 1).trim()
|
||||
if (/\\\s*$/.test(value)) {
|
||||
pendingContinuation = {
|
||||
type: 'pair',
|
||||
key,
|
||||
value: value.replace(/\\\s*$/, ''),
|
||||
separator,
|
||||
}
|
||||
continue
|
||||
}
|
||||
entries.push({ type: 'pair', key, value: unescapeValue(value), separator })
|
||||
}
|
||||
if (pendingContinuation) entries.push(pendingContinuation)
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
export function serializeProperties(entries: PropertiesEntry[]): string {
|
||||
return entries
|
||||
.map((entry) => {
|
||||
if (entry.type === 'blank') return ''
|
||||
if (entry.type === 'comment') return entry.text
|
||||
return `${entry.key.replace(/([ =:])/g, '\\$1')}${entry.separator}${escapeValue(entry.value)}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
export function getProperty(entries: PropertiesEntry[], key: string): string | undefined {
|
||||
const entry = entries.find(
|
||||
(entry): entry is Extract<PropertiesEntry, { type: 'pair' }> =>
|
||||
entry.type === 'pair' && entry.key === key,
|
||||
)
|
||||
return entry?.value
|
||||
}
|
||||
|
||||
export function setProperty(
|
||||
entries: PropertiesEntry[],
|
||||
key: string,
|
||||
value: string,
|
||||
): PropertiesEntry[] {
|
||||
let updated = false
|
||||
const next = entries.map((entry) => {
|
||||
if (entry.type === 'pair' && entry.key === key) {
|
||||
updated = true
|
||||
return { ...entry, value }
|
||||
}
|
||||
return entry
|
||||
})
|
||||
if (!updated) next.push({ type: 'pair', key, value, separator: '=' })
|
||||
return next
|
||||
}
|
||||
206
packages/server/src/server-types.ts
Normal file
206
packages/server/src/server-types.ts
Normal file
@ -0,0 +1,206 @@
|
||||
import type {
|
||||
PaperBuild,
|
||||
ResolveServerJarInput,
|
||||
ServerJarDownload,
|
||||
ServerTypeDefinition,
|
||||
ServerTypeId,
|
||||
} from './types.ts'
|
||||
|
||||
const FABRIC_META_URL = 'https://meta.fabricmc.net/v2'
|
||||
const QUILT_META_URL = 'https://meta.quiltmc.org/v3'
|
||||
const PAPER_API_URL = 'https://fill.papermc.io/v3'
|
||||
const PAPER_PROJECT = 'paper'
|
||||
|
||||
/**
|
||||
* Known server types. `forge`, `neoforge` and `quilt` require an installer run
|
||||
* step that is not implemented yet (TODO) but are registered so the UI and
|
||||
* future CLI share one source of truth.
|
||||
*/
|
||||
export const SERVER_TYPES: Record<ServerTypeId, ServerTypeDefinition> = {
|
||||
vanilla: {
|
||||
id: 'vanilla',
|
||||
label: 'Vanilla',
|
||||
installMode: 'direct',
|
||||
needsLoaderVersion: false,
|
||||
implemented: true,
|
||||
},
|
||||
fabric: {
|
||||
id: 'fabric',
|
||||
label: 'Fabric',
|
||||
installMode: 'direct',
|
||||
needsLoaderVersion: true,
|
||||
implemented: true,
|
||||
},
|
||||
paper: {
|
||||
id: 'paper',
|
||||
label: 'Paper',
|
||||
installMode: 'direct',
|
||||
needsLoaderVersion: false,
|
||||
implemented: true,
|
||||
},
|
||||
forge: {
|
||||
id: 'forge',
|
||||
label: 'Forge',
|
||||
installMode: 'installer',
|
||||
needsLoaderVersion: false,
|
||||
implemented: true,
|
||||
},
|
||||
neoforge: {
|
||||
id: 'neoforge',
|
||||
label: 'NeoForge',
|
||||
installMode: 'installer',
|
||||
needsLoaderVersion: true,
|
||||
implemented: false,
|
||||
},
|
||||
quilt: {
|
||||
id: 'quilt',
|
||||
label: 'Quilt',
|
||||
installMode: 'installer',
|
||||
needsLoaderVersion: true,
|
||||
implemented: false,
|
||||
},
|
||||
}
|
||||
|
||||
export function listServerTypes(): ServerTypeDefinition[] {
|
||||
return Object.values(SERVER_TYPES)
|
||||
}
|
||||
|
||||
export function isServerTypeSupported(type: ServerTypeId): boolean {
|
||||
return SERVER_TYPES[type].implemented
|
||||
}
|
||||
|
||||
/** Base URL of the Forge Maven repository hosting installer and launcher artifacts. */
|
||||
export const FORGE_MAVEN_URL = 'https://maven.minecraftforge.net/net/minecraftforge/forge'
|
||||
|
||||
/** Base URL of the Forge web host that publishes the promotions manifest. */
|
||||
export const FORGE_FILES_URL = 'https://files.minecraftforge.net/net/minecraftforge/forge'
|
||||
|
||||
/** URL of the Forge promotions manifest, mapping `<mc>-recommended`/`latest` to a build. */
|
||||
export function forgePromotionsSlimUrl(): string {
|
||||
return `${FORGE_FILES_URL}/promotions_slim.json`
|
||||
}
|
||||
|
||||
/** URL of the Fabric server launcher jar for a specific game/loader/installer combination. */
|
||||
export function fabricServerJarUrl(
|
||||
gameVersion: string,
|
||||
loaderVersion: string,
|
||||
installerVersion: string,
|
||||
): string {
|
||||
return `${FABRIC_META_URL}/versions/loader/${gameVersion}/${loaderVersion}/${installerVersion}/server/jar`
|
||||
}
|
||||
|
||||
export function fabricInstallerVersionsUrl(): string {
|
||||
return `${FABRIC_META_URL}/versions/installer`
|
||||
}
|
||||
|
||||
export function fabricLoaderVersionsForGameUrl(gameVersion: string): string {
|
||||
return `${FABRIC_META_URL}/versions/loader/${gameVersion}`
|
||||
}
|
||||
|
||||
/** URL of the Quilt server launcher jar for a specific game/loader/installer combination. */
|
||||
export function quiltServerJarUrl(
|
||||
gameVersion: string,
|
||||
loaderVersion: string,
|
||||
installerVersion: string,
|
||||
): string {
|
||||
return `${QUILT_META_URL}/versions/loader/${gameVersion}/${loaderVersion}/${installerVersion}/server/jar`
|
||||
}
|
||||
|
||||
export function quiltInstallerVersionsUrl(): string {
|
||||
return `${QUILT_META_URL}/versions/installer`
|
||||
}
|
||||
|
||||
export function quiltLoaderVersionsForGameUrl(gameVersion: string): string {
|
||||
return `${QUILT_META_URL}/versions/loader/${gameVersion}`
|
||||
}
|
||||
|
||||
export function paperBuildsUrl(gameVersion: string): string {
|
||||
return `${PAPER_API_URL}/projects/${PAPER_PROJECT}/versions/${gameVersion}/builds`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the server jar download for a server type from metadata the caller
|
||||
* fetched. Returns null when the type needs an installer step or required
|
||||
* metadata is missing.
|
||||
*/
|
||||
export function resolveServerJar(
|
||||
type: ServerTypeId,
|
||||
input: ResolveServerJarInput,
|
||||
): ServerJarDownload | null {
|
||||
switch (type) {
|
||||
case 'vanilla': {
|
||||
const server = input.vanillaVersionInfo?.downloads.server
|
||||
if (!server) return null
|
||||
return { url: server.url, filename: 'server.jar', sha1: server.sha1, size: server.size }
|
||||
}
|
||||
case 'fabric': {
|
||||
if (!input.loaderVersion || !input.installerVersion) return null
|
||||
return {
|
||||
url: fabricServerJarUrl(input.gameVersion, input.loaderVersion, input.installerVersion),
|
||||
filename: 'fabric-server.jar',
|
||||
}
|
||||
}
|
||||
case 'quilt': {
|
||||
if (!input.loaderVersion || !input.installerVersion) return null
|
||||
return {
|
||||
url: quiltServerJarUrl(input.gameVersion, input.loaderVersion, input.installerVersion),
|
||||
filename: 'quilt-server.jar',
|
||||
}
|
||||
}
|
||||
case 'paper': {
|
||||
const download = input.paperBuild?.downloads['server:default']
|
||||
if (!download) return null
|
||||
return { url: download.url, filename: 'server.jar' }
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export type PaperBuildsResponse = PaperBuild[]
|
||||
|
||||
/** The newest stable build from a Fill v3 builds response (builds are newest first). */
|
||||
export function latestStablePaperBuild(response: PaperBuildsResponse): PaperBuild | null {
|
||||
return response?.find((build) => build.channel === 'STABLE') ?? null
|
||||
}
|
||||
|
||||
export interface FabricInstallerVersionsResponse {
|
||||
version: string
|
||||
stable: boolean
|
||||
}
|
||||
|
||||
/** The newest installer version from the `/v2/versions/installer` response (a top-level array). */
|
||||
export function pickFabricInstallerVersion(
|
||||
response: FabricInstallerVersionsResponse[],
|
||||
): string | null {
|
||||
return response?.[0]?.version ?? null
|
||||
}
|
||||
|
||||
/** Minimum Java major version required to run a given game version. */
|
||||
/**
|
||||
* Minimum Java major version required to run a given game version.
|
||||
* Handles both the legacy `1.x` scheme and the year-based scheme (`26.2`,
|
||||
* `26w14a`), which needs Java 25.
|
||||
*/
|
||||
export function requiredJavaMajorVersion(gameVersion: string): number {
|
||||
const yearSnapshot = /^(\d{2})w/.exec(gameVersion)
|
||||
if (yearSnapshot) {
|
||||
return Number(yearSnapshot[1]) >= 26 ? 25 : 21
|
||||
}
|
||||
|
||||
const match = /^(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(gameVersion)
|
||||
if (!match) return 25
|
||||
const major = Number(match[1])
|
||||
const minor = Number(match[2] ?? 0)
|
||||
const patch = Number(match[3] ?? 0)
|
||||
|
||||
// Year-based releases (26.1+) require Java 25
|
||||
if (major >= 21) return 25
|
||||
// Legacy 1.x releases
|
||||
if (major === 1) {
|
||||
if (minor > 20 || (minor === 20 && patch >= 5)) return 21
|
||||
if (minor >= 17) return 17
|
||||
return 8
|
||||
}
|
||||
return 25
|
||||
}
|
||||
9
packages/server/src/status.ts
Normal file
9
packages/server/src/status.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { ServerStatus, ServerStatusInput } from './types.ts'
|
||||
|
||||
export function computeServerStatus(input: ServerStatusInput): ServerStatus {
|
||||
if (input.isRunning) return 'running'
|
||||
if (input.isStarting) return 'starting'
|
||||
if (input.lastExitWasCrash) return 'crashed'
|
||||
if (!input.eulaAccepted) return input.eulaFileExists ? 'eula_pending' : 'created'
|
||||
return 'ready'
|
||||
}
|
||||
102
packages/server/src/types.ts
Normal file
102
packages/server/src/types.ts
Normal file
@ -0,0 +1,102 @@
|
||||
export type ServerTypeId = 'vanilla' | 'fabric' | 'paper' | 'forge' | 'neoforge' | 'quilt'
|
||||
|
||||
/**
|
||||
* How a server jar is obtained and booted for a given server type.
|
||||
* `direct`: the downloaded jar is the server itself.
|
||||
* `installer`: an installer must run in the server directory before launch.
|
||||
* `todo`: support is planned but not implemented yet.
|
||||
*/
|
||||
export type ServerInstallMode = 'direct' | 'installer' | 'todo'
|
||||
|
||||
export interface ServerTypeDefinition {
|
||||
id: ServerTypeId
|
||||
label: string
|
||||
installMode: ServerInstallMode
|
||||
needsLoaderVersion: boolean
|
||||
/**
|
||||
* Whether the launcher can actually create and boot this server type today.
|
||||
* `installer` types flip this on as their installer run step lands; types
|
||||
* still in planning stay `false` so the UI hides them until they work.
|
||||
*/
|
||||
implemented: boolean
|
||||
}
|
||||
|
||||
export interface ServerJarDownload {
|
||||
url: string
|
||||
filename: string
|
||||
sha1?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
export interface VanillaVersionInfoDownload {
|
||||
sha1: string
|
||||
size: number
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface VanillaVersionInfo {
|
||||
downloads: { server?: VanillaVersionInfoDownload }
|
||||
}
|
||||
|
||||
export interface PaperBuildDownload {
|
||||
name: string
|
||||
url: string
|
||||
checksums?: { sha256?: string }
|
||||
size?: number
|
||||
}
|
||||
|
||||
/** A build from the PaperMC Fill v3 downloads service. */
|
||||
export interface PaperBuild {
|
||||
id: number
|
||||
channel: string
|
||||
downloads: { 'server:default'?: PaperBuildDownload }
|
||||
}
|
||||
|
||||
export interface ResolveServerJarInput {
|
||||
gameVersion: string
|
||||
loaderVersion?: string
|
||||
installerVersion?: string
|
||||
vanillaVersionInfo?: VanillaVersionInfo
|
||||
paperBuild?: PaperBuild
|
||||
}
|
||||
|
||||
export type ServerStatus = 'created' | 'eula_pending' | 'ready' | 'starting' | 'running' | 'crashed'
|
||||
|
||||
/** Persisted server manifest, stored as `axolotl-server.json` in the server directory. */
|
||||
export interface ManagedServerManifest {
|
||||
id: string
|
||||
name: string
|
||||
serverType: ServerTypeId
|
||||
gameVersion: string
|
||||
loaderVersion?: string
|
||||
createdAt: string
|
||||
javaPath?: string
|
||||
memoryMb?: number
|
||||
jvmArgs?: string[]
|
||||
lastStartedAt?: string
|
||||
}
|
||||
|
||||
export interface ManagedServer extends ManagedServerManifest {
|
||||
path: string
|
||||
status: ServerStatus
|
||||
port?: number
|
||||
eulaAccepted: boolean
|
||||
}
|
||||
|
||||
export interface ServerStatusInput {
|
||||
manifest: Pick<ManagedServerManifest, 'id'>
|
||||
isRunning: boolean
|
||||
isStarting: boolean
|
||||
lastExitWasCrash: boolean
|
||||
eulaAccepted: boolean
|
||||
eulaFileExists: boolean
|
||||
}
|
||||
|
||||
export interface ServerLaunchOptions {
|
||||
javaPath: string
|
||||
memoryMb: number
|
||||
jvmArgs?: string[]
|
||||
}
|
||||
|
||||
export const DEFAULT_SERVER_PORT = 25565
|
||||
export const DEFAULT_SERVER_MEMORY_MB = 2048
|
||||
Reference in New Issue
Block a user