package web import ( "context" "embed" "encoding/json" "fmt" "io/fs" "log" "log/slog" "net/http" "net/url" "os" "strings" "sync" "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 events <-chan derby.Event clients map[chan string]bool clientsMux sync.Mutex port int server *http.Server shutdown chan struct{} logger *slog.Logger } // NewServer creates a new web server func NewServer(clock *derby.DerbyClock, events <-chan derby.Event, port int) (*Server, error) { // Create logger logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelDebug, })) // Create server s := &Server{ router: chi.NewRouter(), clock: clock, events: events, clients: make(map[chan string]bool), clientsMux: sync.Mutex{}, port: port, shutdown: make(chan struct{}), logger: logger, } // Set up routes s.routes() // Start event forwarder go s.forwardEvents() return s, nil } // routes sets up the routes for the server func (s *Server) routes() { // Middleware s.router.Use(middleware.Logger) s.router.Use(middleware.Recoverer) // Create a file server for static files staticFS, err := fs.Sub(content, "static") if err != nil { log.Fatal(err) } // Set up static file server with proper MIME types fileServer := http.FileServer(http.FS(staticFS)) s.router.Get("/static/*", func(w http.ResponseWriter, r *http.Request) { // Set correct MIME types based on file extension path := r.URL.Path if strings.HasSuffix(path, ".js") { w.Header().Set("Content-Type", "application/javascript") } else if strings.HasSuffix(path, ".css") { w.Header().Set("Content-Type", "text/css") } // The key fix: properly handle the path // Remove the /static prefix for the filesystem lookup pathWithoutPrefix := strings.TrimPrefix(r.URL.Path, "/static") // Create a new request with the modified path r2 := new(http.Request) *r2 = *r r2.URL = new(url.URL) *r2.URL = *r.URL r2.URL.Path = pathWithoutPrefix fileServer.ServeHTTP(w, r2) }) // API routes s.router.Route("/api", func(r chi.Router) { r.Post("/reset", s.handleReset()) r.Post("/force-end", s.handleForceEnd()) r.Get("/events", s.handleEvents()) }) // Main page s.router.Get("/", s.handleIndex()) } // Start starts the web server func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.port) s.server = &http.Server{ Addr: addr, Handler: s.router, } // Start server in a goroutine go func() { s.logger.Info("Web server starting", "port", s.port) if err := s.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { s.logger.Error("HTTP server error", "error", err) } }() return nil } // Stop gracefully shuts down the server func (s *Server) Stop() error { // Signal event forwarder to stop close(s.shutdown) // Close all client connections s.clientsMux.Lock() for clientChan := range s.clients { delete(s.clients, clientChan) close(clientChan) } s.clientsMux.Unlock() // Create a context with timeout for shutdown ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() s.logger.Info("Shutting down web server") // Shutdown the HTTP server if s.server != nil { return s.server.Shutdown(ctx) } return nil } // forwardEvents forwards derby events to SSE clients func (s *Server) forwardEvents() { for { select { case event, ok := <-s.events: if !ok { return } // Process the event and send to clients s.broadcastEvent(event) case <-s.shutdown: return } } } // broadcastEvent sends an event to all connected clients func (s *Server) broadcastEvent(event derby.Event) { var message string switch event.Type { case derby.EventRaceStart: s.logger.Info("Broadcasting race start event") statusMsg := struct { Status string `json:"status"` }{ Status: "running", } statusJSON, _ := json.Marshal(statusMsg) message = fmt.Sprintf("event: status\ndata: %s", statusJSON) case derby.EventLaneFinish: s.logger.Info("Broadcasting lane finish event", "lane", event.Result.Lane, "time", event.Result.Time, "place", event.Result.FinishPlace) // Create a message for lane finish laneData := struct { Lane int `json:"lane"` Time float64 `json:"time"` Place int `json:"place"` }{ Lane: event.Result.Lane, Time: event.Result.Time, Place: event.Result.FinishPlace, } laneJSON, _ := json.Marshal(laneData) message = fmt.Sprintf("event: lane-finish\ndata: %s", laneJSON) case derby.EventRaceComplete: s.logger.Info("Broadcasting race complete event") statusMsg := struct { Status string `json:"status"` }{ Status: "finished", } statusJSON, _ := json.Marshal(statusMsg) message = fmt.Sprintf("event: status\ndata: %s", statusJSON) } if message == "" { return } // Send to all clients s.clientsMux.Lock() clientCount := len(s.clients) sentCount := 0 for clientChan := range s.clients { select { case clientChan <- message: sentCount++ default: s.logger.Warn("Client channel is full, event not sent") } } s.logger.Info("Event broadcast complete", "sentCount", sentCount, "totalClients", clientCount, "eventType", event.Type) s.clientsMux.Unlock() } // 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 SSE events 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", "*") // Debug message to confirm connection fmt.Fprintf(w, "event: debug\ndata: {\"message\":\"SSE connection established\"}\n\n") // Flush headers to ensure they're sent to the client if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } else { http.Error(w, "Streaming unsupported!", http.StatusInternalServerError) return } // Create a channel for this client clientChan := make(chan string, 10) // Add client to map with mutex protection s.clientsMux.Lock() s.clients[clientChan] = true clientCount := len(s.clients) s.clientsMux.Unlock() s.logger.Info("New client connected", "clientIP", r.RemoteAddr, "totalClients", clientCount) // Remove client when connection is closed defer func() { s.clientsMux.Lock() delete(s.clients, clientChan) remainingClients := len(s.clients) s.clientsMux.Unlock() close(clientChan) s.logger.Info("Client disconnected", "clientIP", r.RemoteAddr, "remainingClients", remainingClients) }() // Keep connection open and send events as they arrive for { select { case msg, ok := <-clientChan: if !ok { return } fmt.Fprintf(w, "%s\n\n", msg) if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } case <-r.Context().Done(): return } } } }