import { readFileSync, existsSync, statSync } from "fs"; import { join, extname } from "path"; // --- Types --- interface FileInfo { name: string; size: number; duration: number; } interface RoomState { code: string; listed: boolean; fileInfo: FileInfo; playing: boolean; position: number; lastUpdate: number; // timestamp when position was last set users: Map; chatHistory: ChatMessage[]; readyCheck?: { id: string; requestedBy: string; ready: Set }; lastReactionAt?: number; fightPoll?: { id: string; expiresAt: number; votes: Map }; } interface ChatMessage { username: string; message: string; timestamp: number; } // --- Room Management --- const rooms = new Map(); const pendingDisconnects = new Map>(); // key: "roomCode:username" function generateRoomCode(): string { const chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous chars let code = ""; for (let i = 0; i < 6; i++) { code += chars[Math.floor(Math.random() * chars.length)]; } return code; } function getCurrentPosition(room: RoomState): number { if (!room.playing) return room.position; const elapsed = (Date.now() - room.lastUpdate) / 1000; return room.position + elapsed; // assume 1x speed } function broadcastToRoom(room: RoomState, message: object, excludeWs?: WebSocket) { const data = JSON.stringify(message); for (const [, ws] of room.users) { if (ws !== excludeWs && ws.readyState === 1) { ws.send(data); } } } function getUserList(room: RoomState): string[] { return Array.from(room.users.keys()); } function readyCheckState(room: RoomState) { if (!room.readyCheck) return { type: "ready_check", active: false }; return { type: "ready_check", active: true, id: room.readyCheck.id, requestedBy: room.readyCheck.requestedBy, participants: Array.from(room.users, ([username, ws]) => ({ username, ready: room.readyCheck!.ready.has(username), connected: ws.readyState === 1, })), }; } function broadcastReadyCheck(room: RoomState) { broadcastToRoom(room, readyCheckState(room)); } function fightPollState(room: RoomState) { const poll = room.fightPoll; if (!poll) return { type: "fight_poll", active: false }; return { type: "fight_poll", id: poll.id, active: Date.now() < poll.expiresAt, expiresAt: poll.expiresAt, votes: Object.fromEntries(Array.from(poll.votes).filter(([username]) => room.users.has(username))) }; } // --- MIME Types --- const mimeTypes: Record = { ".html": "text/html", ".css": "text/css", ".js": "application/javascript", ".json": "application/json", ".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".ico": "image/x-icon", ".woff2": "font/woff2", ".woff": "font/woff", }; // --- Static File Server --- const publicDir = join(import.meta.dir, "public"); function serveStatic(path: string): Response { // Default to index.html if (path === "/" || path === "") path = "/index.html"; const filePath = join(publicDir, path); // Security: prevent directory traversal if (!filePath.startsWith(publicDir)) { return new Response("Forbidden", { status: 403 }); } if (!existsSync(filePath) || statSync(filePath).isDirectory()) { return new Response("Not Found", { status: 404 }); } const ext = extname(filePath); const contentType = mimeTypes[ext] || "application/octet-stream"; const file = readFileSync(filePath); return new Response(file, { headers: { "Content-Type": contentType }, }); } // --- WebSocket Handler --- interface WSData { username: string; roomCode: string; } const server = Bun.serve({ port: Number(process.env.PORT) || 3000, fetch(req, server) { const url = new URL(req.url); if (url.pathname === "/api/rooms" && req.method === "GET") { const activeRooms = Array.from(rooms.values()).flatMap(room => { const users = Array.from(room.users).filter(([, ws]) => ws.readyState === 1) .map(([name]) => name); return room.listed && users.length ? [{ code: room.code, fileInfo: room.fileInfo, users, playing: room.playing, position: getCurrentPosition(room) }] : []; }); return Response.json({ rooms: activeRooms }, { headers: { "Cache-Control": "no-store" } }); } // WebSocket upgrade if (url.pathname === "/ws") { const upgraded = server.upgrade(req, { data: { username: "", roomCode: "" }, }); if (upgraded) return undefined; return new Response("WebSocket upgrade failed", { status: 500 }); } // Static files return serveStatic(url.pathname); }, websocket: { open(ws) { // Connection opened, wait for messages }, message(ws, raw) { let msg: any; try { msg = JSON.parse(typeof raw === "string" ? raw : new TextDecoder().decode(raw)); } catch { ws.send(JSON.stringify({ type: "error", message: "Invalid JSON" })); return; } switch (msg.type) { case "ping": { ws.send(JSON.stringify({ type: "pong", timestamp: msg.timestamp })); break; } case "create_room": { const username = (msg.username || "").trim(); if (!username) { ws.send(JSON.stringify({ type: "error", message: "Username required" })); return; } if (!msg.fileInfo || !msg.fileInfo.name || !msg.fileInfo.size) { ws.send(JSON.stringify({ type: "error", message: "File info required" })); return; } const code = generateRoomCode(); const room: RoomState = { code, listed: msg.listed !== false, fileInfo: { name: msg.fileInfo.name, size: msg.fileInfo.size, duration: msg.fileInfo.duration || 0, }, playing: false, position: 0, lastUpdate: Date.now(), users: new Map(), chatHistory: [], }; room.users.set(username, ws as unknown as WebSocket); rooms.set(code, room); ws.data.username = username; ws.data.roomCode = code; ws.send( JSON.stringify({ type: "room_created", code, fileInfo: room.fileInfo, users: getUserList(room), }) ); console.log(`[Room ${code}] Created by ${username} — file: ${room.fileInfo.name}`); break; } case "join_room": { const username = (msg.username || "").trim(); const code = (msg.code || "").trim().toUpperCase(); if (!username) { ws.send(JSON.stringify({ type: "error", message: "Username required" })); return; } const room = rooms.get(code); if (!room) { ws.send(JSON.stringify({ type: "error", message: "Room not found" })); return; } if (room.users.has(username)) { ws.send(JSON.stringify({ type: "error", message: "Username already taken in this room" })); return; } // Send room info so client can verify file before fully joining ws.data.username = username; ws.data.roomCode = code; ws.send( JSON.stringify({ type: "room_file_check", code, fileInfo: room.fileInfo, }) ); break; } case "confirm_join": { const code = ws.data.roomCode; const username = ws.data.username; const room = rooms.get(code); if (!room) { ws.send(JSON.stringify({ type: "error", message: "Room not found" })); return; } // Verify file matches if ( !msg.fileInfo || msg.fileInfo.name !== room.fileInfo.name || msg.fileInfo.size !== room.fileInfo.size ) { ws.send( JSON.stringify({ type: "error", message: "File mismatch — you need the exact same file to join", }) ); return; } room.users.set(username, ws as unknown as WebSocket); // Send current state to the new user ws.send( JSON.stringify({ type: "room_joined", code, fileInfo: room.fileInfo, users: getUserList(room), state: { playing: room.playing, position: getCurrentPosition(room), }, chatHistory: room.chatHistory.slice(-50), readyCheck: readyCheckState(room), fightPoll: fightPollState(room), }) ); // Notify others broadcastToRoom( room, { type: "user_joined", username, users: getUserList(room), }, ws as unknown as WebSocket ); console.log(`[Room ${code}] ${username} joined`); if (room.readyCheck) broadcastReadyCheck(room); break; } case "sync": { const room = rooms.get(ws.data.roomCode); if (!room) return; if (msg.action === "play" && room.readyCheck) { room.readyCheck = undefined; broadcastReadyCheck(room); } // Update authoritative state if (msg.action === "play") { room.playing = true; room.position = msg.position ?? getCurrentPosition(room); room.lastUpdate = Date.now(); } else if (msg.action === "pause") { room.position = msg.position ?? getCurrentPosition(room); room.playing = false; room.lastUpdate = Date.now(); } else if (msg.action === "seek") { room.position = msg.position; room.lastUpdate = Date.now(); } // Broadcast to others broadcastToRoom( room, { type: "sync", action: msg.action, position: room.position, playing: room.playing, username: ws.data.username, timestamp: Date.now(), req_id: msg.req_id, }, ws as unknown as WebSocket ); break; } case "ready_check": { const room = rooms.get(ws.data.roomCode); const username = ws.data.username; if (!room || room.users.get(username) !== (ws as unknown as WebSocket)) return; if (msg.action === "begin") { // Repeating /ready shows the existing check without resetting votes. if (!room.readyCheck) { room.readyCheck = { id: crypto.randomUUID(), requestedBy: username, ready: new Set() }; room.position = getCurrentPosition(room); room.playing = false; room.lastUpdate = Date.now(); broadcastToRoom(room, { type: "sync", action: "pause", position: room.position, playing: false }); } } else { const check = room.readyCheck; if (!check || msg.id !== check.id) return; if (msg.action === "vote" && typeof msg.ready === "boolean") { if (msg.ready) check.ready.add(username); else check.ready.delete(username); } else if (msg.action === "cancel" && check.requestedBy === username) { room.readyCheck = undefined; } else if (msg.action === "start" && check.requestedBy === username) { const everyoneReady = Array.from(room.users).every(([name, peer]) => peer.readyState === 1 && check.ready.has(name)); if (!everyoneReady) return; room.readyCheck = undefined; room.playing = true; room.lastUpdate = Date.now(); broadcastToRoom(room, { type: "sync", action: "play", position: room.position, playing: true }); } else return; } broadcastReadyCheck(room); break; } case "reaction": { const room = rooms.get(ws.data.roomCode); if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return; if (!["ko", "bell", "tap", "robbery"].includes(msg.kind)) { ws.send(JSON.stringify({ type: "reaction_error", message: "Unknown fight reaction." })); return; } const now = Date.now(); if (room.lastReactionAt !== undefined && now - room.lastReactionAt < 3000) { ws.send(JSON.stringify({ type: "reaction_error", message: "Fight reactions have a 3-second room cooldown." })); return; } if (msg.kind === "robbery" && room.fightPoll && now < room.fightPoll.expiresAt) { ws.send(JSON.stringify({ type: "reaction_error", message: "A robbery vote is already open." })); ws.send(JSON.stringify(fightPollState(room))); return; } room.lastReactionAt = now; broadcastToRoom(room, { type: "reaction", kind: msg.kind, username: ws.data.username, position: getCurrentPosition(room), timestamp: now }); const chatMsg = { username: ws.data.username, message: `🥊 /${msg.kind}`, timestamp: now }; room.chatHistory.push(chatMsg); if (room.chatHistory.length > 200) room.chatHistory = room.chatHistory.slice(-100); broadcastToRoom(room, { type: "chat", ...chatMsg }); if (msg.kind === "robbery") { const poll = { id: crypto.randomUUID(), expiresAt: now + 30_000, votes: new Map() }; room.fightPoll = poll; broadcastToRoom(room, fightPollState(room)); setTimeout(() => { if (rooms.get(room.code) === room && room.fightPoll?.id === poll.id) { broadcastToRoom(room, fightPollState(room)); } }, 30_000); } break; } case "fight_poll_vote": { const room = rooms.get(ws.data.roomCode); if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return; const poll = room.fightPoll; if (!poll || poll.id !== msg.id || Date.now() >= poll.expiresAt) return; if (msg.choice !== "yes" && msg.choice !== "no") return; poll.votes.set(ws.data.username, msg.choice); broadcastToRoom(room, fightPollState(room)); break; } case "chat": { const room = rooms.get(ws.data.roomCode); if (!room) return; const text = (msg.message || "").trim(); if (!text) return; const chatMsg: ChatMessage = { username: ws.data.username, message: text, timestamp: Date.now(), }; room.chatHistory.push(chatMsg); // Keep chat history bounded if (room.chatHistory.length > 200) { room.chatHistory = room.chatHistory.slice(-100); } broadcastToRoom(room, { type: "chat", username: chatMsg.username, message: chatMsg.message, timestamp: chatMsg.timestamp, }); break; } case "request_state": { const room = rooms.get(ws.data.roomCode); if (!room) return; ws.send( JSON.stringify({ type: "state", state: { playing: room.playing, position: getCurrentPosition(room), }, }) ); break; } case "rejoin_room": { const username = (msg.username || "").trim(); const code = (msg.code || "").trim().toUpperCase(); if (!username || !code) { ws.send(JSON.stringify({ type: "error", message: "Username and room code required" })); return; } const room = rooms.get(code); if (!room) { ws.send(JSON.stringify({ type: "error", message: "Room no longer exists" })); return; } // Cancel any pending disconnect timer for this user const disconnectKey = `${code}:${username}`; const pendingTimer = pendingDisconnects.get(disconnectKey); if (pendingTimer) { clearTimeout(pendingTimer); pendingDisconnects.delete(disconnectKey); console.log(`[Room ${code}] ${username} reconnected (cancelled disconnect timer)`); } // Swap in the new WebSocket ws.data.username = username; ws.data.roomCode = code; room.users.set(username, ws as unknown as WebSocket); // Send full current state to the reconnected client ws.send( JSON.stringify({ type: "room_rejoined", code, fileInfo: room.fileInfo, users: getUserList(room), state: { playing: room.playing, position: getCurrentPosition(room), }, chatHistory: room.chatHistory.slice(-50), readyCheck: readyCheckState(room), fightPoll: fightPollState(room), }) ); // Notify others that user is back broadcastToRoom( room, { type: "user_rejoined", username, users: getUserList(room), }, ws as unknown as WebSocket ); console.log(`[Room ${code}] ${username} rejoined`); if (room.readyCheck) broadcastReadyCheck(room); break; } } }, close(ws) { const { username, roomCode } = ws.data; if (!roomCode) return; const room = rooms.get(roomCode); if (!room) return; if (room.users.get(username) !== (ws as unknown as WebSocket)) return; if (room.readyCheck) { room.readyCheck.ready.delete(username); broadcastReadyCheck(room); } // Don't remove immediately — give a 90s grace period for reconnection const disconnectKey = `${roomCode}:${username}`; console.log(`[Room ${roomCode}] ${username} disconnected (waiting 90s for reconnect)`); const timer = setTimeout(() => { pendingDisconnects.delete(disconnectKey); const currentRoom = rooms.get(roomCode); if (!currentRoom) return; // Only remove if the stored WS is still the old one (not swapped by rejoin) const currentWs = currentRoom.users.get(username); if (currentWs === (ws as unknown as WebSocket)) { currentRoom.users.delete(username); if (currentRoom.fightPoll) { currentRoom.fightPoll.votes.delete(username); broadcastToRoom(currentRoom, fightPollState(currentRoom)); } if (currentRoom.readyCheck) { currentRoom.readyCheck.ready.delete(username); if (currentRoom.readyCheck.requestedBy === username) currentRoom.readyCheck = undefined; broadcastReadyCheck(currentRoom); } console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`); if (currentRoom.users.size === 0) { rooms.delete(roomCode); console.log(`[Room ${roomCode}] Deleted (empty)`); } else { broadcastToRoom(currentRoom, { type: "user_left", username, users: getUserList(currentRoom), }); } } }, 90_000); pendingDisconnects.set(disconnectKey, timer); }, }, }); console.log(`🎬 VideoSync server running on http://localhost:${server.port}`);