You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

363 lines
8.5 KiB

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
}
}
}
}