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