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 server code and fix ownership
|
||||||
COPY --chown=nodejs:nodejs server.js verify-token.js ./
|
COPY --chown=nodejs:nodejs server.js verify-token.js ./
|
||||||
|
|
||||||
# Set ownership for the whole app directory
|
# Create data directory for persistence and set ownership
|
||||||
RUN chown -R nodejs:nodejs /app
|
RUN mkdir -p /app/data && chown -R nodejs:nodejs /app
|
||||||
|
|
||||||
USER nodejs
|
USER nodejs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ services:
|
||||||
- VAPID_SUBJECT=mailto:push@rmaps.online
|
- VAPID_SUBJECT=mailto:push@rmaps.online
|
||||||
# Automatic location request interval (ms) - 0 to disable
|
# Automatic location request interval (ms) - 0 to disable
|
||||||
- LOCATION_REQUEST_INTERVAL=60000
|
- LOCATION_REQUEST_INTERVAL=60000
|
||||||
|
volumes:
|
||||||
|
- rmaps-sync-data:/app/data
|
||||||
labels:
|
labels:
|
||||||
- "traefik.enable=true"
|
- "traefik.enable=true"
|
||||||
# HTTP router (redirects to HTTPS via Cloudflare)
|
# HTTP router (redirects to HTTPS via Cloudflare)
|
||||||
|
|
@ -26,6 +28,9 @@ services:
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
rmaps-sync-data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
traefik-public:
|
traefik-public:
|
||||||
external: true
|
external: true
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,12 @@ import { WebSocketServer } from 'ws';
|
||||||
import { createServer } from 'http';
|
import { createServer } from 'http';
|
||||||
import { parse } from 'url';
|
import { parse } from 'url';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
import { verifyToken, extractTokenFromURL } from './verify-token.js';
|
import { verifyToken, extractTokenFromURL } from './verify-token.js';
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3001;
|
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 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
|
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)');
|
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>
|
// Room state storage: Map<roomSlug, RoomState>
|
||||||
const rooms = new Map();
|
const rooms = new Map();
|
||||||
|
|
||||||
// Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }>
|
// Client tracking: Map<WebSocket, { roomSlug, participantId, claims, readOnly }>
|
||||||
const clients = new Map();
|
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
|
// Using Map keyed by endpoint to deduplicate subscriptions from the same device
|
||||||
const pushSubscriptions = new Map();
|
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) {
|
function getRoomState(slug) {
|
||||||
if (!rooms.has(slug)) {
|
if (!rooms.has(slug)) {
|
||||||
rooms.set(slug, {
|
rooms.set(slug, {
|
||||||
|
|
@ -91,7 +174,19 @@ function cleanupStaleParticipants(room) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const staleIds = [];
|
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)) {
|
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'
|
const lastSeenMs = typeof participant.lastSeen === 'string'
|
||||||
? new Date(participant.lastSeen).getTime()
|
? new Date(participant.lastSeen).getTime()
|
||||||
: participant.lastSeen;
|
: participant.lastSeen;
|
||||||
|
|
@ -102,9 +197,10 @@ function cleanupStaleParticipants(room) {
|
||||||
|
|
||||||
for (const id of staleIds) {
|
for (const id of staleIds) {
|
||||||
delete room.participants[id];
|
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;
|
return staleIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,9 +246,12 @@ async function sendPushToRoom(roomSlug, notification, { excludeEndpoint = null,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up failed subscriptions
|
// Clean up failed subscriptions
|
||||||
for (const endpoint of failedEndpoints) {
|
if (failedEndpoints.length > 0) {
|
||||||
subsMap.delete(endpoint);
|
for (const endpoint of failedEndpoints) {
|
||||||
console.log(`[${roomSlug}] Removed invalid subscription`);
|
subsMap.delete(endpoint);
|
||||||
|
console.log(`[${roomSlug}] Removed invalid subscription`);
|
||||||
|
}
|
||||||
|
persistPushSubscriptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,6 +318,7 @@ function handleMessage(ws, data) {
|
||||||
type: 'full_state',
|
type: 'full_state',
|
||||||
state: room
|
state: room
|
||||||
}));
|
}));
|
||||||
|
persistRooms();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -227,6 +327,7 @@ function handleMessage(ws, data) {
|
||||||
delete room.participants[message.participantId];
|
delete room.participants[message.participantId];
|
||||||
console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`);
|
console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`);
|
||||||
broadcast(clientInfo.roomSlug, message, ws);
|
broadcast(clientInfo.roomSlug, message, ws);
|
||||||
|
persistRooms();
|
||||||
|
|
||||||
// Send push notification
|
// Send push notification
|
||||||
if (leavingParticipant) {
|
if (leavingParticipant) {
|
||||||
|
|
@ -252,6 +353,7 @@ function handleMessage(ws, data) {
|
||||||
// Broadcast to all OTHER participants
|
// Broadcast to all OTHER participants
|
||||||
const count = broadcast(clientInfo.roomSlug, message, ws);
|
const count = broadcast(clientInfo.roomSlug, message, ws);
|
||||||
console.log(`[${clientInfo.roomSlug}] Location update from ${message.participantId} -> ${count} clients`);
|
console.log(`[${clientInfo.roomSlug}] Location update from ${message.participantId} -> ${count} clients`);
|
||||||
|
persistRooms();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -328,6 +430,7 @@ function handleClose(ws) {
|
||||||
status: 'offline'
|
status: 'offline'
|
||||||
});
|
});
|
||||||
console.log(`[${clientInfo.roomSlug}] User went offline: ${clientInfo.participantId} (location preserved)`);
|
console.log(`[${clientInfo.roomSlug}] User went offline: ${clientInfo.participantId} (location preserved)`);
|
||||||
|
persistRooms();
|
||||||
}
|
}
|
||||||
clients.delete(ws);
|
clients.delete(ws);
|
||||||
}
|
}
|
||||||
|
|
@ -336,18 +439,25 @@ function handleClose(ws) {
|
||||||
// Periodic cleanup of empty rooms
|
// Periodic cleanup of empty rooms
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
let changed = false;
|
||||||
for (const [slug, room] of rooms.entries()) {
|
for (const [slug, room] of rooms.entries()) {
|
||||||
// Clean stale participants
|
// Clean stale participants (skips those with push subscriptions)
|
||||||
cleanupStaleParticipants(room);
|
cleanupStaleParticipants(room);
|
||||||
|
|
||||||
// Remove empty rooms older than 24 hours
|
// Remove empty rooms older than 24 hours (only if no push subscriptions either)
|
||||||
if (Object.keys(room.participants).length === 0 &&
|
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) {
|
now - room.lastActivity > 24 * 60 * 60 * 1000) {
|
||||||
rooms.delete(slug);
|
rooms.delete(slug);
|
||||||
pushSubscriptions.delete(slug);
|
pushSubscriptions.delete(slug);
|
||||||
console.log(`Cleaned up empty room: ${slug}`);
|
console.log(`Cleaned up empty room: ${slug}`);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (changed) {
|
||||||
|
persistRooms();
|
||||||
|
persistPushSubscriptions();
|
||||||
|
}
|
||||||
}, 5 * 60 * 1000); // Every 5 minutes
|
}, 5 * 60 * 1000); // Every 5 minutes
|
||||||
|
|
||||||
// Automatic location request - periodically ask all clients for location updates via silent push
|
// Automatic location request - periodically ask all clients for location updates via silent push
|
||||||
|
|
@ -378,9 +488,12 @@ async function requestLocationFromAllRooms() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up failed subscriptions
|
// Clean up failed subscriptions
|
||||||
for (const endpoint of failedEndpoints) {
|
if (failedEndpoints.length > 0) {
|
||||||
subsMap.delete(endpoint);
|
for (const endpoint of failedEndpoints) {
|
||||||
console.log(`[${roomSlug}] Removed stale push subscription`);
|
subsMap.delete(endpoint);
|
||||||
|
console.log(`[${roomSlug}] Removed stale push subscription`);
|
||||||
|
}
|
||||||
|
persistPushSubscriptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -472,6 +585,7 @@ const server = createServer(async (req, res) => {
|
||||||
participantId: participantId || null,
|
participantId: participantId || null,
|
||||||
});
|
});
|
||||||
console.log(`[${roomSlug}] Push subscription added/updated for participant: ${participantId || 'unknown'}`);
|
console.log(`[${roomSlug}] Push subscription added/updated for participant: ${participantId || 'unknown'}`);
|
||||||
|
persistPushSubscriptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||||
|
|
@ -491,6 +605,7 @@ const server = createServer(async (req, res) => {
|
||||||
if (subsMap.has(endpoint)) {
|
if (subsMap.has(endpoint)) {
|
||||||
subsMap.delete(endpoint);
|
subsMap.delete(endpoint);
|
||||||
console.log(`[${roomSlug}] Push subscription removed`);
|
console.log(`[${roomSlug}] Push subscription removed`);
|
||||||
|
persistPushSubscriptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -685,8 +800,11 @@ const server = createServer(async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up failed subscriptions
|
// Clean up failed subscriptions
|
||||||
for (const endpoint of failedEndpoints) {
|
if (failedEndpoints.length > 0) {
|
||||||
subsMap.delete(endpoint);
|
for (const endpoint of failedEndpoints) {
|
||||||
|
subsMap.delete(endpoint);
|
||||||
|
}
|
||||||
|
persistPushSubscriptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue