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:
+93
-10
@@ -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 1–250 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 5–15 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,
|
||||
|
||||
Reference in New Issue
Block a user