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 { checkOvernightPause } from "./overnight-pause-check"; import { calculatePickInfo } from "~/models/draft-utils"; import { logger } from "./logger"; import { db } from "./db"; import { msToSeconds } from "~/lib/draft-timer"; // 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; "draft-room-closed": () => void; "timer-bank-updated": (data: { teamId: string; timeRemaining: number }) => void; "timer-pick-started": (data: { seasonId: string; teamId: string; pickNumber: number; expiresAt: number; // ms timestamp timeRemaining: number; // seconds remaining at emit time }) => void; "timer-overnight-paused": (data: { seasonId: string; teamId: string; resumesAtUTC?: number; }) => void; "autodraft-updated": (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean; source?: "commissioner" | "user"; reason?: "queue_empty" | "pick_complete"; }) => 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; timers?: Array<{ teamId: string; timeRemaining: number }> }) => 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; "watchlist-updated": (data: { participantIds: string[] }) => void; "brackt-evs-updated": (data: { updates: Array<{ participantId: string; expectedValue: number; vorpValue: number }>; }) => void; "draft-state-sync": (data: { currentPickNumber: number; isPaused: boolean; status: string; isOvernightPause: boolean; overnightResumesAt?: number; picks: Array<{ id: string; pickNumber: number; round: number; pickInRound: number; timeUsed: number; team: unknown; participant: unknown; sport: unknown; }>; timers: Array<{ teamId: string; timeRemaining: number; expiresAt?: number; // ms timestamp; present for the currently-active team }>; queue?: Array<{ id: string; teamId: string; seasonId: string; participantId: string; queuePosition: number; }>; watchlistParticipantIds?: string[]; }) => 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; function scheduleDraftRoomClosure(seasonId: string) { if (draftRoomClosureTimers.has(seasonId)) return; const timeout = setTimeout(() => { logger.log(`[Socket] Draft room closure: emitting draft-room-closed for ${seasonId}`); io?.to(`draft-${seasonId}`).emit("draft-room-closed"); draftRoomClosureTimers.delete(seasonId); }, ROOM_CLOSURE_DELAY_MS); draftRoomClosureTimers.set(seasonId, timeout); } export { scheduleDraftRoomClosure }; // 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 const ROOM_CLOSURE_DELAY_MS = 5 * 60 * 1000; // 5 minutes const draftRoomClosureTimers = new Map(); // seasonId -> timeout /** * 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; } try { const seasonData = await db.query.seasons.findFirst({ where: eq(schema.seasons.id, seasonId), columns: { id: true, status: true, draftCompletedAt: true }, }); if (seasonData?.draftCompletedAt) { const elapsed = Date.now() - seasonData.draftCompletedAt.getTime(); if (elapsed >= ROOM_CLOSURE_DELAY_MS) { socket.emit("draft-room-closed"); logger.log(`[Socket] join-draft rejected for ${seasonId}: room closed (completed ${Math.round(elapsed / 60000)}m ago)`); return; } if (!draftRoomClosureTimers.has(seasonId)) { const remaining = ROOM_CLOSURE_DELAY_MS - elapsed; const timeout = setTimeout(() => { logger.log(`[Socket] Draft room closure: emitting draft-room-closed for ${seasonId}`); io?.to(`draft-${seasonId}`).emit("draft-room-closed"); draftRoomClosureTimers.delete(seasonId); }, remaining); draftRoomClosureTimers.set(seasonId, timeout); } } } catch (err) { logger.error("[Socket] join-draft room-closure check failed:", err); } 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, draftSlots, queueItems, watchlistItems] = 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.seasonParticipants, sport: schema.sports, }) .from(schema.draftPicks) .innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id)) .innerJoin( schema.seasonParticipants, eq(schema.draftPicks.participantId, schema.seasonParticipants.id) ) .innerJoin( schema.sportsSeasons, eq(schema.seasonParticipants.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), }), db.query.draftSlots.findMany({ where: eq(schema.draftSlots.seasonId, seasonId), orderBy: asc(schema.draftSlots.draftOrder), }), teamId ? db.query.draftQueue.findMany({ where: and( eq(schema.draftQueue.teamId, teamId), eq(schema.draftQueue.seasonId, seasonId) ), orderBy: asc(schema.draftQueue.queuePosition), }) : Promise.resolve([]), teamId ? db.query.watchlist.findMany({ where: and( eq(schema.watchlist.teamId, teamId), eq(schema.watchlist.seasonId, seasonId) ), }) : Promise.resolve([]), ]); if (seasonData) { // Compute overnight-pause state so reconnecting clients see it immediately. let isOvernightPause = false; let overnightResumesAt: number | undefined; if ( draftSlots.length > 0 && seasonData.status === "draft" && !seasonData.draftPaused ) { const { pickInRound } = calculatePickInfo( seasonData.currentPickNumber ?? 1, draftSlots.length ); const currentSlot = draftSlots.find((s) => s.draftOrder === pickInRound); if (currentSlot) { const result = await checkOvernightPause(seasonData, currentSlot.teamId); isOvernightPause = result.active; overnightResumesAt = result.resumesAtUTC; } } socket.emit("draft-state-sync", { currentPickNumber: seasonData.currentPickNumber || 1, isPaused: seasonData.draftPaused || false, status: seasonData.status, isOvernightPause, overnightResumesAt, picks, timers: timerRows.map((t) => { const nowMs = Date.now(); const expiresAt = t.picksExpiresAt?.getTime(); const computedRemaining = expiresAt && expiresAt > nowMs ? msToSeconds(expiresAt - nowMs) : t.timeRemaining; return { teamId: t.teamId, timeRemaining: computedRemaining, expiresAt: expiresAt && expiresAt > nowMs ? expiresAt : undefined, }; }), queue: teamId ? queueItems : undefined, watchlistParticipantIds: teamId ? watchlistItems.map((w) => w.participantId) : 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); }); 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; }