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

11 KiB

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 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:

  • 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); WebView-Reloads hinterlassen hängende Verbindungen (#1507); und die Maintainer schreiben selbst, sie könnten derzeit keine Zeit in das Plugin stecken (Discussion #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 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.

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