feat(laeufe): Live-Aktualisierung per Ereignisstrom statt Polling (#53)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s

Die Abarbeitungsansicht fragte alle zehn Sekunden nach dem Zustand. Jetzt
melden vier Datenbank-Trigger `NOTIFY lauf_geaendert, '<laufId>'`, eine
LISTEN-Verbindung je Prozess hoert zu, und der Route Handler schiebt
weiter. Gemessen: der Haken des Kollegen ist nach 485 ms beim Zuschauer
(am Handy 487 ms), vorher bis zu zehn Sekunden. Zwanzig offene Laschen
kosten nichts mehr statt zwanzig Abfragen je zehn Sekunden.

Das Ereignis kommt aus der Datenbank und nicht aus dem Dienst, aus
demselben Grund, aus dem auch die Regeln dort stehen: ein Trigger sieht
auch den Seed, eine Migration und die Reparatur per psql. Und NOTIFY
erreicht jede Instanz der Anwendung, ohne einen zweiten Dienst.

Welche Tabellen melden, haengt an dem, was `laufZustand` liefert — nicht
daran, was das Issue vorgeschlagen hat. Mit Triggern nur auf
Schrittergebnis und Lauf waere der Strom ein **Rueckschritt** gewesen:
das Polling verglich den ganzen Stand und sah deshalb auch Kommentare,
ein Strom sieht nur, was gemeldet wird. Der Kommentar des Kollegen waere
nie angekommen, und die Seite haette ausgesehen wie immer. Es melden
jetzt vier Tabellen, und `laufZustand` traegt `anzahlAnhaenge` — ein
Ereignis ohne Entsprechung im Zustand loest nichts aus.

Das Polling bleibt als Rueckfall an **derselben** Adresse, unterschieden
nur am Accept-Kopf: eine Rechtepruefung, ein Lesemodell, eine Nutzlast.
Ein zweiter Endpunkt waere eine zweite Stelle, an der jemand
rechtErforderlich vergessen kann. Bei erreichter Obergrenze antwortet
der Handler gewoehnlich mit JSON; EventSource meldet darauf einen Fehler
und versucht es nicht wieder, also faellt der Client von sich aus
zurueck — ohne Sonderfall.

Nebenbei zwei Fehler in docs/betrieb/reverse-proxy.md behoben, beide aus
#52: `client_max_body_size 8m` bei `ANHANG_MAX_MB=10` haette ein
10-MB-Foto mit 413 abgewiesen, bevor die eigene Meldung zum Zuge kommt.

Closes #53

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-05 09:36:10 +02:00
co-authored by Claude Opus 5
parent 3cbd5026f8
commit 4aa79c93ff
20 changed files with 1640 additions and 84 deletions
+143 -12
View File
@@ -1,21 +1,41 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { laufZustand } from "@/server/queries/laeufe";
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
/**
* Der Zustand eines Laufs fuer die Aktualisierung alle zehn Sekunden (#013).
* Der Zustand eines Laufs – als Strom oder als einzelne Antwort (#013, #033).
*
* **Route Handler pruefen selbst.** Die Huelle unter `src/app/(app)/layout.tsx`
* deckt sie nicht ab — sie liegt in einem anderen Zweig des Baums, und der Proxy
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md).
* sieht nur, **ob** ein Cookie da ist, nie was drinsteht (CLAUDE.md). Ein
* offener Strom ist ein Leseweg wie jeder andere; geprueft wird **bevor** das
* erste Ereignis fliesst.
*
* **Eine Adresse fuer beide Wege.** `EventSource` schickt
* `Accept: text/event-stream`, sonst kommt eine gewoehnliche Anfrage — dieselbe
* Rechtepruefung, dasselbe Lesemodell, dieselbe Nutzlast. Ein zweiter Endpunkt
* waere eine zweite Stelle, an der jemand das Recht vergessen kann, und zwei
* Wahrheiten, die auseinanderlaufen. Das Polling bleibt damit ein echter
* Rueckfall und nicht ein zweiter Codepfad, den niemand mehr faehrt.
*
* Die Antwort enthaelt **keine Befehle**. Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu; alle zehn Sekunden mitgeliefert, tauschte
* es dem Techniker das Passwort unter den Haenden weg, waehrend er es abtippt.
* Zurueck kommen nur Status und Schrittzustaende — genau das, was zwei Browser
* am selben Lauf voneinander wissen muessen.
* entsteht bei jeder Aufloesung neu; mitgeliefert, tauschte es dem Techniker das
* Passwort unter den Haenden weg, waehrend er es abtippt. Zurueck kommen nur
* Status, Schrittzustaende und die Zahl der Kommentare und Anhaenge — genau das,
* was zwei Browser am selben Lauf voneinander wissen muessen.
*/
/*
* Sammelfenster. Wer einen Abschnitt abschnittsweise abhakt, loest ein Dutzend
* NOTIFY in einer Sekunde aus; ohne Fenster waere das ein Dutzend Abfragen des
* Lesemodells je Zuschauer. 150 ms sind unter der Wahrnehmungsschwelle und
* fassen einen Schwung zusammen.
*/
const SAMMELFENSTER_MS = 150;
export async function GET(
_anfrage: Request,
anfrage: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
let benutzerId: string;
@@ -34,16 +54,127 @@ export async function GET(
}
const { id } = await params;
const zustand = await laufZustand(id, { id: benutzerId, rolle });
const benutzer = { id: benutzerId, rolle };
const zustand = await laufZustand(id, benutzer);
if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
// Unterscheidung waere schon eine Auskunft ueber fremde Kunden.
return Response.json({ fehler: "Diesen Lauf gibt es nicht." }, { status: 404 });
}
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
/*
* Kein Strom gewuenscht – oder keiner mehr frei. Bei erreichter Obergrenze
* antwortet der Handler **gewoehnlich**: `EventSource` verlangt
* `text/event-stream`, bekommt JSON, meldet `error` und versucht es nicht
* wieder. Der Client fuellt damit von sich aus auf Polling zurueck, ohne dass
* es dafuer einen Sonderfall oder einen Fehlercode braucht.
*/
if (!willStrom(anfrage.headers.get("accept")) || stroemeOffen() >= maxStroeme()) {
return Response.json(zustand, {
// Nichts zwischenspeichern: die Antwort ist genau dann wertlos, wenn sie
// aus einem Zwischenspeicher kommt.
headers: { "Cache-Control": "no-store" },
});
}
const kodierer = new TextEncoder();
let abmelden: (() => void) | undefined;
let herzschlag: ReturnType<typeof setInterval> | undefined;
let sammler: ReturnType<typeof setTimeout> | undefined;
const strom = new ReadableStream<Uint8Array>({
async start(steuerung) {
let offen = true;
function schicken(text: string): void {
if (!offen) return;
try {
steuerung.enqueue(kodierer.encode(text));
} catch {
// Gegenseite ist weg, bevor `abort` ankam.
offen = false;
}
}
function aufraeumen(): void {
if (!offen) return;
offen = false;
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
try {
steuerung.close();
} catch {
// Schon geschlossen.
}
}
async function standSchicken(): Promise<void> {
if (!offen) return;
try {
const jetzt = await laufZustand(id, benutzer);
if (!jetzt) {
/*
* Lauf weg oder nicht mehr sichtbar — etwa weil jemandem der Kunde
* entzogen wurde. Der Strom endet; der Client holt sich an der
* gewoehnlichen Adresse die 404 und stellt ein.
*/
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-sichtbar" })));
aufraeumen();
return;
}
schicken(sseEreignis("zustand", JSON.stringify(jetzt)));
} catch (fehler) {
console.error("Zustand fuer den Strom konnte nicht gelesen werden:", fehler);
}
}
// Der erste Stand sofort, damit der Client einen Vergleichspunkt hat,
// ohne zusaetzlich zu pollen.
schicken(sseEreignis("zustand", JSON.stringify(zustand)));
try {
abmelden = await aufLaufHoeren(id, () => {
if (sammler) return;
sammler = setTimeout(() => {
sammler = undefined;
void standSchicken();
}, SAMMELFENSTER_MS);
});
} catch (fehler) {
/*
* Keine LISTEN-Verbindung zu bekommen ist kein Grund, die Seite blind
* zu lassen: Strom beenden, der Client fuellt auf Polling zurueck.
*/
console.error("LISTEN fuer den Strom nicht moeglich:", fehler);
aufraeumen();
return;
}
herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
anfrage.signal.addEventListener("abort", aufraeumen);
},
cancel() {
abmelden?.();
if (herzschlag) clearInterval(herzschlag);
if (sammler) clearTimeout(sammler);
},
});
return new Response(strom, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
/*
* Fuer nginx: schaltet die Pufferung fuer **diese** Antwort ab, auch wenn
* in der Konfiguration `proxy_buffering on` steht. Ohne das sammelt nginx
* den Strom und gibt ihn erst am Ende heraus — die Seite sieht dann aus,
* als sei sie hangen geblieben. Die Einstellung steht trotzdem in
* `docs/betrieb/reverse-proxy.md`: diese Kopfzeile versteht nur nginx.
*/
"X-Accel-Buffering": "no",
},
});
}