From 0bea3ba73b3493f1ad725f313d22add1df1ff931 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Thu, 19 Feb 2026 00:10:53 +0000 Subject: [PATCH] feat: persist offline users and push subscriptions to disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rooms and push subscriptions now survive server restarts via JSON files on a Docker volume. Stale participant cleanup skips users who have active push subscriptions — they remain in the room and can still be pinged for location and notifications when offline. Co-Authored-By: Claude Opus 4.6 --- sync-server/Dockerfile | 4 +- sync-server/docker-compose.yml | 5 ++ sync-server/server.js | 144 ++++++++++++++++++++++++++++++--- 3 files changed, 138 insertions(+), 15 deletions(-) diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile index 6040820..921c5dd 100644 --- a/sync-server/Dockerfile +++ b/sync-server/Dockerfile @@ -15,8 +15,8 @@ RUN npm ci --only=production # Copy server code and fix ownership COPY --chown=nodejs:nodejs server.js verify-token.js ./ -# Set ownership for the whole app directory -RUN chown -R nodejs:nodejs /app +# Create data directory for persistence and set ownership +RUN mkdir -p /app/data && chown -R nodejs:nodejs /app USER nodejs diff --git a/sync-server/docker-compose.yml b/sync-server/docker-compose.yml index cfd12b5..93b0e88 100644 --- a/sync-server/docker-compose.yml +++ b/sync-server/docker-compose.yml @@ -11,6 +11,8 @@ services: - VAPID_SUBJECT=mailto:push@rmaps.online # Automatic location request interval (ms) - 0 to disable - LOCATION_REQUEST_INTERVAL=60000 + volumes: + - rmaps-sync-data:/app/data labels: - "traefik.enable=true" # HTTP router (redirects to HTTPS via Cloudflare) @@ -26,6 +28,9 @@ services: retries: 3 start_period: 10s +volumes: + rmaps-sync-data: + networks: traefik-public: external: true diff --git a/sync-server/server.js b/sync-server/server.js index fdcea37..6b576a2 100644 --- a/sync-server/server.js +++ b/sync-server/server.js @@ -2,10 +2,12 @@ import { WebSocketServer } from 'ws'; import { createServer } from 'http'; import { parse } from 'url'; import { randomUUID } from 'crypto'; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import webpush from 'web-push'; import { verifyToken, extractTokenFromURL } from './verify-token.js'; const PORT = process.env.PORT || 3001; +const DATA_DIR = process.env.DATA_DIR || './data'; 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 @@ -23,16 +25,97 @@ if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { console.log('Push notifications disabled (VAPID keys not configured)'); } +// ==================== PERSISTENCE ==================== + +// Ensure data directory exists +if (!existsSync(DATA_DIR)) { + mkdirSync(DATA_DIR, { recursive: true }); + console.log(`Created data directory: ${DATA_DIR}`); +} + +// Load persisted state from disk +function loadFromDisk(filename) { + const path = `${DATA_DIR}/${filename}`; + try { + if (existsSync(path)) { + const data = JSON.parse(readFileSync(path, 'utf-8')); + console.log(`Loaded ${filename}: ${Object.keys(data).length} entries`); + return data; + } + } catch (err) { + console.error(`Failed to load ${filename}:`, err.message); + } + return null; +} + +// Save state to disk (debounced) +let saveTimers = {}; +function saveToDisk(filename, data) { + if (saveTimers[filename]) clearTimeout(saveTimers[filename]); + saveTimers[filename] = setTimeout(() => { + try { + writeFileSync(`${DATA_DIR}/${filename}`, JSON.stringify(data, null, 2)); + } catch (err) { + console.error(`Failed to save ${filename}:`, err.message); + } + }, 2000); // 2-second debounce +} + // Room state storage: Map const rooms = new Map(); // Client tracking: Map const clients = new Map(); -// Push subscriptions: Map> +// Push subscriptions: Map> // Using Map keyed by endpoint to deduplicate subscriptions from the same device const pushSubscriptions = new Map(); +// Restore rooms from disk +const savedRooms = loadFromDisk('rooms.json'); +if (savedRooms) { + for (const [slug, room] of Object.entries(savedRooms)) { + // Mark all restored participants as offline (they'll re-join if they connect) + for (const p of Object.values(room.participants || {})) { + p.status = 'offline'; + } + rooms.set(slug, room); + } + console.log(`Restored ${rooms.size} room(s) with offline participants`); +} + +// Restore push subscriptions from disk +const savedSubs = loadFromDisk('push-subscriptions.json'); +if (savedSubs) { + for (const [slug, entries] of Object.entries(savedSubs)) { + const subsMap = new Map(); + for (const [endpoint, data] of Object.entries(entries)) { + subsMap.set(endpoint, data); + } + pushSubscriptions.set(slug, subsMap); + } + console.log(`Restored push subscriptions for ${pushSubscriptions.size} room(s)`); +} + +// Persist helpers +function persistRooms() { + const obj = {}; + for (const [slug, room] of rooms.entries()) { + obj[slug] = room; + } + saveToDisk('rooms.json', obj); +} + +function persistPushSubscriptions() { + const obj = {}; + for (const [slug, subsMap] of pushSubscriptions.entries()) { + if (subsMap.size > 0) { + obj[slug] = Object.fromEntries(subsMap.entries()); + } + } + saveToDisk('push-subscriptions.json', obj); +} + function getRoomState(slug) { if (!rooms.has(slug)) { rooms.set(slug, { @@ -91,7 +174,19 @@ function cleanupStaleParticipants(room) { const now = Date.now(); const staleIds = []; + // Build set of participant IDs that have active push subscriptions + const pushParticipantIds = new Set(); + const subsMap = pushSubscriptions.get(room.slug); + if (subsMap) { + for (const { participantId } of subsMap.values()) { + if (participantId) pushParticipantIds.add(participantId); + } + } + for (const [id, participant] of Object.entries(room.participants)) { + // Never clean up participants with push subscriptions — they're still reachable + if (pushParticipantIds.has(id)) continue; + const lastSeenMs = typeof participant.lastSeen === 'string' ? new Date(participant.lastSeen).getTime() : participant.lastSeen; @@ -102,9 +197,10 @@ function cleanupStaleParticipants(room) { for (const id of staleIds) { delete room.participants[id]; - console.log(`Cleaned up stale participant: ${id}`); + console.log(`Cleaned up stale participant: ${id} (no push subscription)`); } + if (staleIds.length > 0) persistRooms(); return staleIds; } @@ -150,9 +246,12 @@ async function sendPushToRoom(roomSlug, notification, { excludeEndpoint = null, } // Clean up failed subscriptions - for (const endpoint of failedEndpoints) { - subsMap.delete(endpoint); - console.log(`[${roomSlug}] Removed invalid subscription`); + if (failedEndpoints.length > 0) { + for (const endpoint of failedEndpoints) { + subsMap.delete(endpoint); + console.log(`[${roomSlug}] Removed invalid subscription`); + } + persistPushSubscriptions(); } } @@ -219,6 +318,7 @@ function handleMessage(ws, data) { type: 'full_state', state: room })); + persistRooms(); break; } @@ -227,6 +327,7 @@ function handleMessage(ws, data) { delete room.participants[message.participantId]; console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`); broadcast(clientInfo.roomSlug, message, ws); + persistRooms(); // Send push notification if (leavingParticipant) { @@ -252,6 +353,7 @@ function handleMessage(ws, data) { // Broadcast to all OTHER participants const count = broadcast(clientInfo.roomSlug, message, ws); console.log(`[${clientInfo.roomSlug}] Location update from ${message.participantId} -> ${count} clients`); + persistRooms(); } break; } @@ -328,6 +430,7 @@ function handleClose(ws) { status: 'offline' }); console.log(`[${clientInfo.roomSlug}] User went offline: ${clientInfo.participantId} (location preserved)`); + persistRooms(); } clients.delete(ws); } @@ -336,18 +439,25 @@ function handleClose(ws) { // Periodic cleanup of empty rooms setInterval(() => { const now = Date.now(); + let changed = false; for (const [slug, room] of rooms.entries()) { - // Clean stale participants + // Clean stale participants (skips those with push subscriptions) cleanupStaleParticipants(room); - // Remove empty rooms older than 24 hours - if (Object.keys(room.participants).length === 0 && + // Remove empty rooms older than 24 hours (only if no push subscriptions either) + const hasPushSubs = pushSubscriptions.has(slug) && pushSubscriptions.get(slug).size > 0; + if (Object.keys(room.participants).length === 0 && !hasPushSubs && now - room.lastActivity > 24 * 60 * 60 * 1000) { rooms.delete(slug); pushSubscriptions.delete(slug); console.log(`Cleaned up empty room: ${slug}`); + changed = true; } } + if (changed) { + persistRooms(); + persistPushSubscriptions(); + } }, 5 * 60 * 1000); // Every 5 minutes // Automatic location request - periodically ask all clients for location updates via silent push @@ -378,9 +488,12 @@ async function requestLocationFromAllRooms() { } // Clean up failed subscriptions - for (const endpoint of failedEndpoints) { - subsMap.delete(endpoint); - console.log(`[${roomSlug}] Removed stale push subscription`); + if (failedEndpoints.length > 0) { + for (const endpoint of failedEndpoints) { + subsMap.delete(endpoint); + console.log(`[${roomSlug}] Removed stale push subscription`); + } + persistPushSubscriptions(); } } } @@ -472,6 +585,7 @@ const server = createServer(async (req, res) => { participantId: participantId || null, }); console.log(`[${roomSlug}] Push subscription added/updated for participant: ${participantId || 'unknown'}`); + persistPushSubscriptions(); } res.writeHead(200, { 'Content-Type': 'application/json' }); @@ -491,6 +605,7 @@ const server = createServer(async (req, res) => { if (subsMap.has(endpoint)) { subsMap.delete(endpoint); console.log(`[${roomSlug}] Push subscription removed`); + persistPushSubscriptions(); } } @@ -685,8 +800,11 @@ const server = createServer(async (req, res) => { } // Clean up failed subscriptions - for (const endpoint of failedEndpoints) { - subsMap.delete(endpoint); + if (failedEndpoints.length > 0) { + for (const endpoint of failedEndpoints) { + subsMap.delete(endpoint); + } + persistPushSubscriptions(); } }