Files
rustification-server/docs/realtime/issues/05-wo-lebt-die-ws-verbindung.md
MDietermann 55a2af4756 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.
2026-08-14 19:04:41 +02:00

195 lines
11 KiB
Markdown

# 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.