/** * QUIXZOOM Real-Time WebSocket Server * * Hanterar realtidskommunikation mellan Zoomers och backend. * * Flöde: * Zoomer → Observation → WebSocket → Identity Engine → Knowledge Graph → Mission Generator → AI → Instruktion tillbaka * * Features: * - Låg fördröjning (< 100ms) * - Binära meddelanden för video/bilder * - JSON för metadata och instruktioner * - Heartbeat för att upptäcka frånkoppling * - Rum (rooms) för geografiska områden * - Autentisering via JWT */ const WebSocket = require('ws'); const http = require('http'); const crypto = require('crypto'); class RealtimeServer { constructor(options = {}) { this.port = options.port || 8080; this.heartbeatInterval = options.heartbeatInterval || 30000; this.maxMessageSize = options.maxMessageSize || 10 * 1024 * 1024; // 10MB this.wss = null; this.clients = new Map(); // clientId -> { ws, zoomerId, location, room } this.rooms = new Map(); // roomId -> Set(clientIds) this.messageHandlers = new Map(); // Statistik this.stats = { connections: 0, messagesReceived: 0, messagesSent: 0, bytesReceived: 0, bytesSent: 0, errors: 0, }; } /** * ============================================================ * STARTA SERVER * ============================================================ */ start() { const server = http.createServer((req, res) => { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ status: 'ok', service: 'quixzoom-websocket', stats: this.stats, connections: this.clients.size, })); }); this.wss = new WebSocket.Server({ server, maxPayload: this.maxMessageSize, }); this.wss.on('connection', (ws, req) => this.handleConnection(ws, req)); server.listen(this.port, () => { console.log(`[WS] Real-time server running on port ${this.port}`); }); // Starta heartbeat this.startHeartbeat(); return server; } /** * ============================================================ * HANDLERS * ============================================================ */ registerHandler(messageType, handler) { this.messageHandlers.set(messageType, handler); console.log(`[WS] Registered handler: ${messageType}`); } /** * ============================================================ * CONNECTION HANDLING * ============================================================ */ handleConnection(ws, req) { const clientId = this.generateClientId(); const client = { id: clientId, ws, zoomerId: null, location: null, room: null, connectedAt: Date.now(), lastPing: Date.now(), messagesReceived: 0, messagesSent: 0, }; this.clients.set(clientId, client); this.stats.connections++; console.log(`[WS] Client connected: ${clientId} (total: ${this.clients.size})`); // Skicka välkomstmeddelande this.sendToClient(clientId, { type: 'connected', clientId, timestamp: Date.now(), }); ws.on('message', (data) => this.handleMessage(clientId, data)); ws.on('close', () => this.handleDisconnect(clientId)); ws.on('error', (error) => this.handleError(clientId, error)); ws.on('pong', () => { client.lastPing = Date.now(); }); } handleMessage(clientId, data) { const client = this.clients.get(clientId); if (!client) return; this.stats.messagesReceived++; this.stats.bytesReceived += data.length; client.messagesReceived++; try { const message = JSON.parse(data); console.log(`[WS] [${clientId}] ${message.type}`); // Autentisering if (message.type === 'auth') { this.handleAuth(clientId, message.payload); return; } // Kräv autentisering if (!client.zoomerId) { this.sendToClient(clientId, { type: 'error', error: 'Not authenticated', }); return; } // Hantera meddelande const handler = this.messageHandlers.get(message.type); if (handler) { handler(message.payload, client, this); } else { console.log(`[WS] No handler for: ${message.type}`); } } catch (error) { console.error(`[WS] Error parsing message:`, error.message); this.stats.errors++; } } handleAuth(clientId, payload) { const client = this.clients.get(clientId); if (!client) return; // TODO: Verifiera JWT const zoomerId = payload.zoomerId || `zoomer_${clientId}`; client.zoomerId = zoomerId; console.log(`[WS] Client authenticated: ${clientId} as ${zoomerId}`); this.sendToClient(clientId, { type: 'auth_success', zoomerId, }); } handleDisconnect(clientId) { const client = this.clients.get(clientId); if (!client) return; // Ta bort från rum if (client.room) { this.leaveRoom(clientId, client.room); } this.clients.delete(clientId); console.log(`[WS] Client disconnected: ${clientId} (total: ${this.clients.size})`); } handleError(clientId, error) { console.error(`[WS] Client error: ${clientId}`, error.message); this.stats.errors++; } /** * ============================================================ * RUM (GEOGRAFISKA OMRÅDEN) * ============================================================ */ joinRoom(clientId, roomId) { const client = this.clients.get(clientId); if (!client) return; // Lämna tidigare rum if (client.room) { this.leaveRoom(clientId, client.room); } // Gå med i nytt rum if (!this.rooms.has(roomId)) { this.rooms.set(roomId, new Set()); } this.rooms.get(roomId).add(clientId); client.room = roomId; console.log(`[WS] ${clientId} joined room: ${roomId}`); } leaveRoom(clientId, roomId) { const room = this.rooms.get(roomId); if (room) { room.delete(clientId); if (room.size === 0) { this.rooms.delete(roomId); } } const client = this.clients.get(clientId); if (client) { client.room = null; } console.log(`[WS] ${clientId} left room: ${roomId}`); } broadcastToRoom(roomId, message, excludeClientId = null) { const room = this.rooms.get(roomId); if (!room) return; for (const clientId of room) { if (clientId !== excludeClientId) { this.sendToClient(clientId, message); } } } /** * ============================================================ * SKICKA MEDDELANDEN * ============================================================ */ sendToClient(clientId, message) { const client = this.clients.get(clientId); if (!client || client.ws.readyState !== WebSocket.OPEN) return; const data = JSON.stringify(message); client.ws.send(data); client.messagesSent++; this.stats.messagesSent++; this.stats.bytesSent += data.length; } broadcast(message, excludeClientId = null) { for (const [clientId, client] of this.clients) { if (clientId !== excludeClientId && client.ws.readyState === WebSocket.OPEN) { const data = JSON.stringify(message); client.ws.send(data); client.messagesSent++; this.stats.messagesSent++; this.stats.bytesSent += data.length; } } } /** * ============================================================ * HEARTBEAT * ============================================================ */ startHeartbeat() { setInterval(() => { const now = Date.now(); for (const [clientId, client] of this.clients) { if (now - client.lastPing > this.heartbeatInterval * 2) { console.log(`[WS] Client timeout: ${clientId}`); client.ws.terminate(); this.handleDisconnect(clientId); } else { client.ws.ping(); } } }, this.heartbeatInterval); } /** * ============================================================ * HJÄLPMETODER * ============================================================ */ generateClientId() { return crypto.randomBytes(8).toString('hex'); } getStats() { return { ...this.stats, activeConnections: this.clients.size, activeRooms: this.rooms.size, }; } } // Exportera module.exports = RealtimeServer; // Demo if (require.main === module) { const server = new RealtimeServer({ port: 8080 }); // Registrera handlers server.registerHandler('observation', async (payload, client, server) => { console.log(`[WS] Observation from ${client.zoomerId}:`, payload.objectType); // Simulera processing await new Promise(resolve => setTimeout(resolve, 50)); // Skicka bekräftelse server.sendToClient(client.id, { type: 'observation_ack', observationId: payload.observationId, status: 'received', }); // Simulera Identity Engine await new Promise(resolve => setTimeout(resolve, 100)); server.sendToClient(client.id, { type: 'identity_result', observationId: payload.observationId, objectId: `street_lamp_${Math.floor(Math.random() * 50000).toString().padStart(6, '0')}`, confidence: 0.85 + Math.random() * 0.15, }); // Simulera Mission Generator await new Promise(resolve => setTimeout(resolve, 100)); if (Math.random() > 0.7) { server.sendToClient(client.id, { type: 'instruction', instruction: 'Gå två meter närmare för bättre vinkel', priority: 'medium', }); } }); server.registerHandler('location_update', (payload, client, server) => { client.location = payload.location; // Uppdatera rum baserat på plats const roomId = server.getRoomForLocation(payload.location); if (roomId !== client.room) { server.joinRoom(client.id, roomId); } }); server.registerHandler('telemetry', (payload, client, server) => { // Spara telemetri // console.log(`[WS] Telemetry from ${client.zoomerId}:`, payload); }); // Hjälpmetod för rum server.getRoomForLocation = (location) => { const lat = Math.floor(location.lat * 10); const lng = Math.floor(location.lng * 10); return `${lat},${lng}`; }; server.start(); console.log('\n=== WEBSOCKET SERVER DEMO ==='); console.log('Server running on ws://localhost:8080'); console.log(''); console.log('Test with:'); console.log(' wscat -c ws://localhost:8080'); console.log(''); console.log('Or use the client below...'); }