rmaps-online/sync-server/server.js

803 lines
28 KiB
JavaScript

import { WebSocketServer } from 'ws';
import { createServer } from 'http';
import { parse } from 'url';
import { randomUUID } from 'crypto';
import webpush from 'web-push';
import { verifyToken, extractTokenFromURL } from './verify-token.js';
const PORT = process.env.PORT || 3001;
const STALE_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes (match client-side cleanup)
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<roomSlug, RoomState>
const rooms = new Map();
// Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }>
const clients = new Map();
// Push subscriptions: Map<roomSlug, Map<endpoint, { subscription, participantId }>>
// Using Map keyed by endpoint to deduplicate subscriptions from the same device
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(),
visibility: 'public', // public | public_read | authenticated | members_only
ownerDID: null,
});
}
const room = rooms.get(slug);
room.lastActivity = Date.now();
return room;
}
/**
* Evaluate whether a connection should be allowed based on room visibility.
* Returns { allowed, readOnly, reason }
*/
function evaluateRoomAccess(room, claims, isRead = false) {
const { visibility, ownerDID } = room;
const isOwner = !!(claims && ownerDID && (claims.sub === ownerDID || claims.did === ownerDID));
if (visibility === 'public') {
return { allowed: true, readOnly: false, isOwner };
}
if (visibility === 'public_read') {
// Everyone can connect, but writes require auth
return { allowed: true, readOnly: !claims, isOwner };
}
if (visibility === 'authenticated') {
if (!claims) {
return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' };
}
return { allowed: true, readOnly: false, isOwner };
}
if (visibility === 'members_only') {
if (!claims) {
return { allowed: false, readOnly: false, isOwner: false, reason: 'Authentication required' };
}
// Further membership checks could be added here
return { allowed: true, readOnly: false, isOwner };
}
return { allowed: false, readOnly: false, isOwner: false, reason: 'Unknown visibility' };
}
function cleanupStaleParticipants(room) {
const now = Date.now();
const staleIds = [];
for (const [id, participant] of Object.entries(room.participants)) {
const lastSeenMs = typeof participant.lastSeen === 'string'
? new Date(participant.lastSeen).getTime()
: participant.lastSeen;
if (lastSeenMs && now - lastSeenMs > 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
// excludeParticipantId: skip subscriptions belonging to this participant (e.g. don't notify yourself)
async function sendPushToRoom(roomSlug, notification, { excludeEndpoint = null, excludeParticipantId = null } = {}) {
if (!VAPID_PUBLIC_KEY || !VAPID_PRIVATE_KEY) return;
const subsMap = pushSubscriptions.get(roomSlug);
if (!subsMap || subsMap.size === 0) return;
const payload = JSON.stringify(notification);
const failedEndpoints = [];
for (const [endpoint, { subscription, participantId }] of subsMap.entries()) {
if (excludeEndpoint && endpoint === excludeEndpoint) continue;
if (excludeParticipantId && participantId === excludeParticipantId) continue;
try {
await webpush.sendNotification(subscription, payload);
console.log(`[${roomSlug}] Push sent to ${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(endpoint);
}
}
}
// Clean up failed subscriptions
for (const endpoint of failedEndpoints) {
subsMap.delete(endpoint);
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);
// Block write operations from readOnly connections
const writeOps = ['join', 'leave', 'location', 'status', 'waypoint_add', 'waypoint_remove'];
if (clientInfo.readOnly && writeOps.includes(message.type)) {
ws.send(JSON.stringify({ type: 'error', error: 'Read-only access — authenticate to interact' }));
return;
}
switch (message.type) {
case 'join': {
const participant = {
...message.participant,
lastSeen: new Date().toISOString()
};
// Deduplicate: remove any existing participant with same name but different ID (ghost entries)
for (const [existingId, existing] of Object.entries(room.participants)) {
if (existing.name === participant.name && existingId !== participant.id) {
console.log(`[${clientInfo.roomSlug}] Removing ghost participant: ${existingId} (replaced by ${participant.id})`);
delete room.participants[existingId];
broadcast(clientInfo.roomSlug, { type: 'leave', participantId: existingId }, ws);
}
}
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 (exclude the joining user themselves)
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,
},
}, { excludeParticipantId: participant.id });
// 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 = new Date().toISOString();
// 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 = new Date().toISOString();
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 = new Date().toISOString();
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, subsMap] of pushSubscriptions.entries()) {
if (subsMap.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 ${subsMap.size} subscribers`);
const failedEndpoints = [];
for (const [endpoint, { subscription }] of subsMap.entries()) {
try {
await webpush.sendNotification(subscription, JSON.stringify({
silent: true,
data: { type: 'location_request', roomSlug }
}));
} catch (error) {
if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(endpoint);
}
}
}
// Clean up failed subscriptions
for (const endpoint of failedEndpoints) {
subsMap.delete(endpoint);
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, Authorization');
}
// 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, participantId } = 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 (deduplicates by endpoint automatically via Map key)
if (roomSlug) {
if (!pushSubscriptions.has(roomSlug)) {
pushSubscriptions.set(roomSlug, new Map());
}
pushSubscriptions.get(roomSlug).set(subscription.endpoint, {
subscription,
participantId: participantId || null,
});
console.log(`[${roomSlug}] Push subscription added/updated for participant: ${participantId || 'unknown'}`);
}
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 subsMap = pushSubscriptions.get(roomSlug);
if (subsMap.has(endpoint)) {
subsMap.delete(endpoint);
console.log(`[${roomSlug}] Push subscription removed`);
}
}
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?.startsWith('/room/') && pathname.endsWith('/config') && req.method === 'POST') {
// Set room visibility and config (requires auth + ownership)
try {
const roomSlug = pathname.replace('/room/', '').replace('/config', '');
const body = await parseJsonBody(req);
const authHeader = req.headers['authorization'] || '';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : null;
const claims = token ? await verifyToken(token) : null;
if (!claims) {
res.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Authentication required' }));
return;
}
const room = getRoomState(roomSlug);
// Only the owner (or first authenticated user) can change room config
if (room.ownerDID && room.ownerDID !== claims.sub && room.ownerDID !== claims.did) {
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Only the room owner can change settings' }));
return;
}
// Set owner if not set
if (!room.ownerDID) {
room.ownerDID = claims.sub || claims.did;
}
const validVisibilities = ['public', 'public_read', 'authenticated', 'members_only'];
if (body.visibility && validVisibilities.includes(body.visibility)) {
room.visibility = body.visibility;
}
if (body.name) {
room.name = body.name;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
success: true,
room: { slug: room.slug, visibility: room.visibility, ownerDID: room.ownerDID, name: room.name }
}));
} catch (error) {
console.error('Room config error:', error);
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Failed to update room config' }));
}
} else if (pathname === '/push/request-location' && req.method === 'POST') {
// Manually trigger location request for a room or specific participant
// Online clients get a silent WebSocket message; offline clients get a VISIBLE push callout
try {
const { roomSlug, participantId, callerName } = 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;
}
// Build a set of participantIds that are currently connected via WebSocket
const onlineParticipantIds = new Set();
for (const [ws, clientInfo] of clients.entries()) {
if (clientInfo.roomSlug === roomSlug && ws.readyState === 1 && clientInfo.participantId) {
onlineParticipantIds.add(clientInfo.participantId);
}
}
// Send WebSocket message to connected clients, deduplicated by name
const locationRequestMsg = JSON.stringify({ type: 'request_location', manual: true });
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)'}`);
// Send push notifications to subscribers that are NOT online via WebSocket
// Online users: silent push (they already got the WS message)
// Offline users: visible "callout" notification so they know someone is looking for them
const subsMap = pushSubscriptions.get(roomSlug);
if (subsMap && subsMap.size > 0) {
const failedEndpoints = [];
const pingerName = callerName || 'Someone';
for (const [endpoint, { subscription, participantId: subParticipantId }] of subsMap.entries()) {
// If targeting specific participant, skip others
if (targetName && subParticipantId) {
const subParticipant = room?.participants?.[subParticipantId];
if (subParticipant && subParticipant.name !== targetName) continue;
}
const isOnline = subParticipantId && onlineParticipantIds.has(subParticipantId);
try {
if (isOnline) {
// Online users already got the WS message; send silent push as backup
await webpush.sendNotification(subscription, JSON.stringify({
silent: true,
data: { type: 'location_request', roomSlug, manual: true }
}));
} else {
// Offline users: send a VISIBLE callout notification
await webpush.sendNotification(subscription, JSON.stringify({
title: `📍 ${pingerName} is looking for you!`,
body: `Tap to share your location in ${roomSlug}`,
tag: `callout-${roomSlug}`,
data: {
type: 'callout',
roomSlug,
callerName: pingerName,
manual: true,
},
actions: [
{ action: 'view', title: 'Open Map' },
],
requireInteraction: true,
}));
}
pushSent++;
} catch (error) {
pushFailed++;
if (error.statusCode === 404 || error.statusCode === 410) {
failedEndpoints.push(endpoint);
}
}
}
// Clean up failed subscriptions
for (const endpoint of failedEndpoints) {
subsMap.delete(endpoint);
}
}
// Collect last known locations of targeted participants for the response
const lastLocations = {};
if (room) {
for (const [pid, p] of Object.entries(room.participants)) {
if (targetName && p.name !== targetName) continue;
if (p.location) {
lastLocations[pid] = {
name: p.name,
location: p.location,
status: p.status,
lastSeen: p.lastSeen,
};
}
}
}
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,
lastLocations,
}));
} 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', async (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]);
// Extract and verify auth token from query string
const token = extractTokenFromURL(req.url);
let claims = null;
if (token) {
claims = await verifyToken(token);
if (claims) {
console.log(`[${roomSlug}] Authenticated connection: ${claims.username || claims.sub}`);
}
}
// Check room access based on visibility
const room = getRoomState(roomSlug);
const access = evaluateRoomAccess(room, claims);
if (!access.allowed) {
console.log(`[${roomSlug}] Connection rejected: ${access.reason}`);
ws.close(4001, access.reason || 'Access denied');
return;
}
console.log(`New connection to room: ${roomSlug}${access.readOnly ? ' (read-only)' : ''}`);
// Register client with auth info
clients.set(ws, { roomSlug, participantId: null, claims, readOnly: access.readOnly });
// If authenticated and room has no owner, set this user as owner
if (claims && !room.ownerDID && Object.keys(room.participants).length === 0) {
room.ownerDID = claims.sub || claims.did;
console.log(`[${roomSlug}] Room owner set to: ${room.ownerDID}`);
}
// 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`);
});