Honest baseline after removing 45 gaming suppressions. Structural: lib/game plus root hooks extraction, CORS origin echo, DEV_ORIGINS layering. Fixes: error-cause binding, discord 5xx split, null-cache guard, co-located tests, rejecting-fetch coverage, naming and typing cleanup, socket controller, cache bound, cascade helpers, latch resets. Hardening: per-IP rate limit on API routes, deterministic-miss UX, retry cooldown plus cap. Suite 140 green, build plus lint clean.
208 lines
6.0 KiB
TypeScript
208 lines
6.0 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import type { LanyardData } from "@/lib/game/lanyard";
|
|
import { DISCORD_ID, LANYARD_API, LANYARD_WS } from "@/lib/game/lanyard";
|
|
import { fetchJson } from "@/lib/client-fetch";
|
|
|
|
const WS_OPEN_TIMEOUT_MS = 10_000;
|
|
const RECONNECT_BASE_MS = 2_000;
|
|
const RECONNECT_MAX_MS = 30_000;
|
|
const SAFETY_POLL_MS = 60_000;
|
|
const HEARTBEAT_FALLBACK_MS = 30_000;
|
|
|
|
interface LanyardMessage {
|
|
op: number;
|
|
t?: string;
|
|
d?: unknown;
|
|
}
|
|
|
|
function parseLanyardMessage(data: unknown): LanyardMessage | null {
|
|
if (typeof data !== "string") {
|
|
return null;
|
|
}
|
|
try {
|
|
return JSON.parse(data) as LanyardMessage;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isLanyardData(value: unknown): value is LanyardData {
|
|
if (typeof value !== "object" || value === null) {
|
|
return false;
|
|
}
|
|
const candidate = value as { activities?: unknown; discord_status?: unknown };
|
|
if (!Array.isArray(candidate.activities)) {
|
|
return false;
|
|
}
|
|
const status = candidate.discord_status;
|
|
return status === "online" || status === "idle" || status === "dnd" || status === "offline";
|
|
}
|
|
function nextBackoffDelay(current: number): number {
|
|
return Math.min(current * 2, RECONNECT_MAX_MS);
|
|
}
|
|
|
|
async function fetchLanyardSnapshot(): Promise<LanyardData | null> {
|
|
const json = await fetchJson<{ success: boolean; data: LanyardData }>(LANYARD_API, {
|
|
cache: "no-store",
|
|
});
|
|
return json && json.success ? json.data : null;
|
|
}
|
|
|
|
interface LanyardSocketEvents {
|
|
onData: (data: LanyardData) => void;
|
|
}
|
|
|
|
interface LanyardSocket {
|
|
start: () => void;
|
|
stop: () => void;
|
|
}
|
|
|
|
function createLanyardSocket(events: LanyardSocketEvents): LanyardSocket {
|
|
let disposed = false;
|
|
let socket: WebSocket | null = null;
|
|
let heartbeatTimer: number | undefined;
|
|
let reconnectTimer: number | undefined;
|
|
let safetyTimer: number | undefined;
|
|
let reconnectDelay = RECONNECT_BASE_MS;
|
|
|
|
async function pollOnce(): Promise<void> {
|
|
const snapshot = await fetchLanyardSnapshot();
|
|
if (snapshot && !disposed) {
|
|
events.onData(snapshot);
|
|
}
|
|
}
|
|
|
|
function scheduleReconnect(): void {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
window.clearTimeout(reconnectTimer);
|
|
reconnectTimer = window.setTimeout(() => {
|
|
reconnectDelay = nextBackoffDelay(reconnectDelay);
|
|
connect();
|
|
}, reconnectDelay);
|
|
}
|
|
|
|
function dropSocket(current: WebSocket): void {
|
|
current.onclose = null;
|
|
current.onerror = null;
|
|
current.onmessage = null;
|
|
if (socket === current) {
|
|
socket = null;
|
|
}
|
|
try {
|
|
current.close();
|
|
} catch {
|
|
if (socket === current) {
|
|
socket = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function connect(): void {
|
|
if (disposed) {
|
|
return;
|
|
}
|
|
let current: WebSocket;
|
|
try {
|
|
current = new WebSocket(LANYARD_WS);
|
|
} catch {
|
|
scheduleReconnect();
|
|
return;
|
|
}
|
|
socket = current;
|
|
let settled = false;
|
|
|
|
const openTimeout = window.setTimeout(() => {
|
|
if (!settled) {
|
|
settled = true;
|
|
dropSocket(current);
|
|
if (!disposed) {
|
|
void pollOnce();
|
|
scheduleReconnect();
|
|
}
|
|
}
|
|
}, WS_OPEN_TIMEOUT_MS);
|
|
|
|
const handleDrop = (): void => {
|
|
if (settled && socket !== current) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
window.clearTimeout(openTimeout);
|
|
window.clearInterval(heartbeatTimer);
|
|
dropSocket(current);
|
|
if (!disposed) {
|
|
void pollOnce();
|
|
scheduleReconnect();
|
|
}
|
|
};
|
|
|
|
current.onmessage = (event: MessageEvent) => {
|
|
const message = parseLanyardMessage(event.data);
|
|
if (!message) {
|
|
return;
|
|
}
|
|
if (message.op === 1) {
|
|
settled = true;
|
|
window.clearTimeout(openTimeout);
|
|
reconnectDelay = RECONNECT_BASE_MS;
|
|
const interval =
|
|
(message.d as { heartbeat_interval?: number } | undefined)
|
|
?.heartbeat_interval ?? HEARTBEAT_FALLBACK_MS;
|
|
window.clearInterval(heartbeatTimer);
|
|
heartbeatTimer = window.setInterval(() => {
|
|
try {
|
|
current.send(JSON.stringify({ op: 3 }));
|
|
} catch {
|
|
handleDrop();
|
|
}
|
|
}, interval);
|
|
current.send(JSON.stringify({ op: 2, d: { subscribe_to_id: DISCORD_ID } }));
|
|
} else if (
|
|
message.op === 0 &&
|
|
(message.t === "INIT_STATE" || message.t === "PRESENCE_UPDATE")
|
|
) {
|
|
if (!disposed && isLanyardData(message.d)) {
|
|
events.onData(message.d);
|
|
}
|
|
}
|
|
};
|
|
current.onerror = handleDrop;
|
|
current.onclose = handleDrop;
|
|
}
|
|
|
|
function start(): void {
|
|
connect();
|
|
safetyTimer = window.setInterval(() => {
|
|
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
void pollOnce();
|
|
}
|
|
}, SAFETY_POLL_MS);
|
|
}
|
|
|
|
function stop(): void {
|
|
disposed = true;
|
|
window.clearTimeout(reconnectTimer);
|
|
window.clearInterval(safetyTimer);
|
|
window.clearInterval(heartbeatTimer);
|
|
if (socket) {
|
|
dropSocket(socket);
|
|
}
|
|
}
|
|
|
|
return { start, stop };
|
|
}
|
|
|
|
export function useLanyard(initial: LanyardData | null): LanyardData | null {
|
|
const [data, setData] = useState<LanyardData | null>(initial);
|
|
|
|
useEffect(() => {
|
|
const controller = createLanyardSocket({ onData: (snapshot) => setData(snapshot) });
|
|
controller.start();
|
|
return () => controller.stop();
|
|
}, []);
|
|
|
|
return data;
|
|
}
|