feat: create WebSocket client and real-time sync manager
This commit is contained in:
34
__tests__/lib/websocket/client.test.ts
Normal file
34
__tests__/lib/websocket/client.test.ts
Normal file
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
119
lib/websocket/client.ts
Normal file
119
lib/websocket/client.ts
Normal file
@@ -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<void> {
|
||||||
|
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<string, any>): 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 = []
|
||||||
|
}
|
||||||
|
}
|
||||||
86
lib/websocket/sync-manager.ts
Normal file
86
lib/websocket/sync-manager.ts
Normal file
@@ -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<void> {
|
||||||
|
this.userId = userId
|
||||||
|
await this.client.connect(userId)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendHighlightCreate(highlight: BibleHighlight): Promise<void> {
|
||||||
|
this.client.send('highlight:create', highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendHighlightUpdate(highlight: BibleHighlight): Promise<void> {
|
||||||
|
this.client.send('highlight:update', highlight)
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendHighlightDelete(highlightId: string): Promise<void> {
|
||||||
|
this.client.send('highlight:delete', { highlightId })
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleHighlightCreate(data: BibleHighlight): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user