package spaserver import ( "context" "fmt" "net" "net/http" "time" "code.nochebuena.dev/einherjar/contracts/logging" "code.nochebuena.dev/einherjar/spa-server/health" "code.nochebuena.dev/einherjar/spa-server/spa" ) const shutdownTimeout = 10 * time.Second // Server is a lifecycle-managed HTTP server that serves a single-page application. // Register with [launcher.New] via Append — it implements [lifecycle.Component]. type Server struct { logger logging.Logger cfg Config srv *http.Server } // NewServer returns a Server configured by cfg. func NewServer(logger logging.Logger, cfg Config) *Server { return &Server{logger: logger, cfg: cfg} } // OnInit builds the HTTP mux and configures the underlying http.Server. func (s *Server) OnInit() error { mux := http.NewServeMux() mux.Handle("GET /health", health.NewHandler(s.logger)) mux.Handle("/", spa.NewHandler(s.logger, s.cfg.StaticDir)) s.srv = &http.Server{ Addr: fmt.Sprintf(":%d", s.cfg.Port), Handler: mux, } return nil } // OnStart begins serving HTTP requests in a background goroutine. // The TCP listener binds synchronously so a port conflict surfaces immediately. func (s *Server) OnStart() error { ln, err := newListener(s.srv.Addr) if err != nil { return err } s.logger.Info("spa-server: listening", "addr", s.srv.Addr, "static_dir", s.cfg.StaticDir) go func() { if err := s.srv.Serve(ln); err != nil && err != http.ErrServerClosed { s.logger.Error("spa-server: serve error", err) } }() return nil } func newListener(addr string) (net.Listener, error) { ln, err := net.Listen("tcp", addr) if err != nil { return nil, fmt.Errorf("spa-server: bind %s: %w", addr, err) } return ln, nil } // OnStop performs a graceful shutdown, waiting up to 10 seconds for in-flight // requests to complete before forcefully closing connections. func (s *Server) OnStop() error { s.logger.Info("spa-server: shutting down") ctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout) defer cancel() return s.srv.Shutdown(ctx) }