311 lines
9.0 KiB
JavaScript
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 }; |