refactor(crdt): implement proxy-resilient Yjs dual-transport sync with indexeddb offline-first and debounced sqlite persistence
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m11s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m11s
This commit is contained in:
1 parent
297f3ed111
commit
f3b3f07e04
10 files changed
+1248
-484
No files matched your search
+8
-5
@@ -27,11 +27,14 @@ Diese Datei dokumentiert eine ehrliche und schonungslose technische Analyse alle
|
||||
- **Betroffene Komponenten:** [`api/websocket.md`](./api/websocket.md), [`modules/workspace.md`](./modules/workspace.md), `internal/ws/hub.go`, `internal/storage/sqlite.go`, `frontend/src/lib/crdt.ts`, `frontend/src/features/workspace/WorkspaceView.tsx`
|
||||
- **Ursache (Historisch):** MindSift nutzte ein kooperatives Dokumenten-Lease-Locking mit Last-Write-Wins. Bei parallelem Zugriff oder Netztrennung konnten Änderungen überschrieben werden.
|
||||
- **Implementierte Lösung (Yjs CRDT):**
|
||||
1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array<string>` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten (Text, Level, Checked, Collapsed) sind in `Y.Map` gekapselt.
|
||||
2. **Wegfall von Ganzdokument-Sperren:** Keine blockierenden Sperren mehr (`isLockedByOther` entfernt). Mehrere Nutzer können zeitgleich am selben Dokument schreiben.
|
||||
3. **Dual-Persistence:** CRDT-Zustände und Delta-Updates werden binär/Base64 in SQLite (`document_crdt_state`, `document_crdt_updates`) persistiert. Zur Aufrechterhaltung relationaler Indizes (Global To-Dos, Tag-Indizierung, Suche) werden Blöcke parallel in `document_blocks` materialisiert.
|
||||
4. **Live-Awareness & Presence:** Block-Fokus und Cursor-Zustand werden via `CRDT_AWARENESS` übertragen. Nutzer sehen ein Live-Badge im Header (`Live (N)`) sowie farbige Kollaborator-Pillen an aktiven Blöcken.
|
||||
5. **REST- & Proxy-Sicherheit:** Ergänzend zu WebSockets stehen Endpunkte `GET /api/v1/documents/:id/crdt` und `PUT /api/v1/documents/:id/crdt` bereit.
|
||||
1. **Block-Level Yjs CRDT:** Implementierung eines vollwertigen CRDT-Modells mit `Y.Doc`. Die Blockreihenfolge wird über ein `Y.Array<string>` (Block-IDs) konfliktfrei mit dem YATA-Algorithmus aufgelöst. Inhalte und Metadaten sind in `Y.Map` gekapselt.
|
||||
2. **Vollständige Eliminierung des Lock-Managers:** Alle pessimistischen Dokumentensperren (`isLockedByOther`, Lease-Heartbeats, 423 Locked) wurden restlos entfernt. Mehrere Nutzer können zeitgleich an denselben Absätzen und Blöcken arbeiten.
|
||||
3. **Proxy-Resiliente Dual-Transport-Architektur:**
|
||||
- **Primary:** Gorilla WebSocket (`/api/v1/ws/docs/:id`) mit binärer Übertragung.
|
||||
- **Fallback:** 4-Sekunden-Watchdog schaltet bei blockiertem WebSocket-Upgrade automatisch auf HTTP-Transport um: Downstream via Server-Sent Events (`GET /api/v1/sync/docs/:id/stream`) und Upstream via HTTP POST (`POST /api/v1/sync/docs/:id/update`).
|
||||
4. **Offline-First via IndexedDB:** Lokale Eingaben landen sofort und unterbrechungsfrei in `y-indexeddb`. Daten gehen auch bei Netzwerkabbrüchen nicht verloren.
|
||||
5. **Asynchroner DocRoom-Hub & Debounced SQLite-Persistenz:** In-Memory `DocRoom` pro aktivem Dokument; SQLite-Schreibvorgänge laufen debounced (2.5 Sekunden Inaktivität oder Raum-Austritt) rein asynchron im Hintergrund, ohne API-Threads oder Tastenschläge zu blockieren.
|
||||
6. **UI Status-Indikator:** Dezent im Header visualisiert: Grün ("Live (WebSocket)"), Gelb ("Eingeschränkt (HTTP-Proxy-Modus)") und Grau ("Offline (Lokal gesichert)").
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -71,8 +71,8 @@ server {
|
||||
proxy_send_timeout 3600s;
|
||||
}
|
||||
|
||||
# 2. Server-Sent Events Fallback (/api/v1/events)
|
||||
location /api/v1/events {
|
||||
# 2. Server-Sent Events Fallback (/api/v1/events & /api/v1/sync/)
|
||||
location ~ ^/api/v1/(events|sync/) {
|
||||
proxy_pass http://127.0.0.1:8080;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
|
||||
Generated
+21
@@ -14,6 +14,7 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2782,6 +2783,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y-indexeddb": {
|
||||
"version": "9.0.12",
|
||||
"resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz",
|
||||
"integrity": "sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lib0": "^0.2.74"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.0.0",
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "GitHub Sponsors ❤",
|
||||
"url": "https://github.com/sponsors/dmonad"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"yjs": "^13.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"prismjs": "^1.30.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"y-indexeddb": "^9.0.12",
|
||||
"yjs": "^13.6.33"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -56,6 +56,7 @@ import { UserAutocomplete } from '../UserAutocomplete';
|
||||
import { UnifiedShareModal } from '../../components/UnifiedShareModal';
|
||||
import { useMobileActions } from '../../context/MobileActionContext';
|
||||
import { DocumentCRDTSession, CollaboratorAwareness, getCollaboratorColor } from '../../lib/crdt';
|
||||
import { YjsHybridProvider, SyncTransportStatus } from '../../lib/yjs-provider';
|
||||
|
||||
interface WorkspaceViewProps {
|
||||
currentUser?: User | null;
|
||||
@@ -226,23 +227,18 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const [showFolderShareModal, setShowFolderShareModal] = useState(false);
|
||||
const [folderShares, setFolderShares] = useState<FolderShare[]>([]);
|
||||
|
||||
// --- DOCUMENT CRDT & COLLABORATION STATE ---
|
||||
// --- DOCUMENT CRDT & HYBRID TRANSPORT STATE ---
|
||||
const crdtSessionRef = useRef<DocumentCRDTSession | null>(null);
|
||||
const providerRef = useRef<YjsHybridProvider | null>(null);
|
||||
const [syncStatus, setSyncStatus] = useState<SyncTransportStatus>('offline');
|
||||
const [activePeers, setActivePeers] = useState<CollaboratorAwareness[]>([]);
|
||||
|
||||
// --- DOCUMENT LOCKING STATE (FALLBACK) ---
|
||||
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
|
||||
const [holdsLock, setHoldsLock] = useState(false);
|
||||
const holdsLockRef = useRef(false);
|
||||
const heartbeatTimerRef = useRef<any>(null);
|
||||
const idleUnlockTimerRef = useRef<any>(null);
|
||||
const currentDocIdRef = useRef<string | null>(null);
|
||||
const isEditorFocusedRef = useRef(false);
|
||||
const isDraggingBlockRef = useRef(false);
|
||||
|
||||
const blockInputRefs = useRef<Record<string, HTMLTextAreaElement | HTMLInputElement | null>>({});
|
||||
|
||||
// With CRDT, multiple users can write simultaneously without lockouts
|
||||
// Collaborative CRDT: multi-user writing with zero lockouts
|
||||
const isLockedByOther = false;
|
||||
const isReadOnly = selectedDoc?.permission === 'read';
|
||||
|
||||
@@ -369,69 +365,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return mergedOrder.map((id) => blockMap.get(id)!).filter(Boolean);
|
||||
};
|
||||
|
||||
const stopHeartbeat = () => {
|
||||
if (heartbeatTimerRef.current) {
|
||||
clearInterval(heartbeatTimerRef.current);
|
||||
heartbeatTimerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const startHeartbeat = (docId: string) => {
|
||||
stopHeartbeat();
|
||||
heartbeatTimerRef.current = setInterval(async () => {
|
||||
if (!holdsLockRef.current) {
|
||||
stopHeartbeat();
|
||||
return;
|
||||
}
|
||||
const res = await api.heartbeatDocLock(docId);
|
||||
if (!res.extended) {
|
||||
holdsLockRef.current = false;
|
||||
setHoldsLock(false);
|
||||
stopHeartbeat();
|
||||
}
|
||||
}, 4000);
|
||||
};
|
||||
|
||||
const releaseMyLock = async (docId?: string) => {
|
||||
const targetDocId = docId || currentDocIdRef.current;
|
||||
if (!targetDocId) return;
|
||||
const wasHolding = holdsLockRef.current;
|
||||
stopHeartbeat();
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
holdsLockRef.current = false;
|
||||
setHoldsLock(false);
|
||||
if (wasHolding || docId) {
|
||||
try {
|
||||
await api.unlockDocument(targetDocId);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const ensureLock = async (force = false): Promise<boolean> => {
|
||||
if (!selectedDoc || selectedDoc.permission === 'read') return false;
|
||||
if (holdsLockRef.current && !force) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const res = await api.lockDocument(selectedDoc.id);
|
||||
if (res.acquired) {
|
||||
holdsLockRef.current = true;
|
||||
setHoldsLock(true);
|
||||
setActiveLock(null);
|
||||
startHeartbeat(selectedDoc.id);
|
||||
return true;
|
||||
} else if (res.lock) {
|
||||
setActiveLock(res.lock);
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to acquire lock', e);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const releaseMyLock = async (_docId?: string) => {};
|
||||
|
||||
const broadcastAwareness = (blockId: string | null) => {
|
||||
if (!selectedDoc) return;
|
||||
@@ -442,43 +376,28 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
color: getCollaboratorColor(currentUser?.id || ''),
|
||||
lastActive: Date.now(),
|
||||
};
|
||||
sendWebSocketMessage({
|
||||
event: 'CRDT_AWARENESS',
|
||||
docId: selectedDoc.id,
|
||||
payload,
|
||||
});
|
||||
if (providerRef.current) {
|
||||
providerRef.current.sendAwareness(payload);
|
||||
} else {
|
||||
sendWebSocketMessage({
|
||||
event: 'CRDT_AWARENESS',
|
||||
docId: selectedDoc.id,
|
||||
payload,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockFocus = (blockIdOrEvent?: string | React.FocusEvent<any>) => {
|
||||
const blockId = typeof blockIdOrEvent === 'string' ? blockIdOrEvent : undefined;
|
||||
isEditorFocusedRef.current = true;
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
if (blockId) {
|
||||
broadcastAwareness(blockId);
|
||||
}
|
||||
if (!isReadOnly) {
|
||||
ensureLock();
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockBlur = () => {
|
||||
isEditorFocusedRef.current = false;
|
||||
broadcastAwareness(null);
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
if (isDraggingBlockRef.current) {
|
||||
return;
|
||||
}
|
||||
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
await flushPendingSave();
|
||||
await releaseMyLock();
|
||||
}
|
||||
}, 8000);
|
||||
};
|
||||
|
||||
// Debounced auto-save refs (500ms delay)
|
||||
@@ -491,29 +410,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
isSavingToBackendRef.current = true;
|
||||
try {
|
||||
const docId = docToSave.id;
|
||||
const dirtyMap = localDirtyBlocksRef.current.get(docId);
|
||||
const deletedSet = localDeletedBlocksRef.current.get(docId);
|
||||
const srvBlocks = syncedBlocksRef.current.get(docId);
|
||||
const srvOrder = syncedOrderRef.current.get(docId) || [];
|
||||
|
||||
const upsertedMap = new Map<string, DocumentBlock>();
|
||||
if (dirtyMap) {
|
||||
dirtyMap.forEach((b, id) => upsertedMap.set(id, b));
|
||||
}
|
||||
(docToSave.blocks || []).forEach((b) => {
|
||||
const srv = srvBlocks?.get(b.id);
|
||||
if (!srv || !blocksAreEqual(b, srv)) {
|
||||
upsertedMap.set(b.id, b);
|
||||
}
|
||||
});
|
||||
|
||||
const upsertedToSend = Array.from(upsertedMap.values());
|
||||
const deletedToSend = Array.from(deletedSet?.values() || []);
|
||||
const currentOrder = (docToSave.blocks || []).map((b) => b.id);
|
||||
const orderChanged =
|
||||
currentOrder.length !== srvOrder.length ||
|
||||
currentOrder.some((id, idx) => id !== srvOrder[idx]);
|
||||
|
||||
const srvMeta = syncedDocMetaRef.current.get(docId);
|
||||
const cleanTags = (docToSave.tags || []).filter((t) => !t.startsWith('folder:'));
|
||||
const cleanTagsJson = JSON.stringify(cleanTags);
|
||||
@@ -524,36 +420,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
srvMeta.tagsJson !== cleanTagsJson ||
|
||||
srvMeta.isPinned !== !!docToSave.isPinned;
|
||||
|
||||
// Clear the sent snapshot from dirty/deleted maps before awaiting network call
|
||||
if (dirtyMap) {
|
||||
upsertedToSend.forEach((b) => {
|
||||
const cur = dirtyMap.get(b.id);
|
||||
if (cur && blocksAreEqual(cur, b)) {
|
||||
dirtyMap.delete(b.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (deletedSet) {
|
||||
deletedToSend.forEach((id) => deletedSet.delete(id));
|
||||
}
|
||||
|
||||
if (upsertedToSend.length > 0 || deletedToSend.length > 0 || orderChanged) {
|
||||
const patchedDoc = await api.patchDocumentBlocks(docId, {
|
||||
upserted: upsertedToSend,
|
||||
deletedIds: deletedToSend,
|
||||
blockOrder: currentOrder,
|
||||
});
|
||||
// Update server block baseline with patched result
|
||||
const nextSrvMap = new Map<string, DocumentBlock>();
|
||||
const nextSrvOrder: string[] = [];
|
||||
(patchedDoc.blocks || []).forEach((b) => {
|
||||
nextSrvMap.set(b.id, { ...b });
|
||||
nextSrvOrder.push(b.id);
|
||||
});
|
||||
syncedBlocksRef.current.set(docId, nextSrvMap);
|
||||
syncedOrderRef.current.set(docId, nextSrvOrder);
|
||||
}
|
||||
|
||||
if (metaChanged) {
|
||||
await api.updateDocument(docId, {
|
||||
title: docToSave.title,
|
||||
@@ -576,7 +442,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
api.getTagDetails(selectedTag).then(setTagDetails).catch(() => {});
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('Failed to save document to backend', e);
|
||||
console.error('Failed to save document metadata to backend', e);
|
||||
} finally {
|
||||
isSavingToBackendRef.current = false;
|
||||
if (saveSucceeded) {
|
||||
@@ -585,17 +451,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
setSaveState('saved');
|
||||
}
|
||||
} else {
|
||||
setSaveState('saving');
|
||||
}
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
await releaseMyLock();
|
||||
}
|
||||
}, 8000);
|
||||
setSaveState('saved');
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -614,48 +470,14 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const persistDocument = async (updatedDoc: Document, immediate = false) => {
|
||||
if (isReadOnly) return;
|
||||
ensureLock();
|
||||
lastLocalEditTimestampRef.current = Date.now();
|
||||
|
||||
// Record block-level delta against previous local state and server baseline
|
||||
const docId = updatedDoc.id;
|
||||
let dirtyMap = localDirtyBlocksRef.current.get(docId);
|
||||
if (!dirtyMap) {
|
||||
dirtyMap = new Map<string, DocumentBlock>();
|
||||
localDirtyBlocksRef.current.set(docId, dirtyMap);
|
||||
}
|
||||
let deletedSet = localDeletedBlocksRef.current.get(docId);
|
||||
if (!deletedSet) {
|
||||
deletedSet = new Set<string>();
|
||||
localDeletedBlocksRef.current.set(docId, deletedSet);
|
||||
}
|
||||
const srvBlocks = syncedBlocksRef.current.get(docId);
|
||||
const prevLocalBlocks =
|
||||
selectedDoc && selectedDoc.id === docId ? selectedDoc.blocks || [] : [];
|
||||
|
||||
const nextIds = new Set<string>();
|
||||
(updatedDoc.blocks || []).forEach((b) => {
|
||||
nextIds.add(b.id);
|
||||
deletedSet!.delete(b.id);
|
||||
const srv = srvBlocks?.get(b.id);
|
||||
if (!srv || !blocksAreEqual(b, srv)) {
|
||||
dirtyMap!.set(b.id, { ...b });
|
||||
}
|
||||
});
|
||||
|
||||
prevLocalBlocks.forEach((prevB) => {
|
||||
if (!nextIds.has(prevB.id)) {
|
||||
dirtyMap!.delete(prevB.id);
|
||||
deletedSet!.add(prevB.id);
|
||||
}
|
||||
});
|
||||
|
||||
// Sync into active CRDT session
|
||||
// 1. Sync into active CRDT session
|
||||
if (crdtSessionRef.current && crdtSessionRef.current.docId === updatedDoc.id) {
|
||||
crdtSessionRef.current.syncFullBlocks(updatedDoc.blocks || []);
|
||||
}
|
||||
|
||||
// 1. Update local UI state immediately for responsive, instant typing
|
||||
// 2. Update local UI state immediately for responsive, instant typing
|
||||
setSelectedDoc(updatedDoc);
|
||||
setDocs((prev) => prev.map((d) => (d.id === updatedDoc.id ? updatedDoc : d)));
|
||||
setSaveState('saving');
|
||||
@@ -668,7 +490,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
|
||||
if (immediate) {
|
||||
await flushPendingSave();
|
||||
await executeSaveToBackend(updatedDoc);
|
||||
} else {
|
||||
debounceSaveTimerRef.current = setTimeout(async () => {
|
||||
debounceSaveTimerRef.current = null;
|
||||
@@ -677,7 +499,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
pendingSaveDocRef.current = null;
|
||||
await executeSaveToBackend(doc);
|
||||
}
|
||||
}, 350);
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -776,13 +598,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const currentUserIdRef = useRef<string | null>(null);
|
||||
currentUserIdRef.current = currentUser?.id || null;
|
||||
|
||||
// Handle document switch, CRDT session lifecycle, and lock status checks
|
||||
// Handle document switch, Yjs CRDT session & YjsHybridProvider lifecycle
|
||||
useEffect(() => {
|
||||
const prevId = currentDocIdRef.current;
|
||||
if (prevId && prevId !== selectedDoc?.id) {
|
||||
flushPendingSave();
|
||||
releaseMyLock(prevId);
|
||||
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: prevId });
|
||||
if (providerRef.current) {
|
||||
providerRef.current.destroy();
|
||||
providerRef.current = null;
|
||||
}
|
||||
if (crdtSessionRef.current) {
|
||||
crdtSessionRef.current.destroy();
|
||||
crdtSessionRef.current = null;
|
||||
@@ -795,88 +619,64 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const targetDoc = selectedDoc;
|
||||
const session = new DocumentCRDTSession(targetDoc.id);
|
||||
session.initFromBlocks(targetDoc.blocks || []);
|
||||
session.onLocalUpdate = (b64Update) => {
|
||||
sendWebSocketMessage({
|
||||
event: 'CRDT_UPDATE',
|
||||
docId: targetDoc.id,
|
||||
payload: { update: b64Update },
|
||||
});
|
||||
};
|
||||
|
||||
session.onRemoteChange = (crdtBlocks) => {
|
||||
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
|
||||
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
||||
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
|
||||
if (!recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
|
||||
if (crdtBlocks && crdtBlocks.length > 0) {
|
||||
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks: crdtBlocks } : prev));
|
||||
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks: crdtBlocks } : d)));
|
||||
}
|
||||
};
|
||||
crdtSessionRef.current = session;
|
||||
|
||||
sendWebSocketMessage({ event: 'CRDT_JOIN', docId: targetDoc.id });
|
||||
|
||||
api
|
||||
.getDocumentCRDT(targetDoc.id)
|
||||
.then((res) => {
|
||||
if (res && crdtSessionRef.current && crdtSessionRef.current.docId === targetDoc.id) {
|
||||
if (res.state) {
|
||||
crdtSessionRef.current.applyRemoteUpdate(res.state);
|
||||
}
|
||||
if (res.updates && Array.isArray(res.updates)) {
|
||||
crdtSessionRef.current.applyRemoteUpdates(res.updates);
|
||||
}
|
||||
const blocks = crdtSessionRef.current.getBlocks();
|
||||
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 2500;
|
||||
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
||||
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(targetDoc.id)?.size || 0) > 0;
|
||||
if (blocks.length > 0 && !recentlyEdited && !hasPendingSave && !hasDirtyBlocks) {
|
||||
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
|
||||
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
|
||||
}
|
||||
const provider = new YjsHybridProvider({
|
||||
docId: targetDoc.id,
|
||||
ydoc: session.doc,
|
||||
onStatusChange: (status) => {
|
||||
setSyncStatus(status);
|
||||
},
|
||||
onSynced: () => {
|
||||
const blocks = session.getBlocks();
|
||||
if (blocks && blocks.length > 0) {
|
||||
setSelectedDoc((prev) => (prev && prev.id === targetDoc.id ? { ...prev, blocks } : prev));
|
||||
setDocs((prev) => prev.map((d) => (d.id === targetDoc.id ? { ...d, blocks } : d)));
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
api
|
||||
.getDocLock(selectedDoc.id)
|
||||
.then((res) => {
|
||||
if (res.locked && res.lock && res.lock.userId !== currentUser?.id) {
|
||||
setActiveLock(res.lock);
|
||||
} else {
|
||||
setActiveLock(null);
|
||||
},
|
||||
onAwareness: (payload) => {
|
||||
if (payload && payload.userId && payload.userId !== currentUser?.id) {
|
||||
setActivePeers((prev) => {
|
||||
const filtered = prev.filter((p) => p.userId !== payload.userId);
|
||||
if (payload.activeBlockId !== null) {
|
||||
return [...filtered, { ...payload, color: getCollaboratorColor(payload.userId), lastActive: Date.now() }];
|
||||
}
|
||||
return filtered;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
} else {
|
||||
setActiveLock(null);
|
||||
},
|
||||
});
|
||||
providerRef.current = provider;
|
||||
}
|
||||
|
||||
return () => {
|
||||
flushPendingSave();
|
||||
if (currentDocIdRef.current) {
|
||||
sendWebSocketMessage({ event: 'CRDT_LEAVE', docId: currentDocIdRef.current });
|
||||
if (crdtSessionRef.current) {
|
||||
crdtSessionRef.current.destroy();
|
||||
crdtSessionRef.current = null;
|
||||
}
|
||||
releaseMyLock(currentDocIdRef.current);
|
||||
if (providerRef.current) {
|
||||
providerRef.current.destroy();
|
||||
providerRef.current = null;
|
||||
}
|
||||
if (crdtSessionRef.current) {
|
||||
crdtSessionRef.current.destroy();
|
||||
crdtSessionRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [selectedDoc?.id]);
|
||||
|
||||
// Window blur / visibility change listener: release lock when user leaves tab/window
|
||||
// Window blur / visibility change listener: flush any pending metadata save
|
||||
useEffect(() => {
|
||||
const handleWindowBlur = () => {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
flushPendingSave();
|
||||
releaseMyLock();
|
||||
}
|
||||
flushPendingSave();
|
||||
};
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden' && !isDraggingBlockRef.current) {
|
||||
isEditorFocusedRef.current = false;
|
||||
if (document.visibilityState === 'hidden') {
|
||||
flushPendingSave();
|
||||
releaseMyLock();
|
||||
}
|
||||
};
|
||||
window.addEventListener('blur', handleWindowBlur);
|
||||
@@ -887,28 +687,15 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Flush pending save and release lock before tab closes or unmounts
|
||||
// Flush pending save before tab closes or unmounts
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
flushPendingSave();
|
||||
if (currentDocIdRef.current && holdsLockRef.current) {
|
||||
try {
|
||||
const token = getToken();
|
||||
fetch(`/api/v1/documents/${currentDocIdRef.current}/lock`, {
|
||||
method: 'DELETE',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
flushPendingSave();
|
||||
if (currentDocIdRef.current && holdsLockRef.current) {
|
||||
releaseMyLock(currentDocIdRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -965,19 +752,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
if (payload && payload.userId) {
|
||||
setActivePeers((prev) => prev.filter((c) => c.userId !== payload.userId));
|
||||
}
|
||||
} else if (msg.event === 'DOC_LOCKED') {
|
||||
const payload = msg.payload as DocLockInfo;
|
||||
if (payload && payload.docId === selectedDocIdRef.current) {
|
||||
if (payload.userId !== currentUserIdRef.current) {
|
||||
setActiveLock(payload);
|
||||
}
|
||||
}
|
||||
} else if (msg.event === 'DOC_UNLOCKED') {
|
||||
const payload = msg.payload as { docId?: string; doc_id?: string; id?: string };
|
||||
const unlockedDocId = payload?.docId || payload?.doc_id || payload?.id;
|
||||
if (unlockedDocId && unlockedDocId === selectedDocIdRef.current) {
|
||||
setActiveLock(null);
|
||||
}
|
||||
} else if (msg.event === 'DOC_BLOCKS_PATCHED') {
|
||||
const patch = msg.payload as DocBlocksPatchEvent;
|
||||
if (!patch || !patch.docId) return;
|
||||
@@ -1155,19 +929,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
const pollInterval = setInterval(async () => {
|
||||
if (!isMounted || document.visibilityState !== 'visible') return;
|
||||
|
||||
// 1. Poll lock status of currently selected document
|
||||
if (selectedDocIdRef.current) {
|
||||
try {
|
||||
const res = await api.getDocLock(selectedDocIdRef.current);
|
||||
if (res.locked && res.lock && res.lock.userId !== currentUserIdRef.current) {
|
||||
setActiveLock(res.lock);
|
||||
} else {
|
||||
setActiveLock(null);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 2. Poll document content if not actively typing and document has changed on server
|
||||
// 1. Poll document content if not actively typing and document has changed on server
|
||||
const recentlyEdited = Date.now() - lastLocalEditTimestampRef.current < 4000;
|
||||
const hasPendingSave = pendingSaveDocRef.current !== null || isSavingToBackendRef.current;
|
||||
const hasDirtyBlocks = (localDirtyBlocksRef.current.get(selectedDocIdRef.current || '')?.size || 0) > 0;
|
||||
@@ -2148,7 +1910,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
segStart = 0
|
||||
) => {
|
||||
if (isReadOnly) return;
|
||||
ensureLock();
|
||||
|
||||
if (slashBlockId === block.id) {
|
||||
if (filteredSlashCommands.length === 0) {
|
||||
@@ -2315,7 +2076,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
|
||||
const handleContentChange = (block: DocumentBlock, newContent: string) => {
|
||||
if (isReadOnly) return;
|
||||
ensureLock();
|
||||
|
||||
// Markdown shortcut: typing '---' creates a divider and inserts a new bullet below
|
||||
if (newContent.trim() === '---') {
|
||||
@@ -2634,13 +2394,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
return;
|
||||
}
|
||||
isDraggingBlockRef.current = true;
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
// Activate write lock immediately upon dragging (optimistic UI + backend broadcast)
|
||||
setHoldsLock(true);
|
||||
ensureLock(true);
|
||||
|
||||
e.dataTransfer.setData('application/mindsift-block-id', blockId);
|
||||
e.dataTransfer.setData('text/plain', blockId);
|
||||
@@ -2652,15 +2405,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
isDraggingBlockRef.current = false;
|
||||
setDraggingBlockId(null);
|
||||
setDragOverBlockTarget(null);
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
await flushPendingSave();
|
||||
await releaseMyLock();
|
||||
}
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const handleBlockDragOver = (e: React.DragEvent, targetBlockId: string) => {
|
||||
@@ -2772,16 +2516,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
}
|
||||
|
||||
persistDocument({ ...selectedDoc, blocks }, true);
|
||||
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
}
|
||||
idleUnlockTimerRef.current = setTimeout(async () => {
|
||||
if (!isEditorFocusedRef.current && !isDraggingBlockRef.current && holdsLockRef.current) {
|
||||
await flushPendingSave();
|
||||
await releaseMyLock();
|
||||
}
|
||||
}, 2500);
|
||||
};
|
||||
|
||||
// Export full document as portable Logseq-indented Markdown
|
||||
@@ -3975,6 +3709,41 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<div className="flex items-center gap-2 text-xs text-muted">
|
||||
{workspaceMode === 'editor' && selectedDoc && (
|
||||
<>
|
||||
{/* Transport Connection Status Indicator */}
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${
|
||||
syncStatus === 'ws'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/25'
|
||||
: syncStatus === 'sse'
|
||||
? 'bg-amber-500/10 text-amber-500 border-amber-500/25'
|
||||
: 'bg-zinc-500/10 text-zinc-400 border-zinc-500/25'
|
||||
}`}
|
||||
title={
|
||||
syncStatus === 'ws'
|
||||
? 'Echtzeit-Synchronisation aktiv über WebSocket'
|
||||
: syncStatus === 'sse'
|
||||
? 'Eingeschränkt über HTTP-Proxy-Modus (SSE & POST Fallback)'
|
||||
: 'Offline: Alle Änderungen lokal in IndexedDB gesichert'
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
syncStatus === 'ws'
|
||||
? 'bg-emerald-500 animate-pulse'
|
||||
: syncStatus === 'sse'
|
||||
? 'bg-amber-500'
|
||||
: 'bg-zinc-400'
|
||||
}`}
|
||||
/>
|
||||
<span>
|
||||
{syncStatus === 'ws'
|
||||
? 'Live (WebSocket)'
|
||||
: syncStatus === 'sse'
|
||||
? 'Eingeschränkt (HTTP-Proxy-Modus)'
|
||||
: 'Offline (Lokal gesichert)'}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="badge-accent">
|
||||
<Check className="w-3 h-3" />
|
||||
{saveState === 'saving' ? 'Speichert…' : 'Gespeichert'}
|
||||
@@ -4144,7 +3913,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{activePeers.length > 0 ? (
|
||||
{activePeers.length > 0 && (
|
||||
<span
|
||||
className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1.5 animate-pulse"
|
||||
title={`Live-Kollaboration via CRDT aktiv: ${activePeers.map((c) => c.userName).join(', ')}`}
|
||||
@@ -4153,12 +3922,7 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
<Users className="w-2.5 h-2.5" />
|
||||
<span>Live ({activePeers.length})</span>
|
||||
</span>
|
||||
) : holdsLock ? (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-semibold border border-emerald-500/30 bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></span>
|
||||
<span>CRDT Sync aktiv</span>
|
||||
</span>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{selectedDoc.sharedWith && selectedDoc.sharedWith.length > 0 && selectedDoc.isOwner !== false && (
|
||||
<button
|
||||
@@ -4307,7 +4071,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => {
|
||||
if (!isReadOnly) {
|
||||
ensureLock();
|
||||
autoResizeTextarea(e.target);
|
||||
persistDocument({ ...selectedDoc, title: e.target.value });
|
||||
}
|
||||
@@ -4346,7 +4109,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onBlur={handleBlockBlur}
|
||||
onChange={(e) => {
|
||||
if (!isReadOnly) {
|
||||
ensureLock();
|
||||
autoResizeTextarea(e.target);
|
||||
updateBlock(zoomedBlock.id, { content: e.target.value });
|
||||
}
|
||||
@@ -4440,15 +4202,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{/* Drag Handle on hover - tightened spacing to the bullet */}
|
||||
<div
|
||||
draggable={!isReadOnly}
|
||||
onMouseDown={() => {
|
||||
if (!isReadOnly) {
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
ensureLock(true);
|
||||
}
|
||||
}}
|
||||
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||
onDragEnd={handleBlockDragEnd}
|
||||
className="opacity-0 group-hover:opacity-100 flex items-center pt-1 text-faint hover:text-accent cursor-grab active:cursor-grabbing flex-shrink-0 -ml-1 mr-0.5"
|
||||
@@ -4478,15 +4231,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
{/* Interactive Bullet Dot (Click: Zoom-in! Drag: Reorder! Drop on it: Nest as child!) */}
|
||||
<div
|
||||
draggable={!isReadOnly}
|
||||
onMouseDown={() => {
|
||||
if (!isReadOnly) {
|
||||
if (idleUnlockTimerRef.current) {
|
||||
clearTimeout(idleUnlockTimerRef.current);
|
||||
idleUnlockTimerRef.current = null;
|
||||
}
|
||||
ensureLock(true);
|
||||
}
|
||||
}}
|
||||
onDragStart={(e) => !isReadOnly && handleBlockDragStart(e, block.id)}
|
||||
onDragEnd={handleBlockDragEnd}
|
||||
onDragOver={(e) => {
|
||||
@@ -4728,7 +4472,6 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
|
||||
onChangeContent={(content) => handleContentChange(block, content)}
|
||||
onChangeLang={(lang) => {
|
||||
if (!isReadOnly) {
|
||||
ensureLock();
|
||||
updateBlock(block.id, { lang });
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
import * as Y from 'yjs';
|
||||
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||
import { getToken } from './api';
|
||||
|
||||
export type SyncTransportStatus = 'ws' | 'sse' | 'offline';
|
||||
|
||||
export interface YjsHybridProviderOptions {
|
||||
docId: string;
|
||||
ydoc: Y.Doc;
|
||||
onStatusChange?: (status: SyncTransportStatus) => void;
|
||||
onAwareness?: (payload: any) => void;
|
||||
onSynced?: () => void;
|
||||
}
|
||||
|
||||
export class YjsHybridProvider {
|
||||
public readonly docId: string;
|
||||
public readonly ydoc: Y.Doc;
|
||||
private status: SyncTransportStatus = 'offline';
|
||||
private indexeddbProvider: IndexeddbPersistence;
|
||||
private ws: WebSocket | null = null;
|
||||
private eventSource: EventSource | null = null;
|
||||
private wsWatchdogTimer: any = null;
|
||||
private destroyed: boolean = false;
|
||||
private isConnectingWS: boolean = false;
|
||||
private isConnectingSSE: boolean = false;
|
||||
private onStatusChange?: (status: SyncTransportStatus) => void;
|
||||
private onAwareness?: (payload: any) => void;
|
||||
private onSynced?: () => void;
|
||||
|
||||
constructor(options: YjsHybridProviderOptions) {
|
||||
this.docId = options.docId;
|
||||
this.ydoc = options.ydoc;
|
||||
this.onStatusChange = options.onStatusChange;
|
||||
this.onAwareness = options.onAwareness;
|
||||
this.onSynced = options.onSynced;
|
||||
|
||||
// 1. Offline-First: Bind IndexedDB persistence
|
||||
this.indexeddbProvider = new IndexeddbPersistence(`mindsift-doc-${this.docId}`, this.ydoc);
|
||||
this.indexeddbProvider.on('synced', () => {
|
||||
if (this.destroyed) return;
|
||||
if (this.onSynced) {
|
||||
this.onSynced();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Listen to local document updates
|
||||
this.ydoc.on('update', this.handleDocUpdate);
|
||||
|
||||
// 3. Connect via Primary Transport (WebSocket) with 4-second watchdog fallback
|
||||
this.connect();
|
||||
|
||||
// 4. Online/offline window event listeners
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('online', this.handleOnline);
|
||||
window.addEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(newStatus: SyncTransportStatus) {
|
||||
if (this.status !== newStatus) {
|
||||
this.status = newStatus;
|
||||
if (this.onStatusChange) {
|
||||
this.onStatusChange(newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public getStatus(): SyncTransportStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
private handleOnline = () => {
|
||||
if (this.destroyed) return;
|
||||
if (this.status === 'offline') {
|
||||
this.connect();
|
||||
}
|
||||
};
|
||||
|
||||
private handleOffline = () => {
|
||||
if (this.destroyed) return;
|
||||
this.setStatus('offline');
|
||||
};
|
||||
|
||||
public connect() {
|
||||
if (this.destroyed) return;
|
||||
this.connectWebSocket();
|
||||
}
|
||||
|
||||
private connectWebSocket() {
|
||||
if (this.destroyed || this.isConnectingWS) return;
|
||||
this.isConnectingWS = true;
|
||||
|
||||
this.cleanupWebSocket();
|
||||
|
||||
const token = getToken() || '';
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/v1/ws/docs/${encodeURIComponent(this.docId)}?token=${encodeURIComponent(token)}`;
|
||||
|
||||
// 4-second watchdog timer: If WebSocket doesn't establish within 4 seconds, fallback to HTTP/SSE
|
||||
this.clearWatchdog();
|
||||
this.wsWatchdogTimer = setTimeout(() => {
|
||||
if (this.status !== 'ws') {
|
||||
console.warn(`[YjsProvider] WebSocket timeout (4s) for doc ${this.docId}. Switching to HTTP fallback (SSE + POST)...`);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}, 4000);
|
||||
|
||||
try {
|
||||
const ws = new WebSocket(wsUrl);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
this.ws = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
this.isConnectingWS = false;
|
||||
this.clearWatchdog();
|
||||
this.cleanupSSE();
|
||||
this.setStatus('ws');
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
const update = new Uint8Array(event.data);
|
||||
Y.applyUpdate(this.ydoc, update, 'remote');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'CRDT_INIT' && data.payload) {
|
||||
this.handleInit(data.payload);
|
||||
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
|
||||
this.handleRemoteUpdate(data.payload);
|
||||
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
|
||||
if (this.onAwareness) {
|
||||
this.onAwareness(data.payload);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[YjsProvider] WS message parse error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (err) => {
|
||||
console.warn(`[YjsProvider] WebSocket error on doc ${this.docId}:`, err);
|
||||
this.isConnectingWS = false;
|
||||
this.fallbackToHTTP();
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
this.isConnectingWS = false;
|
||||
// If closed abnormally (proxy drop, 1006, etc.), fallback to HTTP
|
||||
if (!this.destroyed && this.status === 'ws') {
|
||||
console.warn(`[YjsProvider] WebSocket closed (code ${event.code}). Falling back to HTTP/SSE...`);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
console.warn(`[YjsProvider] WebSocket creation failed:`, e);
|
||||
this.isConnectingWS = false;
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackToHTTP() {
|
||||
if (this.destroyed) return;
|
||||
this.cleanupWebSocket();
|
||||
this.connectSSE();
|
||||
}
|
||||
|
||||
private connectSSE() {
|
||||
if (this.destroyed || this.isConnectingSSE) return;
|
||||
this.isConnectingSSE = true;
|
||||
|
||||
this.cleanupSSE();
|
||||
|
||||
const token = getToken() || '';
|
||||
const sseUrl = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/stream?token=${encodeURIComponent(token)}`;
|
||||
|
||||
try {
|
||||
const es = new EventSource(sseUrl);
|
||||
this.eventSource = es;
|
||||
|
||||
es.onopen = () => {
|
||||
this.isConnectingSSE = false;
|
||||
this.setStatus('sse');
|
||||
};
|
||||
|
||||
es.onmessage = (event) => {
|
||||
if (this.destroyed) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.event === 'CRDT_INIT' && data.payload) {
|
||||
this.handleInit(data.payload);
|
||||
} else if (data.event === 'CRDT_UPDATE' && data.payload) {
|
||||
this.handleRemoteUpdate(data.payload);
|
||||
} else if (data.event === 'CRDT_AWARENESS' && data.payload) {
|
||||
if (this.onAwareness) {
|
||||
this.onAwareness(data.payload);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
|
||||
es.onerror = () => {
|
||||
this.isConnectingSSE = false;
|
||||
if (!this.destroyed) {
|
||||
this.setStatus('offline');
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
this.isConnectingSSE = false;
|
||||
this.setStatus('offline');
|
||||
}
|
||||
}
|
||||
|
||||
private handleInit(payload: { state?: string; updates?: string[] }) {
|
||||
if (payload.state) {
|
||||
try {
|
||||
const stateBytes = this.base64ToUint8(payload.state);
|
||||
Y.applyUpdate(this.ydoc, stateBytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply initial state:', e);
|
||||
}
|
||||
}
|
||||
if (Array.isArray(payload.updates)) {
|
||||
for (const u of payload.updates) {
|
||||
if (u) {
|
||||
try {
|
||||
const updateBytes = this.base64ToUint8(u);
|
||||
Y.applyUpdate(this.ydoc, updateBytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply initial update:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.onSynced) {
|
||||
this.onSynced();
|
||||
}
|
||||
}
|
||||
|
||||
private handleRemoteUpdate(payload: { update?: string }) {
|
||||
if (payload.update) {
|
||||
try {
|
||||
const bytes = this.base64ToUint8(payload.update);
|
||||
Y.applyUpdate(this.ydoc, bytes, 'remote');
|
||||
} catch (e) {
|
||||
console.error('[YjsProvider] Failed to apply remote update:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private handleDocUpdate = (update: Uint8Array, origin: any) => {
|
||||
// Only send updates originating locally (ignore 'remote' and 'indexeddb')
|
||||
if (this.destroyed || origin === 'remote' || origin === this.indexeddbProvider) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(update);
|
||||
return;
|
||||
} catch (e) {
|
||||
console.warn('[YjsProvider] Failed to send update over WS, falling back to HTTP POST:', e);
|
||||
this.fallbackToHTTP();
|
||||
}
|
||||
}
|
||||
|
||||
// Upstream via HTTP POST fallback
|
||||
this.sendUpdateViaHTTP(update);
|
||||
};
|
||||
|
||||
private async sendUpdateViaHTTP(update: Uint8Array) {
|
||||
const token = getToken() || '';
|
||||
const url = `/api/v1/sync/docs/${encodeURIComponent(this.docId)}/update?token=${encodeURIComponent(token)}`;
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: new Blob([update as any]),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.warn(`[YjsProvider] HTTP sync update status: ${res.status}`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[YjsProvider] Network error during HTTP sync update (persisted locally in IndexedDB):', e);
|
||||
this.setStatus('offline');
|
||||
}
|
||||
}
|
||||
|
||||
public sendAwareness(payload: any) {
|
||||
if (this.destroyed) return;
|
||||
|
||||
if (this.status === 'ws' && this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
try {
|
||||
this.ws.send(JSON.stringify({
|
||||
event: 'CRDT_AWARENESS',
|
||||
docId: this.docId,
|
||||
payload,
|
||||
}));
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// HTTP awareness fallback
|
||||
const token = getToken() || '';
|
||||
fetch(`/api/v1/documents/${encodeURIComponent(this.docId)}/awareness`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
private clearWatchdog() {
|
||||
if (this.wsWatchdogTimer) {
|
||||
clearTimeout(this.wsWatchdogTimer);
|
||||
this.wsWatchdogTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupWebSocket() {
|
||||
this.clearWatchdog();
|
||||
if (this.ws) {
|
||||
try {
|
||||
this.ws.onopen = null;
|
||||
this.ws.onmessage = null;
|
||||
this.ws.onerror = null;
|
||||
this.ws.onclose = null;
|
||||
this.ws.close();
|
||||
} catch {}
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
private cleanupSSE() {
|
||||
if (this.eventSource) {
|
||||
try {
|
||||
this.eventSource.onopen = null;
|
||||
this.eventSource.onmessage = null;
|
||||
this.eventSource.onerror = null;
|
||||
this.eventSource.close();
|
||||
} catch {}
|
||||
this.eventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
private base64ToUint8(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
public destroy() {
|
||||
this.destroyed = true;
|
||||
this.clearWatchdog();
|
||||
this.cleanupWebSocket();
|
||||
this.cleanupSSE();
|
||||
this.ydoc.off('update', this.handleDocUpdate);
|
||||
if (this.indexeddbProvider) {
|
||||
try {
|
||||
this.indexeddbProvider.destroy();
|
||||
} catch {}
|
||||
}
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('online', this.handleOnline);
|
||||
window.removeEventListener('offline', this.handleOffline);
|
||||
}
|
||||
}
|
||||
}
|
||||
+163
-91
@@ -69,6 +69,9 @@ func (s *Server) Router() http.Handler {
|
||||
r.Get("/api/v1/ws", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/spaces/{spaceId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/lists/{listId}", s.handleWebSocket)
|
||||
r.Get("/api/v1/ws/docs/{id}", s.handleDocWebSocket)
|
||||
r.Get("/api/v1/sync/docs/{id}/stream", s.handleDocSyncStream)
|
||||
r.Post("/api/v1/sync/docs/{id}/update", s.handleDocSyncUpdate)
|
||||
r.Get("/api/v1/events", s.handleSSE)
|
||||
r.Post("/api/v1/events/rooms", s.handleSSERooms)
|
||||
|
||||
@@ -868,16 +871,6 @@ func (s *Server) handleUpdateDocument(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
// Check document lock
|
||||
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
|
||||
w.WriteHeader(http.StatusLocked)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
|
||||
"currentLock": lock,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title *string `json:"title"`
|
||||
Icon *string `json:"icon"`
|
||||
@@ -907,16 +900,6 @@ func (s *Server) handlePatchDocumentBlocks(w http.ResponseWriter, r *http.Reques
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
// Check document lock
|
||||
if lock := s.wsHub.GetDocLock(docID); lock != nil && lock.UserID != userID {
|
||||
w.WriteHeader(http.StatusLocked)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"error": "Dokument wird aktuell bearbeitet von " + lock.UserName,
|
||||
"currentLock": lock,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var req domain.DocBlocksPatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
@@ -1324,68 +1307,178 @@ func (s *Server) handleGetCollaborators(w http.ResponseWriter, r *http.Request)
|
||||
writeJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// --- DOCUMENT LOCKING ---
|
||||
// --- DOCUMENT LOCKING (Neutralized - Yjs CRDT Pessimistic Locks Removed) ---
|
||||
|
||||
func (s *Server) handleGetDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
lock := s.wsHub.GetDocLock(docID)
|
||||
if lock == nil {
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": true, "lock": lock})
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"locked": false})
|
||||
}
|
||||
|
||||
func (s *Server) handleLockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
|
||||
user, err := s.store.GetUserByID(userID)
|
||||
userName := "Ein Benutzer"
|
||||
if err == nil && user != nil && user.DisplayName != "" {
|
||||
userName = user.DisplayName
|
||||
}
|
||||
|
||||
info, acquired, currentLock := s.wsHub.AcquireDocLock(docID, userID, userName, 20*time.Second)
|
||||
if !acquired {
|
||||
w.WriteHeader(http.StatusLocked)
|
||||
_ = json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"locked": true,
|
||||
"acquired": false,
|
||||
"currentLock": currentLock,
|
||||
"message": "Wird aktuell bearbeitet von " + currentLock.UserName,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_LOCKED", info)
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"locked": true,
|
||||
"locked": false,
|
||||
"acquired": true,
|
||||
"lock": info,
|
||||
"docId": docID,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleHeartbeatDocLock(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
ok := s.wsHub.HeartbeatDocLock(docID, userID, 20*time.Second)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"extended": ok})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"extended": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleUnlockDoc(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
docID := chi.URLParam(r, "id")
|
||||
user, _ := s.store.GetUserByID(userID)
|
||||
isAdmin := user != nil && user.Role == "admin"
|
||||
released := s.wsHub.ReleaseDocLock(docID, userID, isAdmin)
|
||||
if released {
|
||||
s.wsHub.BroadcastDocEvent(docID, "DOC_UNLOCKED", map[string]string{"docId": docID, "doc_id": docID})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": released})
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"unlocked": true})
|
||||
}
|
||||
|
||||
// --- DOCUMENT CRDT REALTIME SYNC ---
|
||||
// --- DOCUMENT CRDT REALTIME SYNC & DUAL TRANSPORT ---
|
||||
|
||||
func (s *Server) authenticateUserFromReq(r *http.Request) string {
|
||||
token := r.URL.Query().Get("token")
|
||||
if token == "" {
|
||||
if authHdr := r.Header.Get("Authorization"); strings.HasPrefix(authHdr, "Bearer ") {
|
||||
token = strings.TrimPrefix(authHdr, "Bearer ")
|
||||
}
|
||||
}
|
||||
if token != "" {
|
||||
if claims, err := auth.ValidateToken(s.cfg.JWTSecret, token); err == nil {
|
||||
return claims.UserID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GET /api/v1/ws/docs/{id}
|
||||
func (s *Server) handleDocWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Sec-Websocket-Key") != "" {
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Connection")), "upgrade") {
|
||||
r.Header.Set("Connection", "Upgrade")
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(r.Header.Get("Upgrade")), "websocket") {
|
||||
r.Header.Set("Upgrade", "websocket")
|
||||
}
|
||||
}
|
||||
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
|
||||
if userID != "anonymous" {
|
||||
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
|
||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ServeDocWS(w, r, userID)
|
||||
}
|
||||
|
||||
// GET /api/v1/sync/docs/{id}/stream
|
||||
func (s *Server) handleDocSyncStream(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
|
||||
if userID != "anonymous" {
|
||||
if _, err := s.store.GetDocumentByID(userID, docID); err != nil {
|
||||
writeError(w, http.StatusForbidden, "Kein Zugriff auf dieses Dokument")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ServeDocSSE(w, r, userID)
|
||||
}
|
||||
|
||||
// POST /api/v1/sync/docs/{id}/update
|
||||
func (s *Server) handleDocSyncUpdate(w http.ResponseWriter, r *http.Request) {
|
||||
docID := chi.URLParam(r, "id")
|
||||
if docID == "" {
|
||||
writeError(w, http.StatusBadRequest, "docId erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
userID := s.authenticateUserFromReq(r)
|
||||
if userID == "" {
|
||||
userID = auth.GetUserID(r.Context())
|
||||
}
|
||||
if userID == "" {
|
||||
writeError(w, http.StatusUnauthorized, "Nicht autorisiert")
|
||||
return
|
||||
}
|
||||
|
||||
doc, err := s.store.GetDocumentByID(userID, docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
if !doc.IsOwner && doc.Permission != "write" {
|
||||
writeError(w, http.StatusForbidden, "Keine Schreibberechtigung")
|
||||
return
|
||||
}
|
||||
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
var updateBytes []byte
|
||||
|
||||
if strings.Contains(contentType, "application/octet-stream") {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Fehler beim Lesen des Payloads")
|
||||
return
|
||||
}
|
||||
updateBytes = body
|
||||
} else {
|
||||
var req struct {
|
||||
Update string `json:"update"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges JSON")
|
||||
return
|
||||
}
|
||||
if req.State != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(req.State); err == nil && len(raw) > 0 {
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.CompactState(raw, userID)
|
||||
}
|
||||
}
|
||||
if req.Update != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.Update)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 für Update")
|
||||
return
|
||||
}
|
||||
updateBytes = raw
|
||||
}
|
||||
}
|
||||
|
||||
if len(updateBytes) > 0 {
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
room.ApplyUpdate(updateBytes, userID, nil, nil)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{"success": true, "docId": docID})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
userID := auth.GetUserID(r.Context())
|
||||
@@ -1394,19 +1487,8 @@ func (s *Server) handleGetDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusNotFound, "Dokument nicht gefunden")
|
||||
return
|
||||
}
|
||||
stateBlob, updates, err := s.store.GetDocumentCRDTState(docID)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
b64State := ""
|
||||
if len(stateBlob) > 0 {
|
||||
b64State = base64.StdEncoding.EncodeToString(stateBlob)
|
||||
}
|
||||
var b64Updates []string
|
||||
for _, u := range updates {
|
||||
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
|
||||
}
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
b64State, b64Updates := room.GetInitialSnapshot()
|
||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||
"docId": docID,
|
||||
"state": b64State,
|
||||
@@ -1434,21 +1516,14 @@ func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiger Request-Body")
|
||||
return
|
||||
}
|
||||
room := s.wsHub.GetOrCreateDocRoom(docID)
|
||||
if req.Update != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.Update)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format für Update")
|
||||
return
|
||||
}
|
||||
if err := s.store.AppendDocumentCRDTUpdate(docID, raw, userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
s.wsHub.BroadcastRoom("doc:"+docID, "CRDT_UPDATE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"update": req.Update,
|
||||
"fromUserId": userID,
|
||||
}, nil)
|
||||
room.ApplyUpdate(raw, userID, nil, nil)
|
||||
}
|
||||
if req.State != "" {
|
||||
raw, err := base64.StdEncoding.DecodeString(req.State)
|
||||
@@ -1456,10 +1531,7 @@ func (s *Server) handleSaveDocCRDT(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "Ungültiges Base64 Format")
|
||||
return
|
||||
}
|
||||
if err := s.store.SaveDocumentCRDTState(docID, raw, userID); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
room.CompactState(raw, userID)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -1015,3 +1016,83 @@ func TestSSEAndAwarenessEndpoints(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRoomDualTransportAndDebouncedPersistence(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "test_dual_transport.db")
|
||||
store, err := storage.NewStore(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to init store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
cfg := &config.Config{
|
||||
JWTSecret: "test-secret-dual-transport",
|
||||
AllowRegistration: true,
|
||||
}
|
||||
hub := ws.NewHub()
|
||||
srv := NewServer(cfg, store, hub, nil)
|
||||
router := srv.Router()
|
||||
|
||||
user, _ := store.CreateUser("dual@test.local", "hash", "Dual User", "member")
|
||||
token, _ := auth.GenerateToken(cfg.JWTSecret, user.ID, user.Email, user.Role)
|
||||
|
||||
doc, _ := store.CreateDocument(user.ID, "", "Dual Transport Doc", "📄", "", nil, nil, false)
|
||||
|
||||
// 1. Test POST /api/v1/sync/docs/{id}/update with JSON base64
|
||||
upd1 := base64.StdEncoding.EncodeToString([]byte{0x01, 0x02, 0x03})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader([]byte(`{"update":"`+upd1+`"}`)))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from sync update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 2. Test POST /api/v1/sync/docs/{id}/update with octet-stream binary
|
||||
binaryUpd := []byte{0x04, 0x05, 0x06}
|
||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/sync/docs/"+doc.ID+"/update", bytes.NewReader(binaryUpd))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
rec = httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from binary sync update, got %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// 3. Test GET /api/v1/sync/docs/{id}/stream SSE connection
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/sync/docs/"+doc.ID+"/stream?token="+token, nil).WithContext(ctx)
|
||||
rec = httptest.NewRecorder()
|
||||
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
if !strings.Contains(rec.Header().Get("Content-Type"), "text/event-stream") {
|
||||
t.Fatalf("expected text/event-stream, got %s", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
bodyStr := rec.Body.String()
|
||||
if !strings.Contains(bodyStr, "CRDT_INIT") {
|
||||
t.Fatalf("expected CRDT_INIT in SSE stream, got %s", bodyStr)
|
||||
}
|
||||
|
||||
// 4. Test Lock Neutrality: document update and block patch work without 423 Locked
|
||||
updateReq := httptest.NewRequest(http.MethodPut, "/api/v1/documents/"+doc.ID, bytes.NewReader([]byte(`{"title":"Updated Without Locks"}`)))
|
||||
updateReq.Header.Set("Authorization", "Bearer "+token)
|
||||
updateReq.Header.Set("Content-Type", "application/json")
|
||||
updateRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(updateRec, updateReq)
|
||||
|
||||
if updateRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 OK from document update, got %d: %s", updateRec.Code, updateRec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,473 @@
|
||||
package ws
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
// DocWSClient represents an active WebSocket connection to a specific document room.
|
||||
type DocWSClient struct {
|
||||
hub *Hub
|
||||
room *DocRoom
|
||||
conn *websocket.Conn
|
||||
userID string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// DocSSEClient represents an active SSE connection to a specific document room.
|
||||
type DocSSEClient struct {
|
||||
hub *Hub
|
||||
room *DocRoom
|
||||
id string
|
||||
userID string
|
||||
send chan []byte
|
||||
}
|
||||
|
||||
// DocRoom coordinates in-memory real-time updates and debounced persistence for one document.
|
||||
type DocRoom struct {
|
||||
mu sync.Mutex
|
||||
docID string
|
||||
hub *Hub
|
||||
stateBlob []byte
|
||||
pendingUpdates [][]byte
|
||||
persistedUpdatesCount int
|
||||
wsClients map[*DocWSClient]bool
|
||||
sseClients map[*DocSSEClient]bool
|
||||
debounceTimer *time.Timer
|
||||
dirty bool
|
||||
lastUserID string
|
||||
}
|
||||
|
||||
func (h *Hub) GetOrCreateDocRoom(docID string) *DocRoom {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
if h.docRooms == nil {
|
||||
h.docRooms = make(map[string]*DocRoom)
|
||||
}
|
||||
|
||||
if room, exists := h.docRooms[docID]; exists {
|
||||
return room
|
||||
}
|
||||
|
||||
room := &DocRoom{
|
||||
docID: docID,
|
||||
hub: h,
|
||||
wsClients: make(map[*DocWSClient]bool),
|
||||
sseClients: make(map[*DocSSEClient]bool),
|
||||
}
|
||||
|
||||
// Pre-load current state from CRDT store if available
|
||||
if h.crdtStore != nil {
|
||||
stateBlob, updates, err := h.crdtStore.GetDocumentCRDTState(docID)
|
||||
if err == nil {
|
||||
room.stateBlob = stateBlob
|
||||
room.pendingUpdates = updates
|
||||
room.persistedUpdatesCount = len(updates)
|
||||
}
|
||||
}
|
||||
|
||||
h.docRooms[docID] = room
|
||||
return room
|
||||
}
|
||||
|
||||
// ApplyUpdate processes an incremental Yjs binary update in-memory, broadcasts it immediately
|
||||
// to all other connected clients (WS & SSE), and schedules an asynchronous debounced flush to SQLite.
|
||||
func (r *DocRoom) ApplyUpdate(update []byte, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) {
|
||||
if len(update) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.pendingUpdates = append(r.pendingUpdates, update)
|
||||
r.dirty = true
|
||||
if fromUserID != "" {
|
||||
r.lastUserID = fromUserID
|
||||
}
|
||||
|
||||
b64Update := base64.StdEncoding.EncodeToString(update)
|
||||
msg := domain.WSMessage{
|
||||
Event: "CRDT_UPDATE",
|
||||
Payload: map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"update": b64Update,
|
||||
"fromUserId": fromUserID,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
msgBytes, _ := json.Marshal(msg)
|
||||
|
||||
// Broadcast to all WS clients except sender
|
||||
for client := range r.wsClients {
|
||||
if senderWS != nil && client == senderWS {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Broadcast to all SSE clients except sender
|
||||
for client := range r.sseClients {
|
||||
if senderSSE != nil && client == senderSSE {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule 2.5s debounced persistence
|
||||
r.scheduleDebouncedFlushLocked()
|
||||
r.mu.Unlock()
|
||||
|
||||
// Also inform legacy room subscribers if any
|
||||
r.hub.BroadcastRoom("doc:"+r.docID, "CRDT_UPDATE", map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"update": b64Update,
|
||||
"fromUserId": fromUserID,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// CompactState replaces the incremental pending updates with a full compacted state blob.
|
||||
func (r *DocRoom) CompactState(stateBlob []byte, fromUserID string) {
|
||||
if len(stateBlob) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.stateBlob = stateBlob
|
||||
r.pendingUpdates = nil
|
||||
r.persistedUpdatesCount = 0
|
||||
r.dirty = true
|
||||
if fromUserID != "" {
|
||||
r.lastUserID = fromUserID
|
||||
}
|
||||
r.scheduleDebouncedFlushLocked()
|
||||
r.mu.Unlock()
|
||||
}
|
||||
|
||||
// BroadcastAwareness forwards user awareness state to all other participants in the room.
|
||||
func (r *DocRoom) BroadcastAwareness(payload json.RawMessage, fromUserID string, senderWS *DocWSClient, senderSSE *DocSSEClient) {
|
||||
msg := domain.WSMessage{
|
||||
Event: "CRDT_AWARENESS",
|
||||
Payload: payload,
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
msgBytes, _ := json.Marshal(msg)
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
for client := range r.wsClients {
|
||||
if senderWS != nil && client == senderWS {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
for client := range r.sseClients {
|
||||
if senderSSE != nil && client == senderSSE {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case client.send <- msgBytes:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *DocRoom) scheduleDebouncedFlushLocked() {
|
||||
if r.debounceTimer != nil {
|
||||
r.debounceTimer.Stop()
|
||||
}
|
||||
r.debounceTimer = time.AfterFunc(2500*time.Millisecond, func() {
|
||||
r.Flush()
|
||||
})
|
||||
}
|
||||
|
||||
// Flush persists in-memory pending updates to SQLite asynchronously in a background goroutine.
|
||||
func (r *DocRoom) Flush() {
|
||||
r.mu.Lock()
|
||||
if !r.dirty {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Prepare data to flush
|
||||
docID := r.docID
|
||||
userID := r.lastUserID
|
||||
stateBlob := r.stateBlob
|
||||
var updatesToFlush [][]byte
|
||||
|
||||
if len(stateBlob) > 0 && len(r.pendingUpdates) == 0 {
|
||||
// Compacted state snapshot
|
||||
r.dirty = false
|
||||
r.mu.Unlock()
|
||||
|
||||
go func() {
|
||||
if r.hub.crdtStore != nil {
|
||||
_ = r.hub.crdtStore.SaveDocumentCRDTState(docID, stateBlob, userID)
|
||||
}
|
||||
}()
|
||||
return
|
||||
}
|
||||
|
||||
if r.persistedUpdatesCount < len(r.pendingUpdates) {
|
||||
updatesToFlush = make([][]byte, len(r.pendingUpdates)-r.persistedUpdatesCount)
|
||||
copy(updatesToFlush, r.pendingUpdates[r.persistedUpdatesCount:])
|
||||
r.persistedUpdatesCount = len(r.pendingUpdates)
|
||||
}
|
||||
r.dirty = false
|
||||
r.mu.Unlock()
|
||||
|
||||
if len(updatesToFlush) > 0 {
|
||||
go func() {
|
||||
if r.hub.crdtStore != nil {
|
||||
for _, u := range updatesToFlush {
|
||||
_ = r.hub.crdtStore.AppendDocumentCRDTUpdate(docID, u, userID)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// GetInitialSnapshot returns the base64-encoded state blob and array of incremental updates.
|
||||
func (r *DocRoom) GetInitialSnapshot() (string, []string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
b64State := ""
|
||||
if len(r.stateBlob) > 0 {
|
||||
b64State = base64.StdEncoding.EncodeToString(r.stateBlob)
|
||||
}
|
||||
|
||||
var b64Updates []string
|
||||
for _, u := range r.pendingUpdates {
|
||||
if len(u) > 0 {
|
||||
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
|
||||
}
|
||||
}
|
||||
return b64State, b64Updates
|
||||
}
|
||||
|
||||
// ServeDocWS handles WebSocket connection for GET /api/v1/ws/docs/{id}
|
||||
func (r *DocRoom) ServeDocWS(w http.ResponseWriter, req *http.Request, userID string) {
|
||||
conn, err := upgrader.Upgrade(w, req, nil)
|
||||
if err != nil {
|
||||
log.Printf("[doc-ws] upgrade error for doc %s: %v", r.docID, err)
|
||||
return
|
||||
}
|
||||
|
||||
client := &DocWSClient{
|
||||
hub: r.hub,
|
||||
room: r,
|
||||
conn: conn,
|
||||
userID: userID,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.wsClients[client] = true
|
||||
r.mu.Unlock()
|
||||
|
||||
// Send initial snapshot
|
||||
state, updates := r.GetInitialSnapshot()
|
||||
initMsg := domain.WSMessage{
|
||||
Event: "CRDT_INIT",
|
||||
Payload: map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"state": state,
|
||||
"updates": updates,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
}
|
||||
initBytes, _ := json.Marshal(initMsg)
|
||||
client.send <- initBytes
|
||||
|
||||
go client.writePump()
|
||||
go client.readPump()
|
||||
}
|
||||
|
||||
// ServeDocSSE handles SSE stream fallback for GET /api/v1/sync/docs/{id}/stream
|
||||
func (r *DocRoom) ServeDocSSE(w http.ResponseWriter, req *http.Request, userID string) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
|
||||
client := &DocSSEClient{
|
||||
hub: r.hub,
|
||||
room: r,
|
||||
id: domain.NewID("sse-doc"),
|
||||
userID: userID,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.sseClients[client] = true
|
||||
r.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
r.mu.Lock()
|
||||
delete(r.sseClients, client)
|
||||
clientCount := len(r.wsClients) + len(r.sseClients)
|
||||
r.mu.Unlock()
|
||||
|
||||
if clientCount == 0 {
|
||||
r.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
// Send initial connected comment and CRDT_INIT
|
||||
fmt.Fprintf(w, ": connected (transport: sse, docId: %s)\n\n", r.docID)
|
||||
state, updates := r.GetInitialSnapshot()
|
||||
initMsg, _ := json.Marshal(domain.WSMessage{
|
||||
Event: "CRDT_INIT",
|
||||
Payload: map[string]interface{}{
|
||||
"docId": r.docID,
|
||||
"state": state,
|
||||
"updates": updates,
|
||||
},
|
||||
Timestamp: domain.NowISO(),
|
||||
})
|
||||
fmt.Fprintf(w, "data: %s\n\n", initMsg)
|
||||
flusher.Flush()
|
||||
|
||||
ticker := time.NewTicker(15 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
notify := req.Context().Done()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-notify:
|
||||
return
|
||||
case msg, ok := <-client.send:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", msg)
|
||||
flusher.Flush()
|
||||
case <-ticker.C:
|
||||
// Ping to keep proxy connection alive
|
||||
fmt.Fprintf(w, ": ping\n\n")
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocWSClient) readPump() {
|
||||
defer func() {
|
||||
c.room.mu.Lock()
|
||||
delete(c.room.wsClients, c)
|
||||
clientCount := len(c.room.wsClients) + len(c.room.sseClients)
|
||||
c.room.mu.Unlock()
|
||||
|
||||
_ = c.conn.Close()
|
||||
|
||||
// Broadcast awareness leave
|
||||
c.room.BroadcastAwareness([]byte(fmt.Sprintf(`{"userId":"%s","activeBlockId":null}`, c.userID)), c.userID, nil, nil)
|
||||
|
||||
if clientCount == 0 {
|
||||
c.room.Flush()
|
||||
}
|
||||
}()
|
||||
|
||||
c.conn.SetReadLimit(8 * 1024 * 1024) // 8MB limit for binary/text updates
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
_ = c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
|
||||
for {
|
||||
msgType, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
if msgType == websocket.BinaryMessage {
|
||||
// Direct binary Yjs update
|
||||
c.room.ApplyUpdate(message, c.userID, c, nil)
|
||||
continue
|
||||
}
|
||||
|
||||
// Text JSON message
|
||||
var incoming struct {
|
||||
Event string `json:"event"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
if json.Unmarshal(message, &incoming) == nil {
|
||||
switch incoming.Event {
|
||||
case "CRDT_UPDATE":
|
||||
var p struct {
|
||||
Update string `json:"update"`
|
||||
}
|
||||
if json.Unmarshal(incoming.Payload, &p) == nil && p.Update != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
|
||||
c.room.ApplyUpdate(raw, c.userID, c, nil)
|
||||
}
|
||||
}
|
||||
case "CRDT_COMPACT":
|
||||
var p struct {
|
||||
State string `json:"state"`
|
||||
}
|
||||
if json.Unmarshal(incoming.Payload, &p) == nil && p.State != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
|
||||
c.room.CompactState(raw, c.userID)
|
||||
}
|
||||
}
|
||||
case "CRDT_AWARENESS":
|
||||
c.room.BroadcastAwareness(incoming.Payload, c.userID, c, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *DocWSClient) writePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
_ = c.conn.Close()
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
_ = c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
_ = c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-28
@@ -62,6 +62,7 @@ type Hub struct {
|
||||
sseUserRooms map[string]map[*SSEClient]bool
|
||||
sseRooms map[string]map[*SSEClient]bool
|
||||
locks map[string]*docLockItem
|
||||
docRooms map[string]*DocRoom
|
||||
crdtStore CRDTStore
|
||||
}
|
||||
|
||||
@@ -74,6 +75,7 @@ func NewHub() *Hub {
|
||||
sseUserRooms: make(map[string]map[*SSEClient]bool),
|
||||
sseRooms: make(map[string]map[*SSEClient]bool),
|
||||
locks: make(map[string]*docLockItem),
|
||||
docRooms: make(map[string]*DocRoom),
|
||||
}
|
||||
go h.cleanupLocksLoop()
|
||||
return h
|
||||
@@ -811,23 +813,14 @@ func (c *Client) readPump() {
|
||||
}
|
||||
case "CRDT_JOIN":
|
||||
c.hub.JoinRoom(c, "doc:"+docID)
|
||||
if c.hub.crdtStore != nil {
|
||||
stateBlob, updates, err := c.hub.crdtStore.GetDocumentCRDTState(docID)
|
||||
if err == nil && (len(stateBlob) > 0 || len(updates) > 0) {
|
||||
var b64Updates []string
|
||||
for _, u := range updates {
|
||||
b64Updates = append(b64Updates, base64.StdEncoding.EncodeToString(u))
|
||||
}
|
||||
b64State := ""
|
||||
if len(stateBlob) > 0 {
|
||||
b64State = base64.StdEncoding.EncodeToString(stateBlob)
|
||||
}
|
||||
c.SendEvent("CRDT_INIT", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"state": b64State,
|
||||
"updates": b64Updates,
|
||||
})
|
||||
}
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
b64State, b64Updates := room.GetInitialSnapshot()
|
||||
if len(b64State) > 0 || len(b64Updates) > 0 {
|
||||
c.SendEvent("CRDT_INIT", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"state": b64State,
|
||||
"updates": b64Updates,
|
||||
})
|
||||
}
|
||||
case "CRDT_UPDATE":
|
||||
var p struct {
|
||||
@@ -835,15 +828,9 @@ func (c *Client) readPump() {
|
||||
}
|
||||
_ = json.Unmarshal(incoming.Payload, &p)
|
||||
if p.Update != "" {
|
||||
c.hub.BroadcastRoom("doc:"+docID, "CRDT_UPDATE", map[string]interface{}{
|
||||
"docId": docID,
|
||||
"update": p.Update,
|
||||
"fromUserId": c.userID,
|
||||
}, c)
|
||||
if c.hub.crdtStore != nil {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
|
||||
_ = c.hub.crdtStore.AppendDocumentCRDTUpdate(docID, raw, c.userID)
|
||||
}
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.Update); err == nil && len(raw) > 0 {
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
room.ApplyUpdate(raw, c.userID, nil, nil)
|
||||
}
|
||||
}
|
||||
case "CRDT_COMPACT":
|
||||
@@ -851,13 +838,16 @@ func (c *Client) readPump() {
|
||||
State string `json:"state"`
|
||||
}
|
||||
_ = json.Unmarshal(incoming.Payload, &p)
|
||||
if p.State != "" && c.hub.crdtStore != nil {
|
||||
if p.State != "" {
|
||||
if raw, err := base64.StdEncoding.DecodeString(p.State); err == nil && len(raw) > 0 {
|
||||
_ = c.hub.crdtStore.SaveDocumentCRDTState(docID, raw, c.userID)
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
room.CompactState(raw, c.userID)
|
||||
}
|
||||
}
|
||||
case "CRDT_AWARENESS":
|
||||
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS", incoming.Payload, c)
|
||||
room := c.hub.GetOrCreateDocRoom(docID)
|
||||
room.BroadcastAwareness(incoming.Payload, c.userID, nil, nil)
|
||||
case "CRDT_LEAVE":
|
||||
c.hub.LeaveRoom(c, "doc:"+docID)
|
||||
c.hub.BroadcastRoom("doc:"+docID, "CRDT_AWARENESS_LEAVE", map[string]interface{}{
|
||||
|
||||
Reference in new issue
Block a user