rmaps-online/sync-server/server.js

265 lines
7.4 KiB
JavaScript

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<roomSlug, RoomState>
const rooms = new Map();
// Client tracking: Map<WebSocket, { roomSlug, participantId }>
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 && room.participants[clientInfo.participantId]) {
// Don't delete - mark as offline and preserve last location
room.participants[clientInfo.participantId].status = 'offline';
room.participants[clientInfo.participantId].lastSeen = Date.now();
broadcast(clientInfo.roomSlug, {
type: 'status',
participantId: clientInfo.participantId,
status: 'offline'
});
console.log(`[${clientInfo.roomSlug}] User went offline: ${clientInfo.participantId} (location preserved)`);
}
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`);
});