Files
video-sync/server.ts
Peter Stockings cc0136d712 Initial setup
2026-02-28 20:16:53 +11:00

379 lines
11 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;
speed: number;
lastUpdate: number; // timestamp when position was last set
users: Map<string, WebSocket>;
chatHistory: ChatMessage[];
}
interface ChatMessage {
username: string;
message: string;
timestamp: number;
}
// --- Room Management ---
const rooms = new Map<string, RoomState>();
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 * room.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());
}
// --- 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 "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,
speed: 1,
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),
speed: room.speed,
},
chatHistory: room.chatHistory.slice(-50),
})
);
// Notify others
broadcastToRoom(
room,
{
type: "user_joined",
username,
users: getUserList(room),
},
ws as unknown as WebSocket
);
console.log(`[Room ${code}] ${username} joined`);
break;
}
case "sync": {
const room = rooms.get(ws.data.roomCode);
if (!room) return;
// 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();
} else if (msg.action === "speed") {
room.position = getCurrentPosition(room);
room.speed = msg.speed;
room.lastUpdate = Date.now();
}
// Broadcast to others
broadcastToRoom(
room,
{
type: "sync",
action: msg.action,
position: room.position,
playing: room.playing,
speed: room.speed,
username: ws.data.username,
timestamp: Date.now(),
},
ws as unknown as WebSocket
);
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),
speed: room.speed,
},
})
);
break;
}
}
},
close(ws) {
const { username, roomCode } = ws.data;
if (!roomCode) return;
const room = rooms.get(roomCode);
if (!room) return;
room.users.delete(username);
console.log(`[Room ${roomCode}] ${username} left`);
if (room.users.size === 0) {
rooms.delete(roomCode);
console.log(`[Room ${roomCode}] Deleted (empty)`);
} else {
broadcastToRoom(room, {
type: "user_left",
username,
users: getUserList(room),
});
}
},
},
});
console.log(`🎬 VideoSync server running on http://localhost:${server.port}`);