import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import { parse } from 'url'; import { randomUUID } from 'crypto'; import webpush from 'web-push'; const PORT = process.env.PORT || 3001; const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour const LOCATION_REQUEST_INTERVAL_MS = parseInt(process.env.LOCATION_REQUEST_INTERVAL || '60000', 10); // 60 seconds default // VAPID keys for push notifications // Generate with: npx web-push generate-vapid-keys const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || ''; const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || ''; const VAPID_SUBJECT = process.env.VAPID_SUBJECT || 'mailto:push@rmaps.online'; // Configure web-push if keys are available if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); console.log('Push notifications enabled'); } else { console.log('Push notifications disabled (VAPID keys not configured)'); } // Room state storage: Map const rooms = new Map(); // Client tracking: Map const clients = new Map(); // Push subscriptions: Map> const pushSubscriptions = new Map(); function getRoomState(slug) { if (!rooms.has(slug)) { rooms.set(slug, { id: randomUUID(), slug: slug, name: slug, createdAt: new Date().toISOString(), participants: {}, waypoints: [], 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; } // Send push notification to all subscriptions for a room async function sendPushToRoom(roomSlug, notification, excludeEndpoint = null) { if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return; const subs = pushSubscriptions.get(roomSlug); if (!subs || subs.size === 0) return; const payload = JSON.stringify(notification); const failedEndpoints = []; for (const sub of subs) { if (excludeEndpoint && sub.endpoint === excludeEndpoint) continue; try { await webpush.sendNotification(sub, payload); console.log(`[${roomSlug}] Push sent to ${sub.endpoint.slice(-20)}`); } catch (error) { console.error(`[${roomSlug}] Push failed:`, error.statusCode || error.message); // Remove invalid subscriptions if (error.statusCode === 404 || error.statusCode === 410) { failedEndpoints.push(sub.endpoint); } } } // Clean up failed subscriptions for (const endpoint of failedEndpoints) { for (const sub of subs) { if (sub.endpoint === endpoint) { subs.delete(sub); console.log(`[${roomSlug}] Removed invalid subscription`); } } } } 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 push notification to others sendPushToRoom(clientInfo.roomSlug, { title: 'Friend Joined! 👋', body: `${participant.name} ${participant.emoji} joined the room`, tag: `join-${participant.id}`, data: { type: 'join', roomSlug: clientInfo.roomSlug, participantId: participant.id, participantName: participant.name, }, }); // Send current state to the new participant ws.send(JSON.stringify({ type: 'full_state', state: room })); break; } case 'leave': { const leavingParticipant = room.participants[message.participantId]; delete room.participants[message.participantId]; console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`); broadcast(clientInfo.roomSlug, message, ws); // Send push notification if (leavingParticipant) { sendPushToRoom(clientInfo.roomSlug, { title: 'Friend Left', body: `${leavingParticipant.name} ${leavingParticipant.emoji} left the room`, tag: `leave-${message.participantId}`, data: { type: 'leave', roomSlug: clientInfo.roomSlug, participantId: message.participantId, }, }); } 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); // Send push notification for meeting points if (message.waypoint.type === 'meetup') { const creator = room.participants[clientInfo.participantId]; sendPushToRoom(clientInfo.roomSlug, { title: 'Meeting Point Set! 📍', body: `${creator?.name || 'Someone'} set a meeting point: ${message.waypoint.name}`, tag: `waypoint-${message.waypoint.id}`, requireInteraction: true, data: { type: 'waypoint', roomSlug: clientInfo.roomSlug, waypointId: message.waypoint.id, waypointName: message.waypoint.name, }, actions: [ { action: 'view', title: 'View on Map' }, { action: 'dismiss', title: 'Dismiss' }, ], }); } 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); pushSubscriptions.delete(slug); console.log(`Cleaned up empty room: ${slug}`); } } }, 5 * 60 * 1000); // Every 5 minutes // Automatic location request - periodically ask all clients for location updates via silent push async function requestLocationFromAllRooms() { if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return; for (const [roomSlug, subs] of pushSubscriptions.entries()) { if (subs.size === 0) continue; const room = rooms.get(roomSlug); // Only request if room has participants (active room) if (!room || Object.keys(room.participants).length === 0) continue; console.log(`[${roomSlug}] Requesting location from ${subs.size} subscribers`); const failedEndpoints = []; for (const sub of subs) { try { await webpush.sendNotification(sub, JSON.stringify({ silent: true, data: { type: 'location_request', roomSlug } })); } catch (error) { if (error.statusCode === 404 || error.statusCode === 410) { failedEndpoints.push(sub.endpoint); } } } // Clean up failed subscriptions for (const endpoint of failedEndpoints) { for (const sub of subs) { if (sub.endpoint === endpoint) { subs.delete(sub); console.log(`[${roomSlug}] Removed stale push subscription`); } } } } } // Start automatic location request interval if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY && LOCATION_REQUEST_INTERVAL_MS > 0) { setInterval(requestLocationFromAllRooms, LOCATION_REQUEST_INTERVAL_MS); console.log(`Automatic location requests enabled every ${LOCATION_REQUEST_INTERVAL_MS / 1000}s`); } // Parse JSON body from request async function parseJsonBody(req) { return new Promise((resolve, reject) => { let body = ''; req.on('data', chunk => body += chunk); req.on('end', () => { try { resolve(JSON.parse(body)); } catch (e) { reject(new Error('Invalid JSON')); } }); req.on('error', reject); }); } // Add CORS headers function addCorsHeaders(res) { res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); } // Create HTTP server for health checks and push subscription endpoints const server = createServer(async (req, res) => { const { pathname } = parse(req.url); addCorsHeaders(res); // Handle CORS preflight if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } if (pathname === '/health') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', rooms: rooms.size, clients: clients.size, pushEnabled: !!(VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY), 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, pushSubscriptions: pushSubscriptions.get(slug)?.size || 0, lastActivity: room.lastActivity }; } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ rooms: roomStats, totalClients: clients.size })); } else if (pathname === '/push/vapid-public-key') { // Return VAPID public key for client res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ publicKey: VAPID_PUBLIC_KEY })); } else if (pathname === '/push/subscribe' && req.method === 'POST') { // Subscribe to push notifications try { const { subscription, roomSlug } = await parseJsonBody(req); if (!subscription || !subscription.endpoint) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Invalid subscription' })); return; } // Store subscription for the room if (roomSlug) { if (!pushSubscriptions.has(roomSlug)) { pushSubscriptions.set(roomSlug, new Set()); } pushSubscriptions.get(roomSlug).add(subscription); console.log(`[${roomSlug}] Push subscription added`); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (error) { console.error('Push subscribe error:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to subscribe' })); } } else if (pathname === '/push/unsubscribe' && req.method === 'POST') { // Unsubscribe from push notifications try { const { endpoint, roomSlug } = await parseJsonBody(req); if (roomSlug && pushSubscriptions.has(roomSlug)) { const subs = pushSubscriptions.get(roomSlug); for (const sub of subs) { if (sub.endpoint === endpoint) { subs.delete(sub); console.log(`[${roomSlug}] Push subscription removed`); break; } } } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (error) { console.error('Push unsubscribe error:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to unsubscribe' })); } } else if (pathname === '/push/test' && req.method === 'POST') { // Send test push notification try { const { roomSlug } = await parseJsonBody(req); if (roomSlug) { await sendPushToRoom(roomSlug, { title: 'Test Notification 🔔', body: 'Push notifications are working!', tag: 'test', data: { type: 'test', roomSlug }, }); } res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true })); } catch (error) { console.error('Push test error:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to send test' })); } } else if (pathname === '/push/request-location' && req.method === 'POST') { // Manually trigger location request for a room or specific participant // Uses WebSocket for online clients, push for offline/background try { const { roomSlug, participantId } = await parseJsonBody(req); if (!roomSlug) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'roomSlug required' })); return; } let wsSent = 0; let pushSent = 0; let pushFailed = 0; // Get room to access participant names for deduplication const room = rooms.get(roomSlug); // If targeting a specific participant, get their name for deduplication let targetName = null; if (participantId && room?.participants?.[participantId]) { targetName = room.participants[participantId].name; } // Send WebSocket message to connected clients, deduplicated by name const locationRequestMsg = JSON.stringify({ type: 'request_location' }); const pingedNames = new Set(); for (const [ws, clientInfo] of clients.entries()) { if (clientInfo.roomSlug === roomSlug && ws.readyState === 1) { // Get participant info for this client const participant = room?.participants?.[clientInfo.participantId]; const name = participant?.name; // If targeting specific participant, only ping that name if (targetName && name !== targetName) { continue; } // Skip if we've already pinged this name if (name && pingedNames.has(name)) { console.log(`[${roomSlug}] Skipping duplicate ping for: ${name}`); continue; } ws.send(locationRequestMsg); wsSent++; if (name) { pingedNames.add(name); } } } console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients${targetName ? ` (target: ${targetName})` : ' (deduped by name)'}`); // Then, send push notifications to offline subscribers const subs = pushSubscriptions.get(roomSlug); if (subs && subs.size > 0) { const failedEndpoints = []; for (const sub of subs) { try { await webpush.sendNotification(sub, JSON.stringify({ silent: true, data: { type: 'location_request', roomSlug } })); pushSent++; } catch (error) { pushFailed++; if (error.statusCode === 404 || error.statusCode === 410) { failedEndpoints.push(sub.endpoint); } } } // Clean up failed subscriptions for (const endpoint of failedEndpoints) { for (const sub of subs) { if (sub.endpoint === endpoint) { subs.delete(sub); } } } } console.log(`[${roomSlug}] Manual location request: ${wsSent} WebSocket, ${pushSent} push sent, ${pushFailed} push failed`); res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ success: true, websocket: wsSent, push: pushSent, pushFailed, total: wsSent + pushSent })); } catch (error) { console.error('Location request error:', error); res.writeHead(500, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Failed to request location' })); } } 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`); console.log(`Push API: http://localhost:${PORT}/push/subscribe`); });