/* * InkNote — Client Sync Engine * ───────────────────────────── * Handles offline-first sync with conflict resolution. * Works in React Native, Flutter (via JS bridge), Electron, and web. * * Usage: * import { SyncEngine } from './sync-client'; * const sync = new SyncEngine('https://your-server.com', deviceId); * await sync.authenticate(email, password); * sync.start(); // begins periodic sync + WebSocket listener * sync.stop(); // stops sync */ class SyncEngine { constructor(serverUrl, deviceId) { this.serverUrl = serverUrl.replace(/\/$/, ""); this.deviceId = deviceId; this.token = null; this.ws = null; this.syncInterval = null; this.pendingChanges = []; this.lastSyncTimestamp = null; this.listeners = new Map(); this.isOnline = true; this.isSyncing = false; } /* ─── Authentication ─── */ async authenticate(email, password) { const res = await fetch(`${this.serverUrl}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }), }); if (!res.ok) throw new Error("Authentication failed"); const data = await res.json(); this.token = data.token; return data.user; } async register(email, password, displayName) { const res = await fetch(`${this.serverUrl}/api/auth/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password, displayName }), }); if (!res.ok) throw new Error("Registration failed"); const data = await res.json(); this.token = data.token; return data.user; } /* ─── Event system ─── */ on(event, callback) { if (!this.listeners.has(event)) this.listeners.set(event, []); this.listeners.get(event).push(callback); return () => { const cbs = this.listeners.get(event); const idx = cbs.indexOf(callback); if (idx > -1) cbs.splice(idx, 1); }; } emit(event, data) { const cbs = this.listeners.get(event) || []; cbs.forEach((cb) => cb(data)); } /* ─── Start / Stop ─── */ start(intervalMs = 30000) { this.connectWebSocket(); this.syncInterval = setInterval(() => this.sync(), intervalMs); this.sync(); // immediate first sync // Listen for online/offline if (typeof window !== "undefined") { window.addEventListener("online", () => { this.isOnline = true; this.emit("status", "online"); this.sync(); }); window.addEventListener("offline", () => { this.isOnline = false; this.emit("status", "offline"); }); } } stop() { if (this.syncInterval) clearInterval(this.syncInterval); if (this.ws) this.ws.close(); this.ws = null; } /* ─── WebSocket ─── */ connectWebSocket() { const wsUrl = this.serverUrl.replace(/^http/, "ws") + "/ws"; this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { this.ws.send(JSON.stringify({ type: "auth", token: this.token, deviceId: this.deviceId, })); }; this.ws.onmessage = (event) => { try { const msg = JSON.parse(event.data); if (msg.type === "auth_ok") { this.emit("status", "connected"); } else if (msg.type.endsWith("_created") || msg.type.endsWith("_updated") || msg.type.endsWith("_deleted")) { this.emit("remote_change", msg); this.sync(); // re-sync on remote change } } catch { /* ignore parse errors */ } }; this.ws.onclose = () => { this.emit("status", "disconnected"); // Auto-reconnect after 5s setTimeout(() => { if (this.token) this.connectWebSocket(); }, 5000); }; this.ws.onerror = () => { this.emit("status", "error"); }; } /* ─── Queue changes for sync ─── */ queueChange(entityType, entityId, action, data) { const change = { entityType, entityId, action, data: { ...data, id: entityId }, timestamp: new Date().toISOString(), deviceId: this.deviceId, }; this.pendingChanges.push(change); // Persist to local storage for crash recovery this.savePendingChanges(); // Try immediate sync if online if (this.isOnline && !this.isSyncing) { this.sync(); } } /* ─── Full sync cycle ─── */ async sync() { if (!this.token || !this.isOnline || this.isSyncing) return; this.isSyncing = true; this.emit("status", "syncing"); try { const res = await fetch(`${this.serverUrl}/api/sync`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${this.token}`, "X-Device-Id": this.deviceId, }, body: JSON.stringify({ lastSyncTimestamp: this.lastSyncTimestamp, deviceId: this.deviceId, changes: this.pendingChanges, }), }); if (!res.ok) throw new Error(`Sync failed: ${res.status}`); const result = await res.json(); // Process results for (const pushed of result.pushed) { if (pushed.status === "ok") { this.pendingChanges = this.pendingChanges.filter((c) => c.data?.id !== pushed.id); } } // Handle conflicts for (const conflict of result.conflicts) { this.emit("conflict", conflict); } // Apply pulled changes from server for (const change of result.pulled) { this.emit("remote_change", change); } this.lastSyncTimestamp = result.syncTimestamp; this.savePendingChanges(); this.emit("status", "synced"); } catch (err) { this.emit("status", "error"); console.error("Sync error:", err); } finally { this.isSyncing = false; } } /* ─── Local persistence for pending changes ─── */ savePendingChanges() { try { if (typeof localStorage !== "undefined") { localStorage.setItem(`inknote_pending_${this.deviceId}`, JSON.stringify(this.pendingChanges)); localStorage.setItem(`inknote_last_sync_${this.deviceId}`, this.lastSyncTimestamp || ""); } } catch { /* ignore storage errors */ } } loadPendingChanges() { try { if (typeof localStorage !== "undefined") { const pending = localStorage.getItem(`inknote_pending_${this.deviceId}`); if (pending) this.pendingChanges = JSON.parse(pending); this.lastSyncTimestamp = localStorage.getItem(`inknote_last_sync_${this.deviceId}`) || null; } } catch { /* ignore storage errors */ } } /* ─── API helpers ─── */ async fetchNotes() { const res = await fetch(`${this.serverUrl}/api/notes`, { headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId }, }); return res.json(); } async fetchNote(id) { const res = await fetch(`${this.serverUrl}/api/notes/${id}`, { headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId }, }); return res.json(); } async createNote(note) { const res = await fetch(`${this.serverUrl}/api/notes`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId, }, body: JSON.stringify(note), }); const data = await res.json(); this.queueChange("note", data.id, "create", note); return data; } async updateNote(id, updates) { const res = await fetch(`${this.serverUrl}/api/notes/${id}`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId, }, body: JSON.stringify(updates), }); if (res.status === 409) { const conflict = await res.json(); this.emit("conflict", conflict); return conflict; } this.queueChange("note", id, "update", updates); return res.json(); } async deleteNote(id) { await fetch(`${this.serverUrl}/api/notes/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId }, }); this.queueChange("note", id, "delete", { id }); } async uploadPDF(file) { const formData = new FormData(); formData.append("pdf", file); const res = await fetch(`${this.serverUrl}/api/notes/pdf`, { method: "POST", headers: { Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId, }, body: formData, }); return res.json(); } async saveDrawing(noteId, drawingDataUrl) { await fetch(`${this.serverUrl}/api/notes/${noteId}/drawing`, { method: "PUT", headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.token}`, "X-Device-Id": this.deviceId, }, body: JSON.stringify({ drawingData: drawingDataUrl }), }); } } export { SyncEngine };