brackt/server/socket.ts
Chris Parsons 64a60a75d8
fix: eliminate socket ghost connection and stale state on reconnect (#38)
- Reduce ping detection window from ~45s to ~15s (pingTimeout 5s, pingInterval 10s)
- Listen to window offline/online events for instant UI feedback on network drop
- Force immediate reconnect on online event instead of waiting for backoff
- Fix stuck-reconnecting bug when network returns within ping timeout window
- Wrap on/off/emit in useCallback to prevent listener re-registration every second
- Expose reconnectCount to trigger revalidator.revalidate() after each reconnect
- Reset hasConnectedOnce ref per socket instance to avoid spurious revalidations
- Remove stale socket ref from hook return value; add emit helper instead

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 12:24:04 -08:00

243 lines
8.4 KiB
TypeScript

import { Server as SocketIOServer, Socket } from "socket.io";
import type { Server as HTTPServer } from "http";
import { drizzle } from "drizzle-orm/postgres-js";
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import * as schema from "~/database/schema";
import { eq, and } from "drizzle-orm";
// Lazy-initialized DB for socket-level validation queries (team ownership checks)
let _socketDb: PostgresJsDatabase<typeof schema> | null = null;
function getSocketDb(): PostgresJsDatabase<typeof schema> {
if (!_socketDb) {
const url = process.env.DATABASE_URL;
if (!url) throw new Error("DATABASE_URL is required");
_socketDb = drizzle(postgres(url), { schema });
}
return _socketDb;
}
// Socket event types
interface ServerToClientEvents {
"test-message": (data: {
originalMessage: any;
serverResponse: string;
serverTimestamp: string;
socketId: string;
}) => void;
"pick-made": (data: any) => 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";
}) => 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;
}
interface ClientToServerEvents {
"join-draft": (seasonId: string, teamId?: string) => void;
"leave-draft": (seasonId: string) => void;
"test-event": (data: any) => void;
}
// Global type augmentation
declare global {
var __socketIO: SocketIOServer | undefined;
}
let io: SocketIOServer<ClientToServerEvents, ServerToClientEvents> | 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<string, Set<string>>(); // seasonId -> Set<teamId>
/**
* Initialize Socket.IO server
*/
export function initializeSocketIO(httpServer: HTTPServer): SocketIOServer {
if (io) {
console.log("Socket.IO already initialized");
return io;
}
// Create typed Socket.IO server
io = new SocketIOServer<ClientToServerEvents, ServerToClientEvents>(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<ClientToServerEvents, ServerToClientEvents>) => {
console.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) {
console.error("No seasonId provided for join-draft");
return;
}
socket.join(`draft-${seasonId}`);
currentSeasonId = seasonId;
console.log(`Socket ${socket.id} joined draft-${seasonId}`);
// If teamId provided, validate it belongs to this season before tracking
if (teamId) {
try {
const db = getSocketDb();
const team = await db.query.teams.findFirst({
where: and(eq(schema.teams.id, teamId), eq(schema.teams.seasonId, seasonId)),
});
if (!team) {
console.warn(`[Socket] join-draft rejected: team ${teamId} does not belong to season ${seasonId}`);
return;
}
} catch (err) {
console.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)!;
// 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 });
console.log(`Team ${teamId} connected to draft-${seasonId}. Total connected: ${seasonConnectedTeams.size}`);
}
});
socket.on("leave-draft", (seasonId: string) => {
if (!seasonId) return;
socket.leave(`draft-${seasonId}`);
console.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);
console.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 });
console.log(`Team ${currentTeamId} disconnected from draft-${seasonId}`);
}
});
socket.on("test-event", (data: any) => {
console.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,
});
console.log("✅ Sent test-message response to client:", socket.id);
});
socket.on("disconnect", () => {
console.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);
console.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 });
console.log(`Team ${currentTeamId} disconnected from draft-${currentSeasonId}`);
}
});
});
// Store globally for route handlers
global.__socketIO = io;
console.log("Socket.IO initialized");
// Start the draft timer system (async import but don't await)
import("./timer").then(({ startDraftTimerSystem }) => {
startDraftTimerSystem();
}).catch((error) => {
console.error("Failed to start timer system:", error);
});
// Start the daily snapshot system
import("./snapshots").then(({ startSnapshotSystem }) => {
startSnapshotSystem();
}).catch((error) => {
console.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;
}