Change Request: Live-Aktualisierung mit Server-Sent Events

https://bildung.social/@oerinformatik/

https://oer-informatik.de/vue-change-request-live

Server-Sent Events, SSE, EventSource, SseEmitter, Polling, WebSocket, Change Request, Embrace Change, Regressionstest, Spring Boot, Vue 3, Extreme Programming

tl/dr; (ca. 36 min Lesezeit): Mitten im Projekt wünscht sich die Schule, dass sich die Liste der Fahrten von selbst aktualisiert. In Extreme Programming ist ein solcher Änderungswunsch kein Störfall: Das Team schreibt ihn als Karte auf, schätzt ihn nach einem kurzen Spike und plant die Iteration neu. Der Artikel vergleicht Polling, Server-Sent Events und WebSocket, zeigt das Format text/event-stream, baut im Backend mit SseEmitter einen Ereignisstrom, der erst nach dem Commit sendet, und lässt die Vue-App mit EventSource still neu laden. Dazu kommen Verbindungsabbrüche, nginx, Tests mit Attrappen und Playwright und die Regression über alle bisherigen Tests [AI-Assisted]. (Zuletzt geändert am 11.11.2026)

Dieser Artikel ist Teil einer Artikelreihe über die Web-App mit Vue 3 und TypeScript: Projektübersicht / XP-Projektstart / TypeScript für Java-Entwickler*innen / Komponenten testgetrieben / API-Client aus OpenAPI / Router und Continuous Integration / Formulare und Validierung / Refactoring mit Pinia / Anmeldung mit Keycloak / Fahrer-Sicht und Coding Standards / Build und Auslieferung / End-to-End-Tests mit Playwright / Change Request Live-Aktualisierung

Schon fit in diesem Thema? Nutze die Kann-Liste und die Leitfragen unter dem Artikel als Checkliste, ob Du die relevanten Kompetenzen bereits hast.

Dieser Artikel baut auf der Kann-Liste des Artikels End-to-End- und Akzeptanztests mit Playwright auf.

Als Grundlagen zur Live-Aktualisierung kann ich nach Durchlesen des Artikels…

  • … beschreiben, wie ein XP-Team einen Änderungswunsch der Kund*in aufnimmt (Karte, Spike, Schätzung, neue Planung) und warum Embrace Change auf automatisierte Tests angewiesen ist.

  • … Polling, Long Polling, Server-Sent Events und WebSocket nach Richtung, Protokoll, Aufwand und Einsatzzweck unterscheiden.

  • … den Aufbau eines Ereignisstroms im Format text/event-stream (Felder event, data, id, retry, Kommentare) lesen und erklären.

  • … erklären, wie EventSource im Browser eine Verbindung öffnet, Ereignisse meldet und sich nach einem Abbruch selbst neu verbindet.

Darüber hinaus kann ich als Expertenwissen zu Server-Sent Events in Spring Boot und Vue…

  • … im Backend mit SseEmitter einen Ereignisstrom anbieten, offene Verbindungen verwalten und Ereignisse erst nach dem Commit senden.

  • … in Vue eine Live-Aktualisierung als austauschbare Abhängigkeit einbinden, die Verbindung beim Entfernen der Komponente schließen und still neu laden.

  • … Grenzen von EventSource (kein Header Authorization, Aufgeben bei HTTP-Fehlern, verpasste Ereignisse) und die Einstellungen eines Reverse Proxys für Ereignisströme berücksichtigen.

  • … Live-Aktualisierung mit Attrappen, nachgebildetem Ereignisstrom und MockMvc testen und mit Regressionstests absichern, dass bestehende Kundentests grün bleiben.

Überblick: Im Backend veröffentlichen BuchungService und AngebotService ein Ereignis AngebotGeaendert, AngebotEreignisse schickt es nach dem Commit über eine Liste von SseEmittern als text/event-stream mit id, event und data. nginx reicht den Strom ohne Puffer an den Browser weiter, wo EventSource das Ereignis angebot-geaendert an FahrtenSuche.vue meldet. Die Suche lädt mit ihrem Filter neu und holt die Daten weiter per GET /v1/angebote mit Bearer-Token (mit KI erstellt)

Stand im Projekt: Iteration 6 beginnt mit einem Änderungswunsch

Seit dem letzten Artikel laufen die Kundentests der Web-App im echten Browser, Iteration 5 ist abgeschlossen, die Version heißt 0.2.1. Für die nächste Kundenstunde lagen im Mitfahrlabor zwei Karten bereit: WEB-12 (Buchung stornieren, 2 Punkte) und WEB-13 (App auf Englisch, 5 Punkte). Thomas Becker bringt als Kunde vor Ort aber etwas Neues mit. Mehrere Mitfahrende haben sich beschwert: Wer auf einen freien Platz wartet, drückt im Minutentakt auf „Neu laden“. Oksana Melnyk hat es so beschrieben: „Ich sehe ‚2 Plätze frei‘, frage an, und dann ist die Fahrt längst voll.“ Die Schule wünscht sich, dass sich die Liste der Fahrten von selbst aktualisiert.

Ein solcher Änderungswunsch (Change Request) trifft ein Projekt nach klassischem Vorgehen hart: Anforderungen sind abgestimmt, Pläne gemacht, eine Änderung braucht ein Verfahren mit Antrag, Bewertung und Freigabe. In Extreme Programming (XP) ist er dagegen der Normalfall. Der Wert dahinter heißt Embrace Change: Änderungen sind willkommen, weil die Kund*in erst beim Benutzen der Software merkt, was sie wirklich braucht. Das geht nur, wenn eine Änderung billig bleibt, und dafür sorgen die Praktiken der vorigen Artikel: kurze Iterationen, Kundentests für jede Karte, Refactoring mit Testnetz und eine Pipeline, die bei jeder Integration alle Tests ausführt.

Embrace Change: den Wunsch ins Planning Game holen

Das Team nimmt den Wunsch nicht nebenbei an, sondern holt ihn ins Planning Game. Thomas schreibt mit dem Paar eine neue Karte:

WEB-14 Fahrtenliste aktualisiert sich selbst (Änderungswunsch der Schule) Als Mitfahrende*r möchte ich, dass sich die Liste der Fahrten von selbst aktualisiert, damit ich nicht ständig neu laden muss, während ich auf einen freien Platz warte.

  • KT-14.1: Ist die Suche offen und bestätigt Ben eine Anfrage zu Angebot 1, zeigt die Liste ohne Neuladen innerhalb von 5 Sekunden „07:00 Uhr, 1 Platz frei“.
  • KT-14.2: Wird eine angezeigte Fahrt voll oder abgesagt, verschwindet sie aus der Liste; der gewählte Filter bleibt erhalten.
  • KT-14.3: Ist der Ereignisstrom nicht erreichbar, funktioniert die Suche wie bisher.

