Files
2026-02-17 19:25:52 +01:00

311 lines
9.0 KiB
JavaScript

/*
* 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 };