import { EventEmitter } from "events"; import { OpCodes, type GatewayEvents } from "./types"; export interface GatewayClientOptions { token: string; intents: number; shard?: [number, number]; gatewayUrl?: string; } export interface GatewayClientInstance { on(event: K, callback: (data: GatewayEvents[K]) => void): void; once(event: K, callback: (data: GatewayEvents[K]) => void): void; off(event: K, callback: (data: GatewayEvents[K]) => void): void; presence: { update: (payload: { status: "online" | "dnd" | "idle" | "invisible"; activities: Array<{ name: string; type: number }>; }) => void; }; guilds: (guildId: string) => { requestMembers: (payload: { query?: string; limit?: number; user_ids?: string[] }) => void; }; connect: () => void; disconnect: () => void; } export function Gateway(options: GatewayClientOptions): GatewayClientInstance { const emitter = new EventEmitter(); let ws: WebSocket | null = null; const baseGatewayUrl = options.gatewayUrl ?? "wss://gateway.discord.gg/?v=10&encoding=json"; let sequenceNum: number | null = null; let sessionId: string | null = null; let resumeGatewayUrl: string | null = null; let heartbeatTimeoutId: any = null; let heartbeatIntervalId: any = null; let connectionTimeoutId: any = null; let reconnectTimeoutId: any = null; let isHeartbeatAcked = true; let isReconnecting = false; const reconnectDelay = 5000; const send = (op: OpCodes, d: any) => { if (ws && ws.readyState === 1) { ws.send(JSON.stringify({ op, d })); } else { console.warn(`Attempted to send payload (OP ${op}) but WebSocket is not open.`); } }; const heartbeat = () => { if (!isHeartbeatAcked) { console.warn("Heartbeat ACK missed. Reconnecting..."); handleReconnect(); return; } isHeartbeatAcked = false; send(OpCodes.Heartbeat, sequenceNum); }; const startHeartbeat = (intervalMs: number) => { stopHeartbeat(); isHeartbeatAcked = true; const jitter = Math.random() * intervalMs; heartbeatTimeoutId = setTimeout(() => { heartbeat(); heartbeatIntervalId = setInterval(heartbeat, intervalMs); }, jitter); }; const stopHeartbeat = () => { if (heartbeatTimeoutId) { clearTimeout(heartbeatTimeoutId); heartbeatTimeoutId = null; } if (heartbeatIntervalId) { clearInterval(heartbeatIntervalId); heartbeatIntervalId = null; } }; const startConnectionTimeout = () => { stopConnectionTimeout(); connectionTimeoutId = setTimeout(() => { console.warn("Gateway HELLO timeout reached. Reconnecting..."); handleReconnect(); }, 15000); }; const stopConnectionTimeout = () => { if (connectionTimeoutId) { clearTimeout(connectionTimeoutId); connectionTimeoutId = null; } }; const identify = () => { const osPlatform = typeof process !== "undefined" ? process.platform : "browser"; send(OpCodes.Identify, { token: options.token, intents: options.intents, properties: { os: osPlatform, browser: "@purrkit/gateway", device: "@purrkit/gateway", }, shard: options.shard, }); }; const resume = () => { send(OpCodes.Resume, { token: options.token, session_id: sessionId, seq: sequenceNum, }); }; const connect = () => { isReconnecting = false; stopConnectionTimeout(); let url = baseGatewayUrl; if (sessionId && resumeGatewayUrl) { try { const parsedUrl = new URL(resumeGatewayUrl); parsedUrl.searchParams.set("v", "10"); parsedUrl.searchParams.set("encoding", "json"); url = parsedUrl.toString(); } catch (error) { console.error( "Failed to parse resume gateway URL, falling back to base gateway URL:", error, ); sessionId = null; sequenceNum = null; } } try { ws = new WebSocket(url); } catch (error) { console.error("Failed to construct WebSocket:", error); handleReconnect(); return; } startConnectionTimeout(); ws.onopen = () => { // wait on HELLO }; ws.onmessage = (event) => { let payload; try { const rawData = typeof event.data === "string" ? event.data : event.data.toString(); payload = JSON.parse(rawData); } catch (e) { console.error("Failed to parse gateway JSON payload:", e); return; } const { op, d, s, t } = payload; if (s !== null && s !== undefined) { sequenceNum = s; } switch (op) { case OpCodes.Dispatch: if (t === "READY") { sessionId = d.session_id; resumeGatewayUrl = d.resume_gateway_url; } emitter.emit(t, d); break; case OpCodes.Heartbeat: send(OpCodes.Heartbeat, sequenceNum); break; case OpCodes.Reconnect: handleReconnect(); break; case OpCodes.InvalidSession: { const jitterDelay = Math.floor(Math.random() * 4000) + 1000; if (d) { handleReconnect(jitterDelay); } else { sequenceNum = null; sessionId = null; resumeGatewayUrl = null; handleReconnect(jitterDelay); } break; } case OpCodes.Hello: stopConnectionTimeout(); startHeartbeat(d.heartbeat_interval); if (sessionId && resumeGatewayUrl) { resume(); } else { identify(); } break; case OpCodes.HeartbeatAck: isHeartbeatAcked = true; break; } }; ws.onclose = (event) => { stopHeartbeat(); stopConnectionTimeout(); const fatalCodes = [4004, 4010, 4011, 4012, 4013, 4014]; if (fatalCodes.includes(event.code)) { console.error( `Gateway connection closed with fatal code: ${event.code}. Reconnection aborted.`, ); emitter.emit("fatal_disconnect", event.code); return; } if (event.code === 4007 || event.code === 4009) { sequenceNum = null; sessionId = null; resumeGatewayUrl = null; } handleReconnect(); }; ws.onerror = (error) => { console.error("Gateway WebSocket error:", error); }; }; const handleReconnect = (delay = reconnectDelay) => { if (isReconnecting) return; isReconnecting = true; stopHeartbeat(); stopConnectionTimeout(); if (reconnectTimeoutId) { clearTimeout(reconnectTimeoutId); reconnectTimeoutId = null; } if (ws) { ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; try { ws.close(); } catch {} ws = null; } reconnectTimeoutId = setTimeout(() => connect(), delay); }; const disconnect = () => { stopHeartbeat(); stopConnectionTimeout(); sessionId = null; resumeGatewayUrl = null; sequenceNum = null; if (reconnectTimeoutId) { clearTimeout(reconnectTimeoutId); reconnectTimeoutId = null; } if (ws) { ws.onopen = null; ws.onmessage = null; ws.onclose = null; ws.onerror = null; ws.close(1000, "Clean close requested"); ws = null; } }; const on = ( event: K, callback: (data: GatewayEvents[K]) => void, ) => { emitter.on(event, callback); }; const once = ( event: K, callback: (data: GatewayEvents[K]) => void, ) => { emitter.once(event, callback); }; const off = ( event: K, callback: (data: GatewayEvents[K]) => void, ) => { emitter.off(event, callback); }; const presence = { update(payload: any) { send(OpCodes.PresenceUpdate, payload); }, }; const guilds = (guildId: string) => { return { requestMembers(payload: any) { send(OpCodes.RequestGuildMemberResponses, { guild_id: guildId, ...payload, }); }, }; }; return { on, once, off, presence, guilds, connect, disconnect, }; }