package web import ( "embed" "fmt" "io/fs" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "track-gopher/derby" "track-gopher/web/templates" ) //go:embed static var content embed.FS // Server represents the web server for the derby clock type Server struct { router *chi.Mux clock *derby.DerbyClock raceEvents chan derby.Event clients map[chan string]bool port int } // NewServer creates a new web server func NewServer(clock *derby.DerbyClock, port int) (*Server, error) { // Create server s := &Server{ router: chi.NewRouter(), clock: clock, raceEvents: make(chan derby.Event, 10), clients: make(map[chan string]bool), port: port, } // Set up routes s.routes() // Start event forwarder go s.forwardEvents() return s, nil } // routes sets up the HTTP routes func (s *Server) routes() { // Middleware s.router.Use(middleware.Logger) s.router.Use(middleware.Recoverer) s.router.Use(middleware.Timeout(10 * time.Second)) // Static files staticFS, _ := fs.Sub(content, "static") s.router.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) // Pages s.router.Get("/", s.handleIndex()) // API endpoints s.router.Post("/api/reset", s.handleReset()) s.router.Post("/api/force-end", s.handleForceEnd()) s.router.Get("/api/status", s.handleStatus()) s.router.Get("/api/events", s.handleEvents()) } // Start starts the web server func (s *Server) Start() error { fmt.Printf("Starting web server on port %d...\n", s.port) return http.ListenAndServe(fmt.Sprintf(":%d", s.port), s.router) } // forwardEvents forwards derby clock events to the web clients func (s *Server) forwardEvents() { for event := range s.clock.Events() { // Store the event for new clients s.raceEvents <- event // Create the SSE message based on the event type var message string switch event.Type { case derby.EventRaceStart: message = "event: race-start\ndata: {\"status\": \"running\"}\n\n" case derby.EventLaneFinish: result := event.Result message = fmt.Sprintf("event: lane-finish\ndata: {\"lane\": %d, \"place\": %d, \"time\": %.4f}\n\n", result.Lane, result.FinishPlace, result.Time) case derby.EventRaceComplete: message = "event: race-complete\ndata: {\"status\": \"finished\"}\n\n" } // Send to all connected clients for clientChan := range s.clients { select { case clientChan <- message: // Message sent successfully default: // Client is not receiving, remove it delete(s.clients, clientChan) close(clientChan) } } } } // handleIndex handles the index page func (s *Server) handleIndex() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { templates.Index().Render(r.Context(), w) } } // handleReset handles the reset API endpoint func (s *Server) handleReset() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := s.clock.Reset(); err != nil { http.Error(w, fmt.Sprintf("Failed to reset clock: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status": "reset"}`)) } } // handleForceEnd handles the force end API endpoint func (s *Server) handleForceEnd() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { if err := s.clock.ForceEnd(); err != nil { http.Error(w, fmt.Sprintf("Failed to force end race: %v", err), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"status": "forced"}`)) } } // handleStatus handles the status API endpoint func (s *Server) handleStatus() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { status := s.clock.Status() var statusStr string switch status { case derby.StatusIdle: statusStr = "idle" case derby.StatusRunning: statusStr = "running" case derby.StatusFinished: statusStr = "finished" } w.Header().Set("Content-Type", "application/json") w.Write([]byte(fmt.Sprintf(`{"status": "%s"}`, statusStr))) } } // handleEvents handles the SSE events endpoint func (s *Server) handleEvents() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { // Set headers for SSE w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("Access-Control-Allow-Origin", "*") // Create a channel for this client clientChan := make(chan string, 10) s.clients[clientChan] = true // Clean up when the client disconnects defer func() { delete(s.clients, clientChan) close(clientChan) }() // Send initial status status := s.clock.Status() var statusStr string switch status { case derby.StatusIdle: statusStr = "idle" case derby.StatusRunning: statusStr = "running" case derby.StatusFinished: statusStr = "finished" } fmt.Fprintf(w, "event: status\ndata: {\"status\": \"%s\"}\n\n", statusStr) w.(http.Flusher).Flush() // Keep the connection open for { select { case message, ok := <-clientChan: if !ok { return } // Send the message to the client fmt.Fprint(w, message) w.(http.Flusher).Flush() case <-r.Context().Done(): // Client disconnected return } } } }