Beim Schätzen merkt das Team, dass ihm Erfahrung fehlt: Niemand hat bisher eine Verbindung gebaut, über die der Server von sich aus etwas schickt. Wie in Iteration 4 bei Keycloak setzt es deshalb einen Spike an, einen zeitlich begrenzten Versuch, der eine Frage klärt und dessen Code danach weggeworfen wird. Der Spike bekommt 1 Punkt. Danach schätzt das Paar WEB-14 auf 3 Punkte.

Die Velocity bleibt nach „Yesterday’s Weather“ bei 5 Punkten. Spike und WEB-14 ergeben 4 Punkte, WEB-12 mit 2 Punkten passt nicht mehr dazu. Die Entscheidung trifft Thomas, nicht das Team: Ihm ist die Live-Aktualisierung wichtiger als das Stornieren, also rücken WEB-12 und WEB-13 eine Iteration nach hinten. So sieht der Plan aus:

Planung von Iteration 6 nach dem Änderungswunsch (Velocity 5 Punkte, geplant 4)
Karte Punkte Kundentests Stand
Spike Live-Aktualisierung 1 – dieser Artikel
WEB-14 Fahrtenliste aktualisiert sich selbst 3 KT-14.1 bis KT-14.3 dieser Artikel
WEB-12 Buchung stornieren 2 – nächste Iteration
WEB-13 App auf Englisch 5 – nächste Iteration, vorher teilen

Bemerkenswert ist, was nicht passiert: Niemand ändert die bisherigen Story Cards, niemand schreibt ein Änderungsdokument. Die neue Karte steht neben den alten, die Kundentests von WEB-01 bis WEB-11 gelten unverändert weiter. Sie werden zum Regressionstest: Nach der Änderung müssen sie genauso grün sein wie vorher.

Drei Wege zu aktuellen Daten

Der Spike vergleicht die Verfahren, mit denen eine Anzeige im Browser aktuell bleibt. Den einfachsten kennt die Schule schon vom Flurmonitor (siehe Automatisch aktualisieren): Der Browser fragt im festen Takt nach.

  • Polling: Der Browser schickt alle n Sekunden eine gewöhnliche Anfrage. Einfach, funktioniert überall, aber er fragt auch, wenn sich nichts geändert hat, und erfährt eine Änderung erst beim nächsten Takt. Kurze Takte bei vielen Geräten belasten den Server.
  • Long Polling: Der Browser fragt, der Server hält die Antwort zurück, bis sich etwas ändert oder eine Wartezeit abläuft; danach fragt der Browser sofort wieder. Schneller als Polling, aber jede Nachricht kostet eine neue Anfrage.
  • Server-Sent Events (SSE): Der Browser öffnet eine gewöhnliche HTTP-Anfrage, der Server antwortet mit dem Typ text/event-stream und lässt die Antwort offen. Jedes Ereignis ist ein paar Textzeilen in dieser Antwort. Die Richtung ist nur Server → Browser; was der Browser schicken will, schickt er wie bisher per fetch. Der Standard ist Teil von HTML (WHATWG HTML Living Standard, Abschnitt „Server-sent events“), im Browser heißt die Schnittstelle EventSource.
  • WebSocket: Nach einem HTTP-Handshake wechselt die Verbindung auf ein eigenes Protokoll (RFC 6455), über das beide Seiten jederzeit Nachrichten schicken. Das passt zu Chats, gemeinsamen Editoren und Spielen, braucht aber auf beiden Seiten mehr Code (eigenes Nachrichtenformat, eigene Wiederverbindung) und einen Proxy, der das Protokoll durchreicht.
Ergebnis des Spikes: Verfahren für aktuelle Daten im Browser
Merkmal Polling Server-Sent Events WebSocket
Richtung Browser fragt Server → Browser beide Richtungen
Protokoll HTTP, je Takt eine Anfrage HTTP, eine offene Antwort eigenes Protokoll nach Upgrade
Verzögerung bis zu einem Takt sofort sofort
Wiederverbinden entfällt macht der Browser selbst selbst programmieren
Header Authorization ja (fetch) nein (EventSource) nein (Browser-API)
Aufwand im Backend kein neuer SseEmitter WebSocket-Handler, Nachrichtenformat
Typischer Einsatz Anzeigen mit festem Takt Benachrichtigungen, Live-Listen Chat, Zusammenarbeit, Spiele

Für WEB-14 schickt nur der Server etwas, der Browser bleibt bei seinen bisherigen Anfragen. Die Wahl fällt deshalb auf Server-Sent Events. Auch eine Progressive Web App mit Push-Benachrichtigungen hat das Team kurz betrachtet: Sie würde auch bei geschlossener App benachrichtigen, braucht aber einen Service Worker, die Zustimmung der Nutzenden und einen Push-Dienst. Für eine offene Suchseite ist das zu viel. Der Spike-Code wird gelöscht, übrig bleiben die Tabelle oben und die Schätzung.

Das Format text/event-stream

Ein Ereignisstrom ist UTF-8-Text. Jede Zeile hat die Form feld: wert (das Leerzeichen nach dem Doppelpunkt ist erlaubt, aber nicht nötig), eine Leerzeile schließt ein Ereignis ab. So sieht ein Ausschnitt des Stroms aus, den das Backend nach Abschluss von WEB-14 schickt:

id:7
event:angebot-geaendert
data:{"angebotId":1,"grund":"bestaetigt"}

:lebenszeichen

id:8
event:angebot-geaendert
data:{"angebotId":4,"grund":"abgesagt"}
Felder eines Ereignisses im Format text/event-stream
Feld Bedeutung
data Inhalt des Ereignisses (Text, hier JSON); mehrere data-Zeilen werden mit Zeilenumbruch verbunden
event Name des Ereignisses; im Browser der Typ, auf den ein Lauscher hört. Ohne event heißt es message
id Kennung des Ereignisses; nach einem Abbruch schickt der Browser die letzte als Header Last-Event-ID mit
retry Wartezeit in Millisekunden, bevor der Browser sich nach einem Abbruch neu verbindet
: am Zeilenanfang Kommentar, den der Browser ignoriert; hält die Verbindung durch Proxys hindurch am Leben

Ein offener Strom lässt sich ohne Browser beobachten: curl mit der Option -N gibt jede Zeile sofort aus, statt zu puffern.

