bae705aa97
- Add NFC ePassport roadmap (ICAO 9303, eIDAS) - Add TensorFlow.js edge face detection (BlazeFace) - Add structured audit logger (GDPR-compliant) - Risk scoring support Part of KYC Apple Native UX v1.1.0
401 lines
10 KiB
JavaScript
401 lines
10 KiB
JavaScript
/**
|
|
* 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...');
|
|
}
|