thermograph/frontend/server/main.go
emi 92e74c585a
All checks were successful
Sync infra to hosts / sync-beta (push) Successful in 13s
Sync infra to hosts / sync-prod (push) Successful in 12s
secrets-guard / encrypted (push) Successful in 7s
shell-lint / shellcheck (push) Successful in 8s
Build + push frontend image (Forgejo registry) / build-push (push) Successful in 53s
Deploy frontend to beta VPS / deploy (push) Successful in 1m16s
secrets-guard / encrypted (pull_request) Successful in 5s
shell-lint / shellcheck (pull_request) Successful in 6s
frontend: rewrite the SSR content service in Go (#28)
Ports frontend/ (Jinja2/FastAPI, ~1180 LOC) to Go with html/template. No
climate math, no DB, no auth -- every route fetches from the backend's
/content/* API.

Verified with a golden-HTML diff, not just unit tests: both the Python
original and the Go rewrite were run against the same committed fixtures
and every route compared byte-for-byte, confirmed programmatically. That
process caught defects unit tests alone missed, since map[string]any has
no compile-time field check:

- Render-context keys were snake_case throughout while the templates read
  PascalCase fields. A missing map key doesn't error, it silently renders
  empty -- title, meta description, canonical URL, OpenGraph tags, and the
  homepage's entire ranked list were blank on every page despite every
  route returning 200. Fixed by renaming every key to match each
  template's own documented field contract, and passing API structs
  straight through wherever their fields already matched (removes a whole
  layer of future drift risk).
- Three pages 500'd: ToolHref needed a composed href, not a bare
  "lat,lon" fragment; the records table needed the raw API struct.
- JSON-LD was double-encoded: <script type="application/ld+json"> is
  JAVASCRIPT context to html/template's escaper regardless of the
  script's type attribute, so template.HTML gets re-escaped as a quoted
  JS string. Needed template.JS. The glossary term page's JSON-LD was
  never built at all -- added.
- html/template silently strips literal HTML and JS comments from parsed
  output (verified in isolation) -- both need a FuncMap function
  returning template.HTML/template.JS to survive.

Packaging: 187MB -> 22.6MB. Two defects caught before reaching a host: the
Swarm stack's entrypoint override with no explicit command drops the
image's CMD entirely (every deploy would have exited 127), and
COPY --chown by name fails under the classic Docker builder on Alpine.
Both fixed.

go build/vet/test -race clean; docker build passes its embedded test step
under both BuildKit and the classic builder; shellcheck 0 findings.
2026-07-24 00:53:48 +00:00

174 lines
5.6 KiB
Go

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