Phase 4 of the collaboration plan:
- The sync engine listens for DOC_SEQ in the note's realtime room and syncs
right away. Without WebSocket/SSE it long-polls /docs/{id}/changes (25 s);
after three failures it falls back to polling every 3 s and retries long
polling when the browser comes back online.
- With push or long polling the engine only syncs every 7 s while holding
leases (renewal), otherwise every 30 s as a safety net. Focusing a block
announces its lease immediately (otherwise it could take up to 30 s).
- Realtime connection: SSE counts as connected only after its first event;
a stream that stays silent for 5 s (buffering proxy) switches SSE off for
the tab. App-level PING/PONG detects WebSockets that are accepted but
forward nothing and switches to SSE after 35 s.
- The editor shows the active channel for shared notes.
- Tests: engine channel tests (push, heartbeat, lease on focus, long
polling, interval fallback, switching), realtime fallback tests with fake
WebSocket/EventSource, Go tests for PONG and DOC_SEQ delivery, and
e2e/channels.e2e.mjs measuring latency behind six simulated proxy setups
(0.2-0.3 s on all live channels, ~3 s with plain polling).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5.0 KiB
Echtzeit: WebSocket und SSE
Der Server verteilt Änderungen als JSON-Ereignisse an verbundene Clients. Die Ereignisse entstehen nach einer REST-Änderung; es gibt keinen Schreibkanal über die Verbindung. Implementierung: hub.go, Client: api.ts.
Keine CRDTs, keine Sperren, keine Cursor-Presence. Frühere Fassungen dieser Doku beschrieben DocRooms,
clientId-Echo-Schutz und Yjs; das existiert nicht (mehr).
Transporte
| Transport | Endpunkt | Hinweise |
|---|---|---|
| WebSocket | GET /api/v1/ws (auch /ws/lists/{id}, /ws/spaces/{id}) |
Ping alle 30 s, Lesefrist 60 s, Nachrichten bis 4 MB |
| SSE | GET /api/v1/events |
Kommentar-Ping alle 15 s, Header X-Accel-Buffering: no |
Der Client versucht zuerst WebSocket und wechselt in folgenden Fällen für die Sitzung des Tabs auf SSE:
- Aufbau gelingt nicht innerhalb von 2,5 s, oder die Verbindung bricht unsauber ab (z. B. Code 1006/1008).
- Herzschlag: Alle 15 s schickt der Client
{"event":"PING"}, der Server antwortetPONG. Kommt 35 s lang keine Nachricht, gilt der WebSocket als stumm (Proxy nimmt das Upgrade an, leitet aber nichts weiter).
Über SSE liefert der Server zuerst SSE_CONNECTED mit einer sseId; erst dann gilt SSE als verbunden. Bleibt dieses erste Ereignis 5 s nach dem Öffnen aus, puffert ein Proxy den Stream: SSE wird für den Tab abgeschaltet. Räume werden über POST /api/v1/events/rooms (sseId, room, action: join/leave) abonniert.
Ohne WebSocket und SSE nutzt die Notiz-Synchronisation Long-Polling (GET /docs/{id}/changes, bis 25 s), und wenn auch das dreimal scheitert, eine Abfrage alle 3 s (siehe modules/workspace.md).
Authentifizierung: Token als ?token=<JWT|API-Key> oder Authorization-Header. Ohne gültiges Token antworten /ws und /events mit 401. Das Token wird nur beim Verbinden geprüft (FLAW-22).
Räume: Jeder Beitritt (Raum in der URL, JOIN_ROOM, SSE-Startparameter rooms/docId, POST /events/rooms) wird geprüft: doc:<id> nur mit Lesezugriff auf die Notiz, sonst muss die ID eine lesbare geteilte Liste sein. Nicht erlaubte Räume werden ignoriert, POST /events/rooms antwortet dann mit 403. SSE-Räume kann nur der Besitzer der jeweiligen sseId ändern.
Nachrichtenformat
{ "event": "TASK_UPDATED", "listId": "", "payload": { … }, "timestamp": "2026-10-27T12:00:00Z" }
Client → Server versteht der Server nur: {"event":"JOIN_ROOM","room":"<id>"}, {"event":"LEAVE_ROOM","room":"<id>"} und {"event":"PING"} (Antwort PONG). Räume heißen doc:<docId> für Notizen oder <listId> für geteilte Listen.
Zustellung
| Kanal | Wer erhält es |
|---|---|
Benutzer-Kanal (BroadcastToUser) |
alle Verbindungen eines Benutzers |
Raum (BroadcastRoom, Broadcast) |
Verbindungen im Raum |
Mehrere Benutzer (BroadcastToUsers) |
alle Verbindungen einer berechneten Benutzergruppe, z. B. Eigentümer und Mitglieder einer Aufgabenliste |
Ereignisse
| Ereignis | Auslöser | Kanal |
|---|---|---|
TASK_CREATED |
POST /inbox, Webhook |
Eigentümer und Benutzer der Liste |
TASK_UPDATED |
PATCH /thoughts/{id}, Triage |
Eigentümer und Benutzer der alten und neuen Liste |
TASK_DELETED |
DELETE /thoughts/{id} |
Eigentümer und Benutzer der Liste |
TASK_REORDERED |
Aufgaben umsortieren | Benutzer aller betroffenen Listen |
TASK_LIST_UPDATED / TASK_LIST_DELETED |
Liste ändern, (un)teilen, löschen; Payload nur {id}, Clients laden ihre Listen neu |
Benutzer vor und nach der Änderung |
DOC_CREATED, DOC_UPDATED, DOC_DELETED |
Notiz anlegen/ändern/löschen, Freigaben | berechtigte Benutzer |
DOC_SEQ |
Änderung an Notiz, Leases oder Präsenz (docId, headSeq); Clients holen die Daten per /docs/{id}/sync |
Raum doc:<id> |
DOC_BLOCKS_PATCHED |
PATCH /documents/{id}/blocks (docId, updatedBy, upserted, deletedIds, blockOrder) |
Raum doc:<id> und berechtigte Benutzer |
WORKSPACE_FOLDERS_UPDATED |
Ordner/Mounts ändern | Benutzer selbst; bei geteilten Ordnern Eigentümer und alle Beteiligten |
LIST_CREATED, LIST_UPDATED, LIST_DELETED, LIST_SHARED, LIST_UNSHARED, LIST_CLEARED |
Listenverwaltung | Benutzer bzw. Raum der Liste |
ITEM_CREATED, ITEM_UPDATED, ITEM_TOGGLED, ITEM_DELETED, ITEM_REORDERED |
Listeneinträge | Raum der Liste |
USER_PRESENCE |
Verbindung auf-/abgebaut (onlineCount) |
Raum |
SSE_CONNECTED |
Nur SSE, direkt nach Verbindung | Verbindung |
Verhalten im Frontend
- Die globale Verbindung (ein Singleton) leitet jede Nachricht als Browser-Ereignis
mindsift:wsweiter; Ansichten (TasksView,WorkspaceView,SharedListsView) hören darauf und laden bzw. patchen ihren Zustand. - Nach einem Abbruch verbindet sich der Client mit Verzögerung neu.
- Weil Notizen nur vom Eigentümer geschrieben werden, dient
DOC_BLOCKS_PATCHEDvor allem dazu, Lesenden und weiteren Geräten des Eigentümers Änderungen zu zeigen. Es gibt keine Konfliktauflösung (letzter Schreiber gewinnt).