UNPKG

@klastra/ksync

Version:

Real-time sync engine for modern applications with 300k+ ops/sec performance

366 lines (365 loc) 12.2 kB
import { WebSocketServer, WebSocket } from 'ws'; import { createServer } from 'http'; import { EventEmitter } from 'events'; export class KSyncServer extends EventEmitter { wss; server; clients = new Map(); rooms = new Map(); // roomId -> clientIds events = new Map(); // roomId -> events config; constructor(config = {}) { super(); this.config = { port: 8080, auth: {}, cors: { origin: '*', credentials: true }, rateLimit: { maxConnections: 1000, maxEventsPerSecond: 100 }, storage: { type: 'memory' }, debug: false, ...config }; this.server = createServer(); this.wss = new WebSocketServer({ server: this.server, verifyClient: this.verifyClient.bind(this) }); this.setupServer(); } // 🚀 Super simple API for users async start() { return new Promise((resolve) => { this.server.listen(this.config.port, () => { console.log(`🚀 kSync Server running on ws://localhost:${this.config.port}`); resolve(); }); }); } async stop() { return new Promise((resolve) => { this.wss.close(); this.server.close(() => resolve()); }); } // 📡 Broadcast to all clients in a room broadcast(room, data) { const roomClients = this.rooms.get(room); if (!roomClients) return; const message = { type: 'event', room, data, timestamp: Date.now() }; roomClients.forEach(clientId => { const client = this.clients.get(clientId); if (client?.ws.readyState === WebSocket.OPEN) { client.ws.send(JSON.stringify(message)); } }); // Store event for sync if (!this.events.has(room)) { this.events.set(room, []); } const roomEvents = this.events.get(room); if (roomEvents) { roomEvents.push(data); } } // 🎯 Send to specific client sendToClient(clientId, data) { const client = this.clients.get(clientId); if (client?.ws.readyState === WebSocket.OPEN) { client.ws.send(JSON.stringify({ type: 'event', data, timestamp: Date.now() })); } } // 👥 Get all clients in a room getRoomClients(room) { const roomClients = this.rooms.get(room); if (!roomClients) return []; return Array.from(roomClients) .map(id => this.clients.get(id)) .filter(Boolean); } // 📊 Get server stats getStats() { return { clients: this.clients.size, rooms: this.rooms.size, events: Array.from(this.events.values()).reduce((sum, events) => sum + events.length, 0), uptime: process.uptime() }; } // 🔧 Private setup methods setupServer() { this.wss.on('connection', this.handleConnection.bind(this)); // Periodic cleanup setInterval(() => this.cleanup(), 60000); // Ping clients setInterval(() => this.pingClients(), 30000); } verifyClient(info) { // Basic rate limiting if (this.clients.size >= (this.config.rateLimit?.maxConnections || 1000)) { return false; } // CORS check const origin = info.origin; const allowedOrigins = this.config.cors.origin; if (allowedOrigins !== '*' && Array.isArray(allowedOrigins)) { return allowedOrigins.includes(origin); } return true; } handleConnection(ws) { const clientId = this.generateClientId(); const client = { id: clientId, ws, rooms: new Set(), lastPing: Date.now(), metadata: {} }; this.clients.set(clientId, client); this.log(`Client connected: ${clientId}`); ws.on('message', (data) => this.handleMessage(client, data)); ws.on('close', () => this.handleDisconnect(client)); ws.on('error', (error) => this.handleError(client, error)); ws.on('pong', () => client.lastPing = Date.now()); // Send welcome message this.sendToClient(clientId, { type: 'connected', clientId, serverTime: Date.now() }); this.emit('client-connected', client); } async handleMessage(client, data) { try { const message = JSON.parse(data.toString()); switch (message.type) { case 'auth': await this.handleAuth(client, message); break; case 'join': this.handleJoin(client, message); break; case 'leave': this.handleLeave(client, message); break; case 'event': this.handleEvent(client, message); break; case 'sync': this.handleSync(client, message); break; case 'ping': client.ws.send(JSON.stringify({ type: 'pong' })); break; } } catch (error) { this.log(`Error handling message from ${client.id}:`, error); } } async handleAuth(client, message) { if (!this.config.auth.verify) { client.userId = message.data?.userId || 'anonymous'; this.sendToClient(client.id, { type: 'auth-success', userId: client.userId }); return; } try { const result = await this.config.auth.verify(message.data?.token); client.userId = result.userId; client.metadata = { ...client.metadata, ...result }; this.sendToClient(client.id, { type: 'auth-success', userId: client.userId, metadata: client.metadata }); this.emit('client-authenticated', client); } catch (error) { this.sendToClient(client.id, { type: 'auth-error', error: 'Authentication failed' }); } } handleJoin(client, message) { const room = message.room; if (!room) return; // Add client to room client.rooms.add(room); if (!this.rooms.has(room)) { this.rooms.set(room, new Set()); } this.rooms.get(room).add(client.id); // Send existing events to sync client const roomEvents = this.events.get(room) || []; if (roomEvents.length > 0) { this.sendToClient(client.id, { type: 'sync', room, events: roomEvents }); } // Notify other clients (not the joining client themselves) const roomClients = this.rooms.get(room); if (roomClients) { const joinMessage = { type: 'user-joined', clientId: client.id, userId: client.userId, timestamp: Date.now() }; roomClients.forEach(clientId => { if (clientId !== client.id) { const targetClient = this.clients.get(clientId); if (targetClient?.ws.readyState === WebSocket.OPEN) { targetClient.ws.send(JSON.stringify(joinMessage)); } } }); } this.log(`Client ${client.id} joined room: ${room}`); this.emit('client-joined-room', client, room); } handleLeave(client, message) { const room = message.room; if (!room) return; this.removeClientFromRoom(client, room); } handleEvent(client, message) { const room = message.room; if (!room || !client.rooms.has(room)) return; // Add metadata const eventData = { ...message.data, _meta: { clientId: client.id, userId: client.userId, timestamp: Date.now() } }; // **FIX: Store event in server memory** if (!this.events.has(room)) { this.events.set(room, []); } this.events.get(room).push(eventData); // Keep only last 1000 events per room to prevent memory bloat const roomEvents = this.events.get(room); if (roomEvents.length > 1000) { roomEvents.splice(0, roomEvents.length - 1000); } // Broadcast to other clients in room (excluding sender to prevent duplication) const roomClients = this.rooms.get(room); if (!roomClients) return; const messageToSend = { type: 'event', room, data: eventData, timestamp: Date.now() }; roomClients.forEach(clientId => { // Skip the sender to prevent event duplication if (clientId !== client.id) { const targetClient = this.clients.get(clientId); if (targetClient?.ws.readyState === WebSocket.OPEN) { targetClient.ws.send(JSON.stringify(messageToSend)); } } }); this.emit('event', room, eventData, client); } handleSync(client, message) { const room = message.room; if (!room) return; const roomEvents = this.events.get(room) || []; const fromTimestamp = message.data?.fromTimestamp || 0; const eventsToSync = roomEvents.filter(event => (event._meta?.timestamp || 0) > fromTimestamp); this.sendToClient(client.id, { type: 'sync', room, events: eventsToSync, timestamp: Date.now() }); } handleDisconnect(client) { // Remove from all rooms client.rooms.forEach(room => { this.removeClientFromRoom(client, room); }); this.clients.delete(client.id); this.log(`Client disconnected: ${client.id}`); this.emit('client-disconnected', client); } handleError(client, error) { this.log(`Client error (${client.id}):`, error); this.emit('client-error', client, error); } removeClientFromRoom(client, room) { client.rooms.delete(room); const roomClients = this.rooms.get(room); if (roomClients) { roomClients.delete(client.id); // Notify others this.broadcast(room, { type: 'user-left', clientId: client.id, userId: client.userId }); // Clean up empty rooms if (roomClients.size === 0) { this.rooms.delete(room); } } this.emit('client-left-room', client, room); } pingClients() { this.clients.forEach(client => { if (client.ws.readyState === WebSocket.OPEN) { client.ws.ping(); } }); } cleanup() { const now = Date.now(); const timeout = 60000; // 1 minute this.clients.forEach(client => { if (now - client.lastPing > timeout) { client.ws.terminate(); this.handleDisconnect(client); } }); } generateClientId() { return `client-${Date.now()}-${Math.random().toString(36).slice(2)}`; } log(...args) { if (this.config.debug) { console.log('[kSync]', ...args); } } } // 🎯 Quick start helpers export function createKSyncServer(config) { return new KSyncServer(config); } // 🚀 One-liner server start export async function startKSyncServer(port = 8080) { const server = new KSyncServer({ port, debug: true }); await server.start(); return server; }