diff --git a/docs/NOTIFICATION_SYSTEM_REFACTORING.md b/docs/NOTIFICATION_SYSTEM_REFACTORING.md index ba1e0e5c..3edae773 100644 --- a/docs/NOTIFICATION_SYSTEM_REFACTORING.md +++ b/docs/NOTIFICATION_SYSTEM_REFACTORING.md @@ -234,6 +234,28 @@ The refactoring maintains performance characteristics: - LocalStorage operations remain unchanged - WebSocket integration unaffected +## WebSocket Reconnection (Issue #405) + +The real-time notification provider now uses a dedicated `NotificationSocketService` +(`src/lib/notifications/socket.ts`) with production-ready reconnection behavior: + +- **Exponential backoff** with configurable jitter to avoid thundering herds +- **Automatic reconnect** on unexpected disconnects and connection errors +- **Outbound message queue** so read/clear actions are sent after reconnect +- **Browser lifecycle hooks** (`online`, `visibilitychange`) for faster recovery +- **Connection state API** exposed via `NotificationProvider` context (`connectionState`) +- **Graceful shutdown** that clears timers, listeners, and pending reconnect attempts + +Example: + +```typescript +const { connectionState } = useNotifications(); + +if (connectionState.status === 'reconnecting') { + // show subtle reconnecting indicator in the notification UI +} +``` + ## Future Enhancements Potential areas for future improvement: diff --git a/src/lib/notifications/__tests__/socket.test.ts b/src/lib/notifications/__tests__/socket.test.ts new file mode 100644 index 00000000..e9503db5 --- /dev/null +++ b/src/lib/notifications/__tests__/socket.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationSocketService } from '../socket'; + +type WebSocketListener = ((event?: Event | MessageEvent | CloseEvent) => void) | null; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + static OPEN = 1; + static CONNECTING = 0; + static CLOSING = 2; + static CLOSED = 3; + + readonly url: string; + readyState = MockWebSocket.CONNECTING; + onopen: WebSocketListener = null; + onmessage: WebSocketListener = null; + onclose: WebSocketListener = null; + onerror: WebSocketListener = null; + readonly sent: string[] = []; + + constructor(url: string) { + this.url = url; + MockWebSocket.instances.push(this); + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({} as CloseEvent); + } + + simulateOpen(): void { + this.readyState = MockWebSocket.OPEN; + this.onopen?.({} as Event); + } + + simulateMessage(data: unknown): void { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + } + + simulateError(): void { + this.onerror?.({} as Event); + } +} + +describe('NotificationSocketService', () => { + beforeEach(() => { + vi.useFakeTimers(); + MockWebSocket.instances = []; + vi.stubGlobal('WebSocket', MockWebSocket); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('connects and delivers incoming notifications', () => { + const service = new NotificationSocketService('wss://example.com/notifications'); + const received: Array<{ id: string }> = []; + + service.subscribe((notification) => received.push(notification)); + service.connect(); + + const socket = MockWebSocket.instances[0]; + socket.simulateOpen(); + socket.simulateMessage({ + event: 'notification', + payload: { + id: 'n-1', + type: 'info', + title: 'Hello', + timestamp: '2026-06-24T10:00:00.000Z', + read: false, + }, + }); + + expect(received).toHaveLength(1); + expect(received[0].id).toBe('n-1'); + expect(service.getConnectionState().status).toBe('connected'); + }); + + it('reconnects with exponential backoff after an unexpected close', () => { + const service = new NotificationSocketService('wss://example.com/notifications', { + initialReconnectDelay: 1_000, + reconnectJitter: 0, + }); + + service.connect(); + MockWebSocket.instances[0].simulateOpen(); + MockWebSocket.instances[0].close(); + + expect(service.getConnectionState().status).toBe('reconnecting'); + expect(MockWebSocket.instances).toHaveLength(1); + + vi.advanceTimersByTime(1_000); + expect(MockWebSocket.instances).toHaveLength(2); + expect(service.getConnectionState().reconnectAttempts).toBe(1); + }); + + it('resets reconnect delay after a successful connection', () => { + const service = new NotificationSocketService('wss://example.com/notifications', { + initialReconnectDelay: 1_000, + reconnectJitter: 0, + }); + + service.connect(); + MockWebSocket.instances[0].simulateOpen(); + MockWebSocket.instances[0].close(); + + vi.advanceTimersByTime(1_000); + MockWebSocket.instances[1].simulateOpen(); + MockWebSocket.instances[1].close(); + + vi.advanceTimersByTime(999); + expect(MockWebSocket.instances).toHaveLength(2); + + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + expect(service.getConnectionState().reconnectAttempts).toBe(1); + }); + + it('queues outbound messages while disconnected and flushes on reconnect', () => { + const service = new NotificationSocketService('wss://example.com/notifications'); + + service.connect(); + service.send('notification_read', { id: 'queued-1' }); + + const socket = MockWebSocket.instances[0]; + socket.simulateOpen(); + + expect(socket.sent).toHaveLength(1); + expect(JSON.parse(socket.sent[0])).toEqual({ + event: 'notification_read', + payload: { id: 'queued-1' }, + }); + }); + + it('stops reconnecting after reaching max attempts', () => { + const service = new NotificationSocketService('wss://example.com/notifications', { + initialReconnectDelay: 100, + maxReconnectAttempts: 2, + reconnectJitter: 0, + }); + + service.connect(); + MockWebSocket.instances[0].close(); + + vi.advanceTimersByTime(100); + MockWebSocket.instances[1].close(); + + vi.advanceTimersByTime(200); + expect(MockWebSocket.instances).toHaveLength(2); + + vi.advanceTimersByTime(400); + expect(MockWebSocket.instances).toHaveLength(2); + expect(service.getConnectionState().lastError).toContain('Max reconnection attempts'); + }); + + it('does not reconnect after an intentional disconnect', () => { + const service = new NotificationSocketService('wss://example.com/notifications', { + initialReconnectDelay: 100, + reconnectJitter: 0, + }); + + service.connect(); + MockWebSocket.instances[0].simulateOpen(); + service.disconnect(); + + vi.advanceTimersByTime(500); + expect(MockWebSocket.instances).toHaveLength(1); + expect(service.getConnectionState().status).toBe('disconnected'); + }); + + it('reconnects immediately when the browser comes back online', () => { + const service = new NotificationSocketService('wss://example.com/notifications', { + initialReconnectDelay: 5_000, + reconnectJitter: 0, + }); + + service.connect(); + const initialCount = MockWebSocket.instances.length; + MockWebSocket.instances[0].close(); + + window.dispatchEvent(new Event('online')); + + expect(MockWebSocket.instances.length).toBeGreaterThan(initialCount); + }); + + it('notifies connection state subscribers', () => { + const service = new NotificationSocketService('wss://example.com/notifications'); + const states: string[] = []; + + service.onConnectionStateChange((state) => states.push(state.status)); + service.connect(); + MockWebSocket.instances[0].simulateOpen(); + + expect(states).toEqual(['idle', 'connecting', 'connected']); + }); +}); diff --git a/src/lib/notifications/index.ts b/src/lib/notifications/index.ts index 465b7e03..8aa85649 100644 --- a/src/lib/notifications/index.ts +++ b/src/lib/notifications/index.ts @@ -5,6 +5,7 @@ export * from './types'; export * from './service'; +export * from './socket'; export { generateRecommendations } from './recommendation-engine'; // Re-export utility functions from notificationUtils diff --git a/src/lib/notifications/socket.ts b/src/lib/notifications/socket.ts new file mode 100644 index 00000000..fd648c06 --- /dev/null +++ b/src/lib/notifications/socket.ts @@ -0,0 +1,283 @@ +import type { BaseNotification, NotificationEvent } from './types'; + +export type NotificationEventType = NotificationEvent['event']; + +export type NotificationSocketStatus = + | 'idle' + | 'connecting' + | 'connected' + | 'disconnected' + | 'reconnecting'; + +export interface NotificationSocketConnectionState { + status: NotificationSocketStatus; + reconnectAttempts: number; + lastConnectedAt?: Date; + lastError?: string; +} + +export interface NotificationSocketOptions { + initialReconnectDelay?: number; + maxReconnectDelay?: number; + maxReconnectAttempts?: number; + reconnectJitter?: number; +} + +type NotificationListener = (notification: BaseNotification) => void; +type ConnectionStateListener = (state: NotificationSocketConnectionState) => void; + +interface OutboundMessage { + event: NotificationEventType; + payload: unknown; +} + +const DEFAULT_INITIAL_RECONNECT_DELAY = 1_000; +const DEFAULT_MAX_RECONNECT_DELAY = 30_000; +const DEFAULT_MAX_RECONNECT_ATTEMPTS = 0; +const DEFAULT_RECONNECT_JITTER = 0.2; + +export class NotificationSocketService { + private ws: WebSocket | null = null; + private readonly listeners = new Set(); + private readonly connectionListeners = new Set(); + private reconnectTimer: ReturnType | null = null; + private reconnectDelay: number; + private readonly initialReconnectDelay: number; + private readonly maxReconnectDelay: number; + private readonly maxReconnectAttempts: number; + private readonly reconnectJitter: number; + private intentionallyClosed = false; + private reconnectAttempts = 0; + private connectionState: NotificationSocketConnectionState = { + status: 'idle', + reconnectAttempts: 0, + }; + private readonly outboundQueue: OutboundMessage[] = []; + private readonly handleOnline = () => { + if (!this.intentionallyClosed && !this.isOpen()) { + this.clearReconnectTimer(); + this.open(); + } + }; + private readonly handleVisibilityChange = () => { + if (document.visibilityState === 'visible') { + this.handleOnline(); + } + }; + + constructor( + private readonly url: string, + options: NotificationSocketOptions = {}, + ) { + this.initialReconnectDelay = options.initialReconnectDelay ?? DEFAULT_INITIAL_RECONNECT_DELAY; + this.maxReconnectDelay = options.maxReconnectDelay ?? DEFAULT_MAX_RECONNECT_DELAY; + this.maxReconnectAttempts = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS; + this.reconnectJitter = options.reconnectJitter ?? DEFAULT_RECONNECT_JITTER; + this.reconnectDelay = this.initialReconnectDelay; + } + + connect(): void { + this.intentionallyClosed = false; + this.registerNetworkListeners(); + this.open(); + } + + disconnect(): void { + this.intentionallyClosed = true; + this.unregisterNetworkListeners(); + this.clearReconnectTimer(); + this.ws?.close(); + this.ws = null; + this.outboundQueue.length = 0; + this.updateConnectionState({ + status: 'disconnected', + reconnectAttempts: 0, + lastError: undefined, + }); + } + + subscribe(listener: NotificationListener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + onConnectionStateChange(listener: ConnectionStateListener): () => void { + this.connectionListeners.add(listener); + listener(this.connectionState); + return () => this.connectionListeners.delete(listener); + } + + getConnectionState(): NotificationSocketConnectionState { + return this.connectionState; + } + + send(event: NotificationEventType, payload: unknown): void { + const message: OutboundMessage = { event, payload }; + + if (this.isOpen()) { + this.ws?.send(JSON.stringify({ event, payload })); + return; + } + + this.outboundQueue.push(message); + } + + private open(): void { + if (this.ws || this.intentionallyClosed) { + return; + } + + if (this.hasReachedMaxAttempts()) { + this.updateConnectionState({ + status: 'disconnected', + reconnectAttempts: this.reconnectAttempts, + lastError: `Max reconnection attempts (${this.maxReconnectAttempts}) reached`, + }); + return; + } + + try { + this.updateConnectionState({ + status: this.reconnectAttempts > 0 ? 'reconnecting' : 'connecting', + reconnectAttempts: this.reconnectAttempts, + }); + + this.ws = new WebSocket(this.url); + + this.ws.onopen = () => { + this.reconnectDelay = this.initialReconnectDelay; + this.reconnectAttempts = 0; + this.updateConnectionState({ + status: 'connected', + reconnectAttempts: 0, + lastConnectedAt: new Date(), + lastError: undefined, + }); + this.flushOutboundQueue(); + }; + + this.ws.onmessage = (event: MessageEvent) => { + try { + const data = JSON.parse(event.data as string) as NotificationEvent; + if (data.event === 'notification') { + const notification = data.payload as BaseNotification; + notification.timestamp = new Date(notification.timestamp); + this.listeners.forEach((listener) => listener(notification)); + } + } catch { + console.warn('[NotificationSocket] Failed to parse message', event.data); + } + }; + + this.ws.onclose = () => { + this.ws = null; + if (!this.intentionallyClosed) { + this.updateConnectionState({ + status: 'disconnected', + reconnectAttempts: this.reconnectAttempts, + }); + this.scheduleReconnect(); + } + }; + + this.ws.onerror = () => { + this.updateConnectionState({ + status: 'disconnected', + reconnectAttempts: this.reconnectAttempts, + lastError: 'WebSocket connection error', + }); + this.ws?.close(); + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to open WebSocket'; + console.error('[NotificationSocket] Failed to open', error); + this.updateConnectionState({ + status: 'disconnected', + reconnectAttempts: this.reconnectAttempts, + lastError: message, + }); + this.scheduleReconnect(); + } + } + + private scheduleReconnect(): void { + if (this.intentionallyClosed || this.reconnectTimer || this.hasReachedMaxAttempts()) { + return; + } + + this.reconnectAttempts += 1; + const delay = this.getReconnectDelay(); + + this.updateConnectionState({ + status: 'reconnecting', + reconnectAttempts: this.reconnectAttempts, + }); + + this.reconnectTimer = setTimeout(() => { + this.reconnectTimer = null; + this.open(); + this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); + }, delay); + } + + private getReconnectDelay(): number { + const jitterRange = this.reconnectDelay * this.reconnectJitter; + const jitter = (Math.random() * 2 - 1) * jitterRange; + return Math.max(0, Math.round(this.reconnectDelay + jitter)); + } + + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + } + + private flushOutboundQueue(): void { + if (!this.isOpen() || this.outboundQueue.length === 0) { + return; + } + + const queuedMessages = [...this.outboundQueue]; + this.outboundQueue.length = 0; + + queuedMessages.forEach(({ event, payload }) => { + this.ws?.send(JSON.stringify({ event, payload })); + }); + } + + private isOpen(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + private hasReachedMaxAttempts(): boolean { + return this.maxReconnectAttempts > 0 && this.reconnectAttempts >= this.maxReconnectAttempts; + } + + private updateConnectionState(updates: Partial): void { + this.connectionState = { + ...this.connectionState, + ...updates, + reconnectAttempts: updates.reconnectAttempts ?? this.connectionState.reconnectAttempts, + }; + this.connectionListeners.forEach((listener) => listener(this.connectionState)); + } + + private registerNetworkListeners(): void { + if (typeof window === 'undefined') { + return; + } + + window.addEventListener('online', this.handleOnline); + document.addEventListener('visibilitychange', this.handleVisibilityChange); + } + + private unregisterNetworkListeners(): void { + if (typeof window === 'undefined') { + return; + } + + window.removeEventListener('online', this.handleOnline); + document.removeEventListener('visibilitychange', this.handleVisibilityChange); + } +} diff --git a/src/providers/Notificationprovider.tsx b/src/providers/Notificationprovider.tsx index b22c3929..25424327 100644 --- a/src/providers/Notificationprovider.tsx +++ b/src/providers/Notificationprovider.tsx @@ -9,6 +9,10 @@ import React, { useCallback, ReactNode, } from 'react'; +import { + NotificationSocketService, + type NotificationSocketConnectionState, +} from '@/lib/notifications/socket'; export type NotificationType = | 'info' @@ -31,101 +35,6 @@ export interface Notification { metadata?: Record; } -type NotificationEventType = 'notification' | 'notification_read' | 'notification_clear'; - -interface NotificationEvent { - event: NotificationEventType; - payload: unknown; -} - -// ────────────────────────────────────────────────────────────────────────────── -// WebSocket service (singleton per URL) -// ────────────────────────────────────────────────────────────────────────────── - -type Listener = (notification: Notification) => void; - -class NotificationSocketService { - private ws: WebSocket | null = null; - private listeners: Set = new Set(); - private reconnectTimer: ReturnType | null = null; - private reconnectDelay = 1000; - private maxReconnectDelay = 30_000; - private intentionallyClosed = false; - - constructor(private url: string) {} - - connect() { - this.intentionallyClosed = false; - this.open(); - } - - private open() { - if (this.ws) return; - try { - this.ws = new WebSocket(this.url); - - this.ws.onopen = () => { - console.info('[NotificationSocket] Connected'); - this.reconnectDelay = 1000; // reset back-off - }; - - this.ws.onmessage = (event: MessageEvent) => { - try { - const data: NotificationEvent = JSON.parse(event.data as string); - if (data.event === 'notification') { - const notification = data.payload as Notification; - notification.timestamp = new Date(notification.timestamp); - this.listeners.forEach((cb) => cb(notification)); - } - } catch { - console.warn('[NotificationSocket] Failed to parse message', event.data); - } - }; - - this.ws.onclose = () => { - this.ws = null; - if (!this.intentionallyClosed) { - this.scheduleReconnect(); - } - }; - - this.ws.onerror = (err) => { - console.error('[NotificationSocket] Error', err); - this.ws?.close(); - }; - } catch (err) { - console.error('[NotificationSocket] Failed to open', err); - this.scheduleReconnect(); - } - } - - private scheduleReconnect() { - this.reconnectTimer = setTimeout(() => { - console.info(`[NotificationSocket] Reconnecting…`); - this.open(); - this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay); - }, this.reconnectDelay); - } - - disconnect() { - this.intentionallyClosed = true; - if (this.reconnectTimer) clearTimeout(this.reconnectTimer); - this.ws?.close(); - this.ws = null; - } - - subscribe(listener: Listener) { - this.listeners.add(listener); - return () => this.listeners.delete(listener); - } - - send(event: NotificationEventType, payload: unknown) { - if (this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ event, payload })); - } - } -} - // ────────────────────────────────────────────────────────────────────────────── // Context // ────────────────────────────────────────────────────────────────────────────── @@ -133,6 +42,7 @@ class NotificationSocketService { interface NotificationContextValue { notifications: Notification[]; unreadCount: number; + connectionState: NotificationSocketConnectionState; markAsRead: (id: string) => void; markAllAsRead: () => void; clearNotification: (id: string) => void; @@ -161,6 +71,11 @@ interface NotificationProviderProps { maxNotifications?: number; } +const DEFAULT_CONNECTION_STATE: NotificationSocketConnectionState = { + status: 'idle', + reconnectAttempts: 0, +}; + export function NotificationProvider({ children, wsUrl, @@ -168,6 +83,8 @@ export function NotificationProvider({ maxNotifications = 200, }: NotificationProviderProps) { const [notifications, setNotifications] = useState(initialNotifications); + const [connectionState, setConnectionState] = + useState(DEFAULT_CONNECTION_STATE); const serviceRef = useRef(null); const unreadCount = notifications.filter((n) => !n.read).length; @@ -187,9 +104,12 @@ export function NotificationProvider({ serviceRef.current = svc; svc.connect(); - const unsubscribe = svc.subscribe(addNotification); + const unsubscribeNotifications = svc.subscribe(addNotification); + const unsubscribeConnection = svc.onConnectionStateChange(setConnectionState); + return () => { - unsubscribe(); + unsubscribeNotifications(); + unsubscribeConnection(); svc.disconnect(); }; }, [wsUrl, addNotification]); @@ -219,6 +139,7 @@ export function NotificationProvider({ value={{ notifications, unreadCount, + connectionState, markAsRead, markAllAsRead, clearNotification, diff --git a/src/providers/__tests__/Notificationprovider.test.tsx b/src/providers/__tests__/Notificationprovider.test.tsx new file mode 100644 index 00000000..1fbcf6df --- /dev/null +++ b/src/providers/__tests__/Notificationprovider.test.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NotificationProvider, useNotifications } from '../Notificationprovider'; + +type WebSocketListener = ((event?: Event | MessageEvent | CloseEvent) => void) | null; + +class MockWebSocket { + static instances: MockWebSocket[] = []; + static OPEN = 1; + static CONNECTING = 0; + + readyState = MockWebSocket.CONNECTING; + onopen: WebSocketListener = null; + onmessage: WebSocketListener = null; + onclose: WebSocketListener = null; + onerror: WebSocketListener = null; + + constructor(public readonly url: string) { + MockWebSocket.instances.push(this); + } + + send(): void {} + + close(): void { + this.onclose?.({} as CloseEvent); + } + + simulateOpen(): void { + this.readyState = MockWebSocket.OPEN; + this.onopen?.({} as Event); + } + + simulateMessage(data: unknown): void { + this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent); + } +} + +function wrapper({ children }: { children: React.ReactNode }) { + return ( + {children} + ); +} + +describe('NotificationProvider', () => { + beforeEach(() => { + vi.useFakeTimers(); + MockWebSocket.instances = []; + vi.stubGlobal('WebSocket', MockWebSocket); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('exposes connection state through context', () => { + const { result } = renderHook(() => useNotifications(), { wrapper }); + + expect(result.current.connectionState.status).toBe('connecting'); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + }); + + expect(result.current.connectionState.status).toBe('connected'); + }); + + it('adds notifications received from the websocket', () => { + const { result } = renderHook(() => useNotifications(), { wrapper }); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + MockWebSocket.instances[0].simulateMessage({ + event: 'notification', + payload: { + id: 'provider-1', + type: 'success', + title: 'Course published', + timestamp: '2026-06-24T10:00:00.000Z', + read: false, + }, + }); + }); + + expect(result.current.notifications).toHaveLength(1); + expect(result.current.notifications[0].title).toBe('Course published'); + expect(result.current.unreadCount).toBe(1); + }); + + it('reports reconnecting state after an unexpected disconnect', () => { + const { result } = renderHook(() => useNotifications(), { wrapper }); + + act(() => { + MockWebSocket.instances[0].simulateOpen(); + MockWebSocket.instances[0].close(); + }); + + expect(result.current.connectionState.status).toBe('reconnecting'); + + act(() => { + vi.advanceTimersByTime(1_000); + }); + + expect(result.current.connectionState.status).toBe('reconnecting'); + expect(MockWebSocket.instances).toHaveLength(2); + }); +});