12 KiB
📡 WebSocket & Echtzeit-Synchronisation
MindSift implementiert eine bidirektionale Echtzeit-Synchronisationsschicht auf Basis von WebSockets (internal/ws/hub.go). Sie sorgt dafür, dass Änderungen an Aufgaben, Triage-Karten, Notizen und geteilten Einkaufslisten ohne manuelles Neuladen sofort auf allen geöffneten Geräten und bei allen Kollaborationspartnern sichtbar werden.
🔌 1. Verbindung & Handshake
Verbindungs-Endpunkte
wss://<domain>/api/v1/ws?token=<jwt-token>
wss://<domain>/api/v1/ws/lists/<listId>?token=<jwt-token>
Authentifizierung & Upgrade
- Die Authentifizierung erfolgt über das JWT-Token im Query-Parameter
?token=...oder denAuthorization: Bearer-Header während des HTTP-Upgrade-Requests. - Der Server extrahiert die
userIDund registriert die Verbindung im internenHub. - Der
Upgraderakzeptiert alle Origins (CheckOrigin: return true), um volle Kompatibilität mit Reverse-Proxies (Traefik, Nginx, Caddy, Cloudflare) und Docker-Netzwerken zu gewährleisten.
🏛️ 2. Hub-Architektur & Rooms
Der WebSocket-Hub unterteilt Verbindungen in drei Abstraktionsebenen:
┌─────────────────────────────────────────────────────────────────┐
│ WebSocket Hub │
├─────────────────┬─────────────────────────────┬─────────────────┤
│ allClients │ userRooms │ rooms │
│ (Alle aktiven │ (Pro Benutzer-ID) │ (Pro Liste/ │
│ Verbindungen) │ Cross-Device Sync für │ Space) │
│ │ Tasks, Triage & Workspace │ Geteilte Liste │
└────────┬────────┴──────────────┬──────────────┴────────┬────────┘
▼ ▼ ▼
Presence Stats TASK_CREATED ITEM_TOGGLED
DOC_LOCKED TASK_UPDATED ITEM_CREATED
DOC_UNLOCKED DOC_UPDATED LIST_UPDATED
allClients(Global): Hält jede aktive Client-Verbindung. Ermöglicht Broadcasts systemweiter Ereignisse (z.B. globale Dokument-Sperrungen).userRooms[userID](Benutzer-Räume): Sendet Events an alle Geräte desselben Benutzers (z.B. Desktop und Smartphone). Erzeugt ein Webhook oder Quick Dump auf dem Smartphone einen Eintrag, taucht dieser zeitgleich auf dem Desktop auf.rooms[listID](Kollaborations-Räume): Multiplexing für geteilte Listen. Alle Mitglieder einer Liste empfangen Aktionen (wie Abhaken, Löschen, Umsortieren) in Echtzeit.
📨 3. Nachrichten-Format (Envelope)
Alle WebSocket-Nachrichten werden als JSON mit einheitlicher Hülle übertragen:
{
"event": "ITEM_TOGGLED",
"listId": "lst_12345",
"payload": {
"itemId": "itm_987",
"isCompleted": true,
"completedBy": "usr_sandro",
"completedByName": "Sandro"
},
"timestamp": "2026-10-26T13:30:00Z"
}
⚡ 4. Granulare Workspace-Echtzeitsynchronisation (/api/v1/ws/docs/{id})
MindSift nutzt für die Outliner-Synchronisation eine schlanke, hochgradig reaktive In-Memory-Raumarchitektur (DocRoom in internal/ws/docroom.go). Clients verbinden sich mit ihrer eindeutigen clientId:
wss://<domain>/api/v1/ws/docs/<docId>?token=<jwt>&clientId=<cli_...>&userName=<Name>&userColor=<Hex>
4.1 JSON-Echtzeit-Eventprotokoll
Die Kommunikation erfolgt über leichtgewichtige JSON-Nachrichten ohne binären CRDT-Overhead:
| Event-Typ | Richtung | Zweck / Payload |
|---|---|---|
doc_state |
Server ➔ Client | Wird direkt beim Verbinden gesendet: Liefert den aktuellen In-Memory-Zustand (title, blocks[], activeCursors[]). |
block_updated |
Bidirektional | Feingranulare Inhalts- oder Statusänderung eines Blocks (blockId, content, level, type, checked, collapsed). |
block_created |
Bidirektional | Erzeugt einen neuen Block (block, afterBlockId, newIndex). |
block_deleted |
Bidirektional | Löscht einen Block anhand seiner blockId. |
block_moved |
Bidirektional | Verschiebt einen Block / Unterbaum (blockId, newIndex, level). |
title_updated |
Bidirektional | Aktualisiert den Dokumententitel (title). |
cursor_moved |
Bidirektional | Ephemere Live-Präsenz & Cursor: Sendet Benutzer-Avatar, Cursorposition und Textselektion (clientId, userId, userName, userColor, blockId, selectionStart, selectionEnd). Wird nicht in SQLite gespeichert! |
client_left |
Server ➔ Client | Wird automatisch ausgelöst, wenn ein Peer die Verbindung trennt, damit Remote-Cursor sofort verschwinden. |
sequenceDiagram
autonumber
actor UserA as Admin (Fenster A, cli_1)
participant Room as DocRoom (Go RAM)
actor UserB as User (Fenster B, cli_2)
participant DB as SQLite (blocks, block_tags, documents)
UserA->>Room: Connect (clientId="cli_1", userName="Admin")
Room-->>UserA: doc_state (title, blocks, activeCursors)
UserB->>Room: Connect (clientId="cli_2", userName="User")
Room-->>UserB: doc_state (title, blocks, activeCursors)
UserA->>Room: block_updated (clientId="cli_1", blockId="b1", content="Neu")
Note over Room: Mutiert RAM-Slice & resetet 1.5s Timer
Room-->>UserB: block_updated (clientId="cli_1", blockId="b1", content="Neu")
Note over UserA: Echo Filter: cli_1 erhält kein Selbstecho!
UserB->>Room: cursor_moved (clientId="cli_2", blockId="b1", selStart=3)
Room-->>UserA: cursor_moved (clientId="cli_2", blockId="b1")
Note over UserA: Farbiger Cursor-Avatar erscheint an b1
Note over Room: 1.5s Inaktivität ohne neue Tastenanschläge
Room->>DB: Debounce-Flush (blocks, Regex #tags -> block_tags, markdown_cache)
4.2 Strikter Echo-Schutz (clientId)
- Jeder Client generiert beim Start einen flüchtigen Schlüssel (
clientIdRef, z. B.cli_a8f9_l0z3). - Der Go-Hub filtert beim Broadcasten im Raum alle Clients heraus, deren Verbindung oder
clientIdmit dem Absender übereinstimmt:if evt.ClientID != "" && client.clientID == evt.ClientID { continue } - Vorteil: Verhindert Cursor-Resets, Fokus-Verluste und Race Conditions durch Selbstechos während des lokalen Tippens.
4.3 1.5-Sekunden Debounce-Flush in SQLite
- Das Tippen im Editor erfolgt latenzfrei direkt im Arbeitsspeicher des
DocRoom. - Sobald 1,5 Sekunden lang keine neuen Tastenanschläge oder Mutationen eintreffen (oder wenn der letzte Client den Raum verlässt), stößt der Raum einen synchronisierten Flush an:
- Persistiert alle Blöcke in
blocks. - Extrahiert alle Hashtags (
#tag) per Regex und aktualisiertblock_tags. - Kompiliert die Blockliste (anhand der
level-Tiefe) in Standard-Markdown und speichert sie indocuments.markdown_cache.
- Persistiert alle Blöcke in
🔒 5. Berechtigungen & Bearbeitungsrechte
- Kollaboration ohne blockierende Locks: Alle Nutzer mit Schreibrechten (
permission === 'owner'oder'write') können zeitgleich im selben Dokument tippen. Pessimistische Locks oder Lease-Heartbeats wurden vollständig entfernt. - Schreibschutz: Verfügt ein Nutzer nur über Leserechte (
permission === 'read'), ist das Eingabefeld gesperrt (readOnly). Er kann das Dokument jedoch in Echtzeit mitlesen und sieht die Cursors der Bearbeiter.
🔄 6. Fallback-Strategie & Proxy-Resilienz (Dual-Transport Architektur)
In restriktiven Firmennetzwerken, hinter Unternehmens-Proxies (z.B. Zscaler, BlueCoat) oder fehlerhaft konfigurierten Reverse-Proxies wird der Upgrade: websocket-Header häufig verworfen (NS_ERROR_CONNECTION_REFUSED, Code 1006 / 1002 / 1008).
MindSift löst dieses Problem durch eine vollwertige Dual-Transport-Architektur (FLAW-03):
┌───────────────────────────────┐
│ MindSift Frontend │
└───────────────┬───────────────┘
│
┌─────────────────────┴─────────────────────┐
▼ ▼
Standard: WebSockets Fallback: SSE + REST
(wss://.../api/v1/ws) (https://.../api/v1/events)
│ │
│ [Proxy Upgrade Block (Code 1006/Timeout)]│
│ ────────────────────────────────────────> │
▼ ▼
┌────────────────────────┐ ┌────────────────────────┐
│ Gorilla WS Conn │ │ Server-Sent Events │
│ Bidirektionaler │ │ (text/event-stream) │
│ TCP-WebSocket │ │ Standard HTTPS Port │
└────────────┬───────────┘ └────────────┬───────────┘
│ │
└─────────────────────┬─────────────────────┘
▼
┌─────────────────────────────┐
│ Realtime Hub │
│ (Multiplexing WS & SSE) │
└─────────────────────────────┘
1. Automatischer SSE-Fallback (GET /api/v1/events)
- Scheitert der WebSocket-Handshake oder blockiert ein Proxy das Upgrade, wechselt MindSift innerhalb von 3,5 Sekunden automatisch auf einen Server-Sent Events (SSE) Stream.
- Da SSE ein standardmäßiger HTTP/1.1- bzw. HTTP/2-GET-Request über Standard-Port 443/80 ist (
Content-Type: text/event-stream), wird er von jedem Proxy und jeder Firewall ungehindert durchgeleitet. - Header
X-Accel-Buffering: noundCache-Control: no-cacheverhindern Pufferung im Proxy; periodische Ping-Kommentare (: ping\n\n) alle 15 Sekunden halten Timeouts fern. - Der Client bleibt dadurch vollständig echtzeitfähig (
Live (SSE)).
2. REST-Endpunkte für Upstream-Aktionen im SSE-Fallback-Modus
Da SSE unidirektional (Server ➔ Client) ist, stellt MindSift für Schreibvorgänge im SSE-Modus REST-Äquivalente bereit:
PATCH /api/v1/documents/:id/blocks: Akzeptiert Block-Mutationen (upserted,deletedIds,blockOrder) und synchronisiert den In-Memory-Raum sowie SQLite.PUT /api/v1/documents/:id: Aktualisiert Dokumenttitel und Metadaten.POST /api/v1/events/rooms: Erlaubt SSE-Clients, Räumen dynamisch beizutreten (join) oder sie zu verlassen (leave).
3. Verbindungs-Indikator
- Grün pulsierend: Live verbunden über WebSockets (
Live (WebSocket)). - Türkis pulsierend: Live verbunden über Server-Sent Events (
Live (Server-Sent Events / Proxy-Stream)). - Gelb/Amber: Temporäre Unterbrechung mit automatischem HTTP-Polling-Schutz.
Ausführliche Konfigurationsbeispiele für Nginx, Traefik, Caddy, Cloudflare und Apache finden sich im separaten Handbuch: 👉 Reverse-Proxy-Konfigurationshandbuch