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:
parent
a54ae04140
commit
0bea3ba73b
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue