1
0

Real weather, real aircraft, a heightfield off the main thread, and instruments

Three things that were built and never connected, connected.

**The weather was already there.** `observe()` has always taken a
`WeatherObservation` and `main.ts` has always passed null, so the cloud,
precipitation, visibility and marine-layer paths in atmosphere.ts had never run
outside a test. The server already shipped NWS, met.no and Open-Meteo, all
configured off. What was actually missing was that a single TERA_ORIGIN_LAT/LNG
served one metro and lied to the other — so weather and traffic are per-region
now, derived from the city's own bounds, and the Bay Area gets its fog while
Long Beach gets its own sky. The route takes ?city= or a validated ?lat=&lng=
and refuses to become an open geocoding proxy for the planet.

**The heightfield moved to a Worker.** 2.3 s of blocked main thread at boot, and
another ~950 ms of point-in-polygon on top of it: the park mask is filled in the
worker now, and block placement samples four corners and only runs the exact
test on a cell that straddles an edge — 8 buildings differ out of 185,036.
createScene is async and takes a Stage as a consequence, and there is a
main-thread fallback because "clone it and it works" has no exception clause.

**Spaces is a chunk you fetch when you reach for the door**, not one everybody
downloads. Same for the godmode tools. The entry chunk is 722 kB rather than
772; three.js is most of what is left and splitting it is a different job.

**Godmode is an instrument panel now** rather than one slider: the date and the
season, not just the hour, so the Meeus moon and the sun's seasonal arc become
visible instead of merely correct; a weather override that says on screen when
it is lying; a frame-time and draw-call readout; and a pose editor that emits a
paste-ready Chapter block, which is the thing that makes adding New York cheap.

Two blockers the review caught:

  - Every city switch leaked 8 GPU textures — one of them a 2048x2048 shadow map
    — and ~10.5 shader programs, and deleteTexture had never been called once in
    the app's lifetime. The renderer was being built per scene; it belongs to the
    canvas, for the life of the page.
  - An upstream fetch that threw rather than returning null skipped the cache
    stamp, so the TTL — the only rate limit on outbound calls — collapsed to one
    upstream request per inbound request, and the caller got a 500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-06 03:25:31 -07:00
