// Package web serves the laundry status API, phone-first page, and embedded // home-screen manifest and icons. package web import ( "context" _ "embed" "encoding/json" "errors" "net/http" "time" "mqtt_washingmachine/internal/status" ) //go:embed static/index.html var indexHTML []byte //go:embed static/manifest.webmanifest var manifest []byte //go:embed static/icon.svg var iconSVG []byte //go:embed static/icon-192.png var icon192 []byte //go:embed static/icon-512.png var icon512 []byte //go:embed static/icon-maskable-512.png var iconMaskable512 []byte //go:embed static/apple-touch-icon.png var appleTouchIcon []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 embedded home-screen manifest and icons require cache revalidation. // 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) }) for _, asset := range []struct { path, contentType string data []byte }{ {"/manifest.webmanifest", "application/manifest+json", manifest}, {"/icons/icon.svg", "image/svg+xml", iconSVG}, {"/icons/icon-192.png", "image/png", icon192}, {"/icons/icon-512.png", "image/png", icon512}, {"/icons/icon-maskable-512.png", "image/png", iconMaskable512}, {"/apple-touch-icon.png", "image/png", appleTouchIcon}, } { mux.HandleFunc("GET "+asset.path, func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", asset.contentType) w.Header().Set("Cache-Control", "no-cache") w.Write(asset.data) }) } 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 } } } }