// client2server - Server-Sent Events broadcaster // // Used by the dashboard for real-time event feed. Clients connect to // /api/events/stream and receive a newline-delimited stream of JSON // envelopes. package main import ( "encoding/json" "fmt" "net/http" "sync" "time" ) type sseClient struct { channel chan []byte filter sseFilter lastPing time.Time } type sseFilter struct { RouterID string EventType string } type sseHub struct { mu sync.RWMutex clients map[*sseClient]struct{} } var hub = &sseHub{ clients: make(map[*sseClient]struct{}), } func (h *sseHub) add(c *sseClient) { h.mu.Lock() defer h.mu.Unlock() h.clients[c] = struct{}{} } func (h *sseHub) remove(c *sseClient) { h.mu.Lock() defer h.mu.Unlock() delete(h.clients, c) close(c.channel) } func (h *sseHub) broadcast(eventType string, payload any) { env := map[string]any{ "type": eventType, "payload": payload, "timestamp": time.Now().UTC().Format(time.RFC3339Nano), } data, err := json.Marshal(env) if err != nil { return } h.mu.RLock() defer h.mu.RUnlock() for c := range h.clients { if c.filter.RouterID != "" { if rid, ok := payload.(map[string]any)["router_id"].(string); ok && rid != c.filter.RouterID { continue } } if c.filter.EventType != "" && c.filter.EventType != eventType { continue } select { case c.channel <- data: default: // drop if client too slow } } } func handleSSEStream(w http.ResponseWriter, r *http.Request) { // Auth via Bearer JWT or legacy token token := r.Header.Get("Authorization") if t := r.URL.Query().Get("token"); t != "" { token = "Bearer " + t } token = trimBearer(token) if token == "" { http.Error(w, "missing token", http.StatusUnauthorized) return } // Accept either a JWT or the legacy shared TOKEN if _, err := ParseJWT(token); err != nil && token != cfg.Token { http.Error(w, "invalid token", http.StatusUnauthorized) return } flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) return } 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") w.WriteHeader(http.StatusOK) flusher.Flush() client := &sseClient{ channel: make(chan []byte, 64), filter: sseFilter{RouterID: r.URL.Query().Get("router_id"), EventType: r.URL.Query().Get("event_type")}, lastPing: time.Now(), } hub.add(client) defer hub.remove(client) // Initial comment so curl shows connection is live _, _ = fmt.Fprintf(w, ": connected\n\n") flusher.Flush() pingTicker := time.NewTicker(15 * time.Second) defer pingTicker.Stop() for { select { case <-r.Context().Done(): return case data := <-client.channel: if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil { return } flusher.Flush() case <-pingTicker.C: if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil { return } flusher.Flush() } } } func trimBearer(s string) string { if len(s) > 7 && s[:7] == "Bearer " { return s[7:] } return s }