parent a6f6a91813
commit e41c90fe8d
39 changed files with 8482 additions and 503 deletions
+93 -10
View File
@@ -23,23 +23,72 @@ without breaking the typecheck, which is the wrong order to find out.
## Routes
| route | body | cached |
| --- | --- | --- |
| `GET /api/v1/health` | `HealthBody` | never |
| `GET /api/v1/flights` | `FlightsBody` | public, `TERA_FLIGHTS_TTL` |
| `GET /api/v1/weather` | `WeatherBody` | public, `TERA_WEATHER_TTL` |
| `GET /api/v1/markers` | `MarkersBody` | public, `TERA_MARKERS_TTL` |
| `GET /api/v1/offices/:id` | `OfficeDoc` | public offices only |
| route | query | body | cached |
| --- | --- | --- | --- |
| `GET /api/v1/health` | — | `HealthBody` | never |
| `GET /api/v1/flights` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `FlightsBody` | public, `TERA_FLIGHTS_TTL` |
| `GET /api/v1/weather` | `?city=` or `?lat=&lng=`; **400** for a place this box does not serve | `WeatherBody` | public, `TERA_WEATHER_TTL` |
| `GET /api/v1/markers` | — | `MarkersBody` | **private; 401 unless signed in** — public empty body when `TERA_MARKERS_SOURCE=none` |
| `GET /api/v1/offices/:id` | — | `OfficeDoc` | public offices only |
Every body is declared once, in `src/server/wire.ts` in the **root** package —
type-only, so it compiles to nothing and both the browser build and this service
import the same declarations without either becoming a dependency of the other.
Both location parameters are optional and omitting them answers for the default
region, which is the first entry in `TERA_REGIONS`. Giving both `city` and a
coordinate is a 400, as is a coordinate this deployment has nothing to say
about; see **Regions** below for why that is a refusal and not a lookup.
`radiusNm` is accepted on `/flights` and deliberately ignored — `routes/flights.ts`
explains what a caller-chosen cache key would dissolve.
`Cache-Control` is fail-closed: a global hook stamps `private, no-store` on every
reply before any route runs, and a route opts in explicitly. A request that
arrived with an `Authorization` header or a cookie never gets a public policy,
whatever the route asked for.
## Regions
**A caller's coordinate is never forwarded upstream. It only selects among the
points the operator configured.** Weather and flights answer for a *resolved
region*, and a request for somewhere this box does not serve is a 400 naming
what it does.
That is an allowlist rather than a lookup because the obvious version — take
`?lat=&lng=` and hand it to NWS — turns an unauthenticated endpoint into a free
geocoding proxy for the planet: an amplifier pointed at somebody else's
public-good API, from an address they will blame, with the operator's own
contact string on every request. It is also what bounds everything downstream,
since the upstream key space *is* the region list: the per-region caches, the
NWS station cache and the adsb.lol poll budget are all bounded by the
environment file and cannot be grown by anybody sending requests.
(`src/regions.ts` has the full reasoning, including why snapping to a coarse
grid was rejected.)
| variable | default | what it does |
| --- | --- | --- |
| `TERA_REGIONS` | *(empty)* | `id:lat,lng[:radiusKm]`, separated by `;` or newlines. The list, in the operator's order; the first is the default. |
```ini
TERA_REGIONS=sf:37.7749,-122.4194;socal:33.82,-118.05:150
```
Ids are the same ones the browser's city packs use. `radiusKm` defaults to 120,
which covers both shipped boards with room to spare and leaves them disjoint. A
malformed entry is dropped with a line in `degraded`, and a spec in which
nothing parses falls back to the shipped pair — a typo is a demotion, never a
refusal to boot.
Left empty, the box serves the two cities the map ships with. An operator who
pointed `TERA_ORIGIN_LAT/_LNG` somewhere else additionally gets that point as a
region named `origin`, first in the list and therefore the default, so a bare
`GET /api/v1/weather` on their box answers exactly as it did before regions
existed.
`GET /api/v1/health` publishes the resolved list as `regions`, in the same
order, so a client can pick its default the way the server does instead of
guessing a `?city=` and getting a 400 it cannot explain.
## Configuration
Everything is `TERA_*`, everything is optional, and **nothing is fatal**. A
@@ -53,7 +102,7 @@ missing weather contact string; this is the correction. (CONTRACT.md §5.1.)
| `TERA_HOST` | `127.0.0.1` | Bind address. `0.0.0.0` inside a container, nowhere else. |
| `TERA_PORT` | `8431` | |
| `TERA_LOG_LEVEL` | `info` | |
| `TERA_ORIGIN_LAT` / `_LNG` | SF | The city this box serves. Weather point and flight-plan centre. |
| `TERA_ORIGIN_LAT` / `_LNG` | SF | Default region only, and superseded entirely by `TERA_REGIONS`. Nothing per-request reads it. |
| `TERA_CORS_ORIGIN` | *(empty)* | Comma-separated. Empty means same-origin only. |
| `TERA_PUBLIC_MAX_AGE` | `60` | `max-age` for routes without their own TTL. |
@@ -83,9 +132,9 @@ is a supported steady state, not an error path.
| --- | --- | --- |
| `TERA_FLIGHTS_SOURCE` | `sim` | `sim`, `adsb`, `dump1090`. |
| `TERA_ADSB_ENDPOINT` | `https://api.adsb.lol` | Also works with airplanes.live. |
| `TERA_ADSB_RADIUS_NM` | `40` | |
| `TERA_ADSB_RADIUS_NM` | `40` | Clamped to 1250 nm, which is what both feeds accept, with a `degraded` line. |
| `TERA_DUMP1090_PATH` | *(empty)* | Path to your receiver's `aircraft.json`. |
| `TERA_FLIGHTS_TTL` | `300` | Clamped to 15 s for live sources. |
| `TERA_FLIGHTS_TTL` | `300` | For live sources, clamped **into 515 s**. |
| `TERA_FLIGHTS_SEED` | `4711` | |
The simulated source is served as a **route plan**, not as positions: the routes,
@@ -93,6 +142,17 @@ a fixed phase origin and a seed, which every browser evaluates in closed form
against wall-clock time. One cacheable request replaces a poll per second, and
two people on different machines see the same aircraft in the same places.
The **floor** under the live TTL is the load-bearing half of that clamp, and it
is why the cell above reads as a range. adsb.lol asks for no more than one
request per second and airplanes.live publishes the same ceiling; both are
volunteer-fed. `TERA_FLIGHTS_TTL=0` used to mean one upstream request per
inbound request — the exact flood the limit exists to stop, delivered by a
setting that reads like "as fresh as possible". With the floor the worst case is
arithmetic rather than a guess: **regions ÷ 5 requests per second** with every
region under continuous load, which for the two shipped here is 0.4/s. An
operator configuring more than five regions and keeping them all warm is the
case to watch.
There is no FlightRadar24 client and there will not be one — their terms forbid
scraping and forbid redistribution, so shipping one in an Apache-2.0 repo would
be publishing instructions for breaking a ToS. An RTL-SDR and `dump1090` on a box
@@ -112,6 +172,29 @@ The API serves a file. It holds no database and no credential, and private
per-user markers are never proxied through it — an authenticated browser calls
Workie directly with its own token, so a private row never enters this process.
**Where a marker feed is configured, it takes a session.** This is the one route
the `member` tier is about, and until it refused somebody the tier was a word in
a type union that no server behaviour corresponded to. An anonymous caller gets
401 with `WWW-Authenticate: Bearer`; a member gets the snapshot with no public
cache policy on it, because a body that took a credential to obtain must not sit
in a shared cache waiting for the next caller. There is deliberately no
`TERA_MARKERS_PUBLIC` escape hatch. (401 rather than the 404 an office answers
with: nothing here is enumerable — one feed, one path, and `/api/v1/health`
already publishes `sources.markers` — so the caller is told the useful thing,
which is "sign in and ask again".)
Two consequences worth knowing before you configure it:
- **A box with no feed still answers 200 and an empty list.** `source: none` is
the zero-config default, there is nothing there to protect, and making a
stranger sign in to be told "no markers" would fail the acceptance test at the
top of this file.
- **`TERA_MARKERS_SOURCE=file` with `TERA_AUTH_MODE=none` is unreachable by
everyone**, because nobody on such a box is ever authenticated. That is the
fail-closed direction and it is the one private offices already take; the
config pushes a line into `degraded` saying so, rather than letting it be
discovered from an empty map.
**Every row must declare where its coordinate came from, and the gate refuses
anything not on the allowlist.** Serving a snapshot of geocoded coordinates is
Public Use of a Derivative Database; if those coordinates came from Nominatim,