Real-time Sync
Real-time Sync Guide
Section titled “Real-time Sync Guide”BitMarks supports real-time synchronization via WebSocket, allowing instant updates across all your devices.
Overview
Section titled “Overview”Real-time sync uses Cloudflare Durable Objects to coordinate WebSocket connections. Each user has a dedicated Durable Object instance that:
- Maintains WebSocket connections from all devices
- Broadcasts changes to connected devices
- Persists changes to the database
┌──────────────┐ ┌──────────────────────────────┐│ Device A │────▶│ ││ (Browser) │◀────│ │└──────────────┘ │ SyncCoordinator │ │ (Durable Object) │┌──────────────┐ │ ││ Device B │────▶│ - WebSocket management ││ (Extension) │◀────│ - Change broadcasting │└──────────────┘ │ - D1 persistence │ │ │┌──────────────┐ │ ││ Device C │────▶│ ││ (Mobile) │◀────│ │└──────────────┘ └──────────────────────────────┘Connecting
Section titled “Connecting”WebSocket Endpoint
Section titled “WebSocket Endpoint”GET /api/v1/sync/realtimeRequired Headers
Section titled “Required Headers”| Header | Description |
|---|---|
Upgrade | Must be websocket |
X-User-ID | Your user ID |
X-Device-ID | Unique device identifier |
Cookie | Session cookie |
Connection Example
Section titled “Connection Example”const connect = async () => { const url = 'wss://app.bitmarks.sh/api/v1/sync/realtime';
const ws = new WebSocket(url, [], { headers: { 'X-User-ID': userId, 'X-Device-ID': deviceId } });
ws.onopen = () => { console.log('Connected to sync server'); };
ws.onmessage = (event) => { const message = JSON.parse(event.data); handleMessage(message); };
ws.onerror = (error) => { console.error('WebSocket error:', error); };
ws.onclose = (event) => { console.log('Disconnected:', event.code, event.reason); // Implement reconnection logic };
return ws;};import { useEffect, useRef, useCallback } from 'react';
function useSyncWebSocket(userId: string, deviceId: string) { const ws = useRef<WebSocket | null>(null); const reconnectTimeout = useRef<number>();
const connect = useCallback(() => { const url = 'wss://app.bitmarks.sh/api/v1/sync/realtime';
ws.current = new WebSocket(url);
ws.current.onopen = () => { console.log('Connected'); };
ws.current.onclose = () => { // Reconnect after 5 seconds reconnectTimeout.current = setTimeout(connect, 5000); };
ws.current.onmessage = (event) => { const message = JSON.parse(event.data); // Handle message... }; }, [userId, deviceId]);
useEffect(() => { connect(); return () => { ws.current?.close(); clearTimeout(reconnectTimeout.current); }; }, [connect]);
return ws;}Message Protocol
Section titled “Message Protocol”Inbound Messages (Client → Server)
Section titled “Inbound Messages (Client → Server)”Sync Event
Section titled “Sync Event”Push a change to the server:
{ type: 'sync', event: { id: 'sync_abc123', user_id: 'user_01ABC', entity_type: 'bookmark', entity_id: 'bm_xyz', operation: 'create', timestamp: 1735430400000, device_id: 'device_456', encrypted_data: 'base64...', data_hash: 'sha256...', conflict_resolved: false }}Keep-alive ping:
{ type: 'ping'}Outbound Messages (Server → Client)
Section titled “Outbound Messages (Server → Client)”Connected
Section titled “Connected”Sent immediately after connection:
{ type: 'connected', deviceId: 'device_456', timestamp: 1735430400000}Response to ping:
{ type: 'pong'}Sync Broadcast
Section titled “Sync Broadcast”Change from another device:
{ type: 'sync', event: { id: 'sync_def789', user_id: 'user_01ABC', entity_type: 'bookmark', entity_id: 'bm_new', operation: 'create', timestamp: 1735430500000, device_id: 'device_789', // Different device encrypted_data: 'base64...', data_hash: 'sha256...', conflict_resolved: false }}Server error:
{ type: 'error', error: 'Invalid message format'}Implementation Guide
Section titled “Implementation Guide”-
Generate Device ID
Section titled “Generate Device ID”Each device needs a unique, persistent identifier:
import { generateDeviceId } from '@bitmarks.sh/core';const getDeviceId = () => {let deviceId = localStorage.getItem('bitmarks_device_id');if (!deviceId) {deviceId = generateDeviceId();localStorage.setItem('bitmarks_device_id', deviceId);}return deviceId;}; -
Connect with Reconnection
Section titled “Connect with Reconnection”class SyncConnection {private ws: WebSocket | null = null;private reconnectDelay = 1000;private maxDelay = 30000;connect() {this.ws = new WebSocket('wss://app.bitmarks.sh/api/v1/sync/realtime');this.ws.onopen = () => {this.reconnectDelay = 1000; // Reset delay};this.ws.onclose = () => {// Exponential backoffsetTimeout(() => this.connect(), this.reconnectDelay);this.reconnectDelay = Math.min(this.reconnectDelay * 2,this.maxDelay);};}} -
Handle Messages
Section titled “Handle Messages”const handleMessage = async (message: WebSocketOutbound) => {switch (message.type) {case 'connected':console.log('Connected as device:', message.deviceId);// Maybe trigger initial syncbreak;case 'pong':// Keep-alive confirmedbreak;case 'sync':// New change from another deviceawait applyRemoteChange(message.event);break;case 'error':console.error('Server error:', message.error);break;}}; -
Send Changes
Section titled “Send Changes”const sendChange = (event: SyncEvent) => {if (ws?.readyState === WebSocket.OPEN) {ws.send(JSON.stringify({type: 'sync',event}));} else {// Queue for laterpendingChanges.push(event);}}; -
Keep-Alive
Section titled “Keep-Alive”// Send ping every 30 secondssetInterval(() => {if (ws?.readyState === WebSocket.OPEN) {ws.send(JSON.stringify({ type: 'ping' }));}}, 30000);
Complete Example
Section titled “Complete Example”import { SyncEvent, WebSocketOutbound } from '@bitmarks.sh/core';
class RealtimeSyncManager { private ws: WebSocket | null = null; private userId: string; private deviceId: string; private encryptionKey: Uint8Array; private pendingChanges: SyncEvent[] = []; private reconnectDelay = 1000; private pingInterval: number | null = null;
constructor(userId: string, deviceId: string, key: Uint8Array) { this.userId = userId; this.deviceId = deviceId; this.encryptionKey = key; }
connect() { const url = 'wss://app.bitmarks.sh/api/v1/sync/realtime'; this.ws = new WebSocket(url);
this.ws.onopen = () => { console.log('WebSocket connected'); this.reconnectDelay = 1000; this.startPing(); this.flushPendingChanges(); };
this.ws.onmessage = (event) => { const message: WebSocketOutbound = JSON.parse(event.data); this.handleMessage(message); };
this.ws.onclose = (event) => { console.log('WebSocket closed:', event.code); this.stopPing(); this.scheduleReconnect(); };
this.ws.onerror = (error) => { console.error('WebSocket error:', error); }; }
disconnect() { this.stopPing(); this.ws?.close(); this.ws = null; }
private handleMessage(message: WebSocketOutbound) { switch (message.type) { case 'connected': console.log('Confirmed connection:', message.deviceId); break;
case 'sync': this.handleRemoteSync(message.event); break;
case 'pong': // Connection healthy break;
case 'error': console.error('Server error:', message.error); break; } }
private async handleRemoteSync(event: SyncEvent) { // Skip our own changes if (event.device_id === this.deviceId) return;
// Decrypt and apply const decrypted = await decryptObject( event.encrypted_data, this.encryptionKey );
// Emit event for UI to handle this.emit('change', { type: event.operation, entity: decrypted }); }
async pushChange( entityType: 'bookmark' | 'history' | 'group', entityId: string, operation: 'create' | 'update' | 'delete', data?: any ) { const encrypted = data ? await encryptObject(data, this.encryptionKey) : undefined;
const event: SyncEvent = { id: `sync_${crypto.randomUUID()}`, user_id: this.userId, entity_type: entityType, entity_id: entityId, operation, timestamp: Date.now(), device_id: this.deviceId, encrypted_data: encrypted, data_hash: await hashData(JSON.stringify(data)), conflict_resolved: false };
if (this.ws?.readyState === WebSocket.OPEN) { this.ws.send(JSON.stringify({ type: 'sync', event })); } else { this.pendingChanges.push(event); } }
private flushPendingChanges() { while (this.pendingChanges.length > 0) { const event = this.pendingChanges.shift()!; this.ws?.send(JSON.stringify({ type: 'sync', event })); } }
private startPing() { this.pingInterval = setInterval(() => { this.ws?.send(JSON.stringify({ type: 'ping' })); }, 30000); }
private stopPing() { if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } }
private scheduleReconnect() { setTimeout(() => { this.connect(); this.reconnectDelay = Math.min(this.reconnectDelay * 2, 30000); }, this.reconnectDelay); }
// Simple event emitter private listeners = new Map<string, Function[]>();
on(event: string, callback: Function) { const list = this.listeners.get(event) || []; list.push(callback); this.listeners.set(event, list); }
private emit(event: string, data: any) { this.listeners.get(event)?.forEach(cb => cb(data)); }}Best Practices
Section titled “Best Practices”Offline Handling
Section titled “Offline Handling”// Queue changes when offlinewindow.addEventListener('online', () => { syncManager.connect();});
window.addEventListener('offline', () => { // Changes are automatically queued});Conflict Resolution
Section titled “Conflict Resolution”Real-time sync uses the same Last-Write-Wins strategy as regular sync:
// Server compares timestampsif (localEvent.timestamp > serverEvent.timestamp) { // Local wins} else if (localEvent.timestamp < serverEvent.timestamp) { // Server wins} else { // Same timestamp: compare device IDs lexicographically}Performance
Section titled “Performance”- Batch rapid changes (debounce)
- Don’t send redundant updates
- Use message compression for large payloads
// Debounce rapid changesconst debouncedSync = debounce((event) => { syncManager.pushChange(event);}, 500);Troubleshooting
Section titled “Troubleshooting”Connection Refused (426)
Section titled “Connection Refused (426)”The server requires WebSocket upgrade:
// Ensure you're using WebSocket, not HTTPconst ws = new WebSocket('wss://...'); // NOT fetch()Frequent Disconnections
Section titled “Frequent Disconnections”- Check network stability
- Ensure ping/pong is working
- Look for proxy/firewall issues
Messages Not Received
Section titled “Messages Not Received”- Verify device ID is consistent
- Check user ID matches
- Ensure session is valid