package web import ( "embed" "fmt" "io/fs" "log" "net/http" "os" "path/filepath" "strings" "github.com/AmanTahiliani/box-box/internal/api" "github.com/AmanTahiliani/box-box/internal/query" "github.com/AmanTahiliani/box-box/internal/store" ) //go:embed assets var assetsFS embed.FS // Server is the box-box web companion HTTP server. type Server struct { client *api.OpenF1Client query *query.Service hub *SSEHub addr string } // NewServer creates a new Server. Call Start() to begin serving. // 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{ client: client, hub: newSSEHub(), addr: fmt.Sprintf(":%d", port), } if st != nil { s.query = query.NewService(st) } return s } // Start registers routes, launches background goroutines, and begins serving. func (s *Server) Start() error { 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) { mux := http.NewServeMux() // REST API — /api/v1/laps/comparison must be registered before /api/v1/laps // because Go's ServeMux uses longest-prefix matching. mux.HandleFunc("/api/v1/race-hub", s.handleRaceHub) mux.HandleFunc("/api/v1/seasons", s.handleSeasons) mux.HandleFunc("/api/v1/weekend", s.handleWeekend) mux.HandleFunc("/api/v1/news/article", s.handleNewsArticle) mux.HandleFunc("/api/v1/news/read", s.handleNewsRead) mux.HandleFunc("/api/v1/news", s.handleNews) 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 staticFS, err := selectStaticFS() if err != nil { 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 } } func spaFileServer(staticFS fs.FS) http.Handler { fileServer := http.FileServer(http.FS(staticFS)) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // If the path maps to a real asset file, serve it directly. if r.URL.Path != "/" { p := strings.TrimPrefix(r.URL.Path, "/") if fs.ValidPath(p) { if info, err := fs.Stat(staticFS, p); err == nil && !info.IsDir() { fileServer.ServeHTTP(w, r) return } } } // 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) }) }