goutils/http/sse.go
mstar 3bb1984f1c
Actually make SseWriter be spec compliant
- Change inbound data type from byte slice to dedicated event message
  type
- Include data validation for id and event type
- Better documentation
2025-06-10 13:33:17 +02:00

78 lines
2.5 KiB
Go

package webutils
import (
"errors"
"fmt"
"net/http"
"strings"
)
type SseEvent struct {
Type *string
Id *string
Data string
}
var (
ErrWriterNotFlushable = errors.New("response writer not flushable")
ErrEventTypeNoNewline = errors.New("event type contained a newline")
ErrEventIdNoNewline = errors.New("event id contained a newline")
)
// SseWriter provides a simple implementation for sending data via Server Side Events
// to the client. The function returns once the dataStream channel is closed.
// The ResponseWriter should not be used after calling this function
//
// Event rules:
// - SseEvent.Type must not contain any newlines
// - SseEvent.Id must not contain any newlines
// - "\r\n" in SseEvent.Data will be replaced with "\n", afterwards "\r" will also be replaced with "\n"
//
// Inspired by and partially copied from https://medium.com/@rian.eka.cahya/server-sent-event-sse-with-go-10592d9c2aa1
// Also why this and not github.com/tmaxmax/go-sse?
// go-sse is not flexible in its use case. You have one handler that doesn't differentiate between clients.
// This, of course, does not work when you have to send individualised notifications as everyone would
// get the same messages, causing confusion in the best case and data leaks in the worst case
func SseWriter(w http.ResponseWriter, dataStream chan SseEvent) error {
w.Header().Set("Access-Control-Expose-Headers", "Content-Type")
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
return ErrWriterNotFlushable
}
for data := range dataStream {
if err := ValidateSseEvent(&data); err != nil {
return err
}
if data.Type != nil {
_, _ = fmt.Fprintf(w, "event: %s\n", *data.Type)
}
if data.Id != nil {
_, _ = fmt.Fprintf(w, "id: %s\n", *data.Id)
}
noCr := strings.ReplaceAll(data.Data, "\r\n", "\n")
noCr = strings.ReplaceAll(noCr, "\r", "\n")
for _, line := range strings.Split(noCr, "\n") {
_, _ = fmt.Fprintf(w, "data: %s\n", line)
}
_, _ = fmt.Fprint(w, "\n\n")
flusher.Flush()
}
return nil
}
// Check if a given event is valid for sending via SseWriter
// This function is also internally called by SseWriter for each outbound event
func ValidateSseEvent(e *SseEvent) error {
if e.Type != nil && strings.ContainsRune(*e.Type, '\n') {
return ErrEventTypeNoNewline
}
if e.Id != nil && strings.ContainsRune(*e.Id, '\n') {
return ErrEventIdNoNewline
}
return nil
}