// Copyright (c) 2026, Pim van Pelt package main import ( "encoding/json" "fmt" "io/fs" "log/slog" "net/http" "strings" "time" buildinfo "git.ipng.ch/ipng/vpp-maglev/cmd" ) func registerHandlers(mux *http.ServeMux, clients []*maglevClient, broker *Broker) { byName := make(map[string]*maglevClient, len(clients)) for _, c := range clients { byName[c.name] = c } mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte("ok\n")) }) mux.HandleFunc("/view/api/version", func(w http.ResponseWriter, _ *http.Request) { writeJSON(w, VersionInfo{ Version: buildinfo.Version(), Commit: buildinfo.Commit(), Date: buildinfo.Date(), }) }) mux.HandleFunc("/view/api/maglevds", func(w http.ResponseWriter, _ *http.Request) { infos := make([]MaglevdInfo, 0, len(clients)) for _, c := range clients { infos = append(infos, c.Info()) } writeJSON(w, infos) }) mux.HandleFunc("/view/api/state", func(w http.ResponseWriter, _ *http.Request) { out := make([]*StateSnapshot, 0, len(clients)) for _, c := range clients { out = append(out, c.Snapshot()) } writeJSON(w, out) }) mux.HandleFunc("/view/api/state/", func(w http.ResponseWriter, r *http.Request) { name := strings.TrimPrefix(r.URL.Path, "/view/api/state/") c, ok := byName[name] if !ok { http.NotFound(w, r) return } writeJSON(w, c.Snapshot()) }) mux.HandleFunc("/view/api/events", func(w http.ResponseWriter, r *http.Request) { serveSSE(w, r, broker) }) mux.HandleFunc("/admin/", func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "admin mode not implemented", http.StatusNotImplemented) }) // Static SPA served from the embedded dist fs, mounted under /view/. staticFS, err := fs.Sub(webFS, "web/dist") if err != nil { slog.Error("embed-subfs", "err", err) return } fileServer := http.FileServer(http.FS(staticFS)) mux.Handle("/view/", http.StripPrefix("/view/", fileServer)) mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/" { http.Redirect(w, r, "/view/", http.StatusFound) return } http.NotFound(w, r) }) } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json") enc := json.NewEncoder(w) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { slog.Error("json-encode", "err", err) } } // serveSSE handles the long-lived /view/api/events stream. The operational // requirements (retry hint, heartbeat, flush-after-write, X-Accel-Buffering, // context-done teardown) are documented in PLAN_FRONTEND.md §SSE operational // requirements. func serveSSE(w http.ResponseWriter, r *http.Request, broker *Broker) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) return } h := w.Header() h.Set("Content-Type", "text/event-stream") h.Set("Cache-Control", "no-cache") h.Set("Connection", "keep-alive") h.Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) // Reconnect hint: EventSource default is 3–5s; 2s feels livelier. fmt.Fprintf(w, "retry: 2000\n\n") flusher.Flush() result := broker.Subscribe(r.Header.Get("Last-Event-ID")) defer broker.Unsubscribe(result.Channel) if result.NeedResync { // No id: line — the browser keeps whatever Last-Event-ID it had, // so subsequent reconnects compare against a real event ID. fmt.Fprintf(w, "event: resync\ndata: {}\n\n") flusher.Flush() } for _, ev := range result.ReplayEvents { if err := writeEvent(w, ev); err != nil { return } flusher.Flush() } heartbeat := time.NewTicker(15 * time.Second) defer heartbeat.Stop() for { select { case <-r.Context().Done(): return case ev, ok := <-result.Channel: if !ok { return } if err := writeEvent(w, ev); err != nil { return } flusher.Flush() case <-heartbeat.C: if _, err := fmt.Fprintf(w, ": ping\n\n"); err != nil { return } flusher.Flush() } } } func writeEvent(w http.ResponseWriter, ev deliveredEvent) error { body, err := json.Marshal(ev.Event) if err != nil { return err } _, err = fmt.Fprintf(w, "id: %s\ndata: %s\n\n", ev.ID, body) return err }