feat: add SSE endpoint and server mode system
Add server-sent events at GET /api/events for live mutation notifications. Add server mode (normal/read-only/degraded) exposed via /health, /ready, and SSE server.state events. New files: - broker.go: SSE event hub with client management, non-blocking fan-out, ring buffer history for Last-Event-ID replay, heartbeat - servermode.go: mode state machine with periodic MinIO health check and SIGUSR1 read-only toggle - sse_handler.go: HTTP handler using http.Flusher and ResponseController to disable WriteTimeout for long-lived SSE - broker_test.go, servermode_test.go: 13 unit tests Modified: - handlers.go: Server struct gains broker/serverState fields, Health/Ready include mode and sse_clients, write handlers emit item.created/updated/deleted and revision.created events - routes.go: register GET /api/events, add RequireWritable middleware to all 8 editor-gated route groups - middleware.go: RequireWritable returns 503 in read-only mode - csv.go, ods.go: emit bulk item.created events after import - storage.go: add Ping() method for health checks - config.go: add ReadOnly field to ServerConfig - main.go: create broker/state, start background goroutines, SIGUSR1 handler, graceful shutdown sequence Closes #38, closes #39
This commit is contained in:
61
internal/api/sse_handler.go
Normal file
61
internal/api/sse_handler.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HandleEvents serves the SSE event stream.
|
||||
// GET /api/events (requires auth, viewer+)
|
||||
func (s *Server) HandleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
flusher, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
writeError(w, http.StatusInternalServerError, "streaming_unsupported", "Streaming not supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Disable the write deadline for this long-lived connection.
|
||||
// The server's WriteTimeout (15s) would otherwise kill it.
|
||||
rc := http.NewResponseController(w)
|
||||
if err := rc.SetWriteDeadline(time.Time{}); err != nil {
|
||||
s.logger.Warn().Err(err).Msg("failed to disable write deadline for SSE")
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("Connection", "keep-alive")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // nginx: disable proxy buffering
|
||||
|
||||
client := s.broker.Subscribe()
|
||||
defer s.broker.Unsubscribe(client)
|
||||
|
||||
// Replay missed events if Last-Event-ID is present.
|
||||
if lastIDStr := r.Header.Get("Last-Event-ID"); lastIDStr != "" {
|
||||
if lastID, err := strconv.ParseUint(lastIDStr, 10, 64); err == nil {
|
||||
for _, ev := range s.broker.EventsSince(lastID) {
|
||||
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, ev.Data)
|
||||
}
|
||||
flusher.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
// Send initial server.state event.
|
||||
fmt.Fprintf(w, "event: server.state\ndata: %s\n\n",
|
||||
mustMarshal(map[string]string{"mode": string(s.serverState.Mode())}))
|
||||
flusher.Flush()
|
||||
|
||||
// Stream loop.
|
||||
for {
|
||||
select {
|
||||
case ev := <-client.ch:
|
||||
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, ev.Data)
|
||||
flusher.Flush()
|
||||
case <-client.closed:
|
||||
return
|
||||
case <-r.Context().Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user