# curl.exe statt curl: in Windows PowerShell 5.1 ist „curl“ ein Alias für Invoke-WebRequest
curl.exe -N http://localhost:8080/v1/angebote/ereignisse

Der Befehl endet nicht von selbst; alle 25 Sekunden erscheint :lebenszeichen, und wer in einem zweiten Fenster eine Anfrage bestätigt, sieht sofort das Ereignis. Abbrechen mit Strg+C.

Der Vertrag: Ein Ereignis nennt nur, was sich geändert hat

Bevor jemand Code schreibt, ergänzt das Paar den Vertrag api/openapi.yaml. Dabei fällt die wichtigste Entwurfsentscheidung: Was steht in einem Ereignis? Zwei Möglichkeiten gibt es.

  • Daten schieben: Das Ereignis enthält das geänderte Angebot. Der Browser spart eine Anfrage, muss aber selbst entscheiden, ob das Angebot zum gewählten Filter passt, ob es jetzt voll ist und wo es in der Liste steht. Die Logik der Suche gäbe es dann zweimal.
  • Benachrichtigen: Das Ereignis nennt nur die ID und den Grund. Der Browser lädt daraufhin wie bisher über GET /v1/angebote. Eine Anfrage mehr, dafür bleibt die Suche, wie sie ist, und das Ereignis enthält keine personenbezogenen Daten.

Das Team wählt die zweite Variante. Sie ist das „Simple Design“ aus XP: die einfachste Lösung, die die Kundentests erfüllt.

  /angebote/ereignisse:
    get:
      tags: [angebote]
      operationId: abonniereAngebotsereignisse
      summary: Änderungen an Angeboten als Server-Sent Events abonnieren
      security: []
      responses:
        '200':
          description: Ereignisstrom (text/event-stream), z. B. „id:7“, „event:angebot-geaendert“, „data:{...}“
          content:
            text/event-stream:
              schema:
                $ref: '#/components/schemas/Angebotsereignis'

Das Schema Angebotsereignis hat die Pflichtfelder angebotId und grund (einer der Werte angelegt, angefragt, bestaetigt, abgelehnt, storniert, geaendert, abgesagt). npm run api:erzeugen erzeugt daraus den Typ Angebotsereignis. Der Generator legt außerdem eine Funktion abonniereAngebotsereignisse() an, die den Strom mit fetch liest. Das Team nutzt sie nicht, Gründe folgen im Abschnitt zur Sicherheit.

Auffällig ist security: []: Der Ereignisstrom ist ohne Anmeldung lesbar. Der Grund steht im nächsten Abschnitt.

Im Backend: SseEmitter

Spring MVC bietet für Server-Sent Events die Klasse SseEmitter. Gibt ein Controller einen SseEmitter zurück, beendet Spring die Anfrage nicht, sondern hält die Antwort offen (asynchrone Verarbeitung der Servlet-API). Über send() schreibt das Backend jederzeit ein Ereignis in diese Antwort, complete() schließt sie. Ein Emitter steht also für genau eine Verbindung zu einem Browser. Wer allen offenen Browsern etwas schicken will, braucht eine Liste der Emitter.

Das Backend gehört in diesem Projekt einem anderen Team. Die Web-App liefert deshalb wie bei der Auslieferung aus static/ Bausteine zum Einbau im Ordner backend-live/ ihres Code-Stands; der Vertrag ist in der Kopie der Web-App schon ergänzt. Der Controller ist kurz:

@RestController
public class AngebotEreignisController {

    private final AngebotEreignisse ereignisse;

    public AngebotEreignisController(AngebotEreignisse ereignisse) {
        this.ereignisse = ereignisse;
    }

    @GetMapping(path = "/v1/angebote/ereignisse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter abonniere(HttpServletResponse antwort) {
        antwort.setHeader("X-Accel-Buffering", "no"); // nginx: diese Antwort nicht puffern
        return ereignisse.verbinde();
    }
}

Die eigentliche Arbeit macht die Komponente AngebotEreignisse. Sie merkt sich die offenen Verbindungen in einer CopyOnWriteArrayList, weil mehrere Threads gleichzeitig zugreifen: Anfragen melden sich an, Services lösen Ereignisse aus, ein Zeitgeber schickt Lebenszeichen.

@Component
public class AngebotEreignisse {

    static final String EREIGNIS = "angebot-geaendert";
    static final long ZEITLIMIT_MS = Duration.ofMinutes(30).toMillis();

    private final List<SseEmitter> verbindungen = new CopyOnWriteArrayList<>();
    private final AtomicLong laufendeNummer = new AtomicLong();

    /** Neue Verbindung: merken und beim Ende wieder vergessen. */
    public SseEmitter verbinde() {
        SseEmitter emitter = new SseEmitter(ZEITLIMIT_MS);
        verbindungen.add(emitter);
        emitter.onCompletion(() -> verbindungen.remove(emitter));
        emitter.onTimeout(emitter::complete);
        emitter.onError(fehler -> verbindungen.remove(emitter));
        return emitter;
    }

    /** Erst nach dem Commit senden, sonst lädt der Browser neu, bevor die Änderung in der Datenbank steht. */
    @TransactionalEventListener(fallbackExecution = true)
    public void beiAenderung(AngebotGeaendert aenderung) {
        String id = String.valueOf(laufendeNummer.incrementAndGet());
        sendeAnAlle(() -> SseEmitter.event().id(id).name(EREIGNIS).data(aenderung, MediaType.APPLICATION_JSON));
    }

    /** Kommentarzeile als Lebenszeichen: hält Proxys die Verbindung offen und findet abgebrochene Verbindungen. */
    @Scheduled(fixedRate = 25, timeUnit = TimeUnit.SECONDS)
    public void lebenszeichen() {
        sendeAnAlle(() -> SseEmitter.event().comment("lebenszeichen"));
    }

    // je Verbindung ein eigener Builder: ein SseEventBuilder darf nur einmal gesendet werden
    private void sendeAnAlle(Supplier<SseEventBuilder> ereignis) {
        for (SseEmitter emitter : verbindungen) {
            try {
                emitter.send(ereignis.get());
            } catch (IOException | IllegalStateException abgebrochen) {
                verbindungen.remove(emitter); // Browser ist weg, die Verbindung wird nicht mehr gebraucht
            }
        }
    }
}

Drei Stellen verdienen einen zweiten Blick.

Zeitlimit: Ohne Angabe gilt für asynchrone Anfragen das Zeitlimit des Servers, in Spring Boot einstellbar über spring.mvc.async.request-timeout. Das Team setzt 30 Minuten ausdrücklich. Läuft die Zeit ab, schließt onTimeout die Verbindung sauber, und der Browser verbindet sich von selbst neu. So sammeln sich keine Verbindungen von Geräten an, die längst schlafen.

