From 6d99631a4be7d15acd52fe64b8d0a684b683d4cb Mon Sep 17 00:00:00 2001 From: Jan Svabenik Date: Thu, 19 Mar 2026 07:26:23 +0100 Subject: [PATCH] refactor: split HTTP and websocket handlers from main --- cmd/sdrd/http_handlers.go | 262 +++++++++++++++++++++++++++++++ cmd/sdrd/main.go | 315 +------------------------------------- cmd/sdrd/ws_handlers.go | 66 ++++++++ 3 files changed, 332 insertions(+), 311 deletions(-) create mode 100644 cmd/sdrd/http_handlers.go create mode 100644 cmd/sdrd/ws_handlers.go diff --git a/cmd/sdrd/http_handlers.go b/cmd/sdrd/http_handlers.go new file mode 100644 index 0000000..1e338a8 --- /dev/null +++ b/cmd/sdrd/http_handlers.go @@ -0,0 +1,262 @@ +package main + +import ( + "context" + "encoding/json" + "log" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "sdr-visual-suite/internal/config" + "sdr-visual-suite/internal/detector" + "sdr-visual-suite/internal/events" + fftutil "sdr-visual-suite/internal/fft" + "sdr-visual-suite/internal/recorder" + "sdr-visual-suite/internal/runtime" +) + +func registerAPIHandlers(mux *http.ServeMux, cfgPath string, cfgManager *runtime.Manager, srcMgr *sourceManager, dspUpdates chan dspUpdate, gpuState *gpuStatus, recMgr *recorder.Manager, sigSnap *signalSnapshot, eventMu *sync.RWMutex) { + mux.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + _ = json.NewEncoder(w).Encode(cfgManager.Snapshot()) + case http.MethodPost: + var update runtime.ConfigUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + prev := cfgManager.Snapshot() + next, err := cfgManager.ApplyConfig(update) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz + if sourceChanged { + if err := srcMgr.ApplyConfig(next); err != nil { + cfgManager.Replace(prev) + http.Error(w, "failed to apply source config", http.StatusInternalServerError) + return + } + } + if err := config.Save(cfgPath, next); err != nil { + log.Printf("config save failed: %v", err) + } + detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || + prev.Detector.MinDurationMs != next.Detector.MinDurationMs || + prev.Detector.HoldMs != next.Detector.HoldMs || + prev.Detector.EmaAlpha != next.Detector.EmaAlpha || + prev.Detector.HysteresisDb != next.Detector.HysteresisDb || + prev.Detector.MinStableFrames != next.Detector.MinStableFrames || + prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || + prev.Detector.CFARMode != next.Detector.CFARMode || + prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || + prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || + prev.Detector.CFARRank != next.Detector.CFARRank || + prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || + prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || + prev.SampleRate != next.SampleRate || + prev.FFTSize != next.FFTSize + windowChanged := prev.FFTSize != next.FFTSize + var newDet *detector.Detector + var newWindow []float64 + if detChanged { + newDet = detector.New(next.Detector, next.SampleRate, next.FFTSize) + } + if windowChanged { + newWindow = fftutil.Hann(next.FFTSize) + } + pushDSPUpdate(dspUpdates, dspUpdate{cfg: next, det: newDet, window: newWindow, dcBlock: next.DCBlock, iqBalance: next.IQBalance, useGPUFFT: next.UseGPUFFT}) + _ = json.NewEncoder(w).Encode(next) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } + }) + + mux.HandleFunc("/api/sdr/settings", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var update runtime.SettingsUpdate + if err := json.NewDecoder(r.Body).Decode(&update); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + prev := cfgManager.Snapshot() + next, err := cfgManager.ApplySettings(update) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz { + if err := srcMgr.ApplyConfig(next); err != nil { + cfgManager.Replace(prev) + http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError) + return + } + } + if prev.DCBlock != next.DCBlock || prev.IQBalance != next.IQBalance { + pushDSPUpdate(dspUpdates, dspUpdate{cfg: next, dcBlock: next.DCBlock, iqBalance: next.IQBalance}) + } + if err := config.Save(cfgPath, next); err != nil { + log.Printf("config save failed: %v", err) + } + _ = json.NewEncoder(w).Encode(next) + }) + + mux.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(srcMgr.Stats()) + }) + mux.HandleFunc("/api/gpu", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(gpuState.snapshot()) + }) + mux.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + limit := 200 + if v := r.URL.Query().Get("limit"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + limit = parsed + } + } + var since time.Time + if v := r.URL.Query().Get("since"); v != "" { + if parsed, err := parseSince(v); err == nil { + since = parsed + } else { + http.Error(w, "invalid since", http.StatusBadRequest) + return + } + } + snap := cfgManager.Snapshot() + eventMu.RLock() + evs, err := events.ReadRecent(snap.EventPath, limit, since) + eventMu.RUnlock() + if err != nil { + http.Error(w, "failed to read events", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(evs) + }) + mux.HandleFunc("/api/signals", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if sigSnap == nil { + _ = json.NewEncoder(w).Encode([]detector.Signal{}) + return + } + _ = json.NewEncoder(w).Encode(sigSnap.get()) + }) + mux.HandleFunc("/api/decoders", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot())) + }) + mux.HandleFunc("/api/recordings", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + snap := cfgManager.Snapshot() + list, err := recorder.ListRecordings(snap.Recorder.OutputDir) + if err != nil { + http.Error(w, "failed to list recordings", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(list) + }) + mux.HandleFunc("/api/recordings/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + id := strings.TrimPrefix(r.URL.Path, "/api/recordings/") + if id == "" { + http.Error(w, "missing id", http.StatusBadRequest) + return + } + snap := cfgManager.Snapshot() + base := filepath.Clean(filepath.Join(snap.Recorder.OutputDir, id)) + if !strings.HasPrefix(base, filepath.Clean(snap.Recorder.OutputDir)) { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + if r.URL.Path == "/api/recordings/"+id+"/audio" { + http.ServeFile(w, r, filepath.Join(base, "audio.wav")) + return + } + if r.URL.Path == "/api/recordings/"+id+"/iq" { + http.ServeFile(w, r, filepath.Join(base, "signal.cf32")) + return + } + if r.URL.Path == "/api/recordings/"+id+"/decode" { + mode := r.URL.Query().Get("mode") + cmd := buildDecoderMap(cfgManager.Snapshot())[mode] + if cmd == "" { + http.Error(w, "decoder not configured", http.StatusBadRequest) + return + } + meta, err := recorder.ReadMeta(filepath.Join(base, "meta.json")) + if err != nil { + http.Error(w, "meta read failed", http.StatusInternalServerError) + return + } + audioPath := filepath.Join(base, "audio.wav") + if _, errStat := os.Stat(audioPath); errStat != nil { + audioPath = "" + } + res, err := recorder.DecodeOnDemand(cmd, filepath.Join(base, "signal.cf32"), meta.SampleRate, audioPath) + if err != nil { + http.Error(w, res.Stderr, http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(res) + return + } + http.ServeFile(w, r, filepath.Join(base, "meta.json")) + }) + mux.HandleFunc("/api/demod", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + q := r.URL.Query() + freq, _ := strconv.ParseFloat(q.Get("freq"), 64) + bw, _ := strconv.ParseFloat(q.Get("bw"), 64) + sec, _ := strconv.Atoi(q.Get("sec")) + if sec < 1 { + sec = 1 + } + if sec > 10 { + sec = 10 + } + mode := q.Get("mode") + data, _, err := recMgr.DemodLive(freq, bw, mode, sec) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "audio/wav") + _, _ = w.Write(data) + }) +} + +func newHTTPServer(addr string, webRoot string, h *hub, cfgPath string, cfgManager *runtime.Manager, srcMgr *sourceManager, dspUpdates chan dspUpdate, gpuState *gpuStatus, recMgr *recorder.Manager, sigSnap *signalSnapshot, eventMu *sync.RWMutex) *http.Server { + mux := http.NewServeMux() + registerWSHandlers(mux, h) + registerAPIHandlers(mux, cfgPath, cfgManager, srcMgr, dspUpdates, gpuState, recMgr, sigSnap, eventMu) + mux.Handle("/", http.FileServer(http.Dir(webRoot))) + return &http.Server{Addr: addr, Handler: mux} +} + +func shutdownServer(server *http.Server) { + ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second) + defer cancelTimeout() + _ = server.Shutdown(ctxTimeout) +} diff --git a/cmd/sdrd/main.go b/cmd/sdrd/main.go index 492cf2e..da803c4 100644 --- a/cmd/sdrd/main.go +++ b/cmd/sdrd/main.go @@ -2,24 +2,18 @@ package main import ( "context" - "encoding/json" "flag" "log" "net/http" "os" "os/signal" "path/filepath" - "strconv" - "strings" "sync" "syscall" "time" - "github.com/gorilla/websocket" - "sdr-visual-suite/internal/config" "sdr-visual-suite/internal/detector" - "sdr-visual-suite/internal/events" fftutil "sdr-visual-suite/internal/fft" "sdr-visual-suite/internal/fft/gpufft" "sdr-visual-suite/internal/mock" @@ -113,308 +107,7 @@ func main() { go runDSP(ctx, srcMgr, cfg, det, window, h, eventFile, eventMu, dspUpdates, gpuState, recMgr, sigSnap) - upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { - origin := r.Header.Get("Origin") - if origin == "" || origin == "null" { - return true - } - // allow same-host or any local IP - return true - }} - http.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("ws upgrade failed: %v (origin: %s)", err, r.Header.Get("Origin")) - return - } - c := &client{conn: conn, send: make(chan []byte, 32), done: make(chan struct{})} - h.add(c) - defer func() { - h.remove(c) - _ = conn.Close() - }() - conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - conn.SetPongHandler(func(string) error { - conn.SetReadDeadline(time.Now().Add(60 * time.Second)) - return nil - }) - go func() { - ping := time.NewTicker(30 * time.Second) - defer ping.Stop() - for { - select { - case msg, ok := <-c.send: - if !ok { - return - } - _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond)) - if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { - return - } - case <-ping.C: - _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) - if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { - log.Printf("ws ping error: %v", err) - return - } - } - } - }() - for { - _, _, err := conn.ReadMessage() - if err != nil { - return - } - } - }) - - http.HandleFunc("/api/config", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch r.Method { - case http.MethodGet: - _ = json.NewEncoder(w).Encode(cfgManager.Snapshot()) - case http.MethodPost: - var update runtime.ConfigUpdate - if err := json.NewDecoder(r.Body).Decode(&update); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - prev := cfgManager.Snapshot() - next, err := cfgManager.ApplyConfig(update) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - sourceChanged := prev.CenterHz != next.CenterHz || prev.SampleRate != next.SampleRate || prev.GainDb != next.GainDb || prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz - if sourceChanged { - if err := srcMgr.ApplyConfig(next); err != nil { - cfgManager.Replace(prev) - http.Error(w, "failed to apply source config", http.StatusInternalServerError) - return - } - } - if err := config.Save(cfgPath, next); err != nil { - log.Printf("config save failed: %v", err) - } - detChanged := prev.Detector.ThresholdDb != next.Detector.ThresholdDb || - prev.Detector.MinDurationMs != next.Detector.MinDurationMs || - prev.Detector.HoldMs != next.Detector.HoldMs || - prev.Detector.EmaAlpha != next.Detector.EmaAlpha || - prev.Detector.HysteresisDb != next.Detector.HysteresisDb || - prev.Detector.MinStableFrames != next.Detector.MinStableFrames || - prev.Detector.GapToleranceMs != next.Detector.GapToleranceMs || - prev.Detector.CFARMode != next.Detector.CFARMode || - prev.Detector.CFARGuardCells != next.Detector.CFARGuardCells || - prev.Detector.CFARTrainCells != next.Detector.CFARTrainCells || - prev.Detector.CFARRank != next.Detector.CFARRank || - prev.Detector.CFARScaleDb != next.Detector.CFARScaleDb || - prev.Detector.CFARWrapAround != next.Detector.CFARWrapAround || - prev.SampleRate != next.SampleRate || - prev.FFTSize != next.FFTSize - windowChanged := prev.FFTSize != next.FFTSize - var newDet *detector.Detector - var newWindow []float64 - if detChanged { - newDet = detector.New(next.Detector, next.SampleRate, next.FFTSize) - } - if windowChanged { - newWindow = fftutil.Hann(next.FFTSize) - } - pushDSPUpdate(dspUpdates, dspUpdate{ - cfg: next, - det: newDet, - window: newWindow, - dcBlock: next.DCBlock, - iqBalance: next.IQBalance, - useGPUFFT: next.UseGPUFFT, - }) - _ = json.NewEncoder(w).Encode(next) - default: - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - } - }) - - http.HandleFunc("/api/sdr/settings", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - var update runtime.SettingsUpdate - if err := json.NewDecoder(r.Body).Decode(&update); err != nil { - http.Error(w, "invalid json", http.StatusBadRequest) - return - } - prev := cfgManager.Snapshot() - next, err := cfgManager.ApplySettings(update) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - if prev.AGC != next.AGC || prev.TunerBwKHz != next.TunerBwKHz { - if err := srcMgr.ApplyConfig(next); err != nil { - cfgManager.Replace(prev) - http.Error(w, "failed to apply sdr settings", http.StatusInternalServerError) - return - } - } - if prev.DCBlock != next.DCBlock || prev.IQBalance != next.IQBalance { - pushDSPUpdate(dspUpdates, dspUpdate{ - cfg: next, - dcBlock: next.DCBlock, - iqBalance: next.IQBalance, - }) - } - if err := config.Save(cfgPath, next); err != nil { - log.Printf("config save failed: %v", err) - } - _ = json.NewEncoder(w).Encode(next) - }) - - http.HandleFunc("/api/stats", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(srcMgr.Stats()) - }) - - http.HandleFunc("/api/gpu", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(gpuState.snapshot()) - }) - - http.HandleFunc("/api/events", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - limit := 200 - if v := r.URL.Query().Get("limit"); v != "" { - if parsed, err := strconv.Atoi(v); err == nil { - limit = parsed - } - } - var since time.Time - if v := r.URL.Query().Get("since"); v != "" { - if parsed, err := parseSince(v); err == nil { - since = parsed - } else { - http.Error(w, "invalid since", http.StatusBadRequest) - return - } - } - snap := cfgManager.Snapshot() - eventMu.RLock() - evs, err := events.ReadRecent(snap.EventPath, limit, since) - eventMu.RUnlock() - if err != nil { - http.Error(w, "failed to read events", http.StatusInternalServerError) - return - } - _ = json.NewEncoder(w).Encode(evs) - }) - - http.HandleFunc("/api/signals", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - if sigSnap == nil { - _ = json.NewEncoder(w).Encode([]detector.Signal{}) - return - } - _ = json.NewEncoder(w).Encode(sigSnap.get()) - }) - - http.HandleFunc("/api/decoders", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(decoderKeys(cfgManager.Snapshot())) - }) - - http.HandleFunc("/api/recordings", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - snap := cfgManager.Snapshot() - list, err := recorder.ListRecordings(snap.Recorder.OutputDir) - if err != nil { - http.Error(w, "failed to list recordings", http.StatusInternalServerError) - return - } - _ = json.NewEncoder(w).Encode(list) - }) - - http.HandleFunc("/api/recordings/", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - id := strings.TrimPrefix(r.URL.Path, "/api/recordings/") - if id == "" { - http.Error(w, "missing id", http.StatusBadRequest) - return - } - snap := cfgManager.Snapshot() - base := filepath.Clean(filepath.Join(snap.Recorder.OutputDir, id)) - if !strings.HasPrefix(base, filepath.Clean(snap.Recorder.OutputDir)) { - http.Error(w, "invalid path", http.StatusBadRequest) - return - } - if r.URL.Path == "/api/recordings/"+id+"/audio" { - http.ServeFile(w, r, filepath.Join(base, "audio.wav")) - return - } - if r.URL.Path == "/api/recordings/"+id+"/iq" { - http.ServeFile(w, r, filepath.Join(base, "signal.cf32")) - return - } - if r.URL.Path == "/api/recordings/"+id+"/decode" { - mode := r.URL.Query().Get("mode") - cmd := buildDecoderMap(cfgManager.Snapshot())[mode] - if cmd == "" { - http.Error(w, "decoder not configured", http.StatusBadRequest) - return - } - meta, err := recorder.ReadMeta(filepath.Join(base, "meta.json")) - if err != nil { - http.Error(w, "meta read failed", http.StatusInternalServerError) - return - } - audioPath := filepath.Join(base, "audio.wav") - if _, errStat := os.Stat(audioPath); errStat != nil { - audioPath = "" - } - res, err := recorder.DecodeOnDemand(cmd, filepath.Join(base, "signal.cf32"), meta.SampleRate, audioPath) - if err != nil { - http.Error(w, res.Stderr, http.StatusInternalServerError) - return - } - _ = json.NewEncoder(w).Encode(res) - return - } - // default: meta.json - http.ServeFile(w, r, filepath.Join(base, "meta.json")) - }) - - http.HandleFunc("/api/demod", func(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - q := r.URL.Query() - freq, _ := strconv.ParseFloat(q.Get("freq"), 64) - bw, _ := strconv.ParseFloat(q.Get("bw"), 64) - sec, _ := strconv.Atoi(q.Get("sec")) - if sec < 1 { - sec = 1 - } - if sec > 10 { - sec = 10 - } - mode := q.Get("mode") - data, _, err := recMgr.DemodLive(freq, bw, mode, sec) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - w.Header().Set("Content-Type", "audio/wav") - _, _ = w.Write(data) - }) - - http.Handle("/", http.FileServer(http.Dir(cfg.WebRoot))) - - server := &http.Server{Addr: cfg.WebAddr} + server := newHTTPServer(cfg.WebAddr, cfg.WebRoot, h, cfgPath, cfgManager, srcMgr, dspUpdates, gpuState, recMgr, sigSnap, eventMu) go func() { log.Printf("web listening on %s", cfg.WebAddr) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -425,9 +118,9 @@ func main() { stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt, syscall.SIGTERM) <-stop - ctxTimeout, cancelTimeout := context.WithTimeout(context.Background(), 5*time.Second) - defer cancelTimeout() - _ = server.Shutdown(ctxTimeout) + shutdownServer(server) } + + diff --git a/cmd/sdrd/ws_handlers.go b/cmd/sdrd/ws_handlers.go new file mode 100644 index 0000000..52386a5 --- /dev/null +++ b/cmd/sdrd/ws_handlers.go @@ -0,0 +1,66 @@ +package main + +import ( + "log" + "net/http" + "time" + + "github.com/gorilla/websocket" +) + +func registerWSHandlers(mux *http.ServeMux, h *hub) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { + origin := r.Header.Get("Origin") + if origin == "" || origin == "null" { + return true + } + return true + }} + + mux.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("ws upgrade failed: %v (origin: %s)", err, r.Header.Get("Origin")) + return + } + c := &client{conn: conn, send: make(chan []byte, 32), done: make(chan struct{})} + h.add(c) + defer func() { + h.remove(c) + _ = conn.Close() + }() + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + go func() { + ping := time.NewTicker(30 * time.Second) + defer ping.Stop() + for { + select { + case msg, ok := <-c.send: + if !ok { + return + } + _ = conn.SetWriteDeadline(time.Now().Add(200 * time.Millisecond)) + if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil { + return + } + case <-ping.C: + _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + log.Printf("ws ping error: %v", err) + return + } + } + } + }() + for { + _, _, err := conn.ReadMessage() + if err != nil { + return + } + } + }) +}