mc-dashboard/lib/mods.ts
hurkicorgi 6c91f7fef0 UX/UI/perf pass: admin tabs, theme toggle, log polish, mod search, JAR cache
- Admin page split into tabs (Server/Players/Chat/Mods/Backups/Logs) with
  hash + localStorage persistence; inactive tabs no longer mount.
- Log viewer: level color-coding, search, level filter chips, auto-scroll
  toggle, copy button, visible-line count.
- Installed mods list: search field + side filter (all/both/server/client)
  with live count; public ModList gets skeleton + empty states and search.
- Theme toggle with no-flash inline init, localStorage + system preference.
- Layout: full OG / Twitter metadata, title template, keywords,
  dual-theme themeColor, metadataBase.
- lib/mods.ts: per-jar mtime+size parse cache (cold 6s -> warm ~45ms on
  /api/mods for the full mod list); cache eviction on mod removal.
- ChatBridge polling eased 3s -> 5s with 2s stale window.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 04:58:25 -06:00

322 lines
9 KiB
TypeScript

import AdmZip from "adm-zip";
import { execSync } from "child_process";
import {
existsSync,
readdirSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "fs";
import { join } from "path";
import { status } from "minecraft-server-util";
import {
MODS_DIR,
CLIENT_MODS_DIR,
MOD_METADATA_FILE,
MC_SERVER_IP,
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";
export type ModMeta = {
modId: string;
displayName: string;
version: string;
filename: string;
size: string;
side: ModSide;
};
// ── Mod metadata persistence ───────────────────────────────
type ModMetadataEntry = { projectId: string; side: ModSide };
type ModMetadataMap = Record<string, ModMetadataEntry>;
function loadModMetadata(): ModMetadataMap {
try {
return JSON.parse(readFileSync(MOD_METADATA_FILE, "utf8"));
} catch {
return {};
}
}
function saveModMetadata(metadata: ModMetadataMap): void {
writeFileSync(MOD_METADATA_FILE, JSON.stringify(metadata, null, 2));
}
export function addModMetadata(
filename: string,
entry: ModMetadataEntry
): void {
const metadata = loadModMetadata();
metadata[filename] = entry;
saveModMetadata(metadata);
invalidateModsCache();
}
export function removeModMetadata(filename: string): void {
const metadata = loadModMetadata();
delete metadata[filename];
saveModMetadata(metadata);
}
// ── Read mod metadata from JAR files ────────────────────────
function extractToml(content: string, key: string): string | null {
const regex = new RegExp(`^\\s*${key}\\s*=\\s*"([^"]*)"`, "m");
const match = content.match(regex);
return match ? match[1] : null;
}
type JarParseCacheEntry = {
mtimeMs: number;
size: number;
meta: Omit<ModMeta, "side">;
};
const jarParseCache = new Map<string, JarParseCacheEntry>();
function parseJarMeta(
dir: string,
filename: string
): Omit<ModMeta, "side"> {
let modId = "unknown";
let displayName = filename
.replace(/-(\d)/, " $1")
.replace(".jar", "")
.replace(/-/g, " ");
let version = "";
try {
const zip = new AdmZip(join(dir, filename));
const toml = zip.getEntry("META-INF/mods.toml");
if (toml) {
const content = toml.getData().toString("utf8");
modId = extractToml(content, "modId") || modId;
displayName = extractToml(content, "displayName") || displayName;
version = extractToml(content, "version") || "";
}
} catch {
// Use filename-based defaults
}
return {
modId,
displayName,
version,
filename,
size: "",
};
}
function extractModMeta(
dir: string,
filename: string
): Omit<ModMeta, "side"> {
const filePath = join(dir, filename);
const stat = statSync(filePath);
const cacheKey = filePath;
const cached = jarParseCache.get(cacheKey);
if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) {
return { ...cached.meta, size: (stat.size / 1024 / 1024).toFixed(1) + " MB" };
}
const meta = parseJarMeta(dir, filename);
jarParseCache.set(cacheKey, {
mtimeMs: stat.mtimeMs,
size: stat.size,
meta,
});
return { ...meta, size: (stat.size / 1024 / 1024).toFixed(1) + " MB" };
}
export function getModDetails(): ModMeta[] {
return memo(MODS_CACHE_KEY, MODS_CACHE_TTL, computeModDetails);
}
function computeModDetails(): ModMeta[] {
const metadata = loadModMetadata();
// Server mods
const serverFiles = readdirSync(MODS_DIR).filter((f) => f.endsWith(".jar"));
const serverMods: ModMeta[] = serverFiles.map((f) => {
const meta = extractModMeta(MODS_DIR, f);
const stored = metadata[f];
const side: ModSide = stored?.side === "server" ? "server" : stored?.side || "both";
return { ...meta, side };
});
// Client mods
let clientMods: ModMeta[] = [];
if (existsSync(CLIENT_MODS_DIR)) {
const clientFiles = readdirSync(CLIENT_MODS_DIR).filter((f) =>
f.endsWith(".jar")
);
clientMods = clientFiles.map((f) => ({
...extractModMeta(CLIENT_MODS_DIR, f),
side: "client" as ModSide,
}));
}
return [...serverMods, ...clientMods];
}
// ── Remove a single mod ─────────────────────────────────────
export function removeMod(filename: string): void {
const serverPath = join(MODS_DIR, filename);
const clientPath = join(CLIENT_MODS_DIR, filename);
if (existsSync(serverPath)) {
unlinkSync(serverPath);
jarParseCache.delete(serverPath);
} else if (existsSync(clientPath)) {
unlinkSync(clientPath);
jarParseCache.delete(clientPath);
} else {
throw new Error(`Mod "${filename}" not found`);
}
removeModMetadata(filename);
invalidateModsCache();
}
export function isClientOnlyMod(filename: string): boolean {
return (
!existsSync(join(MODS_DIR, filename)) &&
existsSync(join(CLIENT_MODS_DIR, filename))
);
}
// ── Server health check ─────────────────────────────────────
export type HealthProgress = {
phase: "waiting" | "process-alive" | "port-open" | "rcon-ready" | "failed" | "process-died";
elapsed: number;
message: string;
};
export async function waitForServerAdaptive(
onProgress?: (p: HealthProgress) => void | Promise<void>,
options?: { initialDelayMs?: number; maxWaitMs?: number }
): Promise<boolean> {
const { initialDelayMs = 8000, maxWaitMs = 600000 } = options ?? {};
const start = Date.now();
const elapsed = () => Date.now() - start;
const elapsedSec = () => Math.round(elapsed() / 1000);
const report = async (phase: HealthProgress["phase"], message: string) => {
await onProgress?.({ phase, elapsed: elapsed(), message });
};
await report("waiting", "Waiting for server process...");
await sleep(initialDelayMs);
let rconFailsWhileActive = 0;
while (elapsed() < maxWaitMs) {
// Tier 1: is the process alive?
let serviceState: string;
try {
serviceState = execSync("systemctl is-active minecraft.service", {
encoding: "utf8",
}).trim();
} catch {
await report("process-died", "Server process is not running");
return false;
}
if (serviceState !== "active" && serviceState !== "activating") {
await report("process-died", `Server process state: ${serviceState}`);
return false;
}
await report("process-alive", `Server process alive (${elapsedSec()}s)`);
// Tier 2: try RCON directly — this is the definitive "server is ready" signal
// RCON only starts after the server is fully loaded ("Done!")
try {
await sendCommand("list");
await report("rcon-ready", "Server is fully online");
return true;
} catch {
rconFailsWhileActive++;
}
// Tier 3: if RCON keeps failing, try MC protocol ping as fallback
// (handles case where RCON is misconfigured but server is actually ready)
if (rconFailsWhileActive >= 6) {
try {
await status(MC_SERVER_IP, MC_SERVER_PORT, { timeout: 3000 });
await report("port-open", "Server is online (port verified, RCON unavailable)");
return true;
} catch {}
}
await sleep(3000);
}
await report("failed", `Server did not respond within ${Math.round(maxWaitMs / 1000)}s`);
return false;
}
export async function waitForServer(timeoutMs = 600000): Promise<boolean> {
return waitForServerAdaptive(undefined, { maxWaitMs: timeoutMs });
}
function sleep(ms: number): Promise<void> {
return new Promise((r) => setTimeout(r, ms));
}
// ── Modpack rebuild ─────────────────────────────────────────
export function rebuildModpack(): void {
// Zip server mods
execSync(
`cd ${MODS_DIR} && rm -f /tmp/modpack-rebuild.zip && zip -j /tmp/modpack-rebuild.zip *.jar`,
{ encoding: "utf8" }
);
// Add client mods to the same zip
if (existsSync(CLIENT_MODS_DIR)) {
try {
execSync(
`cd ${CLIENT_MODS_DIR} && zip -j /tmp/modpack-rebuild.zip *.jar 2>/dev/null || true`,
{ encoding: "utf8" }
);
} catch {}
}
// Copy to web root
execSync(`sudo cp /tmp/modpack-rebuild.zip ${MODPACK_ZIP}`);
execSync(`sudo rm -f ${MODPACK_MODS}/*.jar`);
execSync(`sudo cp ${MODS_DIR}/*.jar ${MODPACK_MODS}/`);
if (existsSync(CLIENT_MODS_DIR)) {
try {
execSync(
`sudo cp ${CLIENT_MODS_DIR}/*.jar ${MODPACK_MODS}/ 2>/dev/null || true`
);
} catch {}
}
// Generate modlist from both directories
execSync(
`(ls ${MODS_DIR}/*.jar -1 2>/dev/null; ls ${CLIENT_MODS_DIR}/*.jar -1 2>/dev/null) | xargs -I{} basename {} | sort | sudo tee /var/www/minecraft/modlist.txt > /dev/null`
);
}