package httpapi import ( "encoding/json" "fmt" "io" "log/slog" "net/http" "time" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/hub" "commilitia.net/cdrop/internal/jwtauth" ) const ( keepaliveInterval = 15 * time.Second probeFrame = ": hi\n\n" ) // handleEvents serves the long-lived SSE channel mandated by brief ยง5. // Sets X-Accel-Buffering / Cache-Control / flush to defeat reverse-proxy buffering. // Sends a probe frame immediately so the client can detect first-byte latency. func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } claims, ok := jwtauth.ClaimsFromContext(r.Context()) if !ok { http.Error(w, "missing claims", http.StatusUnauthorized) return } deviceName, _ := jwtauth.DeviceNameFromContext(r.Context()) if deviceName == "" { http.Error(w, "missing device", http.StatusBadRequest) return } deviceType := jwtauth.DeviceTypeFromContext(r.Context()) w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") w.Header().Set("Connection", "keep-alive") w.WriteHeader(http.StatusOK) if _, err := io.WriteString(w, probeFrame); err != nil { return } flusher.Flush() client := s.hub.Connect(r.Context(), claims.UserID, deviceName, deviceType) defer s.hub.Disconnect(client) slog.Debug("sse connected", "user", claims.UserID, "device", deviceName) ping := time.NewTicker(keepaliveInterval) defer ping.Stop() for { select { case ev, ok := <-client.Events(): if !ok { return } if err := writeSSEEvent(w, ev); err != nil { return } flusher.Flush() case <-ping.C: if _, err := io.WriteString(w, "event: ping\ndata: {}\n\n"); err != nil { return } flusher.Flush() // Refresh last_seen so a connection-only client (no other API calls) // keeps its managed device row alive past the sweeper TTL while connected. if claims.DeviceID != "" { _ = s.queries.TouchDevice(r.Context(), db.TouchDeviceParams{ LastSeen: time.Now().Unix(), Tier: claims.Tier(), DeviceID: claims.DeviceID, UserID: claims.UserID, }) } case <-r.Context().Done(): return } } } func writeSSEEvent(w io.Writer, ev hub.Event) error { payload, err := json.Marshal(ev.Data) if err != nil { return fmt.Errorf("marshal sse data: %w", err) } // SSE frame: "event: \ndata: \n\n". // JSON is single-line via json.Marshal so no `data:` continuation needed. _, err = fmt.Fprintf(w, "event: %s\ndata: %s\n\n", ev.Type, payload) return err }