feat(client): polished toolbar, live online count via SSE
- Redesign in-game client toolbar with refined glass styling and buttons - Live online count + emulator status streamed over SSE multicast - Who's-online tooltip throttled to reduce repeated requests - Fix show button so it always returns the hidden toolbar - Use theme CSS variables instead of hardcoded colors - Add toolbar translations across all 25 locales
This commit is contained in:
1 parent
e1ecb190bc
commit
67b67798b9
32 files changed
+799
-102
No files matched your search
@@ -0,0 +1,98 @@
|
||||
// Server-Sent Events stream of the live online user count for the in-game
|
||||
// client toolbar. Pushes { count } every ~5s so the counter updates in
|
||||
// real-time without the client polling. Closes cleanly when the client
|
||||
// disconnects.
|
||||
//
|
||||
// The first event is sent immediately; subsequent events push the cached
|
||||
// count (10s TTL in the REST endpoint) so repeated reads share one query.
|
||||
|
||||
import { count, eq } from "drizzle-orm";
|
||||
import { cached } from "@/lib/cache";
|
||||
import { db, User } from "@/lib/db";
|
||||
import { rcon } from "@/lib/services/rcon";
|
||||
|
||||
async function fetchOnlineCount(): Promise<number> {
|
||||
try {
|
||||
return await cached("online_count", 10_000, async () => {
|
||||
const [row] = await db
|
||||
.select({ total: count() })
|
||||
.from(User)
|
||||
.where(eq(User.online, "1"));
|
||||
return row?.total ?? 0;
|
||||
});
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Emulator (RCON) reachability, cached and shared across all subscribers so an
|
||||
// N-client toolbar doesn't fire N pings every interval. Falls back to graceful
|
||||
// degradation on failure so the stream never errors out.
|
||||
async function fetchEmulatorStatus(): Promise<boolean> {
|
||||
try {
|
||||
return await cached("emulator_rcon_status", 15_000, () =>
|
||||
rcon.send("ping", null).catch(() => false),
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
async start(controller) {
|
||||
let closed = false;
|
||||
|
||||
const send = async () => {
|
||||
if (closed) return;
|
||||
const [count, emulator] = await Promise.all([
|
||||
fetchOnlineCount(),
|
||||
fetchEmulatorStatus(),
|
||||
]);
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify({ count, emulator })}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
|
||||
// Initial event immediately, then on an interval.
|
||||
await send();
|
||||
const interval = setInterval(() => void send(), 5_000);
|
||||
// SSE comment as a keep-alive ping between data events.
|
||||
const ping = setInterval(() => {
|
||||
if (!closed) {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": ping\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}
|
||||
}, 25_000);
|
||||
|
||||
const stop = () => {
|
||||
closed = true;
|
||||
clearInterval(interval);
|
||||
clearInterval(ping);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
req.signal.addEventListener("abort", stop);
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"content-type": "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-store, no-transform",
|
||||
connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user