Abgebrochene Verbindungen: Schließt jemand den Tab, merkt der Server das oft erst beim nächsten Schreibversuch: send() wirft dann eine IOException. Deshalb entfernt sendeAnAlle() solche Emitter, und das Lebenszeichen alle 25 Sekunden sorgt dafür, dass solche Schreibversuche regelmäßig stattfinden. @Scheduled braucht @EnableScheduling an der Anwendungsklasse.

Ein Builder je Verbindung: SseEmitter.event() liefert einen SseEventBuilder, der beim Senden seinen Inhalt zusammensetzt. Wer denselben Builder an mehrere Emitter schickt, bekommt doppelte Zeilenenden in den Strom. Der Supplier baut daher für jede Verbindung einen neuen.

Ereignisse aus den Services: erst nach dem Commit

Woher weiß AngebotEreignisse, dass sich etwas geändert hat? Die Services rufen die Komponente nicht direkt auf, sondern veröffentlichen ein Application Event von Spring: ein gewöhnliches Java-Objekt, das der ApplicationEventPublisher an alle Methoden verteilt, die mit @EventListener oder @TransactionalEventListener auf seinen Typ hören. Die Service-Schicht kennt damit keine Web-Klassen.

public record AngebotGeaendert(Long angebotId, String grund) {
}

In BuchungService.aendereStatus(...) kommt vor dem return eine Zeile dazu, entsprechend in frageAn(...), AngebotService.legeAn(...) und aendere(...):

ereignisse.publishEvent(new AngebotGeaendert(buchung.getAngebot().getId(), ziel.name().toLowerCase()));

Ein gewöhnlicher @EventListener liefe sofort, noch innerhalb der Transaktion von aendereStatus(). Der Browser bekäme das Ereignis, lüde neu und läse womöglich den alten Stand, weil die Änderung noch nicht festgeschrieben ist. Scheitert die Transaktion danach, hätte er sogar eine Änderung gemeldet bekommen, die es nie gab. @TransactionalEventListener wartet deshalb standardmäßig auf die Phase AFTER_COMMIT: Das Ereignis geht erst hinaus, wenn die Änderung in der Datenbank steht, und bei einem Rollback gar nicht. fallbackExecution = true lässt den Lauscher auch dann laufen, wenn ein Ereignis ohne Transaktion veröffentlicht wird. Den Monitor-Cache leert der Service übrigens schon seit Sprint 2 auf ähnliche Weise mit @CacheEvict; Monitor und Web-App reagieren damit auf dieselben Änderungen.

Sicherheit: Warum der Strom ohne Anmeldung lesbar ist

Seit dem Change Request zu Keycloak prüft das Backend bei /v1/angebote ein Access Token im Header Authorization. Hier stößt das Team auf die wichtigste Grenze von EventSource: Der Browser-Konstruktor kennt keine eigenen Header. Er schickt Cookies mit, wenn man withCredentials setzt, aber kein Bearer-Token. Drei Auswege hat der Spike geprüft:

  • Den Strom mit fetch lesen (so arbeitet die vom Generator erzeugte Funktion oder eine Bibliothek wie @microsoft/fetch-event-source). Dann geht der Header mit, aber Wiederverbindung, Last-Event-ID und das Zerlegen des Stroms muss der eigene Code übernehmen.
  • Das Token als Query-Parameter anhängen. Das landet in Server-Logs und im Verlauf des Browsers und kommt deshalb nicht in Frage.
  • Den Strom ohne Anmeldung anbieten und darauf achten, dass er nichts verrät.

Weil ein Ereignis nur eine Angebots-ID und einen Grund enthält, entscheidet sich das Team für den dritten Weg. Die Daten selbst holt die App weiterhin mit Token. In SicherheitsKonfiguration kommt eine Regel vor .anyRequest().authenticated():

// WEB-14: Ereignisstrom ohne Anmeldung – EventSource kann keinen Header Authorization setzen,
// das Ereignis enthält nur ID und Grund; die Daten selbst bleiben geschützt
.requestMatchers(HttpMethod.GET, "/v1/angebote/ereignisse").permitAll()

Die Entscheidung steht als Kommentar im Code und im Vertrag. Sobald ein Ereignis Namen, Pseudonyme oder Nachrichten enthalten soll, gilt sie nicht mehr.

In Vue: EventSource

Im Browser öffnet new EventSource(adresse) die Verbindung. Lauscher hängt man wie an jedes DOM-Element mit addEventListener(name, lauscher) an. Der Name ist der Wert der Zeile event:, der Inhalt steht in ereignis.data als Text. close() beendet die Verbindung endgültig.

Das Team baut den Zugriff so, wie es seit Artikel 3 Daten lädt: Die Komponente bekommt eine Funktion übergeben, statt selbst EventSource aufzurufen. So lässt sie sich mit einer Attrappe testen, und in jsdom, wo es kein EventSource gibt, ändert sich nichts. Die Datei src/domain/liveaktualisierung.ts:

import type { Angebotsereignis } from './typen'

/** Name des Ereignisses im Strom (Zeile „event:“), wie im Vertrag */
export const ANGEBOT_GEAENDERT = 'angebot-geaendert'

/**
 * WEB-14: Die Funktion meldet sich an, ruft beiAenderung für jedes Ereignis auf
 * und gibt eine Funktion zum Abmelden zurück.
 */
export type AenderungsQuelle<E = Angebotsereignis> = (beiAenderung: (ereignis: E) => void) => () => void

/** Der Teil von EventSource, den die App braucht (im Test durch eine Attrappe ersetzbar) */
export interface Ereignisverbindung {
  addEventListener(typ: string, lauscher: (ereignis: MessageEvent<string>) => void): void
  close(): void
}
export type EventSourceKlasse = new (adresse: string) => Ereignisverbindung

/** Abonniert GET /v1/angebote/ereignisse über die Browser-Schnittstelle EventSource. */
export function sseAenderungen(
  adresse = `${import.meta.env.VITE_API_BASIS ?? '/v1'}/angebote/ereignisse`,
  Klasse: EventSourceKlasse = globalThis.EventSource,
): AenderungsQuelle {
  return (beiAenderung) => {
    if (!Klasse) return () => {} // ohne EventSource (z. B. jsdom): keine Live-Aktualisierung, Suche wie bisher
    const verbindung = new Klasse(adresse)
    verbindung.addEventListener(ANGEBOT_GEAENDERT, (ereignis) => {
      beiAenderung(JSON.parse(ereignis.data) as Angebotsereignis)
    })
    return () => verbindung.close()
  }
}

