package httpx import ( "context" "errors" "fmt" "html/template" "log/slog" "net/http" "time" "bountyboard/internal/auth" "bountyboard/internal/config" "bountyboard/internal/files" "bountyboard/internal/metrics" "bountyboard/internal/store" "bountyboard/internal/workperform" ) // shutdownBudget is the §12 graceful-shutdown drain window. const shutdownBudget = 15 * time.Second type Server struct { cfg *config.Config log *slog.Logger metrics *metrics.Registry store *store.Store files *files.Store templates map[string]*template.Template oidc *auth.OIDCClient mailer *auth.Mailer loginLimiter *auth.RateLimiter checks []ReadinessCheck startedAt time.Time httpSrv *http.Server // late-bound subsystem hooks (see providers.go) atomizer BreakerInfo performer BreakerInfo syncProvider StatusProvider jobsProvider StatusProvider syncTrigger func(customerID string) publishFn func(channel, event string, payload any) enqueueFn func(ctx context.Context, kind string, payload any) (string, error) wsHandler func(w http.ResponseWriter, r *http.Request, userID string) sendTo func(userIDs []string, channel, event string, payload any) performerClient *workperform.Client } // SetSendTo wires targeted hub delivery (chat messages, typing). func (s *Server) SetSendTo(f func(userIDs []string, channel, event string, payload any)) { s.sendTo = f } // SetPerformerClient wires the §5.2 client (also feeds the status panel). func (s *Server) SetPerformerClient(c *workperform.Client) { s.performerClient = c s.performer = c } func New(cfg *config.Config, log *slog.Logger, reg *metrics.Registry, st *store.Store, fs *files.Store) *Server { templates, err := parseTemplates() if err != nil { // Templates are embedded; failure is a build defect caught by any // test or first boot, never a runtime condition. panic(fmt.Sprintf("parse templates: %v", err)) } s := &Server{ cfg: cfg, log: log, metrics: reg, store: st, files: fs, templates: templates, oidc: auth.NewOIDCClient(cfg, log), mailer: auth.NewMailer(cfg.SMTP), loginLimiter: auth.NewRateLimiter(10, 15*time.Minute), startedAt: time.Now(), } mux := http.NewServeMux() mux.HandleFunc("GET /healthz", s.handleHealthz) mux.HandleFunc("GET /readyz", s.handleReadyz) mux.HandleFunc("GET /metricsz", s.handleMetricsz) s.routesAuth(mux) s.routesProfile(mux) s.routesAdmin(mux) s.routesTasks(mux) s.routesBoard(mux) s.routesConsultant(mux) s.routesWorkResults(mux) s.routesMessages(mux) s.routesMetrics(mux) s.routesDocs(mux) mux.HandleFunc("GET /ws", s.handleWS) s.routesWeb(mux) s.httpSrv = &http.Server{ Addr: fmt.Sprintf(":%d", cfg.AppPort), Handler: s.withMiddleware(mux), ReadHeaderTimeout: 10 * time.Second, ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelWarn), } return s } // Handler exposes the full middleware-wrapped handler for tests. func (s *Server) Handler() http.Handler { return s.httpSrv.Handler } // Run serves until ctx is canceled (SIGTERM/SIGINT), then drains in-flight // requests within the shutdown budget. func (s *Server) Run(ctx context.Context) error { errCh := make(chan error, 1) go func() { s.log.Info("http server listening", "addr", s.httpSrv.Addr, "baseUrl", s.cfg.AppBaseURL) if err := s.httpSrv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) { errCh <- err return } errCh <- nil }() select { case err := <-errCh: return fmt.Errorf("http server: %w", err) case <-ctx.Done(): } s.log.Info("shutting down", "budget", shutdownBudget.String()) shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownBudget) defer cancel() if err := s.httpSrv.Shutdown(shutdownCtx); err != nil { return fmt.Errorf("graceful shutdown: %w", err) } return <-errCh }