felt/internal/server/server.go
Mikkel Georgsen 16caa12d64 feat(01-01): implement core infrastructure — NATS, LibSQL, WebSocket hub, HTTP server
- Embedded NATS server with JetStream (sync_interval=always per Jepsen 2025)
- AUDIT and STATE JetStream streams for tournament event durability
- NATS publisher with UUID validation to prevent subject injection
- WebSocket hub with JWT auth (query param), tournament-scoped broadcasting
- Origin validation and slow-consumer message dropping
- chi HTTP router with middleware (logger, recoverer, request ID, CORS, body limits)
- Server timeouts: ReadHeader 10s, Read 30s, Write 60s, Idle 120s, MaxHeader 1MB
- MaxBytesReader middleware for request body limits (1MB default)
- JWT auth middleware with HMAC-SHA256 validation
- Role-based access control (admin > floor > viewer)
- Health endpoint reporting all subsystem status (DB, NATS, WebSocket)
- SvelteKit SPA served via go:embed with fallback routing
- Signal-driven graceful shutdown in reverse startup order
- 9 integration tests covering all verification criteria

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:42:42 +01:00

207 lines
5.2 KiB
Go

// Package server provides the HTTP server for the Felt tournament engine.
// It configures chi router with middleware, defines route groups, and serves
// the SvelteKit SPA via go:embed.
package server
import (
"context"
"database/sql"
"encoding/json"
"log"
"net/http"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/felt-app/felt/frontend"
"github.com/felt-app/felt/internal/server/middleware"
"github.com/felt-app/felt/internal/server/ws"
natsserver "github.com/nats-io/nats-server/v2/server"
)
// Config holds server configuration.
type Config struct {
Addr string
SigningKey []byte
DevMode bool
}
// Server wraps the HTTP server with all dependencies.
type Server struct {
httpServer *http.Server
hub *ws.Hub
db *sql.DB
nats *natsserver.Server
}
// New creates a new HTTP server with all routes and middleware configured.
func New(cfg Config, db *sql.DB, nats *natsserver.Server, hub *ws.Hub) *Server {
r := chi.NewRouter()
// Global middleware
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
// Request body size limit: 1MB default
r.Use(middleware.MaxBytesReader(1 << 20)) // 1MB
// CORS: permissive for development
r.Use(corsMiddleware(cfg.DevMode))
s := &Server{
hub: hub,
db: db,
nats: nats,
}
// API routes
r.Route("/api/v1", func(r chi.Router) {
// Public endpoints (no auth required)
r.Get("/health", s.handleHealth)
// Protected endpoints
r.Group(func(r chi.Router) {
r.Use(middleware.JWTAuth(cfg.SigningKey))
// Stub endpoints — return 200 for now
r.Get("/tournaments", stubHandler("tournaments"))
r.Get("/players", stubHandler("players"))
})
})
// WebSocket endpoint
r.Get("/ws", hub.HandleConnect)
// SvelteKit SPA fallback (must be last)
r.Handle("/*", frontend.Handler())
s.httpServer = &http.Server{
Addr: cfg.Addr,
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 60 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20, // 1MB
}
return s
}
// Handler returns the HTTP handler for use in tests.
func (s *Server) Handler() http.Handler {
return s.httpServer.Handler
}
// ListenAndServe starts the HTTP server.
func (s *Server) ListenAndServe() error {
log.Printf("server: listening on %s", s.httpServer.Addr)
return s.httpServer.ListenAndServe()
}
// Shutdown gracefully shuts down the HTTP server.
func (s *Server) Shutdown(ctx context.Context) error {
log.Printf("server: shutting down")
return s.httpServer.Shutdown(ctx)
}
// handleHealth returns the health status of all subsystems.
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
status := map[string]interface{}{
"status": "ok",
"subsystems": map[string]interface{}{
"database": s.checkDatabase(),
"nats": s.checkNATS(),
"websocket": map[string]interface{}{
"status": "ok",
"clients": s.hub.ClientCount(),
},
},
"timestamp": time.Now().UTC().Format(time.RFC3339),
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(status)
}
// checkDatabase verifies the database is operational.
func (s *Server) checkDatabase() map[string]interface{} {
var result int
err := s.db.QueryRow("SELECT 1").Scan(&result)
if err != nil {
return map[string]interface{}{
"status": "error",
"error": err.Error(),
}
}
return map[string]interface{}{
"status": "ok",
}
}
// checkNATS verifies the NATS server is operational.
func (s *Server) checkNATS() map[string]interface{} {
if s.nats == nil {
return map[string]interface{}{
"status": "error",
"error": "not initialized",
}
}
varz, err := s.nats.Varz(nil)
if err != nil {
return map[string]interface{}{
"status": "error",
"error": err.Error(),
}
}
return map[string]interface{}{
"status": "ok",
"connections": varz.Connections,
"jetstream": s.nats.JetStreamEnabled(),
}
}
// stubHandler returns a handler that responds with 200 and a JSON stub.
func stubHandler(name string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"status": "stub",
"name": name,
"message": "Not yet implemented",
})
}
}
// corsMiddleware returns CORS middleware. In dev mode, it's permissive.
func corsMiddleware(devMode bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devMode {
w.Header().Set("Access-Control-Allow-Origin", "*")
} else {
// In production, only allow same-origin
origin := r.Header.Get("Origin")
if origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS, PATCH")
w.Header().Set("Access-Control-Allow-Headers", "Accept, Authorization, Content-Type, X-Request-ID")
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Access-Control-Max-Age", "300")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}