Die Adresse ist relativ wie alle API-Adressen der App: Im Entwicklungsserver reicht der Vite-Proxy /v1 an das Backend weiter, in der Auslieferung nginx. Für den Browser ist der Strom deshalb derselbe Ursprung, CORS spielt keine Rolle.

Anmelden und abmelden mit dem Lebenszyklus der Komponente

Eine offene Verbindung muss geschlossen werden, wenn niemand mehr zuhört. Wechselt Oksana von der Suche auf die Detailseite, entfernt der Router die Komponente FahrtenSuche; bliebe die Verbindung offen, würde jeder Seitenwechsel eine weitere öffnen. Das Composable useLiveAktualisierung verbindet beides:

import { onScopeDispose } from 'vue'
import type { AenderungsQuelle } from '@/domain/liveaktualisierung'

export function useLiveAktualisierung<E>(quelle: AenderungsQuelle<E> | undefined, aktualisieren: () => void): void {
  if (!quelle) return
  const abmelden = quelle(() => aktualisieren())
  onScopeDispose(abmelden)
}

onScopeDispose() registriert eine Funktion, die Vue aufruft, wenn der aktuelle Effekt-Bereich endet. In einer Komponente ist das der Moment, in dem sie entfernt wird, also dasselbe wie onUnmounted(). Der Vorteil: Das Composable funktioniert auch in einem eigenen effectScope(), etwa in Tests ohne Komponente.

Still neu laden

Was soll bei einem Ereignis passieren? Das Composable useLaden() aus dem Refactoring-Artikel lädt neu, sobald sich der Filter ändert, und zeigt dabei „Fahrten werden geladen …“. Für ein Ereignis wäre das falsch: Die Liste verschwände kurz, und die Live-Region aus WEB-11 würde jedem Screenreader bei jeder Bestätigung irgendwo in der Schule „Fahrten werden geladen“ vorlesen. Lukas Hofmann, der die App mit Screenreader nutzt, hätte das in der Kundenstunde sofort bemerkt. Das Paar ergänzt useLaden() deshalb um neuLaden(), das mit dem letzten Filter lädt, ohne laedt zu setzen:

export function useLaden<A, T>(ausloeser: WatchSource<A>, laden: (wert: A) => Promise<T>): Ladezustand<T> {
  const ergebnis = shallowRef<T>()
  const laedt = ref(false)
  let letzteAnfrage = 0
  let letzterWert: A | undefined
  let geladen = false

  async function lade(wert: A, sichtbar: boolean): Promise<void> {
    const anfrage = ++letzteAnfrage
    letzterWert = wert
    geladen = true
    if (sichtbar) laedt.value = true
    const antwort = await laden(wert)
    if (anfrage !== letzteAnfrage) return // inzwischen wurde neu geladen
    ergebnis.value = antwort
    laedt.value = false
  }

  watch(ausloeser, (wert) => lade(wert, true), { immediate: true })

  return {
    ergebnis,
    laedt,
    neuLaden: () => (geladen ? lade(letzterWert as A, false) : Promise.resolve()),
  }
}

Der Schutz gegen verspätete Antworten bleibt: Auch ein stilles Neuladen bekommt eine laufende Nummer. Ändert Oksana währenddessen den Filter, wird die ältere Antwort verworfen. Die Trefferzahl in der Live-Region ändert sich nur, wenn sich wirklich etwas ändert, und dann ist das Vorlesen erwünscht.

In FahrtenSuche.vue kommen eine optionale Eigenschaft und eine Zeile dazu, SucheSeite.vue übergibt die echte Quelle:

const props = defineProps<{
  quelle: AngebotQuelle
  haltepunkte: readonly Haltepunkt[]
  /** WEB-14: optional, ohne sie verhält sich die Suche wie bisher */
  aenderungen?: AenderungsQuelle
}>()

const filter = ref<AngebotFilter>({})
const { ergebnis, laedt, neuLaden } = useLaden(filter, (aktuellerFilter) => sucheFahrten(props.quelle, aktuellerFilter))
// WEB-14: Ändert sich ein Angebot, lädt die Suche mit demselben Filter still neu – die Liste bleibt dabei stehen
useLiveAktualisierung(props.aenderungen, neuLaden)
<!-- SucheSeite.vue -->
<FahrtenSuche :quelle="quelle" :haltepunkte="haltepunkte" :aenderungen="aenderungen" />

Weil aenderungen optional ist, laufen alle bisherigen Tests der Komponente unverändert. Das Sequenzdiagramm zeigt den ganzen Weg von Bens Bestätigung bis zu Oksanas Liste:

UML-Sequenzdiagramm: Oksanas Web-App erzeugt eine EventSource, die sich bei AngebotEreignisse anmeldet; die Verbindung bleibt offen, alle 25 Sekunden kommt ein Lebenszeichen. Bens Web-App ruft aendereStatus am BuchungService auf, der asynchron AngebotGeaendert veröffentlicht. AngebotEreignisse wartet auf den Commit und schickt dann das Ereignis angebot-geaendert an die EventSource, die beiAenderung in Oksanas Web-App aufruft; die lädt mit Filter und Token neu, die Liste bleibt bis zur Antwort stehen (mit KI erstellt)

Wenn die Verbindung abbricht

Mobile Geräte wechseln das Netz, Laptops schlafen ein, Server starten neu. EventSource nimmt dem Team hier viel ab, aber nicht alles. Das Zustandsdiagramm zeigt die drei Werte von readyState:

UML-Zustandsdiagramm von EventSource: Im zusammengesetzten Zustand aktiv beginnt die Verbindung in CONNECTING (0). Antwort 200 mit text/event-stream führt mit dem Ereignis open nach OPEN (1), wo Ereignisse empfangen werden. Bricht die Verbindung ab, geht es mit error zurück nach CONNECTING und nach retry neu mit Last-Event-ID; ist das Netz nicht erreichbar, bleibt es in CONNECTING. Eine Antwort mit anderem Status oder Typ führt mit error nach CLOSED (2), ebenso close() aus aktiv; dort gibt es keinen neuen Versuch (mit KI erstellt)
  • Abbruch einer bestehenden Verbindung: EventSource meldet error, wartet die Zeit aus retry (Standard je nach Browser einige Sekunden) und verbindet sich neu. Dabei schickt es die letzte id als Header Last-Event-ID mit. Ein Server, der die letzten Ereignisse aufbewahrt, könnte die verpassten nachliefern. Das Backend des Mitfahrlabors tut das nicht, und für WEB-14 ist das auch nicht nötig: Nach der Wiederverbindung genügt es, einmal neu zu laden.
  • Fehlerantwort: Antwortet der Server mit einem anderen Status als 200 oder einem anderen Typ als text/event-stream, etwa 502 von nginx während eines Neustarts oder 401 bei vergessener permitAll-Regel, gibt EventSource endgültig auf (CLOSED). Dann hilft nur ein neues Objekt.
  • Verpasste Ereignisse: Was während der Unterbrechung passiert ist, erfährt der Browser nicht von selbst.

