From b6cf8c7cdcd23663aa4188b3c32955406d9a0910 Mon Sep 17 00:00:00 2001 From: hurkicorgi Date: Mon, 13 Apr 2026 00:59:10 -0600 Subject: [PATCH] Performance: RCON pooling, route caching, parallel status probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - lib/cache.ts: tiny TTL memo for hot paths. - lib/rcon.ts: pooled connection with 30s idle close and one-shot retry; was opening a fresh TCP per RCON call. - /api/status: race protocol-ping + RCON with Promise.any (was sequential with 5s timeouts) and memo 3s; adds Cache-Control. - /api/players: memo 10s, invalidated on mutations. - /api/mods: getModDetails memo 10s (invalidated on install/remove) — eliminates per-request readdirSync/statSync/AdmZip parse. - /api/analytics: reverse-scan JSONL to window cutoff (O(window) vs O(file)) and memo 30s. - /api/logs: memo 2s to throttle journalctl spawns during Live mode. Client: - StatusCard + ServerControls: staleTime 5s, both poll every 10s, dedup via shared query key. - Analytics: staleTime 30s; memoize sparkline SVG paths. - Modrinth search icons + mc-heads avatars: width/height + loading=lazy + decoding=async. - next.config.ts: images.remotePatterns for future next/image migration, explicit compress=true. Co-Authored-By: Claude Opus 4.6 (1M context) --- app/api/analytics/route.ts | 41 ++++++++++++-------- app/api/logs/route.ts | 9 +++-- app/api/mods/route.ts | 4 +- app/api/players/route.ts | 39 ++++++++++--------- app/api/status/route.ts | 70 ++++++++++++++++++++--------------- components/Analytics.tsx | 24 ++++++------ components/ModManager.tsx | 6 ++- components/PlayerAvatar.tsx | 2 + components/ServerControls.tsx | 1 + components/StatusCard.tsx | 3 +- lib/cache.ts | 29 +++++++++++++++ lib/mods.ts | 14 +++++++ lib/rcon.ts | 53 ++++++++++++++++++++++++-- next.config.ts | 7 ++++ 14 files changed, 220 insertions(+), 82 deletions(-) create mode 100644 lib/cache.ts diff --git a/app/api/analytics/route.ts b/app/api/analytics/route.ts index 405f742..525fb61 100644 --- a/app/api/analytics/route.ts +++ b/app/api/analytics/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { readFileSync, existsSync } from "fs"; import { auth } from "@/lib/auth"; +import { memo } from "@/lib/cache"; export const dynamic = "force-dynamic"; @@ -16,6 +17,27 @@ type MetricEntry = { players: string[]; }; +function readWindow(hours: number): MetricEntry[] { + const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); + const raw = readFileSync(ANALYTICS_FILE, "utf8"); + + // Reverse scan: walk from the end, collect until we pass cutoff. + const out: MetricEntry[] = []; + let end = raw.length; + while (end > 0) { + const start = raw.lastIndexOf("\n", end - 1); + const line = raw.slice(start + 1, end).trim(); + end = start; + if (!line) continue; + try { + const entry = JSON.parse(line) as MetricEntry; + if (entry.ts < cutoff) break; // file is append-ordered by time + out.push(entry); + } catch {} + } + return out.reverse(); +} + export async function GET(req: NextRequest) { const session = await auth(); if (!session) { @@ -32,21 +54,10 @@ export async function GET(req: NextRequest) { } try { - const lines = readFileSync(ANALYTICS_FILE, "utf8") - .split("\n") - .filter(Boolean); - - const cutoff = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); - - const entries: MetricEntry[] = []; - for (const line of lines) { - try { - const entry = JSON.parse(line) as MetricEntry; - if (entry.ts >= cutoff) entries.push(entry); - } catch {} - } - - return NextResponse.json(entries); + const entries = memo(`analytics:${hours}`, 30_000, () => readWindow(hours)); + return NextResponse.json(entries, { + headers: { "Cache-Control": "private, max-age=30" }, + }); } catch (e) { return NextResponse.json( { error: (e as Error).message }, diff --git a/app/api/logs/route.ts b/app/api/logs/route.ts index 3545bb5..5245a1f 100644 --- a/app/api/logs/route.ts +++ b/app/api/logs/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { execSync } from "child_process"; import { auth } from "@/lib/auth"; +import { memo } from "@/lib/cache"; export const dynamic = "force-dynamic"; @@ -16,9 +17,11 @@ export async function GET(req: NextRequest) { ); try { - const logs = execSync( - `sudo journalctl -u minecraft.service --no-pager -n ${lines}`, - { encoding: "utf8", timeout: 5000 } + const logs = memo(`logs:${lines}`, 2000, () => + execSync(`sudo journalctl -u minecraft.service --no-pager -n ${lines}`, { + encoding: "utf8", + timeout: 5000, + }) ); return NextResponse.json({ logs }); } catch (e) { diff --git a/app/api/mods/route.ts b/app/api/mods/route.ts index dbe4455..9e41981 100644 --- a/app/api/mods/route.ts +++ b/app/api/mods/route.ts @@ -6,7 +6,9 @@ export const dynamic = "force-dynamic"; export async function GET() { try { const mods = getModDetails(); - return NextResponse.json(mods); + return NextResponse.json(mods, { + headers: { "Cache-Control": "public, max-age=10" }, + }); } catch (e) { return NextResponse.json( { error: (e as Error).message }, diff --git a/app/api/players/route.ts b/app/api/players/route.ts index e701053..e01cba1 100644 --- a/app/api/players/route.ts +++ b/app/api/players/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { readFileSync } from "fs"; import { auth } from "@/lib/auth"; import { sendCommand } from "@/lib/rcon"; +import { memo, invalidate } from "@/lib/cache"; export const dynamic = "force-dynamic"; @@ -28,24 +29,27 @@ export async function GET() { return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); } - const ops = readJson(OPS_FILE).map((e) => ({ - name: e.name, - uuid: e.uuid, - level: e.level, - })); + const data = memo("players", 10_000, () => { + const ops = readJson(OPS_FILE).map((e) => ({ + name: e.name, + uuid: e.uuid, + level: e.level, + })); + const whitelist = readJson(WHITELIST_FILE).map((e) => ({ + name: e.name, + uuid: e.uuid, + })); + const banned = readJson(BANNED_FILE).map((e) => ({ + name: e.name, + uuid: e.uuid, + reason: e.reason, + })); + return { ops, whitelist, banned }; + }); - const whitelist = readJson(WHITELIST_FILE).map((e) => ({ - name: e.name, - uuid: e.uuid, - })); - - const banned = readJson(BANNED_FILE).map((e) => ({ - name: e.name, - uuid: e.uuid, - reason: e.reason, - })); - - return NextResponse.json({ ops, whitelist, banned }); + return NextResponse.json(data, { + headers: { "Cache-Control": "private, max-age=5" }, + }); } // POST — execute a player management command @@ -87,6 +91,7 @@ export async function POST(req: NextRequest) { // Wait for server to write JSON files to disk await new Promise((r) => setTimeout(r, 500)); + invalidate("players"); return NextResponse.json({ ok: true, response }); } catch (e) { diff --git a/app/api/status/route.ts b/app/api/status/route.ts index 8a82f6d..6a691fe 100644 --- a/app/api/status/route.ts +++ b/app/api/status/route.ts @@ -3,48 +3,58 @@ import { status } from "minecraft-server-util"; import { execSync } from "child_process"; import { MC_SERVER_IP, MC_SERVER_PORT } from "@/lib/constants"; import { sendCommand } from "@/lib/rcon"; +import { memoAsync } from "@/lib/cache"; export const dynamic = "force-dynamic"; -export async function GET() { - // Tier 1: MC protocol ping (fastest, gives player/version info) - try { - const result = await status(MC_SERVER_IP, MC_SERVER_PORT, { - timeout: 5000, - }); - return NextResponse.json({ - online: true, - players: { online: result.players.online, max: result.players.max }, - version: result.version.name, - motd: result.motd.clean, - }); - } catch {} +type StatusResult = { + online: boolean; + starting?: boolean; + players: { online: number; max: number }; + version?: string; + motd?: string; +}; - // Tier 2: RCON (server is online but protocol ping failed) - try { - const response = await sendCommand("list"); +async function probeStatus(): Promise { + // Race protocol ping + RCON in parallel — whichever wins first signals "online" + const ping = status(MC_SERVER_IP, MC_SERVER_PORT, { timeout: 3000 }).then( + (r): StatusResult => ({ + online: true, + players: { online: r.players.online, max: r.players.max }, + version: r.version.name, + motd: r.motd.clean, + }) + ); + + const rcon = sendCommand("list").then((response): StatusResult => { const match = response.match(/There are (\d+) of a max of (\d+) players/); - return NextResponse.json({ + return { online: true, players: { online: match ? parseInt(match[1], 10) : 0, max: match ? parseInt(match[2], 10) : 20, }, - }); - } catch {} + }; + }); - // Tier 3: systemctl (process alive but not yet accepting connections) - let starting = false; try { - const out = execSync("systemctl is-active minecraft.service", { - encoding: "utf8", - }).trim(); - starting = out === "active" || out === "activating"; - } catch {} + return await Promise.any([ping, rcon]); + } catch { + // Both failed — check if process is up + let starting = false; + try { + const out = execSync("systemctl is-active minecraft.service", { + encoding: "utf8", + }).trim(); + starting = out === "active" || out === "activating"; + } catch {} + return { online: false, starting, players: { online: 0, max: 0 } }; + } +} - return NextResponse.json({ - online: false, - starting, - players: { online: 0, max: 0 }, +export async function GET() { + const result = await memoAsync("status", 3000, probeStatus); + return NextResponse.json(result, { + headers: { "Cache-Control": "public, max-age=3" }, }); } diff --git a/components/Analytics.tsx b/components/Analytics.tsx index 73f3d67..73d1941 100644 --- a/components/Analytics.tsx +++ b/components/Analytics.tsx @@ -1,7 +1,7 @@ "use client"; import { useQuery } from "@tanstack/react-query"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { Card, @@ -55,16 +55,17 @@ function Sparkline({ ); } - const dataMax = max || Math.max(...data, 1); - const w = 300; - const points = data.map((v, i) => { - const x = (i / (data.length - 1)) * w; - const y = height - (v / dataMax) * (height - 10) - 5; - return `${x},${y}`; - }); - - const pathD = `M${points.join(" L")}`; - const areaD = `${pathD} L${w},${height} L0,${height} Z`; + const { pathD, areaD, w } = useMemo(() => { + const dataMax = max || Math.max(...data, 1); + const width = 300; + const pts = data.map((v, i) => { + const x = (i / (data.length - 1)) * width; + const y = height - (v / dataMax) * (height - 10) - 5; + return `${x},${y}`; + }); + const p = `M${pts.join(" L")}`; + return { pathD: p, areaD: `${p} L${width},${height} L0,${height} Z`, w: width }; + }, [data, max, height]); return (
@@ -99,6 +100,7 @@ export function Analytics() { queryFn: () => fetch(`/api/analytics?hours=${hours}`).then((r) => r.json()), refetchInterval: 60_000, + staleTime: 30_000, }); const latest = metrics.length > 0 ? metrics[metrics.length - 1] : null; diff --git a/components/ModManager.tsx b/components/ModManager.tsx index e934e66..6f2aae4 100644 --- a/components/ModManager.tsx +++ b/components/ModManager.tsx @@ -489,7 +489,11 @@ export function ModManager() { ) : (
diff --git a/components/PlayerAvatar.tsx b/components/PlayerAvatar.tsx index 675d4ad..e648140 100644 --- a/components/PlayerAvatar.tsx +++ b/components/PlayerAvatar.tsx @@ -33,6 +33,8 @@ export function PlayerAvatar({ alt="" width={size} height={size} + loading="lazy" + decoding="async" onError={() => setFailed(true)} className={`rounded shrink-0 bg-muted ${className}`} /> diff --git a/components/ServerControls.tsx b/components/ServerControls.tsx index ceba2a7..ffc29be 100644 --- a/components/ServerControls.tsx +++ b/components/ServerControls.tsx @@ -39,6 +39,7 @@ export function ServerControls() { queryKey: ["status"], queryFn: () => fetch("/api/status").then((r) => r.json()), refetchInterval: 10000, + staleTime: 5000, }); const action = useMutation({ diff --git a/components/StatusCard.tsx b/components/StatusCard.tsx index 58c0c28..aeda672 100644 --- a/components/StatusCard.tsx +++ b/components/StatusCard.tsx @@ -20,7 +20,8 @@ export function StatusCard() { const { data, isLoading } = useQuery({ queryKey: ["status"], queryFn: () => fetch("/api/status").then((r) => r.json()), - refetchInterval: 15000, + refetchInterval: 10000, + staleTime: 5000, }); const copyIP = () => { diff --git a/lib/cache.ts b/lib/cache.ts new file mode 100644 index 0000000..8e46b12 --- /dev/null +++ b/lib/cache.ts @@ -0,0 +1,29 @@ +type Entry = { value: T; expires: number }; + +const store = new Map>(); + +export function memo(key: string, ttlMs: number, fn: () => T): T { + const now = Date.now(); + const hit = store.get(key) as Entry | undefined; + if (hit && hit.expires > now) return hit.value; + const value = fn(); + store.set(key, { value, expires: now + ttlMs }); + return value; +} + +export async function memoAsync( + key: string, + ttlMs: number, + fn: () => Promise +): Promise { + const now = Date.now(); + const hit = store.get(key) as Entry | undefined; + if (hit && hit.expires > now) return hit.value; + const value = await fn(); + store.set(key, { value, expires: now + ttlMs }); + return value; +} + +export function invalidate(prefix: string): void { + for (const k of store.keys()) if (k.startsWith(prefix)) store.delete(k); +} diff --git a/lib/mods.ts b/lib/mods.ts index c8b35ca..e638602 100644 --- a/lib/mods.ts +++ b/lib/mods.ts @@ -18,8 +18,16 @@ import { MC_SERVER_PORT, } from "./constants"; import { sendCommand } from "./rcon"; +import { memo, invalidate } from "./cache"; import type { ModSide } from "./modrinth"; +const MODS_CACHE_KEY = "mods:details"; +const MODS_CACHE_TTL = 10_000; + +export function invalidateModsCache(): void { + invalidate("mods:"); +} + const MODPACK_ZIP = "/var/www/minecraft/modpack.zip"; const MODPACK_MODS = "/var/www/minecraft/mods"; @@ -56,6 +64,7 @@ export function addModMetadata( const metadata = loadModMetadata(); metadata[filename] = entry; saveModMetadata(metadata); + invalidateModsCache(); } export function removeModMetadata(filename: string): void { @@ -108,6 +117,10 @@ function extractModMeta( } export function getModDetails(): ModMeta[] { + return memo(MODS_CACHE_KEY, MODS_CACHE_TTL, computeModDetails); +} + +function computeModDetails(): ModMeta[] { const metadata = loadModMetadata(); // Server mods @@ -149,6 +162,7 @@ export function removeMod(filename: string): void { } removeModMetadata(filename); + invalidateModsCache(); } export function isClientOnlyMod(filename: string): boolean { diff --git a/lib/rcon.ts b/lib/rcon.ts index cd1d5f6..76a71a3 100644 --- a/lib/rcon.ts +++ b/lib/rcon.ts @@ -1,18 +1,65 @@ import { Rcon } from "rcon-client"; import { MC_SERVER_IP, RCON_PORT, RCON_PASSWORD } from "./constants"; -export async function sendCommand(command: string): Promise { +let pooled: Rcon | null = null; +let connecting: Promise | null = null; +let idleTimer: NodeJS.Timeout | null = null; +const IDLE_MS = 30_000; + +async function open(): Promise { const rcon = await Rcon.connect({ host: MC_SERVER_IP, port: RCON_PORT, password: RCON_PASSWORD, timeout: 5000, }); + rcon.on("end", () => { + if (pooled === rcon) pooled = null; + }); + rcon.on("error", () => { + if (pooled === rcon) pooled = null; + }); + return rcon; +} +function scheduleIdleClose() { + if (idleTimer) clearTimeout(idleTimer); + idleTimer = setTimeout(() => { + const r = pooled; + pooled = null; + idleTimer = null; + r?.end().catch(() => {}); + }, IDLE_MS); +} + +async function getConnection(): Promise { + if (pooled) return pooled; + if (connecting) return connecting; + connecting = open() + .then((r) => { + pooled = r; + return r; + }) + .finally(() => { + connecting = null; + }); + return connecting; +} + +export async function sendCommand(command: string): Promise { try { + const rcon = await getConnection(); const response = await rcon.send(command); + scheduleIdleClose(); + return response; + } catch (e) { + // Drop the pooled connection on any error, retry once with fresh + const bad = pooled; + pooled = null; + bad?.end().catch(() => {}); + const rcon = await getConnection(); + const response = await rcon.send(command); + scheduleIdleClose(); return response; - } finally { - rcon.end(); } } diff --git a/next.config.ts b/next.config.ts index 1638b4d..7d4f0c7 100644 --- a/next.config.ts +++ b/next.config.ts @@ -2,6 +2,13 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { serverExternalPackages: ["adm-zip"], + compress: true, + images: { + remotePatterns: [ + { protocol: "https", hostname: "mc-heads.net" }, + { protocol: "https", hostname: "cdn.modrinth.com" }, + ], + }, experimental: { serverActions: { bodySizeLimit: "100mb",