// Package web serves the laundry status API and embedded phone-first page. package web import ( "context" _ "embed" "encoding/json" "errors" "net/http" "time" "mqtt_washingmachine/internal/status" ) //go:embed static/index.html var indexHTML []byte var keepAliveInterval = 25 * time.Second // EventSource provides coalesced refresh notifications and subscriber cleanup. type EventSource interface { Subscribe() (<-chan struct{}, func()) } // NewHandler fetches a fresh snapshot for each API request. Disabling caching // keeps reloads, push refreshes, and polling from showing a previous state. // The server must cancel request contexts on shutdown to close active streams. func NewHandler(snapshot func() status.Result, events EventSource) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.Write(indexHTML) }) mux.HandleFunc("GET /api/status", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") w.Header().Set("Cache-Control", "no-store") json.NewEncoder(w).Encode(snapshot()) }) mux.HandleFunc("GET /api/events", func(w http.ResponseWriter, r *http.Request) { serveEvents(w, r, events) }) return mux } func serveEvents(w http.ResponseWriter, r *http.Request, source EventSource) { if _, ok := w.(http.Flusher); !ok { http.Error(w, "Streaming is not supported", http.StatusInternalServerError) return } controller := http.NewResponseController(w) // Streaming outlives the server's ordinary request write timeout. Test // recorders may support flushing without implementing write deadlines. if err := controller.SetWriteDeadline(time.Time{}); err != nil && !errors.Is(err, http.ErrNotSupported) { http.Error(w, "Cannot start stream", http.StatusInternalServerError) return } // Cancellation must also interrupt a write blocked by an unresponsive // client. Wait for this callback before returning so it cannot use the // ResponseWriter after the handler finishes. interrupted := make(chan struct{}) stop := context.AfterFunc(r.Context(), func() { controller.SetWriteDeadline(time.Now()) close(interrupted) }) defer func() { if !stop() { <-interrupted // Let net/http finish an idle stream's chunked response normally. // A failed blocked write already carries its error in the writer. controller.SetWriteDeadline(time.Time{}) } }() events, cancel := source.Subscribe() defer cancel() w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-store") write := func(frame string) bool { if _, err := w.Write([]byte(frame)); err != nil { return false } return controller.Flush() == nil } const update = "event: update\ndata: {}\n\n" if !write(update) { return } ticker := time.NewTicker(keepAliveInterval) defer ticker.Stop() for { select { case <-r.Context().Done(): return case <-events: if !write(update) { return } case <-ticker.C: if !write(": keep-alive\n\n") { return } } } }