Initial push

This commit is contained in:
2026-03-08 15:49:34 +01:00
parent 8da12bb7d1
commit 47127f276d
101 changed files with 13844 additions and 8 deletions

269
lib/minecraft/process.ts Normal file
View File

@@ -0,0 +1,269 @@
import { EventEmitter } from "node:events"
import { db } from "@/lib/db/index"
import { serverSettings } from "@/lib/db/schema"
import { rconClient } from "@/lib/minecraft/rcon"
// Maximum number of output lines kept in the ring buffer
const RING_BUFFER_SIZE = 500
export interface ServerStatus {
running: boolean
pid?: number
uptime?: number
startedAt?: Date
}
type OutputCallback = (line: string) => void
export class McProcessManager extends EventEmitter {
private process: ReturnType<typeof Bun.spawn> | null = null
private startedAt: Date | null = null
private outputBuffer: string[] = []
private outputCallbacks: Set<OutputCallback> = new Set()
private restartOnCrash = false
private isIntentionalStop = false
private stdoutReader: Promise<void> | null = null
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Start the Minecraft server process.
* Reads java command configuration from the `server_settings` table.
*/
async start(): Promise<void> {
if (this.process !== null) {
throw new Error("Server is already running")
}
const settings = await this.loadSettings()
const cmd = this.buildCommand(settings)
console.log(`[MC] Starting server: ${cmd.join(" ")}`)
this.isIntentionalStop = false
this.restartOnCrash = settings.restartOnCrash ?? false
this.process = Bun.spawn(cmd, {
cwd: settings.minecraftPath ?? process.env.MC_SERVER_PATH ?? process.cwd(),
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
})
this.startedAt = new Date()
// Pipe stdout
this.stdoutReader = this.readStream(
this.process.stdout as ReadableStream<Uint8Array> | null ?? null,
"stdout",
)
// Pipe stderr into the same output stream
void this.readStream(
this.process.stderr as ReadableStream<Uint8Array> | null ?? null,
"stderr",
)
this.emit("started", { pid: this.process.pid })
console.log(`[MC] Server started with PID ${this.process.pid}`)
// Watch for exit
void this.watchExit()
}
/**
* Stop the Minecraft server.
* @param force - if true, kills the process immediately; if false, sends the
* RCON `stop` command and waits for graceful shutdown.
*/
async stop(force = false): Promise<void> {
if (this.process === null) {
throw new Error("Server is not running")
}
this.isIntentionalStop = true
if (force) {
console.log("[MC] Force-killing server process")
this.process.kill()
} else {
console.log("[MC] Sending RCON stop command")
try {
await rconClient.sendCommand("stop")
} catch (err) {
console.warn("[MC] RCON stop failed, killing process:", err)
this.process.kill()
}
}
// Wait up to 30 s for the process to exit
await Promise.race([
this.process.exited,
new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error("Server did not stop in 30 s")), 30_000),
),
])
}
/**
* Restart the Minecraft server.
* @param force - passed through to `stop()`
*/
async restart(force = false): Promise<void> {
if (this.process !== null) {
await this.stop(force)
}
await this.start()
}
/** Returns current process status */
getStatus(): ServerStatus {
const running = this.process !== null
if (!running) return { running: false }
return {
running: true,
pid: this.process!.pid,
startedAt: this.startedAt ?? undefined,
uptime: this.startedAt
? Math.floor((Date.now() - this.startedAt.getTime()) / 1000)
: undefined,
}
}
/** Returns the last RING_BUFFER_SIZE lines of console output */
getOutput(): string[] {
return [...this.outputBuffer]
}
/**
* Register a callback that receives each new output line.
* Returns an unsubscribe function.
*/
onOutput(cb: OutputCallback): () => void {
this.outputCallbacks.add(cb)
return () => this.outputCallbacks.delete(cb)
}
/**
* Write a raw string to the server's stdin (for when RCON is unavailable).
*/
writeStdin(line: string): void {
const stdin = this.process?.stdin
if (!stdin) throw new Error("Server is not running")
// Bun.spawn stdin is a FileSink (not a WritableStream)
const fileSink = stdin as import("bun").FileSink
fileSink.write(new TextEncoder().encode(line + "\n"))
void fileSink.flush()
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private async loadSettings() {
const rows = await db.select().from(serverSettings).limit(1)
const s = rows[0]
if (!s) throw new Error("No server settings found in database")
return s
}
private buildCommand(settings: Awaited<ReturnType<typeof this.loadSettings>>): string[] {
const jarPath = settings.serverJar ?? "server.jar"
const minRam = settings.minRam ?? 1024
const maxRam = settings.maxRam ?? 4096
const extraArgs: string[] = settings.javaArgs
? settings.javaArgs.split(/\s+/).filter(Boolean)
: []
return [
"java",
`-Xms${minRam}M`,
`-Xmx${maxRam}M`,
...extraArgs,
"-jar",
jarPath,
"--nogui",
]
}
private async readStream(
stream: ReadableStream<Uint8Array> | null,
_tag: string,
): Promise<void> {
if (!stream) return
const reader = stream.getReader()
const decoder = new TextDecoder()
let partial = ""
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
const chunk = decoder.decode(value, { stream: true })
partial += chunk
const lines = partial.split("\n")
partial = lines.pop() ?? ""
for (const line of lines) {
this.pushLine(line)
}
}
// Flush remaining partial content
if (partial) this.pushLine(partial)
} catch {
// Stream closed - normal during shutdown
} finally {
reader.releaseLock()
}
}
private pushLine(line: string): void {
// Ring buffer
this.outputBuffer.push(line)
if (this.outputBuffer.length > RING_BUFFER_SIZE) {
this.outputBuffer.shift()
}
this.emit("output", line)
for (const cb of this.outputCallbacks) {
try {
cb(line)
} catch {
// Ignore callback errors
}
}
}
private async watchExit(): Promise<void> {
if (!this.process) return
const exitCode = await this.process.exited
const wasRunning = this.process !== null
this.process = null
this.startedAt = null
await rconClient.disconnect().catch(() => {})
if (wasRunning) {
this.emit("stopped", { exitCode })
console.log(`[MC] Server stopped with exit code ${exitCode}`)
if (!this.isIntentionalStop && this.restartOnCrash) {
this.emit("crash", { exitCode })
console.warn(`[MC] Server crashed (exit ${exitCode}), restarting in 5 s…`)
await new Promise((resolve) => setTimeout(resolve, 5_000))
try {
await this.start()
} catch (err) {
console.error("[MC] Auto-restart failed:", err)
}
}
}
}
}
export const mcProcessManager = new McProcessManager()

129
lib/minecraft/rcon.ts Normal file
View File

@@ -0,0 +1,129 @@
import { Rcon } from "rcon-client"
import { db } from "@/lib/db/index"
import { serverSettings } from "@/lib/db/schema"
// Shell metacharacters that must never appear in RCON commands
const SHELL_METACHAR_RE = /[;&|`$<>\\(){}\[\]!#~]/
export class RconManager {
private client: Rcon | null = null
private connecting = false
private retryCount = 0
private readonly maxRetries = 5
private retryTimer: ReturnType<typeof setTimeout> | null = null
/** True when the underlying Rcon socket is open */
isConnected(): boolean {
return this.client !== null && (this.client as unknown as { authenticated: boolean }).authenticated === true
}
/**
* Connect to the Minecraft RCON server using credentials stored in the DB.
* Resolves when the handshake succeeds, rejects after maxRetries failures.
*/
async connect(): Promise<void> {
if (this.isConnected()) return
if (this.connecting) return
this.connecting = true
try {
const settings = await db.select().from(serverSettings).limit(1)
const cfg = settings[0]
if (!cfg) throw new Error("No server settings found in database")
const rconHost = process.env.MC_RCON_HOST ?? "127.0.0.1"
const rconPort = cfg.rconPort ?? 25575
const rconPassword = cfg.rconPassword ?? process.env.MC_RCON_PASSWORD ?? ""
this.client = new Rcon({
host: rconHost,
port: rconPort,
password: rconPassword,
timeout: 5000,
})
await this.client.connect()
this.retryCount = 0
console.log(`[RCON] Connected to ${rconHost}:${rconPort}`)
} finally {
this.connecting = false
}
}
/** Cleanly close the RCON socket */
async disconnect(): Promise<void> {
if (this.retryTimer) {
clearTimeout(this.retryTimer)
this.retryTimer = null
}
if (this.client) {
try {
await this.client.end()
} catch {
// ignore errors during disconnect
}
this.client = null
}
console.log("[RCON] Disconnected")
}
/**
* Send a command to the Minecraft server via RCON.
* Rejects if the command contains shell metacharacters to prevent injection.
* Auto-reconnects if disconnected (up to maxRetries attempts with backoff).
*/
async sendCommand(command: string): Promise<string> {
this.validateCommand(command)
if (!this.isConnected()) {
await this.connectWithBackoff()
}
if (!this.client) {
throw new Error("RCON client is not connected")
}
try {
const response = await this.client.send(command)
return response
} catch (err) {
// Mark as disconnected and surface error
this.client = null
throw new Error(`RCON command failed: ${err instanceof Error ? err.message : String(err)}`)
}
}
// -------------------------------------------------------------------------
// Private helpers
// -------------------------------------------------------------------------
private validateCommand(command: string): void {
if (!command || typeof command !== "string") {
throw new Error("Command must be a non-empty string")
}
if (command.length > 1024) {
throw new Error("Command exceeds maximum length of 1024 characters")
}
if (SHELL_METACHAR_RE.test(command)) {
throw new Error("Command contains disallowed characters")
}
}
private async connectWithBackoff(): Promise<void> {
while (this.retryCount < this.maxRetries) {
const delay = Math.min(1000 * 2 ** this.retryCount, 30_000)
this.retryCount++
console.warn(`[RCON] Reconnecting (attempt ${this.retryCount}/${this.maxRetries}) in ${delay}ms…`)
await new Promise((resolve) => setTimeout(resolve, delay))
try {
await this.connect()
return
} catch (err) {
console.error(`[RCON] Reconnect attempt ${this.retryCount} failed:`, err)
}
}
throw new Error(`RCON failed to reconnect after ${this.maxRetries} attempts`)
}
}
export const rconClient = new RconManager()

104
lib/minecraft/sync.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* Syncs Minecraft server data (players, plugins) into the local database
* by parsing server logs and using RCON commands.
*/
import { db } from "@/lib/db";
import { mcPlayers, plugins } from "@/lib/db/schema";
import { rconClient } from "@/lib/minecraft/rcon";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
/** Parse the player list from the RCON "list" command response. */
export async function syncOnlinePlayers(): Promise<void> {
try {
const response = await rconClient.sendCommand("list");
// Response format: "There are X of a max of Y players online: player1, player2"
const match = response.match(/players online: (.*)$/);
if (!match) return;
const onlineNames = match[1]
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Mark all players as offline first
await db
.update(mcPlayers)
.set({ isOnline: false })
.where(eq(mcPlayers.isOnline, true));
// Mark online players
for (const name of onlineNames) {
const existing = await db
.select()
.from(mcPlayers)
.where(eq(mcPlayers.username, name))
.get();
if (existing) {
await db
.update(mcPlayers)
.set({ isOnline: true, lastSeen: Date.now() })
.where(eq(mcPlayers.username, name));
}
}
} catch {
// RCON might not be connected — ignore
}
}
/** Parse a log line and update player records accordingly. */
export function parseLogLine(
line: string,
onPlayerJoin?: (name: string) => void,
onPlayerLeave?: (name: string) => void,
): void {
// "[HH:MM:SS] [Server thread/INFO]: PlayerName joined the game"
const joinMatch = line.match(/\[.*\]: (\w+) joined the game/);
if (joinMatch) {
const name = joinMatch[1];
upsertPlayer(name, { isOnline: true, lastSeen: Date.now() });
onPlayerJoin?.(name);
return;
}
// "[HH:MM:SS] [Server thread/INFO]: PlayerName left the game"
const leaveMatch = line.match(/\[.*\]: (\w+) left the game/);
if (leaveMatch) {
const name = leaveMatch[1];
upsertPlayer(name, { isOnline: false, lastSeen: Date.now() });
onPlayerLeave?.(name);
return;
}
}
async function upsertPlayer(
username: string,
data: Partial<typeof mcPlayers.$inferInsert>,
): Promise<void> {
const existing = await db
.select()
.from(mcPlayers)
.where(eq(mcPlayers.username, username))
.get();
if (existing) {
await db
.update(mcPlayers)
.set(data as Record<string, unknown>)
.where(eq(mcPlayers.username, username));
} else {
await db.insert(mcPlayers).values({
id: nanoid(),
uuid: (data as { uuid?: string }).uuid ?? nanoid(), // placeholder until real UUID is known
username,
firstSeen: Date.now(),
lastSeen: Date.now(),
isOnline: false,
playTime: 0,
isBanned: false,
...data,
});
}
}

324
lib/minecraft/versions.ts Normal file
View File

@@ -0,0 +1,324 @@
// ---- Types ------------------------------------------------------------------
export type ServerType = "vanilla" | "paper" | "spigot" | "fabric" | "forge" | "bedrock"
export interface VersionInfo {
id: string
type?: string
releaseTime?: string
url?: string
}
// ---- In-memory cache --------------------------------------------------------
interface CacheEntry<T> {
data: T
expiresAt: number
}
const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
const cache = new Map<string, CacheEntry<unknown>>()
function cacheGet<T>(key: string): T | null {
const entry = cache.get(key)
if (!entry) return null
if (Date.now() > entry.expiresAt) {
cache.delete(key)
return null
}
return entry.data as T
}
function cacheSet<T>(key: string, data: T): void {
cache.set(key, { data, expiresAt: Date.now() + CACHE_TTL_MS })
}
// ---- Vanilla ----------------------------------------------------------------
interface MojangManifest {
latest: { release: string; snapshot: string }
versions: Array<{
id: string
type: string
url: string
releaseTime: string
sha1: string
}>
}
/** Fetch all versions from the official Mojang version manifest. */
export async function fetchVanillaVersions(): Promise<VersionInfo[]> {
const key = "vanilla:versions"
const cached = cacheGet<VersionInfo[]>(key)
if (cached) return cached
const res = await fetch(
"https://piston-meta.mojang.com/mc/game/version_manifest_v2.json",
)
if (!res.ok) {
throw new Error(`Failed to fetch Mojang manifest: ${res.status} ${res.statusText}`)
}
const manifest: MojangManifest = await res.json()
const versions: VersionInfo[] = manifest.versions.map((v) => ({
id: v.id,
type: v.type,
releaseTime: v.releaseTime,
url: v.url,
}))
cacheSet(key, versions)
return versions
}
// ---- Paper ------------------------------------------------------------------
interface PaperBuildsResponse {
project_id: string
project_name: string
version: string
builds: number[]
}
/** Fetch all Paper MC versions from the PaperMC API. */
export async function fetchPaperVersions(): Promise<VersionInfo[]> {
const key = "paper:versions"
const cached = cacheGet<VersionInfo[]>(key)
if (cached) return cached
const res = await fetch("https://api.papermc.io/v2/projects/paper")
if (!res.ok) {
throw new Error(`Failed to fetch Paper versions: ${res.status} ${res.statusText}`)
}
const data: { versions: string[] } = await res.json()
const versions: VersionInfo[] = data.versions.map((id) => ({ id }))
cacheSet(key, versions)
return versions
}
// ---- Fabric -----------------------------------------------------------------
interface FabricGameVersion {
version: string
stable: boolean
}
/** Fetch all Fabric-supported Minecraft versions. */
export async function fetchFabricVersions(): Promise<VersionInfo[]> {
const key = "fabric:versions"
const cached = cacheGet<VersionInfo[]>(key)
if (cached) return cached
const res = await fetch("https://meta.fabricmc.net/v2/versions/game")
if (!res.ok) {
throw new Error(`Failed to fetch Fabric versions: ${res.status} ${res.statusText}`)
}
const data: FabricGameVersion[] = await res.json()
const versions: VersionInfo[] = data.map((v) => ({
id: v.version,
type: v.stable ? "release" : "snapshot",
}))
cacheSet(key, versions)
return versions
}
// ---- Download URL resolution ------------------------------------------------
/**
* Resolve the direct download URL for a given server type + version.
* Throws if the type/version combination cannot be resolved.
*/
export async function getDownloadUrl(
type: ServerType,
version: string,
): Promise<string> {
validateVersion(version)
switch (type) {
case "vanilla":
return getVanillaDownloadUrl(version)
case "paper":
return getPaperDownloadUrl(version)
case "fabric":
return getFabricDownloadUrl(version)
case "spigot":
throw new Error(
"Spigot cannot be downloaded directly; use BuildTools instead.",
)
case "forge":
throw new Error(
"Forge installers must be downloaded from files.minecraftforge.net.",
)
case "bedrock":
throw new Error(
"Bedrock server downloads require manual acceptance of Microsoft's EULA.",
)
default:
throw new Error(`Unsupported server type: ${type}`)
}
}
async function getVanillaDownloadUrl(version: string): Promise<string> {
const versions = await fetchVanillaVersions()
const entry = versions.find((v) => v.id === version)
if (!entry?.url) throw new Error(`Vanilla version not found: ${version}`)
// The URL points to a version JSON; fetch it to get the server jar URL
const cacheKey = `vanilla:jar-url:${version}`
const cached = cacheGet<string>(cacheKey)
if (cached) return cached
const res = await fetch(entry.url)
if (!res.ok) {
throw new Error(`Failed to fetch version manifest for ${version}: ${res.status}`)
}
const versionData: {
downloads: { server?: { url: string } }
} = await res.json()
const url = versionData.downloads.server?.url
if (!url) throw new Error(`No server download available for vanilla ${version}`)
cacheSet(cacheKey, url)
return url
}
async function getPaperDownloadUrl(version: string): Promise<string> {
const cacheKey = `paper:jar-url:${version}`
const cached = cacheGet<string>(cacheKey)
if (cached) return cached
// Get the latest build number for this version
const buildsRes = await fetch(
`https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}`,
)
if (!buildsRes.ok) {
throw new Error(`Paper version ${version} not found: ${buildsRes.status}`)
}
const buildsData: PaperBuildsResponse = await buildsRes.json()
const latestBuild = buildsData.builds.at(-1)
if (latestBuild === undefined) {
throw new Error(`No builds found for Paper ${version}`)
}
const url = `https://api.papermc.io/v2/projects/paper/versions/${encodeURIComponent(version)}/builds/${latestBuild}/downloads/paper-${version}-${latestBuild}.jar`
cacheSet(cacheKey, url)
return url
}
async function getFabricDownloadUrl(version: string): Promise<string> {
const cacheKey = `fabric:jar-url:${version}`
const cached = cacheGet<string>(cacheKey)
if (cached) return cached
// Get latest loader and installer versions
const [loadersRes, installersRes] = await Promise.all([
fetch("https://meta.fabricmc.net/v2/versions/loader"),
fetch("https://meta.fabricmc.net/v2/versions/installer"),
])
if (!loadersRes.ok || !installersRes.ok) {
throw new Error("Failed to fetch Fabric loader/installer versions")
}
const loaders: Array<{ version: string; stable: boolean }> = await loadersRes.json()
const installers: Array<{ version: string; stable: boolean }> = await installersRes.json()
const latestLoader = loaders.find((l) => l.stable)
const latestInstaller = installers.find((i) => i.stable)
if (!latestLoader || !latestInstaller) {
throw new Error("Could not determine latest stable Fabric loader/installer")
}
const url = `https://meta.fabricmc.net/v2/versions/loader/${encodeURIComponent(version)}/${encodeURIComponent(latestLoader.version)}/${encodeURIComponent(latestInstaller.version)}/server/jar`
cacheSet(cacheKey, url)
return url
}
// ---- Download ---------------------------------------------------------------
/**
* Download a server jar to `destPath`.
* @param onProgress - optional callback receiving bytes downloaded and total bytes
*/
export async function downloadServer(
type: ServerType,
version: string,
destPath: string,
onProgress?: (downloaded: number, total: number) => void,
): Promise<void> {
validateDestPath(destPath)
const url = await getDownloadUrl(type, version)
const res = await fetch(url)
if (!res.ok) {
throw new Error(`Failed to download server jar: ${res.status} ${res.statusText}`)
}
const contentLength = Number(res.headers.get("content-length") ?? "0")
const body = res.body
if (!body) throw new Error("Response body is empty")
const file = Bun.file(destPath)
const writer = file.writer()
let downloaded = 0
const reader = body.getReader()
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
writer.write(value)
downloaded += value.byteLength
if (onProgress && contentLength > 0) {
onProgress(downloaded, contentLength)
}
}
await writer.end()
} catch (err) {
writer.end()
throw err
} finally {
reader.releaseLock()
}
console.log(
`[Versions] Downloaded ${type} ${version}${destPath} (${downloaded} bytes)`,
)
}
// ---- Input validation -------------------------------------------------------
/** Minecraft version strings are like "1.21.4", "24w44a" - allow alphanumeric + dots + dashes */
function validateVersion(version: string): void {
if (!version || typeof version !== "string") {
throw new Error("Version must be a non-empty string")
}
if (!/^[\w.\-+]{1,64}$/.test(version)) {
throw new Error(`Invalid version string: ${version}`)
}
}
/** Prevent path traversal in destination paths */
function validateDestPath(destPath: string): void {
if (!destPath || typeof destPath !== "string") {
throw new Error("Destination path must be a non-empty string")
}
if (destPath.includes("..")) {
throw new Error("Destination path must not contain '..'")
}
if (!destPath.endsWith(".jar")) {
throw new Error("Destination path must end with .jar")
}
}