diff --git a/.env.example b/.env.example index 32d9271..59962d7 100644 --- a/.env.example +++ b/.env.example @@ -7,8 +7,10 @@ PORT=3000 # c3nav Integration (optional - defaults to 38c3) C3NAV_BASE_URL=https://38c3.c3nav.de -# Automerge Sync Server (optional - for real-time sync) -AUTOMERGE_SYNC_URL=wss://sync.rmaps.online +# WebSocket Sync Server (required for multi-user location sharing) +# For local development: ws://localhost:3001 +# For production: wss://sync.rmaps.online +NEXT_PUBLIC_SYNC_URL=wss://sync.rmaps.online # Analytics (optional) # NEXT_PUBLIC_PLAUSIBLE_DOMAIN=rmaps.online diff --git a/backlog/tasks/task-5 - Implement-WebSocket-sync-server.md b/backlog/tasks/task-5 - Implement-WebSocket-sync-server.md index 7f1d5c2..c9b6861 100644 --- a/backlog/tasks/task-5 - Implement-WebSocket-sync-server.md +++ b/backlog/tasks/task-5 - Implement-WebSocket-sync-server.md @@ -1,9 +1,10 @@ --- id: task-5 title: Implement WebSocket sync server -status: To Do +status: Done assignee: [] created_date: '2025-12-15 19:37' +updated_date: '2025-12-26 02:15' labels: [] dependencies: [] priority: high @@ -14,3 +15,19 @@ priority: high Deploy a WebSocket sync server for real-time room state synchronization. Consider using Cloudflare Durable Objects or a self-hosted solution. + +## Implementation Notes + + +Implemented Node.js WebSocket sync server in sync-server/ + +Deployed to Netcup RS 8000 at https://sync.rmaps.online + +Updated rmaps-online with NEXT_PUBLIC_SYNC_URL=wss://sync.rmaps.online + +Server handles: join, leave, location, status, waypoint_add, waypoint_remove, full_state, request_state messages + +Includes health check at /health and stats at /stats + +Auto-cleans stale participants (1 hour) and empty rooms (24 hours) + diff --git a/backlog/tasks/task-6 - c3nav-indoor-integration.md b/backlog/tasks/task-6 - c3nav-indoor-integration.md index 9e4434e..44ec385 100644 --- a/backlog/tasks/task-6 - c3nav-indoor-integration.md +++ b/backlog/tasks/task-6 - c3nav-indoor-integration.md @@ -1,9 +1,10 @@ --- id: task-6 title: c3nav indoor integration -status: To Do +status: Done assignee: [] created_date: '2025-12-15 19:37' +updated_date: '2025-12-17 00:44' labels: [] dependencies: [] priority: high @@ -14,3 +15,16 @@ priority: high Deep integration with c3nav: show indoor floor plans, WiFi/BLE positioning, routing through the venue. Display friend positions on indoor maps. + +## Implementation Notes + + +Implemented c3nav indoor map integration: +- Tile proxy API with CORS and session handling +- Data API proxy for levels/bounds/locations +- IndoorMapView component with MapLibre GL +- Floor selector (Level 0-4) +- Tap-to-set indoor position +- Position sync between participants +- Easter egg: 'The Underground of the Underground' + diff --git a/sync-server/.gitignore b/sync-server/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/sync-server/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/sync-server/Dockerfile b/sync-server/Dockerfile new file mode 100644 index 0000000..fadfa7c --- /dev/null +++ b/sync-server/Dockerfile @@ -0,0 +1,25 @@ +FROM node:20-alpine + +WORKDIR /app + +# Create non-root user first +RUN addgroup -g 1001 -S nodejs && \ + adduser -S nodejs -u 1001 + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy server code and fix ownership +COPY --chown=nodejs:nodejs server.js ./ + +# Set ownership for the whole app directory +RUN chown -R nodejs:nodejs /app + +USER nodejs + +EXPOSE 3001 + +CMD ["node", "server.js"] diff --git a/sync-server/docker-compose.yml b/sync-server/docker-compose.yml new file mode 100644 index 0000000..e5eed0d --- /dev/null +++ b/sync-server/docker-compose.yml @@ -0,0 +1,25 @@ +services: + rmaps-sync: + build: . + container_name: rmaps-sync + restart: unless-stopped + environment: + - PORT=3001 + labels: + - "traefik.enable=true" + # HTTP router (redirects to HTTPS via Cloudflare) + - "traefik.http.routers.rmaps-sync.rule=Host(`sync.rmaps.online`)" + - "traefik.http.routers.rmaps-sync.entrypoints=web" + - "traefik.http.services.rmaps-sync.loadbalancer.server.port=3001" + networks: + - traefik-public + healthcheck: + test: ["CMD", "wget", "-q", "--spider", "http://localhost:3001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + +networks: + traefik-public: + external: true diff --git a/sync-server/package-lock.json b/sync-server/package-lock.json new file mode 100644 index 0000000..dd72d71 --- /dev/null +++ b/sync-server/package-lock.json @@ -0,0 +1,36 @@ +{ + "name": "rmaps-sync-server", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "rmaps-sync-server", + "version": "1.0.0", + "dependencies": { + "ws": "^8.18.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/sync-server/package.json b/sync-server/package.json new file mode 100644 index 0000000..8af5b63 --- /dev/null +++ b/sync-server/package.json @@ -0,0 +1,14 @@ +{ + "name": "rmaps-sync-server", + "version": "1.0.0", + "description": "WebSocket sync server for rmaps-online location sharing", + "main": "server.js", + "type": "module", + "scripts": { + "start": "node server.js", + "dev": "node --watch server.js" + }, + "dependencies": { + "ws": "^8.18.0" + } +} diff --git a/sync-server/server.js b/sync-server/server.js new file mode 100644 index 0000000..e779a55 --- /dev/null +++ b/sync-server/server.js @@ -0,0 +1,261 @@ +import { WebSocketServer } from 'ws'; +import { createServer } from 'http'; +import { parse } from 'url'; +import { randomUUID } from 'crypto'; + +const PORT = process.env.PORT || 3001; +const STALE_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour + +// Room state storage: Map +const rooms = new Map(); + +// Client tracking: Map +const clients = new Map(); + +function getRoomState(slug) { + if (!rooms.has(slug)) { + rooms.set(slug, { + id: randomUUID(), + slug: slug, + name: slug, + createdAt: new Date().toISOString(), + participants: {}, + waypoints: [], // Array to match client expectation + 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; +} + +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 current state to the new participant + ws.send(JSON.stringify({ + type: 'full_state', + state: room + })); + break; + } + + case 'leave': { + delete room.participants[message.participantId]; + console.log(`[${clientInfo.roomSlug}] Participant left: ${message.participantId}`); + broadcast(clientInfo.roomSlug, message, ws); + 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); + 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) { + delete room.participants[clientInfo.participantId]; + broadcast(clientInfo.roomSlug, { + type: 'leave', + participantId: clientInfo.participantId + }); + console.log(`[${clientInfo.roomSlug}] Connection closed: ${clientInfo.participantId}`); + } + 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); + console.log(`Cleaned up empty room: ${slug}`); + } + } +}, 5 * 60 * 1000); // Every 5 minutes + +// Create HTTP server for health checks +const server = createServer((req, res) => { + const { pathname } = parse(req.url); + + if (pathname === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ + status: 'ok', + rooms: rooms.size, + clients: clients.size, + 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, + lastActivity: room.lastActivity + }; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ rooms: roomStats, totalClients: clients.size })); + } 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`); +});