From 46ccc797a32e999371b69b1901170da43f8ce951 Mon Sep 17 00:00:00 2001 From: Andrei Date: Wed, 12 Nov 2025 08:14:47 +0000 Subject: [PATCH] feat: create WebSocket client and real-time sync manager --- __tests__/lib/websocket/client.test.ts | 34 +++++++ lib/websocket/client.ts | 119 +++++++++++++++++++++++++ lib/websocket/sync-manager.ts | 86 ++++++++++++++++++ 3 files changed, 239 insertions(+) create mode 100644 __tests__/lib/websocket/client.test.ts create mode 100644 lib/websocket/client.ts create mode 100644 lib/websocket/sync-manager.ts diff --git a/__tests__/lib/websocket/client.test.ts b/__tests__/lib/websocket/client.test.ts new file mode 100644 index 0000000..5935e81 --- /dev/null +++ b/__tests__/lib/websocket/client.test.ts @@ -0,0 +1,34 @@ +import { WebSocketClient } from '@/lib/websocket/client' + +describe('WebSocketClient', () => { + let client: WebSocketClient + + beforeEach(() => { + client = new WebSocketClient('ws://localhost:3011') + }) + + afterEach(() => { + client.disconnect() + }) + + it('should initialize WebSocket client', () => { + expect(client).toBeDefined() + expect(client.isConnected()).toBe(false) + }) + + it('should track queue length when disconnected', () => { + expect(client.getQueueLength()).toBe(0) + client.send('highlight:create', { verseId: 'v-1', color: 'yellow' }) + expect(client.getQueueLength()).toBe(1) + }) + + it('should get client ID', () => { + const clientId = client.getClientId() + expect(clientId).toBeDefined() + expect(clientId.startsWith('client-')).toBe(true) + }) + + it('should provide connection status', () => { + expect(client.isConnected()).toBe(false) + }) +}) diff --git a/lib/websocket/client.ts b/lib/websocket/client.ts new file mode 100644 index 0000000..c349b3c --- /dev/null +++ b/lib/websocket/client.ts @@ -0,0 +1,119 @@ +import { EventEmitter } from 'events' +import { WebSocketMessage, WebSocketMessageType } from './types' + +export class WebSocketClient extends EventEmitter { + private url: string + private clientId: string = `client-${Math.random().toString(36).substr(2, 9)}` + private userId: string | null = null + private connected: boolean = false + private messageQueue: WebSocketMessage[] = [] + private ws: WebSocket | null = null + private reconnectAttempts: number = 0 + private maxReconnectAttempts: number = 5 + private reconnectDelay: number = 1000 + + constructor(url: string) { + super() + this.url = url + } + + getClientId(): string { + return this.clientId + } + + isConnected(): boolean { + return this.connected && this.ws !== null && this.ws.readyState === WebSocket.OPEN + } + + getQueueLength(): number { + return this.messageQueue.length + } + + async connect(userId: string): Promise { + this.userId = userId + + return new Promise((resolve, reject) => { + try { + this.ws = new WebSocket(this.url) + + this.ws.onopen = () => { + this.connected = true + this.reconnectAttempts = 0 + this.emit('connected') + this.flushMessageQueue() + resolve() + } + + this.ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data) + this.emit(message.type, message.payload) + this.emit('message', message) + } catch (error) { + console.error('Failed to parse message:', error) + } + } + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error) + this.emit('error', error) + reject(error) + } + + this.ws.onclose = () => { + this.connected = false + this.emit('disconnected') + this.attemptReconnect() + } + } catch (error) { + reject(error) + } + }) + } + + send(type: WebSocketMessageType, payload: Record): void { + const message: WebSocketMessage = { + type, + payload, + timestamp: Date.now(), + clientId: this.clientId + } + + if (this.isConnected() && this.ws) { + this.ws.send(JSON.stringify(message)) + } else { + this.messageQueue.push(message) + } + } + + private flushMessageQueue(): void { + while (this.messageQueue.length > 0) { + const message = this.messageQueue.shift() + if (message && this.ws) { + this.ws.send(JSON.stringify(message)) + } + } + } + + private attemptReconnect(): void { + if (this.reconnectAttempts < this.maxReconnectAttempts) { + this.reconnectAttempts++ + const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1) + setTimeout(() => { + if (this.userId) { + this.connect(this.userId).catch(() => { + // Retry will happen in onclose + }) + } + }, delay) + } + } + + disconnect(): void { + if (this.ws) { + this.ws.close() + } + this.connected = false + this.messageQueue = [] + } +} diff --git a/lib/websocket/sync-manager.ts b/lib/websocket/sync-manager.ts new file mode 100644 index 0000000..aca9e39 --- /dev/null +++ b/lib/websocket/sync-manager.ts @@ -0,0 +1,86 @@ +import { WebSocketClient } from './client' +import { BibleHighlight } from '@/types' +import { addHighlight, updateHighlight, deleteHighlight } from '../highlight-manager' + +export class RealtimeSyncManager { + private client: WebSocketClient + private userId: string | null = null + + constructor(wsUrl: string) { + this.client = new WebSocketClient(wsUrl) + this.setupListeners() + } + + private setupListeners(): void { + this.client.on('highlight:create', (data) => this.handleHighlightCreate(data)) + this.client.on('highlight:update', (data) => this.handleHighlightUpdate(data)) + this.client.on('highlight:delete', (data) => this.handleHighlightDelete(data)) + this.client.on('disconnected', () => this.handleDisconnect()) + this.client.on('connected', () => this.handleConnect()) + } + + async connect(userId: string): Promise { + this.userId = userId + await this.client.connect(userId) + } + + async sendHighlightCreate(highlight: BibleHighlight): Promise { + this.client.send('highlight:create', highlight) + } + + async sendHighlightUpdate(highlight: BibleHighlight): Promise { + this.client.send('highlight:update', highlight) + } + + async sendHighlightDelete(highlightId: string): Promise { + this.client.send('highlight:delete', { highlightId }) + } + + private async handleHighlightCreate(data: BibleHighlight): Promise { + try { + await addHighlight(data) + this.client.emit('local-update', { type: 'create', highlight: data }) + } catch (error) { + console.error('Failed to create highlight from remote:', error) + } + } + + private async handleHighlightUpdate(data: BibleHighlight): Promise { + try { + await updateHighlight(data) + this.client.emit('local-update', { type: 'update', highlight: data }) + } catch (error) { + console.error('Failed to update highlight from remote:', error) + } + } + + private async handleHighlightDelete(data: { highlightId: string }): Promise { + try { + await deleteHighlight(data.highlightId) + this.client.emit('local-update', { type: 'delete', highlightId: data.highlightId }) + } catch (error) { + console.error('Failed to delete highlight from remote:', error) + } + } + + private handleConnect(): void { + console.log('WebSocket connected - real-time sync active') + } + + private handleDisconnect(): void { + console.log('WebSocket disconnected - falling back to polling') + } + + disconnect(): void { + this.client.disconnect() + } + + isConnected(): boolean { + return this.client.isConnected() + } + + // Export client for direct event listening if needed + get publicClient() { + return this.client + } +}