diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index c66c974..f96c957 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -1,92 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { readFileSync, existsSync } from "fs"; import { auth } from "@/lib/auth"; import { sendCommand } from "@/lib/rcon"; +import { readChatMessages } from "@/lib/chat-log"; export const dynamic = "force-dynamic"; -const LOG_FILE = "/home/minecraft/server/logs/latest.log"; - -type ChatMessage = { - time: string; - type: "chat" | "join" | "leave" | "death" | "server"; - player: string; - message: string; -}; - -function parseLogLine(line: string): ChatMessage | null { - // [HH:MM:SS] [Server thread/INFO] [minecraft/DedicatedServer]: message - const chatMatch = line.match( - /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*<(\w+)>\s*(.*)/ - ); - if (chatMatch) { - return { time: chatMatch[1], type: "chat", player: chatMatch[2], message: chatMatch[3] }; - } - - // Player joins - const joinMatch = line.match( - /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+joined the game/ - ); - if (joinMatch) { - return { time: joinMatch[1], type: "join", player: joinMatch[2], message: "joined the game" }; - } - - // Player leaves - const leaveMatch = line.match( - /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+left the game/ - ); - if (leaveMatch) { - return { time: leaveMatch[1], type: "leave", player: leaveMatch[2], message: "left the game" }; - } - - // Deaths - const deathMatch = line.match( - /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*(\w+)\s+(was |died|drowned|burned|fell|starved|suffocated|hit|blew|withered|tried|experienced|went|walked|froze|was prick|was stung|was impaled|was squashed|was skewered|was squished|was pummeled|discovered)(.*)/ - ); - if (deathMatch) { - return { - time: deathMatch[1], - type: "death", - player: deathMatch[2], - message: deathMatch[3] + (deathMatch[4] || ""), - }; - } - - // Server say command - const sayMatch = line.match( - /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*\[Server\]\s*(.*)/ - ); - if (sayMatch) { - return { time: sayMatch[1], type: "server", player: "Server", message: sayMatch[2] }; - } - - return null; -} - export async function GET(req: NextRequest) { const session = await auth(); if (!session) { return NextResponse.json({ error: "Unauthorized" }, { status: 403 }); } - if (!existsSync(LOG_FILE)) { - return NextResponse.json([]); - } - const maxLines = parseInt(req.nextUrl.searchParams.get("lines") || "100"); try { - const content = readFileSync(LOG_FILE, "utf8"); - const lines = content.split("\n"); - const messages: ChatMessage[] = []; - - // Parse from the end, collect up to maxLines relevant messages - for (let i = lines.length - 1; i >= 0 && messages.length < maxLines; i--) { - const msg = parseLogLine(lines[i]); - if (msg) messages.unshift(msg); - } - - return NextResponse.json(messages); + return NextResponse.json(readChatMessages(maxLines)); } catch (e) { return NextResponse.json( { error: (e as Error).message }, @@ -106,7 +34,6 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid message" }, { status: 400 }); } - // Sanitize: strip newlines/carriage returns to prevent RCON command injection const sanitized = message.replace(/[\r\n]/g, "").trim(); if (!sanitized) { return NextResponse.json({ error: "Empty message" }, { status: 400 }); diff --git a/app/api/events/route.ts b/app/api/events/route.ts new file mode 100644 index 0000000..e38577e --- /dev/null +++ b/app/api/events/route.ts @@ -0,0 +1,106 @@ +import { NextRequest } from "next/server"; +import { auth } from "@/lib/auth"; +import { probeStatus } from "@/lib/server-status"; +import { readChatMessages, logMtime } from "@/lib/chat-log"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const STATUS_INTERVAL_MS = 5000; +const LOG_POLL_MS = 1500; +const HEARTBEAT_MS = 15_000; +const MAX_LIFETIME_MS = 10 * 60 * 1000; + +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 403, + headers: { "Content-Type": "application/json" }, + }); + } + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + async start(controller) { + let closed = false; + const timers: ReturnType[] = []; + const safeSend = (data: string) => { + if (closed) return; + try { + controller.enqueue(encoder.encode(data)); + } catch { + closed = true; + } + }; + const send = (event: string, payload: unknown) => + safeSend(`event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`); + const heartbeat = () => safeSend(`: hb ${Date.now()}\n\n`); + + const cleanup = () => { + if (closed) return; + closed = true; + for (const t of timers) clearTimeout(t); + try { + controller.close(); + } catch {} + }; + req.signal.addEventListener("abort", cleanup); + + // Initial payload + try { + const status = await probeStatus(); + send("status", status); + const chat = readChatMessages(50); + send("chat", chat); + } catch {} + + let lastLogMtime = logMtime(); + + const pollStatus = async () => { + if (closed) return; + try { + const status = await probeStatus(); + send("status", status); + } catch {} + if (!closed) timers.push(setTimeout(pollStatus, STATUS_INTERVAL_MS)); + }; + + const pollLog = () => { + if (closed) return; + try { + const mt = logMtime(); + if (mt && mt !== lastLogMtime) { + lastLogMtime = mt; + const chat = readChatMessages(50); + send("chat", chat); + } + } catch {} + if (!closed) timers.push(setTimeout(pollLog, LOG_POLL_MS)); + }; + + const beat = () => { + if (closed) return; + heartbeat(); + if (!closed) timers.push(setTimeout(beat, HEARTBEAT_MS)); + }; + + timers.push(setTimeout(pollStatus, STATUS_INTERVAL_MS)); + timers.push(setTimeout(pollLog, LOG_POLL_MS)); + timers.push(setTimeout(beat, HEARTBEAT_MS)); + + // Hard cap stream lifetime so auth/session stays fresh on reconnect + timers.push(setTimeout(cleanup, MAX_LIFETIME_MS)); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/app/api/status/route.ts b/app/api/status/route.ts index 6a691fe..3f0f632 100644 --- a/app/api/status/route.ts +++ b/app/api/status/route.ts @@ -1,57 +1,9 @@ import { NextResponse } from "next/server"; -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 { probeStatus } from "@/lib/server-status"; import { memoAsync } from "@/lib/cache"; export const dynamic = "force-dynamic"; -type StatusResult = { - online: boolean; - starting?: boolean; - players: { online: number; max: number }; - version?: string; - motd?: string; -}; - -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 { - online: true, - players: { - online: match ? parseInt(match[1], 10) : 0, - max: match ? parseInt(match[2], 10) : 20, - }, - }; - }); - - try { - 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 } }; - } -} - export async function GET() { const result = await memoAsync("status", 3000, probeStatus); return NextResponse.json(result, { diff --git a/app/providers.tsx b/app/providers.tsx index cbde16b..b9b59ee 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -6,6 +6,9 @@ import { Toaster } from "sonner"; import { useEffect, useState } from "react"; import { CommandPalette } from "@/components/CommandPalette"; import { PlayerDrawer } from "@/components/PlayerDrawer"; +import { EventsBridge } from "@/components/EventsBridge"; +import { OfflineBanner } from "@/components/OfflineBanner"; +import { ServiceWorkerRegister } from "@/components/ServiceWorkerRegister"; export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -33,6 +36,9 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( + + + {children} diff --git a/components/AdminTabs.tsx b/components/AdminTabs.tsx index 476df48..6004bd2 100644 --- a/components/AdminTabs.tsx +++ b/components/AdminTabs.tsx @@ -1,14 +1,39 @@ "use client"; import { useEffect, useState } from "react"; +import dynamic from "next/dynamic"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ServerControls } from "@/components/ServerControls"; import { Analytics } from "@/components/Analytics"; -import { PlayerManager } from "@/components/PlayerManager"; -import { ChatBridge } from "@/components/ChatBridge"; -import { ModManager } from "@/components/ModManager"; -import { BackupManager } from "@/components/BackupManager"; -import { LogViewer } from "@/components/LogViewer"; +import { Skeleton } from "@/components/ui/skeleton"; + +const TabFallback = () => ( +
+ + +
+); + +const PlayerManager = dynamic( + () => import("@/components/PlayerManager").then((m) => ({ default: m.PlayerManager })), + { loading: TabFallback, ssr: false } +); +const ChatBridge = dynamic( + () => import("@/components/ChatBridge").then((m) => ({ default: m.ChatBridge })), + { loading: TabFallback, ssr: false } +); +const ModManager = dynamic( + () => import("@/components/ModManager").then((m) => ({ default: m.ModManager })), + { loading: TabFallback, ssr: false } +); +const BackupManager = dynamic( + () => import("@/components/BackupManager").then((m) => ({ default: m.BackupManager })), + { loading: TabFallback, ssr: false } +); +const LogViewer = dynamic( + () => import("@/components/LogViewer").then((m) => ({ default: m.LogViewer })), + { loading: TabFallback, ssr: false } +); const TABS = [ { value: "server", label: "Server" }, diff --git a/components/ChatBridge.tsx b/components/ChatBridge.tsx index 4656ba3..7ab92f5 100644 --- a/components/ChatBridge.tsx +++ b/components/ChatBridge.tsx @@ -29,7 +29,7 @@ export function ChatBridge() { const { data: messages = [] } = useQuery({ queryKey: ["chat"], queryFn: () => fetch("/api/chat?lines=50").then((r) => r.json()), - refetchInterval: 5000, + refetchInterval: 30_000, staleTime: 2000, }); diff --git a/components/EventsBridge.tsx b/components/EventsBridge.tsx new file mode 100644 index 0000000..998e209 --- /dev/null +++ b/components/EventsBridge.tsx @@ -0,0 +1,51 @@ +"use client"; + +import { useQueryClient } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +import { useEffect } from "react"; + +export function EventsBridge() { + const { data: session, status } = useSession(); + const queryClient = useQueryClient(); + + useEffect(() => { + if (status !== "authenticated" || !session) return; + if (typeof window === "undefined" || !("EventSource" in window)) return; + + let closed = false; + let es: EventSource | null = null; + + const connect = () => { + if (closed) return; + es = new EventSource("/api/events"); + + es.addEventListener("status", (e: MessageEvent) => { + try { + queryClient.setQueryData(["status"], JSON.parse(e.data)); + } catch {} + }); + es.addEventListener("chat", (e: MessageEvent) => { + try { + queryClient.setQueryData(["chat"], JSON.parse(e.data)); + } catch {} + }); + + es.onerror = () => { + // Browser auto-retries, but if the server closed after the 10m cap + // we want a clean reconnect rather than tight reconnect loops. + if (closed) return; + es?.close(); + es = null; + setTimeout(connect, 3000); + }; + }; + + connect(); + return () => { + closed = true; + es?.close(); + }; + }, [status, session, queryClient]); + + return null; +} diff --git a/components/OfflineBanner.tsx b/components/OfflineBanner.tsx new file mode 100644 index 0000000..dc5e7cf --- /dev/null +++ b/components/OfflineBanner.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useEffect, useState } from "react"; + +export function OfflineBanner() { + const [online, setOnline] = useState(true); + + useEffect(() => { + if (typeof navigator === "undefined") return; + setOnline(navigator.onLine); + const on = () => setOnline(true); + const off = () => setOnline(false); + window.addEventListener("online", on); + window.addEventListener("offline", off); + return () => { + window.removeEventListener("online", on); + window.removeEventListener("offline", off); + }; + }, []); + + if (online) return null; + + return ( +
+ You're offline — showing cached data. Live updates will resume automatically. +
+ ); +} diff --git a/components/ServerControls.tsx b/components/ServerControls.tsx index 8eadc4f..1eaf2d6 100644 --- a/components/ServerControls.tsx +++ b/components/ServerControls.tsx @@ -39,7 +39,7 @@ export function ServerControls() { const { data: status, isLoading } = useQuery({ queryKey: ["status"], queryFn: () => fetch("/api/status").then((r) => r.json()), - refetchInterval: 10000, + refetchInterval: 60_000, staleTime: 5000, }); diff --git a/components/ServiceWorkerRegister.tsx b/components/ServiceWorkerRegister.tsx new file mode 100644 index 0000000..36c861b --- /dev/null +++ b/components/ServiceWorkerRegister.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect } from "react"; + +export function ServiceWorkerRegister() { + useEffect(() => { + if (typeof navigator === "undefined") return; + if (!("serviceWorker" in navigator)) return; + if (process.env.NODE_ENV !== "production") return; + + const register = () => { + navigator.serviceWorker + .register("/sw.js", { scope: "/" }) + .catch(() => {}); + }; + + if (document.readyState === "complete") register(); + else window.addEventListener("load", register, { once: true }); + }, []); + + return null; +} diff --git a/components/StatusCard.tsx b/components/StatusCard.tsx index aeda672..ed74557 100644 --- a/components/StatusCard.tsx +++ b/components/StatusCard.tsx @@ -20,7 +20,7 @@ export function StatusCard() { const { data, isLoading } = useQuery({ queryKey: ["status"], queryFn: () => fetch("/api/status").then((r) => r.json()), - refetchInterval: 10000, + refetchInterval: 60_000, staleTime: 5000, }); diff --git a/lib/chat-log.ts b/lib/chat-log.ts new file mode 100644 index 0000000..4aab879 --- /dev/null +++ b/lib/chat-log.ts @@ -0,0 +1,74 @@ +import { readFileSync, existsSync, statSync } from "fs"; + +export const LOG_FILE = "/home/minecraft/server/logs/latest.log"; + +export type ChatMessage = { + time: string; + type: "chat" | "join" | "leave" | "death" | "server"; + player: string; + message: string; +}; + +export function parseLogLine(line: string): ChatMessage | null { + const chatMatch = line.match( + /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*<(\w+)>\s*(.*)/ + ); + if (chatMatch) { + return { time: chatMatch[1], type: "chat", player: chatMatch[2], message: chatMatch[3] }; + } + + const joinMatch = line.match( + /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+joined the game/ + ); + if (joinMatch) { + return { time: joinMatch[1], type: "join", player: joinMatch[2], message: "joined the game" }; + } + + const leaveMatch = line.match( + /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:PlayerList|ServerPlayer)\]:\s*(\w+)\s+left the game/ + ); + if (leaveMatch) { + return { time: leaveMatch[1], type: "leave", player: leaveMatch[2], message: "left the game" }; + } + + const deathMatch = line.match( + /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*(\w+)\s+(was |died|drowned|burned|fell|starved|suffocated|hit|blew|withered|tried|experienced|went|walked|froze|was prick|was stung|was impaled|was squashed|was skewered|was squished|was pummeled|discovered)(.*)/ + ); + if (deathMatch) { + return { + time: deathMatch[1], + type: "death", + player: deathMatch[2], + message: deathMatch[3] + (deathMatch[4] || ""), + }; + } + + const sayMatch = line.match( + /\[(\d{2}:\d{2}:\d{2})\].*\[minecraft\/(?:DedicatedServer|MinecraftServer)\]:\s*\[Server\]\s*(.*)/ + ); + if (sayMatch) { + return { time: sayMatch[1], type: "server", player: "Server", message: sayMatch[2] }; + } + + return null; +} + +export function readChatMessages(maxLines = 50): ChatMessage[] { + if (!existsSync(LOG_FILE)) return []; + const content = readFileSync(LOG_FILE, "utf8"); + const lines = content.split("\n"); + const messages: ChatMessage[] = []; + for (let i = lines.length - 1; i >= 0 && messages.length < maxLines; i--) { + const msg = parseLogLine(lines[i]); + if (msg) messages.unshift(msg); + } + return messages; +} + +export function logMtime(): number { + try { + return statSync(LOG_FILE).mtimeMs; + } catch { + return 0; + } +} diff --git a/lib/server-status.ts b/lib/server-status.ts new file mode 100644 index 0000000..d13db8c --- /dev/null +++ b/lib/server-status.ts @@ -0,0 +1,47 @@ +import { status } from "minecraft-server-util"; +import { execSync } from "child_process"; +import { MC_SERVER_IP, MC_SERVER_PORT } from "./constants"; +import { sendCommand } from "./rcon"; + +export type StatusResult = { + online: boolean; + starting?: boolean; + players: { online: number; max: number }; + version?: string; + motd?: string; +}; + +export async function probeStatus(): Promise { + 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 { + online: true, + players: { + online: match ? parseInt(match[1], 10) : 0, + max: match ? parseInt(match[2], 10) : 20, + }, + }; + }); + + try { + return await Promise.any([ping, rcon]); + } catch { + 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 } }; + } +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..4499422 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,86 @@ +// HurkiCorgi MC — minimal service worker +// Strategy: network-first for HTML/data, stale-while-revalidate for assets, +// offline fallback to the cached shell. + +const VERSION = "v1"; +const SHELL_CACHE = `shell-${VERSION}`; +const ASSET_CACHE = `assets-${VERSION}`; +const SHELL_URLS = ["/", "/icon.svg", "/manifest.json"]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(SHELL_CACHE).then((c) => c.addAll(SHELL_URLS)).catch(() => {}) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all( + keys + .filter((k) => k !== SHELL_CACHE && k !== ASSET_CACHE) + .map((k) => caches.delete(k)) + ); + await self.clients.claim(); + })() + ); +}); + +function isAsset(url) { + return ( + url.pathname.startsWith("/_next/static/") || + url.pathname.endsWith(".svg") || + url.pathname.endsWith(".woff2") || + url.pathname.endsWith(".png") || + url.pathname.endsWith(".ico") + ); +} + +self.addEventListener("fetch", (event) => { + const req = event.request; + if (req.method !== "GET") return; + + const url = new URL(req.url); + if (url.origin !== self.location.origin) return; + + // Never intercept API, auth, SSE, or anything under /api + if (url.pathname.startsWith("/api/")) return; + if (req.headers.get("accept")?.includes("text/event-stream")) return; + + // Assets: stale-while-revalidate + if (isAsset(url)) { + event.respondWith( + caches.open(ASSET_CACHE).then(async (cache) => { + const cached = await cache.match(req); + const fetchPromise = fetch(req) + .then((res) => { + if (res.ok) cache.put(req, res.clone()); + return res; + }) + .catch(() => cached || Response.error()); + return cached || fetchPromise; + }) + ); + return; + } + + // HTML / navigations: network-first with cache fallback + if (req.mode === "navigate" || req.headers.get("accept")?.includes("text/html")) { + event.respondWith( + fetch(req) + .then((res) => { + if (res.ok) { + const clone = res.clone(); + caches.open(SHELL_CACHE).then((c) => c.put(req, clone)); + } + return res; + }) + .catch(async () => { + const cached = await caches.match(req); + return cached || caches.match("/"); + }) + ); + } +});