Some checks failed
Sync infra to hosts / sync-beta (push) Has been skipped
Sync infra to hosts / sync-prod (push) Has been skipped
Sync infra to hosts / sync-dev (push) Failing after 6s
secrets-guard / encrypted (push) Successful in 6s
shell-lint / shellcheck (push) Successful in 13s
Validate observability stack / validate (push) Successful in 17s
PR build (required check) / changes (pull_request) Successful in 6s
secrets-guard / encrypted (pull_request) Successful in 5s
PR build (required check) / build-backend (pull_request) Has been skipped
shell-lint / shellcheck (pull_request) Successful in 6s
PR build (required check) / build-frontend (pull_request) Has been skipped
PR build (required check) / validate-observability (pull_request) Successful in 18s
PR build (required check) / gate (pull_request) Successful in 2s
221 lines
10 KiB
Markdown
221 lines
10 KiB
Markdown
# 5. Frontend deep dive
|
|
|
|
The **SSR + static-asset service**: server-rendered crawlable pages, the
|
|
interactive tool's SPA shells, and every static asset. No climate data, no
|
|
database, no compute — everything comes from the backend's content API over
|
|
HTTP.
|
|
|
|
> **The live service is Go.** `frontend/server/` builds, tests, ships and runs.
|
|
> The Python files one directory up (`app.py`, `content.py`, `api_client.py`,
|
|
> `format.py`, `content_loader.py`, `paths.py`, `templates/*.html.j2`) are the
|
|
> superseded original implementation, kept in-tree as the reference the port was
|
|
> made from. Nothing deploys them, and they hold duplicate copies of a couple of
|
|
> contracts (`api_client.py`'s `API_VERSION` pin, `format.py`'s `F_COUNTRIES`) —
|
|
> keep those in step if you run them locally. Whether to delete them is an open
|
|
> question; raise it rather than doing it in passing.
|
|
|
|
## Shape of the Go service
|
|
|
|
```
|
|
main.go config → content client → glossary/pages → mux →
|
|
listen → graceful shutdown
|
|
internal/config/ every env var the service reads, with the same names,
|
|
defaults and required/optional split as the Python
|
|
internal/contentapi/ the backend /content/* client
|
|
internal/content/ the SSR page handlers, template funcmap, SEO helpers
|
|
internal/contentdata/ glossary.yaml / pages.yaml loader
|
|
internal/format/ unit-aware °C/°F formatting + band names
|
|
internal/handlers/ SPA shells + static serving
|
|
internal/render/ html/template over an embed.FS + ETag helpers
|
|
internal/render/templates/*.tmpl the page templates (embedded in the binary)
|
|
```
|
|
|
|
Dependencies: **stdlib plus `gopkg.in/yaml.v3`** only. The committed SSR copy
|
|
in `frontend/content/*.yaml` is shared with the rest of the repo and uses
|
|
block/folded scalars, so a real YAML parser is genuinely required.
|
|
|
|
**Templates are embedded in the binary; `static/` and `content/` are read from
|
|
disk relative to the working directory** (`frontend/` locally, `/app` in the
|
|
image). That's why you build in `server/` and run from `frontend/`.
|
|
|
|
### Fail-loud boot
|
|
|
|
`main.go` exits at startup on bad configuration *or* bad content: a missing
|
|
backend URL, a garbage TTL, malformed glossary/pages YAML. The Python raised at
|
|
import for the same cases, and the reasoning is identical — a bad content edit
|
|
should break the boot, not silently 500 on the first request.
|
|
|
|
The one deliberate exception is the IndexNow key: boot tries the key fetch once,
|
|
catches failure, and falls back to a lazy per-request lookup. **Boot must
|
|
survive an unreachable backend** — asynchronous FE/BE deploys depend on it.
|
|
Don't make it fatal.
|
|
|
|
## Routes
|
|
|
|
Everything below sits under `THERMOGRAPH_BASE` (default `/thermograph`; the
|
|
image sets `/`).
|
|
|
|
| Path | Kind |
|
|
|---|---|
|
|
| `/` | SSR homepage |
|
|
| `/climate` | SSR climate hub |
|
|
| `/climate/{slug}` | SSR per-city page |
|
|
| `/climate/{slug}/records` | SSR all-time records |
|
|
| `/climate/{slug}/{month}` | SSR per-month page |
|
|
| `/glossary`, `/glossary/{term}` | SSR |
|
|
| `/about`, `/privacy` | SSR |
|
|
| `/robots.txt`, `/sitemap.xml` | generated |
|
|
| `/{indexnow-key}.txt` | the IndexNow ownership file |
|
|
| `/calendar`, `/day`, `/score`, `/compare`, `/legend`, `/alerts` | SPA shells |
|
|
| `/healthz` | liveness, I/O-free (does **not** prove the backend is reachable) |
|
|
| everything else | static assets |
|
|
|
|
The static mount is registered last so the explicit routes win.
|
|
|
|
**The backend owns the page metadata, not this service.** `page_title`,
|
|
`canonical_path`, `breadcrumb` and `jsonld` come from
|
|
`backend/api/content_payloads.py`. Don't recompute them here.
|
|
|
|
### SPA shells and `__ORIGIN__`
|
|
|
|
The shell HTML files in `static/` carry an `__ORIGIN__` placeholder for the
|
|
link-preview/Open Graph tags — preview crawlers need absolute URLs and the host
|
|
differs between dev and prod. The substituted HTML and its ETag are **memoized
|
|
per origin**, not recomputed per request.
|
|
|
|
Origin resolution prefers `X-Forwarded-Host` over `Host`. That matters when the
|
|
request arrives through the backend's catch-all proxy fallback: without it, the
|
|
page would advertise the internal hop's own address as canonical.
|
|
|
|
## The content client
|
|
|
|
`internal/contentapi` is a careful piece of code and worth reading in full
|
|
before you change caching behaviour. It has four properties, all load-bearing:
|
|
|
|
1. **TTL cache** (`THERMOGRAPH_SSR_CACHE_TTL`, default 600s). Climate data
|
|
changes at most hourly, so without this a page-view burst on one city costs
|
|
one backend round trip per request. This is *in addition to* the backend's
|
|
derived-store/ETag caching — this one saves the network hop, the backend's
|
|
saves the recompute.
|
|
2. **Bounded LRU** (2048 entries). `city()` and `city_records()` fold the
|
|
browser-facing `origin` into the cache key, and `origin` is client-controlled
|
|
via `Host`/`X-Forwarded-Host`. An unbounded map there is a cheap
|
|
memory-exhaustion vector.
|
|
3. **Per-key single-flight.** Without it, N concurrent requests missing the same
|
|
key (cold start, or right after TTL expiry on a hot city) each fire their own
|
|
backend call — a thundering herd.
|
|
4. **Origin forwarding.** The original browser-facing origin is passed through as
|
|
`Host` / `X-Forwarded-Proto` so the backend builds JSON-LD `url` fields
|
|
against the public origin rather than the internal `http://backend:8137`
|
|
address.
|
|
|
|
## Static assets
|
|
|
|
Hand-written, no build step, no bundler, no framework.
|
|
|
|
| File | Role |
|
|
|---|---|
|
|
| `app.js` | the main tool: map, search, results, inline SVG chart |
|
|
| `calendar.js` / `day.js` / `score.js` / `compare.js` | the SPA views |
|
|
| `cache.js` | IndexedDB cache + the `/cell` bundle prefetch + `If-None-Match` revalidation |
|
|
| `account.js` | auth; **pins `API_VERSION` and the `uv(path)` helper** |
|
|
| `shared.js` | shared helpers; **`pctOrd()` mirrors the backend's `pct_ordinal()`** |
|
|
| `units.js` | `F_REGIONS` — must match the backend's `F_COUNTRIES` |
|
|
| `chart.js`, `mappicker.js`, `filtersheet.js`, `nav.js`, `push-client.js`, `ios-install.js`, `digest.js`, `climate.js`, `subscriptions.js` | the rest |
|
|
| `style.css` | **the single hand-written stylesheet — every design token lives here** |
|
|
| `sw.js`, `manifest.webmanifest`, icons | PWA |
|
|
|
|
`cache.js` is where the ETag contract actually lands: it fetches
|
|
`/api/v2/cell` once per view-set, slices it, and later revalidates each view
|
|
with `If-None-Match`. The slice→view map must track the backend's payload
|
|
shape. See [contracts](06-contracts.md).
|
|
|
|
Static responses are served with `Cache-Control: public, max-age=300` — short
|
|
on purpose, because asset filenames are **not** content-hashed. It's a
|
|
revalidation window, not an immutable cache.
|
|
|
|
## The design system
|
|
|
|
[`frontend/DESIGN.md`](../../frontend/DESIGN.md) is the source of truth and
|
|
wins over any general design guidance. The essentials:
|
|
|
|
- **Never hardcode a colour.** Every colour is a CSS custom property in
|
|
`style.css`'s `:root`, with a `@media (prefers-color-scheme: light)` override
|
|
right below. Always `var(--token)`.
|
|
- **Grade palettes encode meaning, not decoration.** Temperature is a 9-step
|
|
diverging, colourblind-safe scale (`--rec-cold` … `--normal` green midpoint …
|
|
`--rec-hot`). Precipitation is `--dry` plus `--wet-1…9`. Keep the order and
|
|
midpoints intact.
|
|
- **Inter is not self-hosted.** It renders where the OS has it and falls back to
|
|
`system-ui`. Adding a webfont link is a deliberate decision, not a tweak — it's
|
|
a network dependency on every page.
|
|
- **Inputs are `font-size: 16px` minimum.** Smaller text makes iOS zoom on
|
|
focus. Hard rule.
|
|
- **Mobile-first.** The base stylesheet is the phone layout; wider screens layer
|
|
on via `min-width`. Primary breakpoint 640/641px. The content column widens in
|
|
real steps at 1680 / 2400 / 3400px — it does *not* stay a centred 1200px strip.
|
|
- **Metric order is always `Precip · High · Low`**, everywhere.
|
|
- **Dark is the default**, light comes from `prefers-color-scheme`. Every change
|
|
must look right in both. Honour `prefers-reduced-motion`.
|
|
- Charts are bespoke inline SVG. No canvas, no charting library.
|
|
|
|
### Verifying a visual change
|
|
|
|
**Validate at 390 / 800 / 1920 / 2560 / 3840px in both light and dark.** These
|
|
straddle every breakpoint above.
|
|
|
|
```bash
|
|
cd frontend
|
|
.venv/bin/pip install -q -r tools/requirements.txt
|
|
.venv/bin/python -m playwright install chromium
|
|
.venv/bin/python tools/shoot.py # the full matrix
|
|
.venv/bin/python tools/shoot.py index --width 390 --scheme dark # while iterating
|
|
```
|
|
|
|
PNGs land in `.screenshots/` (gitignored) as `{page}@{width}-{scheme}.png`.
|
|
Point it elsewhere with `SHOTS_BASE`. Chrome DevTools MCP is the other good
|
|
option here — a real browser on your own machine.
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
cd frontend/server && go build ./... && go vet ./... && go test ./...
|
|
```
|
|
|
|
The Go tests read two directories via a fixed relative path: the committed
|
|
golden fixtures (`frontend/tests/fixtures/*.json` — the same set the Python
|
|
golden-diff comparison used) and the SSR copy (`frontend/content/*.yaml`).
|
|
`frontend/Dockerfile`'s builder stage copies them to container-root paths to
|
|
preserve that relationship, and runs `gofmt -l` + `go vet` + `go test` **before**
|
|
building. A failing Go test therefore fails the image build, which is how CI
|
|
catches it — there is no separate frontend test step in the workflows.
|
|
|
|
The Python tiers still exist and still run locally:
|
|
|
|
```bash
|
|
cd frontend
|
|
make test-unit # hermetic: SSR rendering fed committed fixtures
|
|
make test-integration # pulls + runs the real backend image; local only
|
|
make capture-fixtures # refresh tests/fixtures/*.json from a live backend
|
|
```
|
|
|
|
Treat `make capture-fixtures` as the shared operation: those fixtures feed the
|
|
**Go** tests too.
|
|
|
|
## The image
|
|
|
|
`python:3.12-slim` → gone. The frontend image is:
|
|
|
|
- a `golang:1.26` builder that runs `gofmt -l` / `go vet` / `go test`, then
|
|
builds a static `CGO_ENABLED=0 -trimpath -ldflags="-s -w"` binary;
|
|
- an `alpine:3.22` final stage with **bash** (the Swarm stack bind-mounts a
|
|
bash entrypoint shim over the image's entrypoint) and **curl** (the
|
|
healthcheck);
|
|
- **uid 10001**, matching the backend image — that's the uid infra provisions
|
|
readable secrets for. Don't change it.
|
|
- `static/` and `content/` copied to `/app`, read-only at runtime.
|
|
|
|
There is no `WORKERS` knob any more: uvicorn needed a process count, the Go
|
|
server handles concurrency in one process.
|
|
|
|
Next: [Cross-service contracts](06-contracts.md).
|