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>
181 lines
6.8 KiB
TypeScript
181 lines
6.8 KiB
TypeScript
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 – 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<Response> {
|
||
let benutzerId: string;
|
||
let rolle: Parameters<typeof laufZustand>[1]["rolle"];
|
||
try {
|
||
const { benutzer } = await rechtErforderlich("lesen");
|
||
benutzerId = benutzer.id;
|
||
rolle = benutzer.rolle;
|
||
} 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;
|
||
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 });
|
||
}
|
||
|
||
/*
|
||
* 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",
|
||
},
|
||
});
|
||
}
|