Add a reference-equality check so that if HTTP revalidation fails (network error, expired token), the sync effect does not overwrite fresh data that draft-state-sync already applied. Also adds missing dependency array entries (userQueue, timers, autodraftSettings) to the revalidation sync effect. https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms (#48) * fix: sync all draft state on mobile reconnection After a mobile browser returns from a long background period, the draft room had stale picks, wrong "on the clock" display, and inaccurate available player lists. The root cause was that reconnection relied solely on an HTTP revalidation that could fail (expired JWT, flaky network), and the timer-update handler ignored currentPickNumber. Changes: - Server emits draft-state-sync on join-draft with full picks, timers, and season state, giving the client an immediate socket-based sync path that doesn't depend on HTTP revalidation - timer-update handler now syncs currentPickNumber, fixing the "on the clock" display within 1 second of reconnection - Revalidation retry with 3s delay ensures the HTTP path succeeds even when the network is slow to stabilize on mobile return - Revalidation completion now also syncs teamTimers and autodraftStatus - Added draft-state-sync client handler that applies the server snapshot immediately (skipped when revalidation is in-flight to avoid conflicts) Tests: 32 new tests covering reconnection sync, pick buffering/merge, timer-update currentPickNumber sync, draft-state-sync handling, available player filtering, on-the-clock correctness, and revalidation retry logic. https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms * fix: guard against stale revalidation overwriting fresh socket data Add a reference-equality check so that if HTTP revalidation fails (network error, expired token), the sync effect does not overwrite fresh data that draft-state-sync already applied. Also adds missing dependency array entries (userQueue, timers, autodraftSettings) to the revalidation sync effect. https://claude.ai/code/session_01JxJ1CYTiFWV4KTPxTnjrms --------- Co-authored-by: Claude <noreply@anthropic.com>
324 lines
11 KiB
TypeScript
324 lines
11 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, asc } 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";
|
|
queueOnly: boolean;
|
|
}) => 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;
|
|
"draft-state-sync": (data: {
|
|
currentPickNumber: number;
|
|
isPaused: boolean;
|
|
status: string;
|
|
picks: Array<{
|
|
id: string;
|
|
pickNumber: number;
|
|
round: number;
|
|
pickInRound: number;
|
|
timeUsed: number;
|
|
team: any;
|
|
participant: any;
|
|
sport: any;
|
|
}>;
|
|
timers: Array<{
|
|
teamId: string;
|
|
timeRemaining: number;
|
|
}>;
|
|
}) => 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}`);
|
|
}
|
|
|
|
// 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 db = getSocketDb();
|
|
const [seasonData, picks, timerRows] = 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),
|
|
}),
|
|
]);
|
|
|
|
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,
|
|
})),
|
|
});
|
|
}
|
|
} catch (err) {
|
|
console.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}`);
|
|
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;
|
|
}
|