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 { 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 { 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(initial); useEffect(() => { const controller = createLanyardSocket({ onData: (snapshot) => setData(snapshot) }); controller.start(); return () => controller.stop(); }, []); return data; }