sveltekit-sync
Version:
Local-first sync engine for SvelteKit
285 lines (284 loc) • 8.84 kB
JavaScript
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);
}