Für WEB-14 reicht das Verhalten von EventSource: KT-14.3 verlangt nur, dass die Suche ohne Strom weiter funktioniert, und das tut sie, weil die Liste ganz normal geladen wird und ein Ereignis nur zusätzlich auslöst. Ein robusterer Umgang mit Abbrüchen ist Thema der Übungen.

nginx: Ereignisse nicht puffern

Beim ersten Test auf dem Schulserver kommen die Ereignisse nicht einzeln an, sondern gesammelt nach Minuten. Die Ursache ist eine nützliche Voreinstellung von nginx: Als Reverse Proxy puffert es Antworten des Backends, um langsame Clients vom Backend zu entkoppeln. Bei einem Strom, der nie endet, sammelt der Puffer die Ereignisse, bis er voll ist. Dazu kommt proxy_read_timeout mit 60 Sekunden: Schickt das Backend so lange nichts, schließt nginx die Verbindung. Der neue Block in nginx/mitfahrlabor-web.conf steht vor dem allgemeinen Block für /v1/:

    # 1a. WEB-14: Ereignisstrom (Server-Sent Events) – Antworten sofort weiterreichen, Verbindung lange offen halten
    location = /v1/angebote/ereignisse {
        proxy_pass http://127.0.0.1:8080;
        proxy_http_version 1.1;                    # dauerhafte Verbindung zum Backend
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $remote_addr;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_buffering off;                       # jedes Ereignis sofort an den Browser, nicht erst gesammelt
        proxy_read_timeout 1h;                     # Standard 60 s; das Lebenszeichen kommt alle 25 s
    }

Doppelt hält besser: Der Header X-Accel-Buffering: no, den der Controller setzt, schaltet das Puffern für diese eine Antwort ebenfalls ab, auch wenn ein anderer nginx davorsteht, den das Team nicht verwaltet. gzip betrifft den Strom nicht, weil text/event-stream nicht in gzip_types steht; komprimierte Ereignisse würden ebenfalls erst gesammelt.

Unter HTTP/1.1 erlauben Browser je Server nur etwa sechs gleichzeitige Verbindungen. Wer die Suche in sieben Tabs öffnet, blockiert damit andere Anfragen. Über HTTP/2 teilen sich alle Ströme eine Verbindung, und das Problem verschwindet; in nginx schaltet http2 on; im Server-Block es ein (die Konfiguration der Schule nutzt bisher HTTP/1.1, ein Punkt für die Retrospektive).

Testen: Attrappe, nachgebildeter Strom, MockMvc

WEB-14 entsteht wie jede Karte test-first, auf drei Ebenen.

Komponententest mit Attrappe: Für Vitest baut das Paar eine AenderungsQuelle, die der Test selbst auslöst. Er prüft KT-14.1 und KT-14.2 an der Komponente:

function aenderungsAttrappe() {
  let melde: ((e: Angebotsereignis) => void) | undefined
  let abgemeldet = false
  const quelle: AenderungsQuelle = (beiAenderung) => {
    melde = beiAenderung
    return () => (abgemeldet = true)
  }
  return { quelle, aendere: (e: Angebotsereignis) => melde?.(e), istAbgemeldet: () => abgemeldet }
}

it('KT-14.1: zeigt nach einer Bestätigung ohne Neuladen die neue Platzzahl', async () => {
  const backend = veraenderlicheQuelle()
  const live = aenderungsAttrappe()
  const wrapper = mount(FahrtenSuche, {
    props: { quelle: backend.quelle, haltepunkte, aenderungen: live.quelle },
    global,
  })
  await flushPromises()
  expect(wrapper.get('[data-angebot-id="1"]').text()).toContain('2 Plätze frei')

  backend.setze(angebote.map((a) => (a.id === 1 ? { ...a, freiePlaetze: 1 } : a)).filter((a) => a.status === 'offen'))
  live.aendere({ angebotId: 1, grund: 'bestaetigt' })
  await flushPromises()
  expect(wrapper.get('[data-angebot-id="1"]').text()).toContain('1 Platz frei')
})

Weitere Tests in FahrtenSuche.live.spec.ts prüfen, dass beim stillen Neuladen kein Ladehinweis erscheint und die Liste stehen bleibt und dass die Komponente sich beim Entfernen abmeldet. liveaktualisierung.spec.ts prüft sseAenderungen() mit einer Attrappe für EventSource: richtige Adresse, nur Ereignisse namens angebot-geaendert, close() beim Abmelden. useLaden.spec.ts bekommt zwei Tests für neuLaden().

End-to-End-Test im Browser: Playwright kann einen Ereignisstrom mit page.route() nachbilden. Der Trick: Der Handler hält die Antwort zurück, bis der Test das Ereignis auslöst, und antwortet dann mit einem Text im Format text/event-stream. retry: 60000 verhindert, dass der Browser sich im Test sofort neu verbindet. Die Hilfe e2e/hilfen/ereignisse.ts:

export async function bildeEreignisseNach(page: Page): Promise<{ sende: (e: Angebotsereignis) => void }> {
  let sende!: (e: Angebotsereignis) => void
  const ereignis = new Promise<Angebotsereignis>((resolve) => (sende = resolve))
  await page.route('**/v1/angebote/ereignisse', async (route) => {
    const e = await ereignis
    await route.fulfill({
      status: 200,
      headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
      body: `retry: 60000\nid: 1\nevent: angebot-geaendert\ndata: ${JSON.stringify(e)}\n\n`,
    })
  })
  return { sende }
}

Der Kundentest registriert nach dem ersten Laden eine weitere Route für /v1/angebote, die Angebot 1 mit einem freien Platz liefert (die zuletzt registrierte Route gewinnt), und löst dann das Ereignis aus:

