You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

141 lines
4.2 KiB

// 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
}
}
}
}