UNPKG

sveltekit-sync

Version:
285 lines (284 loc) 8.84 kB
import { EventEmitter } from './event-emitter.js'; /** * Client-side realtime connection manager. * Handles SSE connections with automatic reconnection and polling fallback. */ export class RealtimeClient extends EventEmitter { config; eventSource = null; status = 'disconnected'; reconnectAttempts = 0; reconnectTimer = null; heartbeatTimer = null; clientId = ''; lastEventId = ''; constructor(config = {}) { super(); this.config = this.resolveConfig(config); } resolveConfig(config) { return { enabled: config.enabled ?? true, endpoint: config.endpoint ?? '/api/sync/realtime', tables: config.tables ?? [], // Empty = all tables reconnectInterval: config.reconnectInterval ?? 1000, maxReconnectInterval: config.maxReconnectInterval ?? 30000, maxReconnectAttempts: config.maxReconnectAttempts ?? 5, heartbeatTimeout: config.heartbeatTimeout ?? 45000, onStatusChange: config.onStatusChange ?? (() => { }), onOperations: config.onOperations ?? (() => { }), onError: config.onError ?? (() => { }), }; } /** * Initialize the realtime client with a client ID */ init(clientId) { this.clientId = clientId; if (this.config.enabled) { this.connect(); } } /** * Update configuration at runtime */ configure(config) { this.config = { ...this.config, ...config }; } /** * Get current connection status */ getStatus() { return this.status; } /** * Check if currently connected */ isConnected() { return this.status === 'connected'; } /** * Connect to the SSE endpoint */ connect() { if (!this.config.enabled) { console.warn('Realtime is disabled'); return; } if (typeof window === 'undefined' || typeof EventSource === 'undefined') { console.warn('SSE not available, using polling fallback'); this.setStatus('fallback'); return; } if (this.eventSource) { this.disconnect(); } this.setStatus('connecting'); try { const url = this.buildEndpointUrl(); this.eventSource = new EventSource(url); this.eventSource.onopen = () => { this.reconnectAttempts = 0; this.setStatus('connected'); this.startHeartbeatMonitor(); this.emit('connected', {}); }; this.eventSource.onmessage = (event) => { this.handleMessage(event); }; this.eventSource.onerror = (error) => { this.handleError(error); }; // Listen for specific event types this.eventSource.addEventListener('operations', (event) => { this.handleOperationsEvent(event); }); this.eventSource.addEventListener('heartbeat', () => { this.resetHeartbeatMonitor(); }); } catch (error) { this.handleError(error); } } /** * Disconnect from the SSE endpoint */ disconnect() { this.clearTimers(); if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.setStatus('disconnected'); this.emit('disconnected', {}); } /** * Force reconnection */ reconnect() { this.disconnect(); this.reconnectAttempts = 0; this.connect(); } /** * Enable realtime and connect */ enable() { this.config.enabled = true; this.connect(); } /** * Disable realtime and disconnect */ disable() { this.config.enabled = false; this.disconnect(); } /** * Send a message to the server via POST * This enables bidirectional communication (client -> server) */ async send(type, data) { if (!this.config.enabled) { console.warn('Realtime is disabled, cannot send message'); return; } try { const response = await fetch(this.config.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ type, data }), }); if (!response.ok) { throw new Error(`Failed to send message: ${response.statusText}`); } } catch (error) { console.error('Error sending message to server:', error); throw error; } } /** * Join a channel to receive channel-specific events */ async joinChannel(channel) { await this.send('channel:join', { channel }); } /** * Leave a channel */ async leaveChannel(channel) { await this.send('channel:leave', { channel }); } /** * Clean up resources */ destroy() { this.disconnect(); this.removeAllListeners(); } buildEndpointUrl() { const url = new URL(this.config.endpoint, window.location.origin); url.searchParams.set('clientId', this.clientId); if (this.config.tables.length > 0) { url.searchParams.set('tables', this.config.tables.join(',')); } if (this.lastEventId) { url.searchParams.set('lastEventId', this.lastEventId); } return url.toString(); } handleMessage(event) { try { const data = JSON.parse(event.data); if (event.lastEventId) { this.lastEventId = event.lastEventId; } this.emit(data.type, data.data); } catch (error) { console.error('Failed to parse SSE message:', error); } } handleOperationsEvent(event) { try { const data = JSON.parse(event.data); if (event.lastEventId) { this.lastEventId = event.lastEventId; } this.resetHeartbeatMonitor(); this.config.onOperations(data.operations); this.emit('operations', data.operations); } catch (error) { console.error('Failed to parse operations event:', error); } } handleError(error) { console.error('SSE error:', error); this.clearTimers(); if (this.eventSource) { this.eventSource.close(); this.eventSource = null; } this.config.onError(error instanceof Error ? error : new Error('SSE connection failed')); this.emit('error', error); // Attempt reconnection with exponential backoff if (this.reconnectAttempts < this.config.maxReconnectAttempts) { this.scheduleReconnect(); } else { console.warn('Max reconnect attempts reached, falling back to polling'); this.setStatus('fallback'); this.emit('fallback', {}); } } scheduleReconnect() { const delay = Math.min(this.config.reconnectInterval * Math.pow(2, this.reconnectAttempts), this.config.maxReconnectInterval); this.reconnectAttempts++; this.setStatus('connecting'); console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts})`); this.reconnectTimer = setTimeout(() => { this.connect(); }, delay); } startHeartbeatMonitor() { this.resetHeartbeatMonitor(); } resetHeartbeatMonitor() { if (this.heartbeatTimer) { clearTimeout(this.heartbeatTimer); } this.heartbeatTimer = setTimeout(() => { console.warn('Heartbeat timeout, reconnecting...'); this.handleError(new Error('Heartbeat timeout')); }, this.config.heartbeatTimeout); } clearTimers() { if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.heartbeatTimer) { clearTimeout(this.heartbeatTimer); this.heartbeatTimer = null; } } setStatus(status) { if (this.status !== status) { this.status = status; this.config.onStatusChange(status); this.emit('statusChange', status); } } } /** * Create a realtime client with the given configuration. * Simple factory function for ease of use. */ export function createRealtimeClient(config) { return new RealtimeClient(config); }