import { RechteFehler, rechtErforderlich, sitzungNochGueltig } from "@/server/auth/sitzungspflicht"; import type { Sitzung } from "@/lib/sitzung"; 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 – 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). 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; 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, { params }: { params: Promise<{ id: string }> }, ): Promise { let benutzerId: string; let rolle: Parameters[1]["rolle"]; // Die Sitzung wird festgehalten, weil der Strom sie spaeter noch braucht: // `cookies()` gibt es in seinen Rueckrufen nicht mehr. let sitzung: Sitzung; try { const zugang = await rechtErforderlich("lesen"); benutzerId = zugang.benutzer.id; rolle = zugang.benutzer.rolle; sitzung = zugang.sitzung; } catch (fehler) { if (fehler instanceof RechteFehler) { // 401 und nicht 403: der Client soll die Aktualisierung einstellen und // den Benutzer zur Anmeldung schicken, statt weiter zu fragen. return Response.json({ fehler: "Nicht angemeldet." }, { status: 401 }); } throw fehler; } const { id } = await params; let 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 }); } /* * 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 | undefined; let sammler: ReturnType | undefined; const strom = new ReadableStream({ 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 { 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; } /* * **Der Herzschlag prueft auch die Sitzung nach** (Reviewbefund zu #029). * Vorher wurde das Recht genau einmal geprueft, beim Aufbau, und das Paar * aus Kennung und Rolle fuer die Lebensdauer des Stroms festgehalten — ein * deaktiviertes Konto bekam weiter live Daten, bis jemand die Lasche * schloss. Die Pruefung kostet eine Abfrage je offenem Strom und * Herzschlag (25 s); dieselbe Abfrage macht jede gewoehnliche Anfrage * ohnehin. * * Eine **Herabstufung** beendet den Strom nicht, sondern engt ihn ein: die * neue Rolle gilt ab dem naechsten Ereignis. Das ist dieselbe Regel wie * „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md). */ herzschlag = setInterval(() => { void (async () => { const noch = await sitzungNochGueltig(sitzung, "lesen"); if (!noch) { schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-angemeldet" }))); aufraeumen(); return; } if (noch.rolle !== benutzer.rolle) benutzer = { id: benutzerId, rolle: noch.rolle }; 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", }, }); }