Files
elmprodvpn/selective-vpn-api/app/events_handlers.go
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

112 lines
2.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// ---------------------------------------------------------------------
// events (SSE)
// ---------------------------------------------------------------------
// EN: Server-Sent Events transport with replay support via Last-Event-ID/since,
// EN: heartbeat pings, and periodic polling of the in-memory event buffer.
// RU: Транспорт Server-Sent Events с поддержкой реплея через Last-Event-ID/since,
// RU: heartbeat-пингами и периодическим опросом in-memory буфера событий.
// ---------------------------------------------------------------------
// SSE helpers
// ---------------------------------------------------------------------
func parseSinceID(r *http.Request) int64 {
sinceStr := strings.TrimSpace(r.URL.Query().Get("since"))
if sinceStr == "" {
sinceStr = strings.TrimSpace(r.Header.Get("Last-Event-ID"))
}
if sinceStr == "" {
return 0
}
if v, err := strconv.ParseInt(sinceStr, 10, 64); err == nil && v >= 0 {
return v
}
return 0
}
// ---------------------------------------------------------------------
// SSE stream handler
// ---------------------------------------------------------------------
func handleEventsStream(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
ctx := r.Context()
since := parseSinceID(r)
send := func(ev Event) error {
payload, err := json.Marshal(ev)
if err != nil {
return err
}
if _, err := fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Kind, string(payload)); err != nil {
return err
}
flusher.Flush()
return nil
}
// initial replay
for _, ev := range events.since(since) {
if err := send(ev); err != nil {
return
}
since = ev.ID
}
// polling loop; lightweight for localhost
pollEvery := 500 * time.Millisecond
heartbeat := time.Duration(envInt("SVPN_EVENTS_HEARTBEAT_SEC", defaultHeartbeatSeconds)) * time.Second
pollTicker := time.NewTicker(pollEvery)
pingTicker := time.NewTicker(heartbeat)
defer pollTicker.Stop()
defer pingTicker.Stop()
for {
select {
case <-ctx.Done():
return
case <-pingTicker.C:
_, _ = io.WriteString(w, ": ping\n\n")
flusher.Flush()
case <-pollTicker.C:
evs := events.since(since)
if len(evs) == 0 {
continue
}
for _, ev := range evs {
if err := send(ev); err != nil {
return
}
since = ev.ID
}
}
}
}