feat: Add WebSocket sync server for real-time location sharing
- Add sync-server/ with Node.js WebSocket relay server - Server handles join/leave/location/waypoint messages - Auto-cleans stale participants (1hr) and empty rooms (24hr) - Dockerized with Traefik labels for easy deployment - Update .env.example with NEXT_PUBLIC_SYNC_URL - Mark task-5 as Done in backlog Deployed to https://sync.rmaps.online 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
39f8c83ba9
commit
8554a4cb1a
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Deploy a WebSocket sync server for real-time room state synchronization. Consider using Cloudflare Durable Objects or a self-hosted solution.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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)
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
|
|||
|
|
@ -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
|
|||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||
Deep integration with c3nav: show indoor floor plans, WiFi/BLE positioning, routing through the venue. Display friend positions on indoor maps.
|
||||
<!-- SECTION:DESCRIPTION:END -->
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
<!-- SECTION:NOTES:BEGIN -->
|
||||
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'
|
||||
<!-- SECTION:NOTES:END -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
node_modules/
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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<roomSlug, RoomState>
|
||||
const rooms = new Map();
|
||||
|
||||
// Client tracking: Map<WebSocket, { roomSlug, participantId }>
|
||||
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`);
|
||||
});
|
||||
Loading…
Reference in New Issue