2026-03-27 20:12:57 -04:00
|
|
|
package web
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"embed"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io/fs"
|
|
|
|
|
"log"
|
|
|
|
|
"net/http"
|
2026-05-25 02:04:16 -04:00
|
|
|
"os"
|
2026-05-25 02:49:31 -04:00
|
|
|
"path/filepath"
|
2026-03-27 20:12:57 -04:00
|
|
|
"strings"
|
|
|
|
|
|
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/api"
|
2026-05-25 00:49:13 -04:00
|
|
|
"github.com/AmanTahiliani/box-box/internal/query"
|
|
|
|
|
"github.com/AmanTahiliani/box-box/internal/store"
|
2026-03-27 20:12:57 -04:00
|
|
|
)
|
|
|
|
|
|
|
|
|
|
//go:embed assets
|
|
|
|
|
var assetsFS embed.FS
|
|
|
|
|
|
|
|
|
|
// Server is the box-box web companion HTTP server.
|
|
|
|
|
type Server struct {
|
|
|
|
|
client *api.OpenF1Client
|
2026-05-25 00:49:13 -04:00
|
|
|
query *query.Service
|
2026-03-27 20:12:57 -04:00
|
|
|
hub *SSEHub
|
|
|
|
|
addr string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewServer creates a new Server. Call Start() to begin serving.
|
2026-05-25 00:49:13 -04:00
|
|
|
// When st is non-nil, local-first read models are available from the domain DB.
|
|
|
|
|
func NewServer(client *api.OpenF1Client, port int, st *store.Store) *Server {
|
|
|
|
|
s := &Server{
|
2026-03-27 20:12:57 -04:00
|
|
|
client: client,
|
|
|
|
|
hub: newSSEHub(),
|
|
|
|
|
addr: fmt.Sprintf(":%d", port),
|
|
|
|
|
}
|
2026-05-25 00:49:13 -04:00
|
|
|
if st != nil {
|
|
|
|
|
s.query = query.NewService(st)
|
|
|
|
|
}
|
|
|
|
|
return s
|
2026-03-27 20:12:57 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start registers routes, launches background goroutines, and begins serving.
|
|
|
|
|
func (s *Server) Start() error {
|
2026-05-25 02:49:31 -04:00
|
|
|
handler, err := s.routes()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start background goroutines.
|
|
|
|
|
go s.hub.run()
|
|
|
|
|
if os.Getenv("BOXBOX_DISABLE_LIVE") != "1" {
|
|
|
|
|
go s.runLiveFeeds()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return http.ListenAndServe(s.addr, withCORS(withLogging(handler)))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (s *Server) routes() (http.Handler, error) {
|
2026-03-27 20:12:57 -04:00
|
|
|
mux := http.NewServeMux()
|
|
|
|
|
|
|
|
|
|
// REST API — /api/v1/laps/comparison must be registered before /api/v1/laps
|
|
|
|
|
// because Go's ServeMux uses longest-prefix matching.
|
2026-05-25 00:49:13 -04:00
|
|
|
mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub)
|
2026-05-25 02:25:06 -04:00
|
|
|
mux.HandleFunc("/api/v1/seasons", s.handleSeasons)
|
|
|
|
|
mux.HandleFunc("/api/v1/weekend", s.handleWeekend)
|
2026-05-25 15:16:00 -04:00
|
|
|
mux.HandleFunc("/api/v1/news/article", s.handleNewsArticle)
|
|
|
|
|
mux.HandleFunc("/api/v1/news/read", s.handleNewsRead)
|
2026-05-25 12:02:18 -04:00
|
|
|
mux.HandleFunc("/api/v1/news", s.handleNews)
|
2026-03-27 20:12:57 -04:00
|
|
|
mux.HandleFunc("/api/v1/meetings", s.handleMeetings)
|
|
|
|
|
mux.HandleFunc("/api/v1/sessions", s.handleSessions)
|
|
|
|
|
mux.HandleFunc("/api/v1/drivers", s.handleDrivers)
|
|
|
|
|
mux.HandleFunc("/api/v1/results", s.handleResults)
|
|
|
|
|
mux.HandleFunc("/api/v1/grid", s.handleGrid)
|
|
|
|
|
mux.HandleFunc("/api/v1/laps/comparison", s.handleLapsComparison)
|
|
|
|
|
mux.HandleFunc("/api/v1/laps", s.handleLaps)
|
|
|
|
|
mux.HandleFunc("/api/v1/weather", s.handleWeather)
|
|
|
|
|
mux.HandleFunc("/api/v1/race-control", s.handleRaceControl)
|
|
|
|
|
mux.HandleFunc("/api/v1/telemetry", s.handleTelemetry)
|
|
|
|
|
mux.HandleFunc("/api/v1/overtakes", s.handleOvertakes)
|
|
|
|
|
mux.HandleFunc("/api/v1/team-radio", s.handleTeamRadio)
|
|
|
|
|
mux.HandleFunc("/api/v1/championship/drivers", s.handleChampionshipDrivers)
|
|
|
|
|
mux.HandleFunc("/api/v1/championship/teams", s.handleChampionshipTeams)
|
|
|
|
|
mux.HandleFunc("/api/v1/track-outline", s.handleTrackOutline)
|
|
|
|
|
mux.HandleFunc("/api/v1/strategy", s.handleStrategy)
|
|
|
|
|
mux.HandleFunc("/api/v1/live/state", s.handleLiveState)
|
|
|
|
|
mux.HandleFunc("/api/v1/live/stream", s.handleSSEStream)
|
|
|
|
|
|
|
|
|
|
// Static files + SPA catchall
|
2026-05-25 02:49:31 -04:00
|
|
|
staticFS, err := selectStaticFS()
|
2026-03-27 20:12:57 -04:00
|
|
|
if err != nil {
|
2026-05-25 02:49:31 -04:00
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
mux.Handle("/", spaFileServer(staticFS))
|
|
|
|
|
|
|
|
|
|
return mux, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func selectStaticFS() (fs.FS, error) {
|
|
|
|
|
if dist, ok := findFrontendDist("."); ok {
|
|
|
|
|
return os.DirFS(dist), nil
|
|
|
|
|
}
|
|
|
|
|
return fs.Sub(assetsFS, "assets")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func findFrontendDist(start string) (string, bool) {
|
|
|
|
|
dir, err := filepath.Abs(start)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for {
|
|
|
|
|
dist := filepath.Join(dir, "frontend", "dist")
|
|
|
|
|
index := filepath.Join(dist, "index.html")
|
|
|
|
|
if info, err := os.Stat(index); err == nil && !info.IsDir() {
|
|
|
|
|
return dist, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
parent := filepath.Dir(dir)
|
|
|
|
|
if parent == dir {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
dir = parent
|
2026-03-27 20:12:57 -04:00
|
|
|
}
|
2026-05-25 02:49:31 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func spaFileServer(staticFS fs.FS) http.Handler {
|
|
|
|
|
fileServer := http.FileServer(http.FS(staticFS))
|
|
|
|
|
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
2026-03-27 20:12:57 -04:00
|
|
|
// If the path maps to a real asset file, serve it directly.
|
|
|
|
|
if r.URL.Path != "/" {
|
|
|
|
|
p := strings.TrimPrefix(r.URL.Path, "/")
|
2026-05-25 02:49:31 -04:00
|
|
|
if fs.ValidPath(p) {
|
|
|
|
|
if info, err := fs.Stat(staticFS, p); err == nil && !info.IsDir() {
|
|
|
|
|
fileServer.ServeHTTP(w, r)
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-03-27 20:12:57 -04:00
|
|
|
}
|
|
|
|
|
}
|
2026-05-25 02:49:31 -04:00
|
|
|
|
2026-03-27 20:12:57 -04:00
|
|
|
// SPA catchall: all unknown paths serve index.html.
|
|
|
|
|
r2 := *r
|
|
|
|
|
r2.URL.Path = "/"
|
|
|
|
|
fileServer.ServeHTTP(w, &r2)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// withCORS adds permissive CORS headers (localhost use only).
|
|
|
|
|
func withCORS(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS")
|
|
|
|
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
|
|
|
|
|
if r.Method == http.MethodOptions {
|
|
|
|
|
w.WriteHeader(http.StatusNoContent)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// withLogging logs each request to stderr.
|
|
|
|
|
func withLogging(next http.Handler) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
log.Printf("web: %s %s", r.Method, r.URL.RequestURI())
|
|
|
|
|
next.ServeHTTP(w, r)
|
|
|
|
|
})
|
|
|
|
|
}
|