224 lines
5.5 KiB
TypeScript
224 lines
5.5 KiB
TypeScript
import type { Notification, ConnectionState, NotifyEventHandlers } from './types';
|
|
|
|
/**
|
|
* WebSocket manager for real-time notifications.
|
|
*/
|
|
export class WebSocketManager {
|
|
private ws: WebSocket | null = null;
|
|
private readonly wsUrl: string;
|
|
private readonly apiKey: string;
|
|
private readonly debug: boolean;
|
|
private readonly autoReconnect: boolean;
|
|
private readonly maxReconnectAttempts: number;
|
|
private readonly reconnectDelay: number;
|
|
|
|
private reconnectAttempts = 0;
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
private handlers: NotifyEventHandlers = {};
|
|
private subscriptions: Set<string> = new Set();
|
|
private _state: ConnectionState = 'disconnected';
|
|
|
|
constructor(
|
|
wsUrl: string,
|
|
apiKey: string,
|
|
debug: boolean,
|
|
autoReconnect: boolean,
|
|
maxReconnectAttempts: number,
|
|
reconnectDelay: number
|
|
) {
|
|
this.wsUrl = wsUrl;
|
|
this.apiKey = apiKey;
|
|
this.debug = debug;
|
|
this.autoReconnect = autoReconnect;
|
|
this.maxReconnectAttempts = maxReconnectAttempts;
|
|
this.reconnectDelay = reconnectDelay;
|
|
}
|
|
|
|
/**
|
|
* Get connection state.
|
|
*/
|
|
get state(): ConnectionState {
|
|
return this._state;
|
|
}
|
|
|
|
/**
|
|
* Set event handlers.
|
|
*/
|
|
setHandlers(handlers: NotifyEventHandlers): void {
|
|
this.handlers = { ...this.handlers, ...handlers };
|
|
}
|
|
|
|
/**
|
|
* Connect to the WebSocket server.
|
|
*/
|
|
connect(): void {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
return;
|
|
}
|
|
|
|
if (typeof WebSocket === 'undefined') {
|
|
if (this.debug) {
|
|
console.warn('[IronNotify] WebSocket not available in this environment');
|
|
}
|
|
return;
|
|
}
|
|
|
|
this.setState('connecting');
|
|
|
|
try {
|
|
const url = `${this.wsUrl}?token=${encodeURIComponent(this.apiKey)}`;
|
|
this.ws = new WebSocket(url);
|
|
|
|
this.ws.onopen = () => {
|
|
this.reconnectAttempts = 0;
|
|
this.setState('connected');
|
|
|
|
if (this.debug) {
|
|
console.log('[IronNotify] WebSocket connected');
|
|
}
|
|
|
|
// Re-subscribe to any previous subscriptions
|
|
this.subscriptions.forEach((sub) => {
|
|
this.sendMessage({ type: 'subscribe', channel: sub });
|
|
});
|
|
};
|
|
|
|
this.ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
this.handleMessage(data);
|
|
} catch (err) {
|
|
if (this.debug) {
|
|
console.error('[IronNotify] Failed to parse message:', err);
|
|
}
|
|
}
|
|
};
|
|
|
|
this.ws.onerror = (event) => {
|
|
if (this.debug) {
|
|
console.error('[IronNotify] WebSocket error:', event);
|
|
}
|
|
this.handlers.onError?.(new Error('WebSocket error'));
|
|
};
|
|
|
|
this.ws.onclose = () => {
|
|
this.setState('disconnected');
|
|
|
|
if (this.debug) {
|
|
console.log('[IronNotify] WebSocket disconnected');
|
|
}
|
|
|
|
if (this.autoReconnect && this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
this.scheduleReconnect();
|
|
}
|
|
};
|
|
} catch (err) {
|
|
if (this.debug) {
|
|
console.error('[IronNotify] Failed to connect:', err);
|
|
}
|
|
this.handlers.onError?.(err instanceof Error ? err : new Error(String(err)));
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disconnect from the WebSocket server.
|
|
*/
|
|
disconnect(): void {
|
|
if (this.reconnectTimer) {
|
|
clearTimeout(this.reconnectTimer);
|
|
this.reconnectTimer = null;
|
|
}
|
|
|
|
this.reconnectAttempts = this.maxReconnectAttempts; // Prevent reconnection
|
|
|
|
if (this.ws) {
|
|
this.ws.close();
|
|
this.ws = null;
|
|
}
|
|
|
|
this.setState('disconnected');
|
|
}
|
|
|
|
/**
|
|
* Subscribe to a user's notifications.
|
|
*/
|
|
subscribeToUser(userId: string): void {
|
|
const channel = `user:${userId}`;
|
|
this.subscriptions.add(channel);
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.sendMessage({ type: 'subscribe', channel });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Subscribe to app-wide notifications.
|
|
*/
|
|
subscribeToApp(): void {
|
|
const channel = 'app';
|
|
this.subscriptions.add(channel);
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.sendMessage({ type: 'subscribe', channel });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unsubscribe from a channel.
|
|
*/
|
|
unsubscribe(channel: string): void {
|
|
this.subscriptions.delete(channel);
|
|
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.sendMessage({ type: 'unsubscribe', channel });
|
|
}
|
|
}
|
|
|
|
private setState(state: ConnectionState): void {
|
|
this._state = state;
|
|
this.handlers.onConnectionStateChange?.(state);
|
|
}
|
|
|
|
private scheduleReconnect(): void {
|
|
this.setState('reconnecting');
|
|
this.reconnectAttempts++;
|
|
|
|
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1);
|
|
|
|
if (this.debug) {
|
|
console.log(`[IronNotify] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
|
}
|
|
|
|
this.reconnectTimer = setTimeout(() => {
|
|
this.connect();
|
|
}, delay);
|
|
}
|
|
|
|
private sendMessage(message: object): void {
|
|
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
this.ws.send(JSON.stringify(message));
|
|
}
|
|
}
|
|
|
|
private handleMessage(data: any): void {
|
|
switch (data.type) {
|
|
case 'notification':
|
|
this.handlers.onNotification?.(data.notification as Notification);
|
|
break;
|
|
|
|
case 'unread_count':
|
|
this.handlers.onUnreadCountChange?.(data.count);
|
|
break;
|
|
|
|
case 'error':
|
|
this.handlers.onError?.(new Error(data.message));
|
|
break;
|
|
|
|
default:
|
|
if (this.debug) {
|
|
console.log('[IronNotify] Unknown message type:', data.type);
|
|
}
|
|
}
|
|
}
|
|
}
|