import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import { parse } from 'url'; import { randomUUID } from 'crypto'; const PORT = process.env.PORT || 3001; const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour // Room state storage: Map const rooms = new Map(); // Client tracking: Map const clients = new Map(); function getRoomState(slug) { if (!rooms.has(slug)) { rooms.set(slug, { id: randomUUID(), slug: slug, name: slug, createdAt: new Date().toISOString(), participants: {}, waypoints: [], // Array to match client expectation lastActivity: Date.now() }); } const room = rooms.get(slug); room.lastActivity = Date.now(); return room; } function cleanupStaleParticipants(room) { const now = Date.now(); const staleIds = []; for (const [id, participant] of Object.entries(room.participants)) { if (participant.lastSeen && now - participant.lastSeen > STALE_THRESHOLD_MS) { staleIds.push(id); } } for (const id of staleIds) { delete room.participants[id]; console.log(`Cleaned up stale participant: ${id}`); } return staleIds; } function broadcast(roomSlug, message, excludeWs = null) { const messageStr = JSON.stringify(message); let count = 0; for (const [ws, clientInfo] of clients.entries()) { if (clientInfo.roomSlug === roomSlug && ws !== excludeWs && ws.readyState === 1) { ws.send(messageStr); count++; } } return count; } function handleMessage(ws, data) { const clientInfo = clients.get(ws); if (!clientInfo) return; let message; try { message = JSON.parse(data); } catch (e) { console.error('Invalid JSON:', e.message); return; } const room = getRoomState(clientInfo.roomSlug); switch (message.type) { case 'join': { const participant = { ...message.participant, lastSeen: Date.now() }; room.participants[participant.id] = participant; clientInfo.participantId = participant.id; console.log(`[${clientInfo.roomSlug}] ${participant.name} joined (${participant.id})`); // Broadcast join to others broadcast(clientInfo.roomSlug, message, ws); // Send current state to the new participant ws.send(JSON.stringify({ type: 'full_state', state: room })); break; } case 'leave': { delete room.participants[message.participantId]; console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`); broadcast(clientInfo.roomSlug, message, ws); break; } case 'location': { if (room.participants[message.participantId]) { room.participants[message.participantId].location = message.location; room.participants[message.participantId].lastSeen = Date.now(); // Broadcast to all OTHER participants const count = broadcast(clientInfo.roomSlug, message, ws); console.log(`[${clientInfo.roomSlug}] Location update from ${message.participantId} -> ${count} clients`); } break; } case 'status': { if (room.participants[message.participantId]) { room.participants[message.participantId].status = message.status; room.participants[message.participantId].lastSeen = Date.now(); broadcast(clientInfo.roomSlug, message, ws); } break; } case 'waypoint_add': { room.waypoints.push(message.waypoint); console.log(`[${clientInfo.roomSlug}] Waypoint added: ${message.waypoint.id}`); broadcast(clientInfo.roomSlug, message, ws); break; } case 'waypoint_remove': { room.waypoints = room.waypoints.filter(w => w.id !== message.waypointId); console.log(`[${clientInfo.roomSlug}] Waypoint removed: ${message.waypointId}`); broadcast(clientInfo.roomSlug, message, ws); break; } case 'request_state': { cleanupStaleParticipants(room); ws.send(JSON.stringify({ type: 'full_state', state: room })); break; } default: console.log(`Unknown message type: ${message.type}`); } } function handleClose(ws) { const clientInfo = clients.get(ws); if (clientInfo) { const room = rooms.get(clientInfo.roomSlug); if (room && clientInfo.participantId) { delete room.participants[clientInfo.participantId]; broadcast(clientInfo.roomSlug, { type: 'leave', participantId: clientInfo.participantId }); console.log(`[${clientInfo.roomSlug}] Connection closed: ${clientInfo.participantId}`); } clients.delete(ws); } } // Periodic cleanup of empty rooms setInterval(() => { const now = Date.now(); for (const [slug, room] of rooms.entries()) { // Clean stale participants cleanupStaleParticipants(room); // Remove empty rooms older than 24 hours if (Object.keys(room.participants).length === 0 && now - room.lastActivity > 24 * 60 * 60 * 1000) { rooms.delete(slug); console.log(`Cleaned up empty room: ${slug}`); } } }, 5 * 60 * 1000); // Every 5 minutes // Create HTTP server for health checks const server = createServer((req, res) => { const { pathname } = parse(req.url); if (pathname === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', rooms: rooms.size, clients: clients.size, uptime: process.uptime() })); } else if (pathname === '/stats') { const roomStats = {}; for (const [slug, room] of rooms.entries()) { roomStats[slug] = { participants: Object.keys(room.participants).length, waypoints: room.waypoints.length, lastActivity: room.lastActivity }; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ rooms: roomStats, totalClients: clients.size })); } else { res.writeHead(404); res.end('Not found'); } }); // Create WebSocket server const wss = new WebSocketServer({ server }); wss.on('connection', (ws, req) => { const { pathname } = parse(req.url); // Extract room slug from path: /room/{slug} const match = pathname?.match(/^\/room\/([^/]+)$/); if (!match) { console.log(`Invalid path: ${pathname}`); ws.close(4000, 'Invalid room path'); return; } const roomSlug = decodeURIComponent(match[1]); console.log(`New connection to room: ${roomSlug}`); // Register client clients.set(ws, { roomSlug, participantId: null }); // Set up handlers ws.on('message', (data) => handleMessage(ws, data.toString())); ws.on('close', () => handleClose(ws)); ws.on('error', (err) => { console.error('WebSocket error:', err.message); handleClose(ws); }); // Send ping every 30 seconds to keep connection alive const pingInterval = setInterval(() => { if (ws.readyState === 1) { ws.ping(); } else { clearInterval(pingInterval); } }, 30000); ws.on('close', () => clearInterval(pingInterval)); }); server.listen(PORT, () => { console.log(`rmaps sync server listening on port ${PORT}`); console.log(`WebSocket: ws://localhost:${PORT}/room/{slug}`); console.log(`Health check: http://localhost:${PORT}/health`); });