Server-Sent Events sind fast zu einfach, um einen Namen zu haben. data: hello, Leerzeile, fertig. Das ist das Wire-Format. Der erste Spec-Entwurf ist älter als das iPhone, und Browser shippen es nativ seit über einem Jahrzehnt.
Und doch baut jedes Go-Projekt, das SSE served, dieselben vier Teile von Hand: Event-Serialisierung, Connection-Lifecycle, Subscriber-Fan-out, Reconnection-Replay. Ich habe genug davon selbst geschrieben. Mehr als einmal. Dieses Jahr.
Ich wollte ein Live-UI ohne React-Bundle und ohne Build-Step. DataStar macht genau das — der Server pusht DOM-Patches über SSE, der Browser wendet sie an. Aber das offizielle Go-SDK macht einen Patch zu einem Method Call auf einer Live-Connection. Einen Patch ohne Connection konstruieren? Nein. Einen Patch an jeden offenen Tab broadcasten? Not built in. Replay für reconnectende Clients? Not built in.
Also tat ich das Sinnvolle: den Transport, den alle ständig neu schreiben, in go-sse extrahiert — und die Protocol-Layer, die ich eigentlich wollte, obendrauf gebaut: go-datastar.
go-sse: der Transport, den alle neu erfinden
broadcaster := sse.NewBroadcaster[sse.Event]()
mux.HandleFunc("GET /events", func(w http.ResponseWriter, r *http.Request) {
stream := sse.NewStream(w, r)
defer func() { _ = stream.Close() }()
ch := broadcaster.Subscribe()
defer broadcaster.Unsubscribe(ch)
for {
select {
case <-stream.Context().Done():
return
case evt, ok := <-ch:
if !ok || stream.Send(evt) != nil {
return
}
}
}
})
broadcaster.Broadcast(sse.Event{Event: "update", Data: "<div>new</div>"})
Das ist ein kompletter Live-Endpoint. Drei Details, auf die ich stolz bin:
Heartbeats. Nginx, Cloudflare und AWS Load Balancer killen Idle-Connections auf Sicht. stream.Heartbeat(ctx, 15*time.Second) stupst die Connection alle 15 Sekunden an, damit der Türsteher vergisst, dass sie existiert.
Langsame Consumer werden gedroppt. Jeder Subscriber hat einen 64-Event-Buffer. Wenn er voll ist, werden Events gedroppt — nur für diesen Subscriber, still. Das klingt unhöflich. Es ist unhöflich. Es ist auch das einzige korrekte Design: Ein langsamer Tab darf niemals zehntausend Live-Tabs blockieren. Die Entschuldigung ist eingebaut — der Browser reconnectet, schickt Last-Event-ID, und das Replay füllt die Lücke. So als wäre nichts gewesen.
Branded Event-IDs. EventID ist ein Branded Type. Du kannst ihm keine User-ID zuweisen, denn dein Compiler hat jetzt Meinungen.
Das Companion-Modul ssetest parst das Wire-Format, gepinnt gegen den offiziellen Web Platform Tests Corpus. Ich habe keinen SSE-Parser geschrieben, der nur meine eigenen Tests besteht.
go-datastar: Patches sind Nomen
Im offiziellen SDK ist ein Patch ein Verb. sse.PatchElements("<div>Update</div>") schreibt sofort auf den Wire. Der Patch existiert nur, solange die Connection existiert — ein Sprechakt, verschwunden im Moment des Aussprechens.
In go-datastar ist ein Patch ein Nomen. Jeder Patch — Elements, Signals, Scripts, Custom Events — ist ein Wert, der ein winziges Interface implementiert:
patch := datastar.NewElementsPatch("<div>Update</div>",
datastar.WithSelectorID("feed"),
datastar.WithModePrepend(),
)
evt := patch.Event()
broadcaster.Broadcast(evt) // jeder verbundene Tab, sofort
store.Append(evt) // Replay für alle, die reconnecten
Eine Domain-Funktion kann Patches erzeugen, ohne dass HTTP in Sicht ist. Fan-out, Store, Filter pro Subscriber, Replay nach dem Drop. Dasselbe Wire-Format wie das offizielle SDK, byte-für-byte von Golden Tests gepinnt. Der Unterschied ist nicht, was über den Wire geht. Der Unterschied ist, was ein Patch ist, bevor er das Netzwerk trifft.
Was ich bewusst nicht gebaut habe
- WebSockets. SSE ist ein Feuerwehrschlauch Richtung Client. Wenn der Client zurücksprechen muss, dafür gibt es POST.
- Compression. Hier gewinnt das offizielle SDK, und ich verliere gerne: gzip gehört in den Reverse Proxy. Eine Go-Library, die Brotli neu implementiert, ist der Tod von Side Projects.
- Payload-Meinungen. JSON, HTML-Fragmente, plain Strings — dein Feed, dein Format.
Wo das offizielle SDK gewinnt
Wenn du einen Request und eine Response hast, nimm datastar-go. Es hat eingebaute Compression, läuft auf langweiligen Go-Versionen ohne Flags und trackt Client-Releases ab Tag eins. Meins braucht ein experimentelles Compiler-Flag — entweder ein rotes Flag oder ein Persönlichkeitstest.
Wähle go-datastar, wenn Patches Teil deines Application State sind: Live-Feeds, Dashboards, alles, was einen einzelnen Request überlebt.
Probier es aus
Beide Repos haben lauffähige Examples. go run ./example/datastar/, zwei Tabs öffnen, in einem klicken und zusehen, wie der andere von allein updatet. Der am wenigsten beeindruckende Magic Trick, den du je zeigen wirst — und er shipped in einer Binary.
Lies den Code. Der Browser wusste, wie das geht, noch vor dem iPhone 3G (Safari 5). Jetzt kann es dein Go-Server auch.