First Commit
This commit is contained in:
1 parent
fa15d48a52
commit
4709096ab3
8 files changed
+2208
No files matched your search
@@ -0,0 +1,311 @@
|
||||
/*
|
||||
* 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 };
|
||||
Reference in new issue
Block a user