272 lines
8.4 KiB
JavaScript
272 lines
8.4 KiB
JavaScript
// Shared WebSocket connection manager
|
|
// All components should use this instead of creating their own connections
|
|
|
|
class WebSocketManager {
|
|
constructor() {
|
|
this.ws = null;
|
|
this.listeners = new Map(); // Map of listener IDs to callback functions
|
|
this.reconnectTimeout = null;
|
|
this.reconnectDelay = 2000;
|
|
this.isConnecting = false;
|
|
this.listenerIdCounter = 0;
|
|
this.verboseLogging = false; // Set to true to enable verbose WebSocket logging
|
|
|
|
// Track server-side channel subscriptions for re-subscription on reconnect
|
|
this.serverSubscriptions = new Set(); // Channels we want to be subscribed to
|
|
this.confirmedSubscriptions = new Set(); // Channels confirmed by server
|
|
this.pendingSubscriptions = new Set(); // Channels waiting for confirmation
|
|
}
|
|
|
|
connect() {
|
|
// If already connected or connecting, don't create a new connection
|
|
if (this.ws && (this.ws.readyState === WebSocket.CONNECTING || this.ws.readyState === WebSocket.OPEN)) {
|
|
return;
|
|
}
|
|
|
|
if (this.isConnecting) {
|
|
return;
|
|
}
|
|
|
|
this.isConnecting = true;
|
|
|
|
try {
|
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsHost = window.location.host;
|
|
const API_BASE = '/api';
|
|
const url = `${wsProtocol}//${wsHost}${API_BASE}/jobs/ws`;
|
|
|
|
this.ws = new WebSocket(url);
|
|
|
|
this.ws.onopen = () => {
|
|
if (this.verboseLogging) {
|
|
console.log('Shared WebSocket connected');
|
|
}
|
|
this.isConnecting = false;
|
|
|
|
// Re-subscribe to all channels that were previously subscribed
|
|
this.resubscribeToChannels();
|
|
|
|
this.notifyListeners('open', {});
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Message received:', data.type, data.channel || 'no channel', data);
|
|
}
|
|
this.notifyListeners('message', data);
|
|
} catch (error) {
|
|
console.error('WebSocketManager: Failed to parse message:', error, 'Raw data:', event.data);
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (error) => {
|
|
console.error('Shared WebSocket error:', error);
|
|
this.isConnecting = false;
|
|
this.notifyListeners('error', error);
|
|
};
|
|
|
|
this.ws.onclose = (event) => {
|
|
if (this.verboseLogging) {
|
|
console.log('Shared WebSocket closed:', {
|
|
code: event.code,
|
|
reason: event.reason,
|
|
wasClean: event.wasClean
|
|
});
|
|
}
|
|
this.ws = null;
|
|
this.isConnecting = false;
|
|
|
|
// Clear confirmed/pending but keep serverSubscriptions for re-subscription
|
|
this.confirmedSubscriptions.clear();
|
|
this.pendingSubscriptions.clear();
|
|
|
|
this.notifyListeners('close', event);
|
|
|
|
// Always retry connection if we have listeners
|
|
if (this.listeners.size > 0) {
|
|
if (this.reconnectTimeout) {
|
|
clearTimeout(this.reconnectTimeout);
|
|
}
|
|
this.reconnectTimeout = setTimeout(() => {
|
|
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
|
this.connect();
|
|
}
|
|
}, this.reconnectDelay);
|
|
}
|
|
};
|
|
} catch (error) {
|
|
console.error('Failed to create WebSocket:', error);
|
|
this.isConnecting = false;
|
|
// Retry after delay
|
|
this.reconnectTimeout = setTimeout(() => {
|
|
this.connect();
|
|
}, this.reconnectDelay);
|
|
}
|
|
}
|
|
|
|
subscribe(listenerId, callbacks) {
|
|
// Generate ID if not provided
|
|
if (!listenerId) {
|
|
listenerId = `listener_${this.listenerIdCounter++}`;
|
|
}
|
|
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Subscribing listener:', listenerId, 'WebSocket state:', this.ws ? this.ws.readyState : 'no connection');
|
|
}
|
|
this.listeners.set(listenerId, callbacks);
|
|
|
|
// Connect if not already connected
|
|
if (!this.ws || this.ws.readyState === WebSocket.CLOSED) {
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: WebSocket not connected, connecting...');
|
|
}
|
|
this.connect();
|
|
}
|
|
|
|
// If already open, notify immediately
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN && callbacks.open) {
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: WebSocket already open, calling open callback for listener:', listenerId);
|
|
}
|
|
// Use setTimeout to ensure this happens after the listener is registered
|
|
setTimeout(() => {
|
|
if (callbacks.open) {
|
|
callbacks.open();
|
|
}
|
|
}, 0);
|
|
}
|
|
|
|
return listenerId;
|
|
}
|
|
|
|
unsubscribe(listenerId) {
|
|
this.listeners.delete(listenerId);
|
|
|
|
// If no more listeners, we could close the connection, but let's keep it open
|
|
// in case other components need it
|
|
}
|
|
|
|
send(data) {
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Sending message:', data);
|
|
}
|
|
this.ws.send(JSON.stringify(data));
|
|
} else {
|
|
console.warn('WebSocketManager: Cannot send message - connection not open. State:', this.ws ? this.ws.readyState : 'no connection', 'Message:', data);
|
|
}
|
|
}
|
|
|
|
notifyListeners(eventType, data) {
|
|
this.listeners.forEach((callbacks) => {
|
|
if (callbacks[eventType]) {
|
|
try {
|
|
callbacks[eventType](data);
|
|
} catch (error) {
|
|
console.error('Error in WebSocket listener:', error);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
getReadyState() {
|
|
return this.ws ? this.ws.readyState : WebSocket.CLOSED;
|
|
}
|
|
|
|
// Subscribe to a server-side channel (will be re-subscribed on reconnect)
|
|
subscribeToChannel(channel) {
|
|
if (this.serverSubscriptions.has(channel)) {
|
|
// Already subscribed or pending
|
|
return;
|
|
}
|
|
|
|
this.serverSubscriptions.add(channel);
|
|
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
if (!this.confirmedSubscriptions.has(channel) && !this.pendingSubscriptions.has(channel)) {
|
|
this.pendingSubscriptions.add(channel);
|
|
this.send({ type: 'subscribe', channel });
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Subscribing to channel:', channel);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Unsubscribe from a server-side channel (won't be re-subscribed on reconnect)
|
|
unsubscribeFromChannel(channel) {
|
|
this.serverSubscriptions.delete(channel);
|
|
this.confirmedSubscriptions.delete(channel);
|
|
this.pendingSubscriptions.delete(channel);
|
|
|
|
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
this.send({ type: 'unsubscribe', channel });
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Unsubscribing from channel:', channel);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark a channel subscription as confirmed (call this when server confirms)
|
|
confirmSubscription(channel) {
|
|
this.pendingSubscriptions.delete(channel);
|
|
this.confirmedSubscriptions.add(channel);
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Subscription confirmed for channel:', channel);
|
|
}
|
|
}
|
|
|
|
// Mark a channel subscription as failed (call this when server rejects)
|
|
failSubscription(channel) {
|
|
this.pendingSubscriptions.delete(channel);
|
|
this.serverSubscriptions.delete(channel);
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Subscription failed for channel:', channel);
|
|
}
|
|
}
|
|
|
|
// Check if subscribed to a channel
|
|
isSubscribedToChannel(channel) {
|
|
return this.confirmedSubscriptions.has(channel);
|
|
}
|
|
|
|
// Re-subscribe to all channels after reconnect
|
|
resubscribeToChannels() {
|
|
if (this.serverSubscriptions.size === 0) {
|
|
return;
|
|
}
|
|
|
|
if (this.verboseLogging) {
|
|
console.log('WebSocketManager: Re-subscribing to channels:', Array.from(this.serverSubscriptions));
|
|
}
|
|
|
|
for (const channel of this.serverSubscriptions) {
|
|
if (!this.pendingSubscriptions.has(channel)) {
|
|
this.pendingSubscriptions.add(channel);
|
|
this.send({ type: 'subscribe', channel });
|
|
}
|
|
}
|
|
}
|
|
|
|
disconnect() {
|
|
if (this.reconnectTimeout) {
|
|
clearTimeout(this.reconnectTimeout);
|
|
this.reconnectTimeout = null;
|
|
}
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
this.listeners.clear();
|
|
this.serverSubscriptions.clear();
|
|
this.confirmedSubscriptions.clear();
|
|
this.pendingSubscriptions.clear();
|
|
}
|
|
}
|
|
|
|
// Export singleton instance
|
|
export const wsManager = new WebSocketManager();
|
|
|