docs(realtime): record the design decisions behind the realtime layer

Eleven resolved decision tickets plus two research notes, produced with
/wayfinder before any code was written. They live next to the code they explain
rather than in the issue tracker: they are reasoning documents, not open work,
and in a tracker they would drift from the code with no diff and no review.

The value is in the rejected alternatives. Why a counter under a row lock and
not BIGSERIAL, why an event log and not a column on messages, why resync is a
200 with a discriminated body and not a 4xx, why the fan-out trait was deleted
again, why voice is peer-to-peer with a hard six-person cap and no speculative
SFU seam, and why Linux needs a native media stack while the other three
platforms do not.

Two entries record where the spec was wrong and got corrected: created_at was
never a safe sort key, and an unread count cannot be derived without fetching
because seq counts events rather than messages.

Open work is tracked as issues, not here.
This commit is contained in:
2026-08-14 19:04:41 +02:00
parent 099315a538
commit 55a2af4756
14 changed files with 4470 additions and 0 deletions

View File

@@ -0,0 +1,194 @@
# Wo lebt die WS-Verbindung im Client
Type: grilling
Status: resolved
Blocked by: —
Parent: ../map.md
## Question
Läuft die WebSocket-Verbindung im **Tauri-Rust-Layer** oder im **WebView-JavaScript** —
und wie geht das mit N Verbindungen zu N unabhängigen Servern zusammen?
### Ausgangslage
Heute liegt die gesamte Serverkommunikation in Rust: `src-tauri/src/state.rs` hält den
`reqwest::Client`, das Token und die User-ID; `commands/*.rs` exponiert Tauri-Commands
ans Frontend. `BASE_URL` ist eine Konstante — das trägt das Multi-Server-Modell nicht
und muss ohnehin fallen.
### Die Spannung
- **Rust** (`tokio-tungstenite`, Events per `app.emit()` ans Frontend): konsistent mit
dem bestehenden Muster, Token bleibt im Rust-State und nie im WebView, eine
Implementierung für alle vier Plattformen, volle Header-Kontrolle beim Handshake
(löst das Token-im-Query-String-Problem aus *Recherche: WS-Auth, Heartbeat und
Reconnect*), robusterer Reconnect ohne Abhängigkeit vom WebView-Lebenszyklus.
- **JavaScript** (`WebSocket`-API im WebView): eine Zeile statt einer Abhängigkeit,
kein IPC-Hop zwischen Rust und Frontend — und **WebRTC lebt später ohnehin im
WebView**. Ein Signaling-Kanal in Rust und ein Peer-Connection-Objekt in JS bedeutet
zwei Lebenszyklen, die konsistent gehalten werden müssen.
Genau diese Aufteilung ist der Kern der Frage: Chat-Push in Rust ist unstrittig
angenehm, Voice-Signaling in Rust ist es nicht.
### Neues Argument aus der Auth-Recherche (14.08.2026)
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md) hat eine
Asymmetrie gefunden, die schwerer wiegt als erwartet: **die Browser-API verschluckt alle
Fehlerdetails.** WHATWG schreibt vor, dass DNS-Fehler, Connection-Refused, TLS-Fehler,
Subprotocol-Mismatch und Abbruch nach dem Handshake für das Skript ununterscheidbar sein
müssen — immer `CloseEvent.code === 1006`, das `error`-Event ist ein leeres `Event`.
Im WebView ist damit **„Token abgelaufen" nicht von „Server down" unterscheidbar.**
`tungstenite::Error::Http` trägt dagegen die vollständige HTTP-Response inklusive Statuscode
und Body. Bei N Servern gleichzeitig — wo der Nutzer pro Server einen aussagekräftigen
Zustand sehen soll — ist das kein Detail. Dazu kommt: nur im Rust-Layer ist
`Authorization: Bearer` überhaupt möglich, der laut derselben Recherche einzige Kanal, der in
den Proxy-Defaults nicht mitgeloggt wird.
Beides zeigt in dieselbe Richtung. Wer sich für JS entscheidet, muss diese beiden Kosten
ausdrücklich in Kauf nehmen — der WebRTC-Nähe zuliebe kann das trotzdem richtig sein, aber
es ist jetzt eine bewusste Abwägung und keine Nebenwirkung mehr.
### Zu entscheiden
- Ein Ort für beides, oder eine bewusste Trennung (Dumb-Pipe in Rust, Voice-Signaling
in JS)? Falls Trennung: was ist der Preis an doppelter Auth- und
Reconnect-Verwaltung, und rechtfertigt ihn die WebRTC-Nähe?
- **Multi-Server**: N Verbindungen zu N Servern. Wo liegt der Verbindungs-Pool, wie
werden Events dem richtigen Server zugeordnet, wie sieht das im Frontend-State aus?
- Wie ersetzt `BASE_URL` sich durch eine Server-Liste? Wo wird sie persistiert
(heute `tauri-plugin-store` laut README, im Code noch nicht vorhanden)?
- Wenn Rust: welche Ereignisse gehen per `emit` ans Frontend, und in welcher Granularität?
- Wenn JS: wie kommt das Token dorthin, ohne es dauerhaft im WebView zu halten?
### Randbedingung
Die Antwort beeinflusst *Verbindungsaufbau & Auth über WS* (Header-Verfügbarkeit) und
*Client-Integration*. Sie ist bewusst **nicht** von der WebRTC-Recherche blockiert —
falls diese negativ ausfällt, wird das Ticket neu bewertet.
---
## Answer
Entschieden am 14.08.2026.
### 1. Die Verbindung lebt im Tauri-Rust-Layer, eigene Implementierung
`tokio-tungstenite`, Verbindungs-Pool im `AppState` neben Token und Server-Liste, Zustellung
ans Frontend per `app.emit()`. Das ist keine neue Architektur, sondern die bestehende —
`reqwest`, Token und `user_id` liegen bereits dort.
Vier Gründe, jeder einzeln belegt in
[*Recherche: WS-Auth, Heartbeat und Reconnect*](03-research-ws-auth-reconnect.md):
- **`Authorization: Bearer` ist möglich.** Laut der Log-Matrix der Recherche der einzige
Kanal, den die Defaults von nginx, Apache und Caddy nicht mitschreiben — Caddy redigiert
ihn sogar aktiv.
- **Fehler sind diagnostizierbar.** `tungstenite::Error::Http` trägt die vollständige
Server-Antwort. Im WebView schreibt WHATWG vor, dass alle Fehlerdetails ununterscheidbar
sind (immer `1006`) — bei N Servern, für die je ein aussagekräftiger Zustand angezeigt
werden soll, ist das disqualifizierend.
- **Das Token verlässt den Rust-Prozess nie.**
- **Eine Implementierung statt vier WebView-Engines** (WebView2, WKWebView, Android-Chromium,
WebKitGTK), und Reconnect und Pool überleben einen WebView-Reload.
**Geprüft und verworfen: `tauri-plugin-websocket`.** Der Mittelweg wäre real — der Socket
läuft dort in Rust, `connect()` nimmt `headers`. Dagegen sprechen: die API ist dünn
(connect/send/addListener/disconnect), Reconnect, Backoff und Multi-Server-Pool schreibt man
trotzdem selbst, nur eben in TypeScript; es gibt ein dokumentiertes Rennen mit
Nachrichtenverlust zwischen `connect()` und `addListener()`
([plugins-workspace#3152](https://github.com/tauri-apps/plugins-workspace/issues/3152));
WebView-Reloads hinterlassen hängende Verbindungen
([#1507](https://github.com/tauri-apps/plugins-workspace/issues/1507)); und die Maintainer
schreiben selbst, sie könnten derzeit keine Zeit in das Plugin stecken
([Discussion #10096](https://github.com/orgs/tauri-apps/discussions/10096)). Man bekäme
JS-Lebenszyklus, IPC-Hop und eine ungepflegte Abhängigkeit zugleich.
### 2. Die WebRTC-Spannung löst sich auf
Voice-Signaling läuft **ebenfalls über den Rust-Socket**; nur die `RTCPeerConnection` selbst
lebt im WebView. Eine SDP-Nachricht kostet damit zwei IPC-Hops — bei einer Handvoll
Nachrichten pro Anrufaufbau irrelevant, IPC liegt im Sub-Millisekunden-Bereich.
Die im Ticket formulierte Sorge („zwei Lebenszyklen, die konsistent gehalten werden müssen")
war die falsche Abwägung: eine zweite Browser-WebSocket nur für Signaling würde eine zweite
Auth **ohne Header**, einen zweiten Reconnect und ein Token im WebView zurückholen — also
genau die drei Nachteile, die gerade verworfen wurden.
### 3. Rust macht den Backfill und emittiert aufgelöste Entitäten
Rust empfängt den Dumb-Pipe-Event, ruft selbst `?since=<cursor>` über das bestehende
`reqwest` und emittiert die fertigen Entitäten. Damit liegt die **Cursor-Buchhaltung pro
Conversation und pro Server dort, wo Verbindung, Token und Server-Liste ohnehin liegen** —
an einer Stelle statt an zweien. Das Debouncing (zehn Signale in zwei Sekunden ergeben einen
Fetch) sitzt ebenfalls in Rust.
Das Frontend hat **keine Cursor-Logik**. Es bekommt „hier sind aktualisierte Entitäten für
Conversation X auf Server Y" und ersetzt.
Der `resync`-Fall aus [*Monotoner Cursor*](02-monotoner-cursor.md) muss als **eigenes
Signal** ans Frontend durchgereicht werden, damit es seinen Cache für die betroffene
Conversation verwirft — er ist kein Datenereignis, sondern eine Anweisung.
**Bewusst in Kauf genommen:** Rust lädt auch für Conversations nach, die gerade nicht
sichtbar sind. Ob das gedrosselt werden soll — „nur einen Ungelesen-Punkt setzen statt zu
laden" — ist eine Frage des Zustandsmodells und gehört nach
[*Client-Integration*](10-client-integration.md).
### 4. Alle konfigurierten Server sind verbunden, solange die App im Vordergrund ist
Nicht nur der sichtbare. Das ist der Zweck des Multi-Server-Modells: Ungelesen-Anzeigen über
Server hinweg funktionieren nur, wenn alle verbunden sind — sonst ist der Realtime-Layer für
alles außer dem aktiven Server wertlos. Bei realistischen zwei bis fünf Servern sind N
Sockets plus N Heartbeats unkritisch, und Background-Betrieb ist ohnehin out of scope, die
Akkufrage stellt sich also nur im Vordergrund.
Keine plattformabhängige Politik: fehlende Ungelesen-Punkte auf dem Handy, die auf dem
Desktop da sind, werden als Fehler wahrgenommen und nicht als Sparmaßnahme.
### 5. `BASE_URL` fällt; Geheimnisse kommen hinter eine Naht
Die Konstante `BASE_URL` in `src-tauri/src/state.rs` wird durch eine **Server-Liste**
ersetzt (URL, Anzeigename, User-ID), persistiert über `tauri-plugin-store` — unkritische
Metadaten.
Tokens und Refresh-Tokens laufen über ein schmales **`SecretStore`-Trait** mit zwei
Methoden. Dessen Implementierung darf vorerst das Einfachste sein, das funktioniert; die
endgültige Wahl trifft der **E2EE-Folge-Effort**, weil er exakt dasselbe Problem für
Geräteschlüssel hat — in verschärfter Form, denn ein Geräteschlüssel ist unwiederbringlich,
ein Token läuft ab. Dasselbe Problem zweimal zu lösen wäre Verschwendung; es jetzt falsch zu
lösen teurer als es zu vertagen.
Zur Einordnung der verworfenen Alternative: der OS-Keychain ist inhaltlich das richtige
Ziel, hat aber auf Linux — einer erklärten Client-Plattform — die schwächste Stelle, weil
Secret Service einen laufenden gnome-keyring oder KWallet voraussetzt.
### Begriffe
| Begriff | Bedeutung |
|---|---|
| **Server-Liste** | Die im Client eingetragenen, voneinander unabhängigen Instanzen. Kein Server-zu-Server-Verkehr; der Client ist der einzige Aggregator. |
| **Verbindungs-Pool** | Die N offenen Sockets im `AppState`, einer je eingetragenem Server. |
| **`SecretStore`** | Naht für alles, was nicht im Klartext auf die Platte darf. Implementierung offen, wird vom E2EE-Effort gewählt. |
### Bestandskorrektur
Der Client pollt heute **doch** — `MessageList.tsx:33` fährt ein `setInterval(load, 3000)`
zusätzlich zum `refreshKey`. Die frühere Notiz in der Map („kein Realtime, nur `refreshKey`")
war falsch; der Client-README lag richtig. Ersetzt werden also **beide** Mechanismen.
### Folgen für andere Tickets
- [*Verbindungsaufbau & Auth über WS*](07-verbindungsaufbau-und-auth.md): die
Token-Transportfrage ist damit praktisch entschieden (`Authorization: Bearer`). Offen
bleiben dort Token-Ablauf, Autorisierung pro Conversation, Heartbeat-Intervall,
Verbindungslimits und der selbstgebaute Graceful Shutdown.
- [*Client-Integration*](10-client-integration.md): das Zustandsmodell im Frontend wird
deutlich kleiner, weil dort keine Cursor-Logik mehr liegt. Neu dazu kommt die Frage, ob
Rust für unsichtbare Conversations überhaupt nachladen soll.
- [*Voice-Signaling-Protokoll*](09-voice-signaling-protokoll.md): Signaling läuft über den
Rust-Socket, die `RTCPeerConnection` im WebView. Wo der signierte DTLS-Fingerprint
entsteht — Rust oder WebCrypto im WebView — bleibt dort zu entscheiden.