582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
import { readFileSync, existsSync, statSync } from "fs";
|
|
import { join, extname } from "path";
|
|
|
|
// --- Types ---
|
|
interface FileInfo {
|
|
name: string;
|
|
size: number;
|
|
duration: number;
|
|
}
|
|
|
|
interface RoomState {
|
|
code: string;
|
|
fileInfo: FileInfo;
|
|
playing: boolean;
|
|
position: number;
|
|
lastUpdate: number; // timestamp when position was last set
|
|
users: Map<string, WebSocket>;
|
|
chatHistory: ChatMessage[];
|
|
readyCheck?: { id: string; requestedBy: string; ready: Set<string> };
|
|
lastReactionAt?: number;
|
|
fightPoll?: { id: string; expiresAt: number; votes: Map<string, "yes" | "no"> };
|
|
}
|
|
|
|
interface ChatMessage {
|
|
username: string;
|
|
message: string;
|
|
timestamp: number;
|
|
}
|
|
|
|
// --- Room Management ---
|
|
const rooms = new Map<string, RoomState>();
|
|
const pendingDisconnects = new Map<string, ReturnType<typeof setTimeout>>(); // 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<string, string> = {
|
|
".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<WSData>({
|
|
port: Number(process.env.PORT) || 3000,
|
|
|
|
fetch(req, server) {
|
|
const url = new URL(req.url);
|
|
|
|
// 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,
|
|
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<string, "yes" | "no">() };
|
|
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}`);
|