import type { Socket } from "socket.io"; import { Server as SocketIOServer } from "socket.io"; import type { Server as HTTPServer } from "http"; import * as schema from "~/database/schema"; import { eq, and, asc } from "drizzle-orm"; import { logger } from "./logger"; import { db } from "./db"; // Socket event types interface ServerToClientEvents { "test-message": (data: { originalMessage: unknown; serverResponse: string; serverTimestamp: string; socketId: string; }) => void; "pick-made": (data: unknown) => void; "draft-started": (data: { seasonId: string; currentPickNumber: number }) => void; "draft-completed": () => void; "timer-update": (data: { seasonId: string; teamId: string; timeRemaining: number; currentPickNumber: number; }) => void; "autodraft-updated": (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean; source?: "commissioner" | "user"; }) => void; "team-connected": (data: { teamId: string }) => void; "team-disconnected": (data: { teamId: string }) => void; "connected-teams-list": (data: { teamIds: string[] }) => void; "draft-paused": (data: { seasonId: string; paused: boolean }) => void; "draft-resumed": (data: { seasonId: string; paused: boolean }) => void; "participant-removed-from-queues": (data: { participantId: string }) => void; "queue-updated": (data: { queue: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number }> }) => void; "draft-state-sync": (data: { currentPickNumber: number; isPaused: boolean; status: string; picks: Array<{ id: string; pickNumber: number; round: number; pickInRound: number; timeUsed: number; team: unknown; participant: unknown; sport: unknown; }>; timers: Array<{ teamId: string; timeRemaining: number; }>; queue?: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number; }>; }) => void; } interface ClientToServerEvents { "join-draft": (seasonId: string, teamId?: string) => void; "leave-draft": (seasonId: string) => void; "test-event": (data: unknown) => void; } // Global type augmentation declare global { var __socketIO: SocketIOServer | undefined; } let io: SocketIOServer | null = null; // Track connected teams per season (in-memory, single-instance only). // If the server restarts or runs as multiple instances this map resets. // For multi-instance deployments, replace with a Redis-backed Socket.IO adapter. const connectedTeams = new Map>(); // seasonId -> Set /** * Initialize Socket.IO server */ export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer { if (io) { logger.log("Socket.IO already initialized"); return io; } // Create typed Socket.IO server io = new SocketIOServer(httpServer, { // Faster dead-connection detection than the defaults (20s/25s). // pingTimeout: how long to wait for a pong before declaring the connection dead. // pingInterval: how often to send a ping. // Together these detect a crashed server in ~15s instead of ~45s. // Trade-off: more heartbeat traffic at scale; revisit if server load becomes a concern. pingTimeout: 5000, pingInterval: 10000, cors: process.env.NODE_ENV === "production" && process.env.APP_URL ? { origin: process.env.APP_URL, credentials: true, } : undefined, }); // Connection handling io.on("connection", (socket: Socket) => { logger.log("Client connected:", socket.id); // Store team ID for this socket let currentTeamId: string | undefined; let currentSeasonId: string | undefined; socket.on("join-draft", async (seasonId: string, teamId?: string) => { if (!seasonId) { logger.error("No seasonId provided for join-draft"); return; } socket.join(`draft-${seasonId}`); currentSeasonId = seasonId; logger.log(`Socket ${socket.id} joined draft-${seasonId}`); // If teamId provided, validate it belongs to this season before tracking if (teamId) { try { const team = await db.query.teams.findFirst({ where: and(eq(schema.teams.id, teamId), eq(schema.teams.seasonId, seasonId)), }); if (!team) { logger.warn(`[Socket] join-draft rejected: team ${teamId} does not belong to season ${seasonId}`); return; } } catch (err) { logger.error("[Socket] join-draft team validation failed:", err); return; } currentTeamId = teamId; socket.join(`team-${teamId}`); // Initialize the season's connected teams set if it doesn't exist if (!connectedTeams.has(seasonId)) { connectedTeams.set(seasonId, new Set()); } // Get current connected teams for this season const seasonConnectedTeams = connectedTeams.get(seasonId) ?? new Set(); if (!connectedTeams.has(seasonId)) connectedTeams.set(seasonId, seasonConnectedTeams); // Send the list of already-connected teams to this socket socket.emit("connected-teams-list", { teamIds: Array.from(seasonConnectedTeams) }); // Add this team to the connected teams set seasonConnectedTeams.add(teamId); // Broadcast to everyone else that this team connected io?.to(`draft-${seasonId}`).emit("team-connected", { teamId }); logger.log(`Team ${teamId} connected to draft-${seasonId}. Total connected: ${seasonConnectedTeams.size}`); } // Send full draft state to the joining client so it can sync immediately. // This is critical for mobile reconnection: the client may have been in the // background for minutes/hours and missed many picks. The revalidate() path // (HTTP loader re-fetch) covers this too, but it can fail due to expired auth // tokens or flaky mobile networks. This socket-based sync provides an // additional, more reliable path since the socket is already connected. try { const [seasonData, picks, timerRows, queueItems] = await Promise.all([ db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), }), db .select({ id: schema.draftPicks.id, pickNumber: schema.draftPicks.pickNumber, round: schema.draftPicks.round, pickInRound: schema.draftPicks.pickInRound, timeUsed: schema.draftPicks.timeUsed, team: schema.teams, participant: schema.participants, sport: schema.sports, }) .from(schema.draftPicks) .innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id)) .innerJoin( schema.participants, eq(schema.draftPicks.participantId, schema.participants.id) ) .innerJoin( schema.sportsSeasons, eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id) ) .innerJoin( schema.sports, eq(schema.sportsSeasons.sportId, schema.sports.id) ) .where(eq(schema.draftPicks.seasonId, seasonId)) .orderBy(asc(schema.draftPicks.pickNumber)), db.query.draftTimers.findMany({ where: eq(schema.draftTimers.seasonId, seasonId), }), teamId ? db.query.draftQueue.findMany({ where: and( eq(schema.draftQueue.teamId, teamId), eq(schema.draftQueue.seasonId, seasonId) ), orderBy: asc(schema.draftQueue.queuePosition), }) : Promise.resolve([]), ]); if (seasonData) { socket.emit("draft-state-sync", { currentPickNumber: seasonData.currentPickNumber || 1, isPaused: seasonData.draftPaused || false, status: seasonData.status, picks, timers: timerRows.map((t) => ({ teamId: t.teamId, timeRemaining: t.timeRemaining, })), queue: teamId ? queueItems : undefined, }); } } catch (err) { logger.error("[Socket] draft-state-sync query failed:", err); // Non-fatal — the client will fall back to HTTP revalidation } }); socket.on("leave-draft", (seasonId: string) => { if (!seasonId) return; socket.leave(`draft-${seasonId}`); logger.log(`Socket ${socket.id} left draft-${seasonId}`); // Emit disconnection event if team was tracked if (currentTeamId) { socket.leave(`team-${currentTeamId}`); // Remove team from connected teams tracking const seasonConnectedTeams = connectedTeams.get(seasonId); if (seasonConnectedTeams) { seasonConnectedTeams.delete(currentTeamId); logger.log(`Team ${currentTeamId} removed from tracking. Remaining: ${seasonConnectedTeams.size}`); // Clean up empty sets if (seasonConnectedTeams.size === 0) { connectedTeams.delete(seasonId); } } io?.to(`draft-${seasonId}`).emit("team-disconnected", { teamId: currentTeamId }); logger.log(`Team ${currentTeamId} disconnected from draft-${seasonId}`); } }); socket.on("test-event", (data: unknown) => { logger.log("📨 Received test-event from client:", socket.id, data); socket.emit("test-message", { originalMessage: data, serverResponse: "Hello from server!", serverTimestamp: new Date().toISOString(), socketId: socket.id, }); logger.log("✅ Sent test-message response to client:", socket.id); }); socket.on("disconnect", () => { logger.log("Client disconnected:", socket.id); // Emit disconnection event if team was tracked if (currentTeamId && currentSeasonId) { // Remove team from connected teams tracking const seasonConnectedTeams = connectedTeams.get(currentSeasonId); if (seasonConnectedTeams) { seasonConnectedTeams.delete(currentTeamId); logger.log(`Team ${currentTeamId} removed from tracking on disconnect. Remaining: ${seasonConnectedTeams.size}`); // Clean up empty sets if (seasonConnectedTeams.size === 0) { connectedTeams.delete(currentSeasonId); } } io?.to(`draft-${currentSeasonId}`).emit("team-disconnected", { teamId: currentTeamId }); logger.log(`Team ${currentTeamId} disconnected from draft-${currentSeasonId}`); } }); }); // Store globally for route handlers global.__socketIO = io; logger.log("Socket.IO initialized"); // Start the draft timer system (async import but don't await) import("./timer").then(({ startDraftTimerSystem }) => { startDraftTimerSystem(); }).catch((error) => { logger.error("Failed to start timer system:", error); }); // Start the daily snapshot system import("./snapshots").then(({ startSnapshotSystem }) => { startSnapshotSystem(); }).catch((error) => { logger.error("Failed to start snapshot system:", error); }); return io; } /** * Get the Socket.IO server instance */ export function getSocketIO(): SocketIOServer { const instance = io || global.__socketIO; if (!instance) { throw new Error("Socket.IO not initialized. Call initializeSocketIO first."); } return instance; }