test('KT-14.1: nach der Bestätigung zeigt die offene Suche ohne Neuladen 1 freien Platz', async ({ page }) => {
  const strom = await bildeEreignisseNach(page)
  await page.goto('/')
  const angebot1 = page.getByRole('listitem').filter({ hasText: 'Gütersloh zur Schule' })
  await expect(angebot1).toContainText('07:00 Uhr, 2 Plätze frei')

  // Ben bestätigt eine Anfrage: ab jetzt liefert das Backend Angebot 1 mit einem freien Platz
  const offen = angebote.filter((a) => a.status === 'offen')
  await page.route('**/v1/angebote', (route) =>
    route.fulfill({ json: offen.map((a) => (a.id === 1 ? { ...a, freiePlaetze: 1 } : a)) }),
  )
  strom.sende({ angebotId: 1, grund: 'bestaetigt' })

  await expect(angebot1).toContainText('07:00 Uhr, 1 Platz frei')
  await expect(page.getByRole('status')).toHaveText('4 Fahrten gefunden')
})

Die Web-First Assertion toContainText() wartet bis zu fünf Sekunden, genau die Frist aus KT-14.1. Damit der Test nicht zufällig grün ist, hat das Paar ihn einmal gegen eine SucheSeite.vue ohne :aenderungen laufen lassen: rot mit „Expected substring: 07:00 Uhr, 1 Platz frei, Received string: … 2 Plätze frei“. KT-14.3 kommt ohne Nachbildung aus: bildeBackendNach() antwortet auf jede Anfrage ohne Token mit 401, EventSource gibt auf, und die Suche zeigt trotzdem vier Fahrten.

Backend mit MockMvc: Für die Bausteine in backend-live/ liegt AngebotEreignisseTest bei. MockMvc prüft mit request().asyncStarted(), dass die Antwort offen bleibt, und liest danach den bisher geschriebenen Strom:

@Test
void sendetErstNachDemCommit() throws Exception {
    MvcResult verbindung = verbinde();
    transaktion.executeWithoutResult(status -> {
        veroeffentlicher.publishEvent(new AngebotGeaendert(4L, "geaendert"));
        assertThat(contentOf(verbindung)).doesNotContain("angebot-geaendert"); // noch nicht festgeschrieben
    });
    assertThat(contentOf(verbindung)).contains("\"angebotId\":4");
}

Weitere Tests prüfen das Format (event:angebot-geaendert, data:{"angebotId":1,"grund":"bestaetigt"}), dass bei einem Rollback nichts gesendet wird, das Lebenszeichen als Kommentar und das Aufräumen beendeter Verbindungen.

Regression: Was die alten Tests jetzt wert sind

Der Change Request berührt Stellen, die viele Karten nutzen: useLaden() steckt in Suche und Detailseite, FahrtenSuche.vue trägt die Live-Region aus WEB-11, der Vertrag erzeugt den ganzen API-Client neu. Ohne Tests müsste Thomas in der Kundenstunde alle Karten von WEB-01 bis WEB-11 noch einmal durchklicken. Mit Tests läuft dieselbe Prüfung in Sekunden:

Regressionstests nach WEB-14
Prüfung Befehl Ergebnis Stand 12
Komponenten- und Integrationstests npm test 116 Tests in 23 Dateien grün (vorher 107 in 21)
Typen, Teamregeln, Format npm run type-check, npm run lint, npm run format:check ohne Befund
Kundentests im Browser npm run test:e2e 14 Tests grün, darunter KT-11.1 und KT-11.2
Backend-Bausteine Test in backend-live/ 5 Tests grün

Ein Regressionstest ist kein eigener Testtyp, sondern ein Zweck: Bereits bestandene Tests werden nach einer Änderung erneut ausgeführt, um ungewollte Nebenwirkungen zu finden. In XP läuft er bei jeder Integration automatisch, weil die Pipeline ohnehin alle Tests ausführt. Der Wert der Tests aus elf Artikeln zeigt sich genau hier: Sie machen den Änderungswunsch billig. Die Regression hat beim Umbau auch wirklich etwas gefunden. In einer Fassung ohne die Zeile if (!Klasse) return () => {} scheitern sieben bestehende Tests in App.spec.ts und navigation.spec.ts, darunter KT-08.2 und KT-08.3: Sie rendern die ganze App mit Router in jsdom, die Startseite ist die Suche, und jsdom kennt kein EventSource. Der Aufruf new Klasse(adresse) wirft dann einen TypeError. Mit der Prüfung verhält sich die Suche in solchen Umgebungen wie vor WEB-14, genau das verlangt KT-14.3.

Iteration 6 abgeschlossen

In der Kundenstunde öffnet Thomas die Suche auf zwei Geräten: auf dem Beamer als Oksana, auf dem Telefon als Ben. Ben bestätigt eine Anfrage, und noch bevor er das Telefon weglegt, steht auf dem Beamer „07:00 Uhr, 1 Platz frei“. Lukas prüft mit seinem Screenreader, dass er nur dann etwas hört, wenn sich die Trefferzahl ändert. Thomas nimmt WEB-14 ab. Velocity von Iteration 6: 4 Punkte (Spike 1, WEB-14 3). Weil eine neue Funktion dazukommt, hebt das Paar die Version auf 0.3.0 und trägt WEB-14 im CHANGELOG.md unter „Neu“ ein. Das Backend-Team bekommt die Bausteine aus backend-live/ mit einer Notiz, und stories.md hält fest: WEB-12 und WEB-13 sind die Kandidaten für Iteration 7.

Die Ergebnisse im Repository

Den vollständigen Code-Stand findest Du im Ordner src/12-vue-change-request-live des Repositorys dieser Reihe: den ergänzten Vertrag in api/openapi.yaml mit neu erzeugtem Client, src/domain/liveaktualisierung.ts, src/composables/useLiveAktualisierung.ts, das erweiterte useLaden(), die Änderungen an FahrtenSuche.vue und SucheSeite.vue, die neuen Tests (Vitest und e2e/web-14-live-aktualisierung.spec.ts), den nginx-Block und im Ordner backend-live/ die Bausteine für das Backend mit Einbauanleitung und Test. README.md, stories.md und CHANGELOG.md sind fortgeschrieben, die Version ist 0.3.0.

Nächster Schritt: zurückblicken

Mit WEB-14 endet diese Artikelreihe, nicht das Projekt: WEB-12 und WEB-13 warten auf Iteration 7, und jede weitere Karte durchläuft denselben Kreislauf aus Planning Game, Kundentests, Paarprogrammierung und kleiner Auslieferung. Bevor das Team weitermacht, hält es eine Retrospektive über sechs Iterationen. Die Projektübersicht fasst zusammen, was in der Reihe entstanden ist und welche XP-Praktiken wo vorkamen. Wer sehen möchte, wie dieselbe Schule mit einem anderen Vorgehensmodell arbeitet, findet beim Flurmonitor mit Scrum einen eigenen Change Request zu Keycloak.

