// thermograph-frontend: the SSR frontend service — server-rendered content // pages (climate hub / per-city / month / records / glossary / about), the // interactive tool's SPA shells, and every static asset. All climate data // comes from the backend's content API over HTTP (internal/contentapi); this // process holds no data of its own. // // Go port of app.py: configuration, the route wiring (internal/content for // the content.py routes, internal/handlers for the SPA shells + static // assets), and the process lifecycle uvicorn used to own (listen, access // logs, graceful shutdown). package main import ( "context" "errors" "log/slog" "mime" "net" "net/http" "os" "os/signal" "syscall" "time" "thermograph/frontend/internal/config" "thermograph/frontend/internal/content" "thermograph/frontend/internal/contentapi" "thermograph/frontend/internal/contentdata" "thermograph/frontend/internal/handlers" "thermograph/frontend/internal/render" ) func main() { logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) slog.SetDefault(logger) // Fail loud at boot on bad configuration or content (missing backend URL, // garbage TTL, malformed glossary/pages YAML) — the Python raised at // import for the same cases: a missing backend URL or a bad content edit // should break the boot, not silently 500 on the first request. cfg, err := config.Load() if err != nil { fatal(logger, "config", err) } client := contentapi.New(contentapi.Options{ BaseURL: cfg.APIBaseInternal, BasePrefix: cfg.Base, // backend runs under the same THERMOGRAPH_BASE APIVersion: cfg.APIVersion, TTL: cfg.SSRCacheTTL, }) glossary, err := contentdata.LoadGlossary(cfg.ContentDir) if err != nil { fatal(logger, "glossary", err) } pages, err := contentdata.LoadPages(cfg.ContentDir) if err != nil { fatal(logger, "pages", err) } // Templates parse once, at boot, with the full FuncMap (html/template // resolves function names at parse time) — the Go analogue of the Jinja // environment's auto_reload=False. engine, err := render.New(content.FuncMap(cfg)) if err != nil { fatal(logger, "templates", err) } // The PWA manifest is served as a static file; register its media type so // the file server labels it correctly (not in Go's default mime table). if err := mime.AddExtensionType(".webmanifest", "application/manifest+json"); err != nil { fatal(logger, "mime", err) } // app.py-layer routes: SPA shells + static assets + the bare-BASE redirect. app := handlers.New(handlers.Options{ Base: cfg.Base, StaticDir: cfg.StaticDir, GoogleVerify: cfg.GoogleVerify, BingVerify: cfg.BingVerify, Log: logger, }) // content.py-layer routes. content.New takes a *log.Logger; bridge it into // slog so its lines (IndexNow boot warning, render failures) land on // stdout structured like everything else. contentHandlers, err := content.New(cfg, client, engine, glossary, pages, slog.NewLogLogger(logger.Handler(), slog.LevelWarn)) if err != nil { fatal(logger, "content", err) } mux := http.NewServeMux() // Liveness probe: the process is up and serving. Deliberately does no I/O // — no call to the backend — so it stays cheap and reliable for a tight // healthcheck interval. Readiness (backend reachable) is a separate concern. mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(`{"status":"ok"}`)) }) // Register order does not matter for precedence (most-specific pattern // wins), but content registration performs the eager IndexNow key fetch — // same point in boot as the Python's content.register(app). Its lazy // fallback route needs the static handler to delegate non-key requests to. contentHandlers.Register(mux, app.Static()) app.Register(mux) srv := &http.Server{ Addr: net.JoinHostPort("", cfg.Port), // 0.0.0.0:PORT, like uvicorn --host 0.0.0.0 Handler: accessLog(logger, mux), ReadHeaderTimeout: 10 * time.Second, } // Graceful shutdown on SIGINT/SIGTERM: stop accepting, drain in-flight // requests, then exit — what uvicorn did for the Python process. ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() errCh := make(chan error, 1) go func() { logger.Info("thermograph-frontend listening", "port", cfg.Port, "base", cfg.Base) errCh <- srv.ListenAndServe() }() select { case err := <-errCh: if !errors.Is(err, http.ErrServerClosed) { fatal(logger, "serve", err) } case <-ctx.Done(): shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() if err := srv.Shutdown(shutdownCtx); err != nil { logger.Error("shutdown", "err", err) os.Exit(1) } logger.Info("shut down cleanly") } } func fatal(logger *slog.Logger, stage string, err error) { logger.Error(stage, "err", err) os.Exit(1) } // accessLog is the uvicorn access log's replacement: one structured line per // request on stdout. func accessLog(logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} next.ServeHTTP(rec, r) logger.Info("request", "method", r.Method, "path", r.URL.Path, "status", rec.status, "dur_ms", time.Since(start).Milliseconds(), ) }) } // statusRecorder captures the response status for the access log. type statusRecorder struct { http.ResponseWriter status int } func (rec *statusRecorder) WriteHeader(status int) { rec.status = status rec.ResponseWriter.WriteHeader(status) }