/**
* Unified WebSocket Manager
*
* Provides a single persistent WebSocket connection with:
* - Automatic reconnection with exponential backoff
* - Message routing via pub/sub pattern
* - Activity tracking
* - Support for authenticated and anonymous users
* - Connection state management
*/
import { MessageRouter } from './message-router';
export enum ConnectionState {
DISCONNECTED = 'disconnected',
CONNECTING = 'connecting',
CONNECTED = 'connected',
RECONNECTING = 'reconnecting',
FAILED = 'failed',
}
// Reconnection configuration
const INITIAL_RETRY_DELAY = 1000; // 1 second
const MAX_RETRY_DELAY = 30000; // 30 seconds
const MAX_RETRIES = Infinity; // Never give up
export class UnifiedWebSocketManager {
private ws: WebSocket | null = null;
private state: ConnectionState = ConnectionState.DISCONNECTED;
private stateListeners: Set<(state: ConnectionState) => void> = new Set();
private router: MessageRouter;
private retryCount = 0;
private retryTimeout: number | null = null;
private heartbeatInterval: number | null = null;
private sessionId: string | null = null;
constructor() {
this.router = new MessageRouter();
}
/**
* Get current connection state.
*/
getState(): ConnectionState {
return this.state;
}
/**
* Register a listener for connection state changes.
*
* @param callback - Function called when state changes
* @returns Unsubscribe function
*/
onStateChange(callback: (state: ConnectionState) => void): () => void {
this.stateListeners.add(callback);
return () => this.stateListeners.delete(callback);
}
/**
* Subscribe to messages on a specific topic.
*
* @param topic - Topic to subscribe to
* @param callback - Function called when message arrives
* @param filter - Optional filter function
* @returns Unsubscribe function
*/
subscribe(
topic: string,
callback: (message: any) => void,
filter?: (message: any) => boolean
): () => void {
return this.router.subscribe(topic, callback, filter);
}
/**
* Connect to the unified WebSocket endpoint.
*
* Uses message-based authentication: connects first, then sends auth message.
* This avoids exposing tokens in URLs (more secure than query params).
*/
async connect(): Promise<void> {
if (
this.state === ConnectionState.CONNECTING ||
this.state === ConnectionState.CONNECTED
) {
console.log('WebSocket already connecting or connected');
return;
}
this.setState(ConnectionState.CONNECTING);
try {
// Determine protocol
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
const host = window.location.host;
// Build URL with fingerprint only (no token in URL)
const params = new URLSearchParams();
const fingerprint = await this.getBrowserFingerprint();
params.append('fingerprint', fingerprint);
const url = `${protocol}://${host}/api/v1/ws/unified?${params.toString()}`;
// Create WebSocket connection
this.ws = new WebSocket(url);
// Setup event handlers
this.ws.onopen = this.handleOpen.bind(this);
this.ws.onmessage = this.handleMessage.bind(this);
this.ws.onclose = this.handleClose.bind(this);
this.ws.onerror = this.handleError.bind(this);
} catch (error) {
console.error('Failed to create WebSocket connection:', error);
this.scheduleReconnect();
}
}
/**
* Disconnect from WebSocket.
*
* @param code - Close code (1000 = normal closure)
*/
disconnect(code: number = 1000): void {
// Cancel any pending reconnection
if (this.retryTimeout !== null) {
clearTimeout(this.retryTimeout);
this.retryTimeout = null;
}
// Stop heartbeat
this.stopHeartbeat();
// Close WebSocket
if (this.ws) {
this.ws.close(code);
this.ws = null;
}
this.setState(ConnectionState.DISCONNECTED);
}
/**
* Send a message through the WebSocket.
*
* @param data - Data to send
* @returns true if sent successfully, false otherwise
*/
send(data: any): boolean {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
return true;
}
console.warn('Cannot send message: WebSocket not connected');
return false;
}
/**
* Handle WebSocket open event.
*/
private handleOpen(): void {
console.log('Unified WebSocket connected');
// Reset retry count on successful connection
this.retryCount = 0;
this.setState(ConnectionState.CONNECTED);
// Authenticate via message if we have a token
const token = localStorage.getItem('accessToken');
if (token) {
this.send({ type: 'authenticate', token });
console.log('Sent authentication message');
}
// Start heartbeat
this.startHeartbeat();
}
/**
* Handle incoming WebSocket message.
*/
private handleMessage(event: MessageEvent): void {
try {
const message = JSON.parse(event.data);
// Handle handshake
if (message.type === 'handshake') {
this.sessionId = message.session_id;
console.log(
`Session established: ${this.sessionId}, authenticated: ${message.authenticated}`
);
return;
}
// Handle authentication response
if (message.type === 'authenticated') {
console.log(`Authenticated as: ${message.user?.username}`);
// Route to subscribers so they can react (e.g., refresh data)
this.router.route(message);
return;
}
// Handle authentication error
if (message.type === 'auth_error') {
console.warn(`Authentication failed: ${message.error}`);
// Token might be expired, clear it
localStorage.removeItem('accessToken');
return;
}
// Handle ping
if (message.type === 'ping') {
this.send({ type: 'pong' });
return;
}
// Route message to subscribers
this.router.route(message);
} catch (error) {
console.error('Failed to process WebSocket message:', error);
}
}
/**
* Handle WebSocket close event.
*/
private handleClose(event: CloseEvent): void {
console.log(`WebSocket closed: code=${event.code}, reason=${event.reason}`);
this.stopHeartbeat();
this.ws = null;
// Only reconnect if not a normal closure
if (event.code !== 1000) {
this.setState(ConnectionState.RECONNECTING);
this.scheduleReconnect();
} else {
this.setState(ConnectionState.DISCONNECTED);
}
}
/**
* Handle WebSocket error event.
*/
private handleError(event: Event): void {
console.error('WebSocket error:', event);
// Error will be followed by close event, which handles reconnection
}
/**
* Schedule a reconnection attempt with exponential backoff.
*/
private scheduleReconnect(): void {
if (this.retryCount >= MAX_RETRIES) {
console.error('Max reconnection attempts reached');
this.setState(ConnectionState.FAILED);
return;
}
const delay = this.calculateRetryDelay(this.retryCount);
console.log(`Reconnecting in ${delay}ms (attempt ${this.retryCount + 1})`);
this.retryTimeout = window.setTimeout(() => {
this.retryCount++;
this.connect();
}, delay);
}
/**
* Calculate retry delay with exponential backoff and jitter.
*/
private calculateRetryDelay(retryCount: number): number {
const exponentialDelay = Math.min(
INITIAL_RETRY_DELAY * Math.pow(2, retryCount),
MAX_RETRY_DELAY
);
// Add jitter (±25%)
const jitter = exponentialDelay * 0.25 * (Math.random() - 0.5);
return exponentialDelay + jitter;
}
/**
* Start heartbeat to keep connection alive.
*/
private startHeartbeat(): void {
// Send pong every 30 seconds as keepalive
this.heartbeatInterval = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 'pong' });
}
}, 30000);
}
/**
* Stop heartbeat interval.
*/
private stopHeartbeat(): void {
if (this.heartbeatInterval !== null) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
/**
* Update connection state and notify listeners.
*/
private setState(newState: ConnectionState): void {
if (this.state !== newState) {
this.state = newState;
this.stateListeners.forEach((listener) => {
try {
listener(newState);
} catch (error) {
console.error('Error in state change listener:', error);
}
});
}
}
/**
* Get or generate browser fingerprint for anonymous user tracking.
*/
private async getBrowserFingerprint(): Promise<string> {
// Check if we already have a fingerprint
let fingerprint = localStorage.getItem('browserFingerprint');
if (!fingerprint) {
// Generate fingerprint from browser characteristics
const components = [
navigator.userAgent,
navigator.language,
screen.colorDepth,
screen.width,
screen.height,
new Date().getTimezoneOffset(),
!!window.sessionStorage,
!!window.localStorage,
];
// Create hash from components
const componentString = components.join('|');
fingerprint = await this.simpleHash(componentString);
// Store for future use
localStorage.setItem('browserFingerprint', fingerprint);
}
return fingerprint;
}
/**
* Simple hash function for browser fingerprint.
*/
private async simpleHash(str: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(str);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
return hashHex.substring(0, 32); // First 32 chars
}
}
// Global singleton instance
export const unifiedWebSocketManager = new UnifiedWebSocketManager();