593 lines
19 KiB
JavaScript
593 lines
19 KiB
JavaScript
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<roomSlug, RoomState>
|
|
const rooms = new Map();
|
|
|
|
// Client tracking: Map<WebSocket, { roomSlug, participantId }>
|
|
const clients = new Map();
|
|
|
|
// Push subscriptions: Map<roomSlug, Set<subscription>>
|
|
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
|
|
// Uses WebSocket for online clients, push for offline/background
|
|
try {
|
|
const { roomSlug } = 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;
|
|
|
|
// First, send WebSocket message to all connected clients in the room
|
|
const locationRequestMsg = JSON.stringify({ type: 'request_location' });
|
|
for (const [ws, clientInfo] of clients.entries()) {
|
|
if (clientInfo.roomSlug === roomSlug && ws.readyState === 1) {
|
|
ws.send(locationRequestMsg);
|
|
wsSent++;
|
|
}
|
|
}
|
|
console.log(`[${roomSlug}] Location request via WebSocket: ${wsSent} clients`);
|
|
|
|
// 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`);
|
|
});
|