feat: persist offline users and push subscriptions to disk

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-19 00:10:53 +00:00
parent a54ae04140
commit 0bea3ba73b
3 changed files with 138 additions and 15 deletions

View File

@ -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

View File

@ -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

View File

@ -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<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 }>>
// Push subscriptions: Map<roomSlug, Map<endpoint, { subscription, participantId, participantName }>>
// 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();
}
}