460 lines
12 KiB
JavaScript
460 lines
12 KiB
JavaScript
/**
|
|
* WebSocket Manager
|
|
* Handles real-time communication with the VNA backend
|
|
*/
|
|
|
|
export class WebSocketManager {
|
|
constructor(config, notifications) {
|
|
this.config = config;
|
|
this.notifications = notifications;
|
|
this.ws = null;
|
|
this.isConnected = false;
|
|
this.isConnecting = false;
|
|
this.reconnectAttempts = 0;
|
|
this.reconnectTimer = null;
|
|
this.pingTimer = null;
|
|
this.lastPing = null;
|
|
this.eventListeners = new Map();
|
|
|
|
// Message queue for when disconnected
|
|
this.messageQueue = [];
|
|
this.maxQueueSize = 100;
|
|
|
|
// Statistics
|
|
this.stats = {
|
|
messagesReceived: 0,
|
|
messagesPerSecond: 0,
|
|
lastMessageTime: null,
|
|
connectionTime: null,
|
|
bytesReceived: 0
|
|
};
|
|
|
|
// Rate limiting for message processing
|
|
this.messageRateCounter = [];
|
|
this.rateWindowMs = 1000;
|
|
}
|
|
|
|
/**
|
|
* Connect to WebSocket server
|
|
*/
|
|
async connect() {
|
|
if (this.isConnected || this.isConnecting) {
|
|
console.log('🔌 WebSocket already connected/connecting');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.isConnecting = true;
|
|
this.emit('connecting');
|
|
|
|
console.log(`🔌 Connecting to WebSocket: ${this.config.url}`);
|
|
|
|
this.ws = new WebSocket(this.config.url);
|
|
this.setupWebSocketEvents();
|
|
|
|
// Wait for connection or timeout
|
|
await this.waitForConnection(5000);
|
|
|
|
} catch (error) {
|
|
this.isConnecting = false;
|
|
console.error('❌ WebSocket connection failed:', error);
|
|
this.handleConnectionError(error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for WebSocket connection with timeout
|
|
*/
|
|
waitForConnection(timeoutMs) {
|
|
return new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error('WebSocket connection timeout'));
|
|
}, timeoutMs);
|
|
|
|
const checkConnection = () => {
|
|
if (this.isConnected) {
|
|
clearTimeout(timeout);
|
|
resolve();
|
|
} else if (this.ws?.readyState === WebSocket.CLOSED ||
|
|
this.ws?.readyState === WebSocket.CLOSING) {
|
|
clearTimeout(timeout);
|
|
reject(new Error('WebSocket connection failed'));
|
|
}
|
|
};
|
|
|
|
// Check immediately and then every 100ms
|
|
checkConnection();
|
|
const interval = setInterval(() => {
|
|
checkConnection();
|
|
if (this.isConnected) {
|
|
clearInterval(interval);
|
|
}
|
|
}, 100);
|
|
|
|
// Clean up interval on resolve/reject
|
|
const originalResolve = resolve;
|
|
const originalReject = reject;
|
|
resolve = (...args) => {
|
|
clearInterval(interval);
|
|
originalResolve(...args);
|
|
};
|
|
reject = (...args) => {
|
|
clearInterval(interval);
|
|
originalReject(...args);
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set up WebSocket event handlers
|
|
*/
|
|
setupWebSocketEvents() {
|
|
if (!this.ws) return;
|
|
|
|
this.ws.onopen = (event) => {
|
|
console.log('✅ WebSocket connected');
|
|
this.isConnected = true;
|
|
this.isConnecting = false;
|
|
this.reconnectAttempts = 0;
|
|
this.stats.connectionTime = new Date();
|
|
|
|
this.startPingPong();
|
|
this.processPendingMessages();
|
|
this.emit('connected', event);
|
|
|
|
this.notifications.show({
|
|
type: 'success',
|
|
title: 'Connected',
|
|
message: 'Real-time connection established'
|
|
});
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
this.handleMessage(event.data);
|
|
} catch (error) {
|
|
console.error('❌ Error processing WebSocket message:', error);
|
|
this.notifications.show({
|
|
type: 'error',
|
|
title: 'Message Error',
|
|
message: 'Failed to process received data'
|
|
});
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('❌ WebSocket error:', error);
|
|
this.handleConnectionError(error);
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
console.log('🔌 WebSocket closed:', event.code, event.reason);
|
|
this.handleDisconnection(event);
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Handle incoming messages
|
|
*/
|
|
handleMessage(data) {
|
|
// Update statistics
|
|
this.stats.messagesReceived++;
|
|
this.stats.lastMessageTime = new Date();
|
|
this.stats.bytesReceived += data.length;
|
|
|
|
// Rate limiting check
|
|
this.updateMessageRate();
|
|
|
|
try {
|
|
let parsedData;
|
|
|
|
// Handle different message types
|
|
if (typeof data === 'string') {
|
|
if (data === 'ping') {
|
|
this.handlePing();
|
|
return;
|
|
} else if (data === 'pong') {
|
|
this.handlePong();
|
|
return;
|
|
} else {
|
|
parsedData = JSON.parse(data);
|
|
}
|
|
} else {
|
|
// Handle binary data if needed
|
|
console.warn('⚠️ Received binary data, not implemented');
|
|
return;
|
|
}
|
|
|
|
// Emit data event
|
|
this.emit('data', parsedData);
|
|
|
|
} catch (error) {
|
|
console.error('❌ Failed to parse WebSocket message:', error);
|
|
console.log('📝 Raw message:', data);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update message rate statistics
|
|
*/
|
|
updateMessageRate() {
|
|
const now = Date.now();
|
|
this.messageRateCounter.push(now);
|
|
|
|
// Remove messages outside the rate window
|
|
this.messageRateCounter = this.messageRateCounter.filter(
|
|
time => now - time < this.rateWindowMs
|
|
);
|
|
|
|
this.stats.messagesPerSecond = this.messageRateCounter.length;
|
|
}
|
|
|
|
/**
|
|
* Handle ping message
|
|
*/
|
|
handlePing() {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.ws.send('pong');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle pong message
|
|
*/
|
|
handlePong() {
|
|
if (this.lastPing) {
|
|
const latency = Date.now() - this.lastPing;
|
|
console.log(`🏓 WebSocket latency: ${latency}ms`);
|
|
this.lastPing = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start ping-pong mechanism
|
|
*/
|
|
startPingPong() {
|
|
this.pingTimer = setInterval(() => {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.lastPing = Date.now();
|
|
this.ws.send('ping');
|
|
}
|
|
}, 30000); // Ping every 30 seconds
|
|
}
|
|
|
|
/**
|
|
* Handle connection errors
|
|
*/
|
|
handleConnectionError(error) {
|
|
console.error('❌ WebSocket connection error:', error);
|
|
|
|
if (!this.isConnected) {
|
|
this.notifications.show({
|
|
type: 'error',
|
|
title: 'Connection Failed',
|
|
message: 'Unable to establish real-time connection'
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle disconnection
|
|
*/
|
|
handleDisconnection(event) {
|
|
const wasConnected = this.isConnected;
|
|
this.isConnected = false;
|
|
this.isConnecting = false;
|
|
|
|
// Clean up timers
|
|
if (this.pingTimer) {
|
|
clearInterval(this.pingTimer);
|
|
this.pingTimer = null;
|
|
}
|
|
|
|
this.emit('disconnected', event);
|
|
|
|
if (wasConnected) {
|
|
this.notifications.show({
|
|
type: 'warning',
|
|
title: 'Disconnected',
|
|
message: 'Real-time connection lost. Attempting to reconnect...'
|
|
});
|
|
|
|
// Auto-reconnect
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Schedule automatic reconnection
|
|
*/
|
|
scheduleReconnect() {
|
|
if (this.reconnectTimer) return;
|
|
|
|
const delay = Math.min(
|
|
this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts),
|
|
30000 // Max 30 seconds
|
|
);
|
|
|
|
console.log(`🔄 Scheduling reconnect in ${delay}ms (attempt ${this.reconnectAttempts + 1})`);
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.reconnectTimer = null;
|
|
this.reconnect();
|
|
}, delay);
|
|
}
|
|
|
|
/**
|
|
* Manually trigger reconnection
|
|
*/
|
|
async reconnect() {
|
|
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
console.error('❌ Max reconnection attempts reached');
|
|
this.notifications.show({
|
|
type: 'error',
|
|
title: 'Connection Failed',
|
|
message: 'Unable to reconnect after multiple attempts'
|
|
});
|
|
return;
|
|
}
|
|
|
|
this.reconnectAttempts++;
|
|
|
|
// Close existing connection
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
|
|
try {
|
|
await this.connect();
|
|
} catch (error) {
|
|
console.error(`❌ Reconnection attempt ${this.reconnectAttempts} failed:`, error);
|
|
this.scheduleReconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Send message
|
|
*/
|
|
send(data) {
|
|
if (!this.isConnected || !this.ws) {
|
|
// Queue message for later
|
|
if (this.messageQueue.length < this.maxQueueSize) {
|
|
this.messageQueue.push(data);
|
|
console.log('📤 Message queued (not connected)');
|
|
} else {
|
|
console.warn('⚠️ Message queue full, dropping message');
|
|
}
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
const message = typeof data === 'string' ? data : JSON.stringify(data);
|
|
this.ws.send(message);
|
|
return true;
|
|
} catch (error) {
|
|
console.error('❌ Failed to send message:', error);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process pending messages after reconnection
|
|
*/
|
|
processPendingMessages() {
|
|
if (this.messageQueue.length > 0) {
|
|
console.log(`📤 Processing ${this.messageQueue.length} queued messages`);
|
|
|
|
const messages = [...this.messageQueue];
|
|
this.messageQueue = [];
|
|
|
|
messages.forEach(message => {
|
|
this.send(message);
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect WebSocket
|
|
*/
|
|
disconnect() {
|
|
console.log('🔌 Disconnecting WebSocket');
|
|
|
|
// Clear reconnection timer
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
|
|
// Clear ping timer
|
|
if (this.pingTimer) {
|
|
clearInterval(this.pingTimer);
|
|
this.pingTimer = null;
|
|
}
|
|
|
|
// Close WebSocket
|
|
if (this.ws) {
|
|
this.ws.close(1000, 'Manual disconnect');
|
|
this.ws = null;
|
|
}
|
|
|
|
this.isConnected = false;
|
|
this.isConnecting = false;
|
|
this.reconnectAttempts = 0;
|
|
}
|
|
|
|
/**
|
|
* Get connection statistics
|
|
*/
|
|
getStats() {
|
|
return {
|
|
...this.stats,
|
|
isConnected: this.isConnected,
|
|
isConnecting: this.isConnecting,
|
|
reconnectAttempts: this.reconnectAttempts,
|
|
queuedMessages: this.messageQueue.length
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Event listener management
|
|
*/
|
|
on(event, callback) {
|
|
if (!this.eventListeners.has(event)) {
|
|
this.eventListeners.set(event, []);
|
|
}
|
|
this.eventListeners.get(event).push(callback);
|
|
}
|
|
|
|
off(event, callback) {
|
|
if (this.eventListeners.has(event)) {
|
|
const listeners = this.eventListeners.get(event);
|
|
const index = listeners.indexOf(callback);
|
|
if (index > -1) {
|
|
listeners.splice(index, 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
emit(event, data) {
|
|
if (this.eventListeners.has(event)) {
|
|
this.eventListeners.get(event).forEach(callback => {
|
|
try {
|
|
callback(data);
|
|
} catch (error) {
|
|
console.error(`❌ Error in event listener for ${event}:`, error);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cleanup
|
|
*/
|
|
destroy() {
|
|
this.disconnect();
|
|
this.eventListeners.clear();
|
|
this.messageQueue = [];
|
|
}
|
|
} |