In den Übungsaufgaben zu diesem Artikel baust Du eine Live-Anzeige für Mitfahrgesuche in Vue und im Backend, behandelst Verbindungsabbrüche und wägst Polling, Server-Sent Events und WebSocket für neue Wünsche der Schule ab.

Leitfragen

  1. Wie nimmt ein XP-Team einen Änderungswunsch der Kund*in auf, und warum ist das in XP kein Störfall?

Der Wunsch wird mit der Kund*in als neue Karte mit Kundentests aufgeschrieben, bei fehlender Erfahrung nach einem kurzen Spike geschätzt und im Planning Game eingeplant; die Kund*in entscheidet, welche Karten dafür nach hinten rücken. Embrace Change funktioniert, weil kurze Iterationen, automatisierte Tests und Refactoring eine Änderung billig halten. siehe Abschnitt „Embrace Change“

  1. Worin unterscheiden sich Polling, Server-Sent Events und WebSocket, und warum passt SSE zu WEB-14?

Beim Polling fragt der Browser im Takt; bei SSE hält er eine HTTP-Antwort offen, in die nur der Server schreibt; WebSocket wechselt auf ein eigenes Protokoll mit beiden Richtungen. Für WEB-14 schickt nur der Server etwas, SSE nutzt gewöhnliches HTTP und verbindet sich selbst neu. siehe Abschnitt „Drei Wege zu aktuellen Daten“

  1. Woraus besteht ein Ereignis im Format text/event-stream?

Aus Zeilen feld: wert, abgeschlossen durch eine Leerzeile: data (Inhalt), event (Name, sonst message), id (für Last-Event-ID), retry (Wartezeit vor der Wiederverbindung); Zeilen mit : am Anfang sind Kommentare, etwa Lebenszeichen. siehe Abschnitt „Das Format text/event-stream“

  1. Warum enthält ein Ereignis nur ID und Grund, und warum ist der Strom ohne Anmeldung lesbar?

Die App lädt nach einem Ereignis ohnehin mit ihrem Filter neu; die Suchlogik bleibt an einer Stelle (Simple Design). EventSource kann keinen Header Authorization setzen; weil das Ereignis keine personenbezogenen Daten enthält, ist der Strom offen, die Daten selbst lädt die App weiter mit Token. siehe Abschnitt „Der Vertrag“ und „Sicherheit“

  1. Wozu dienen SseEmitter, die Liste der Verbindungen und das Lebenszeichen im Backend?

Ein SseEmitter hält die Antwort einer Anfrage offen und schreibt mit send() Ereignisse hinein. Die Liste sammelt alle offenen Verbindungen, damit ein Ereignis an alle geht; beendete Verbindungen werden über onCompletion, onError und gescheiterte Sendeversuche entfernt. Das Lebenszeichen hält Proxys die Verbindung offen und deckt abgebrochene Verbindungen auf. siehe Abschnitt „Im Backend: SseEmitter“

  1. Warum sendet das Backend mit @TransactionalEventListener statt mit @EventListener?

Ein @EventListener liefe noch in der Transaktion; der Browser könnte neu laden, bevor die Änderung festgeschrieben ist, und bei einem Rollback eine Änderung melden, die es nie gab. @TransactionalEventListener sendet erst nach dem Commit (Phase AFTER_COMMIT), bei einem Rollback gar nicht. siehe Abschnitt „Ereignisse aus den Services“

  1. Warum lädt die Suche bei einem Ereignis mit neuLaden() statt über den Filter neu, und warum meldet sie sich mit onScopeDispose() ab?

neuLaden() lädt mit dem letzten Filter, ohne den Ladehinweis zu zeigen: Die Liste bleibt stehen, und Screenreader lesen nicht bei jeder Änderung „Fahrten werden geladen“ vor. onScopeDispose() schließt die Verbindung, wenn die Komponente entfernt wird; sonst öffnete jeder Seitenwechsel eine weitere. siehe Abschnitt „In Vue: EventSource“

  1. Was macht EventSource bei Verbindungsabbrüchen selbst, und wo sind seine Grenzen?

Bricht eine bestehende Verbindung ab, verbindet es sich nach retry selbst neu und schickt die letzte id als Last-Event-ID. Bei einer Antwort mit anderem Status als 200 oder anderem Typ gibt es auf (CLOSED); Ereignisse aus der Pause liefert es nicht nach, und eigene Header kann es nicht setzen. siehe Abschnitt „Wenn die Verbindung abbricht“

  1. Was muss ein Reverse Proxy wie nginx für Server-Sent Events anders machen?

Er darf die Antwort nicht puffern (proxy_buffering off oder Header X-Accel-Buffering: no) und die Verbindung nicht nach der üblichen Lesezeit schließen (proxy_read_timeout länger als der Abstand der Lebenszeichen); Komprimierung des Stroms ist ebenfalls zu vermeiden. siehe Abschnitt „nginx“

  1. Was ist ein Regressionstest, und welche Rolle spielt er beim Change Request?

Das erneute Ausführen bestandener Tests nach einer Änderung, um ungewollte Nebenwirkungen zu finden. Die Kundentests von WEB-01 bis WEB-11 und alle Komponententests laufen nach WEB-14 unverändert grün; ohne sie müsste die Kund*in alle Karten erneut von Hand prüfen. siehe Abschnitt „Regression“


Hinweis zur Nachnutzung als Open Educational Resource (OER)

Dieser Artikel und seine Texte, Bilder, Grafiken, Code und sonstiger Inhalt sind - sofern nicht anders angegeben - lizenziert unter CC BY 4.0. Nennung gemäß TULLU-Regel bitte wie folgt: “Change Request: Live-Aktualisierung mit Server-Sent Events” von oer-informatik.de (H. Stein), Lizenz: CC BY 4.0. Der Artikel wurde unter https://oer-informatik.de/vue-change-request-live veröffentlicht, die Quelltexte sind in weiterverarbeitbarer Form verfügbar im Repository unter https://gitlab.com/oer-informatik/programmierung/vue. Erstellt mit KI-Assistenz; von mir umfassend redaktionell überarbeitet, geprüft und freigegeben. Stand: 11.11.2026.

[Kommentare zum Artikel lesen, schreiben] / [Artikel teilen] / [gitlab-Issue zum Artikel schreiben]

Kommentare gerne per Mastodon, Verbesserungsvorschläge per gitlab issue (siehe oben). Beitrag teilen: