The client data layer
@azohra/meteo.station/client: the framework-free polling loop and station
stores every binding rides — the mirror of @azohra/meteo.station/server,
one subpath per side of the wire. The react hooks are thin wrappers over
this layer; a binding for any other framework (or none) subscribes to the
same stores, so no binding can drift on cadence, parsing, merging, or
degradation. Importing the subpath is safe anywhere (SSR passes, node
tests); its loops only ever run against a live fetch.
The mount base
Section titled “The mount base”Every entry point takes the mount base — where
createStationFeedHandler is mounted, e.g. "/api/wind" — and builds its
own route from it, mirroring the handler’s pathname-suffix routing.
feedEndpoint(base) and currentEndpoint(base, stationId) (exported from
@azohra/meteo.station) are the only two routes.
The poller
Section titled “The poller”createJsonPoller(url, { parse, intervalMsFor, fetchInit?, initial? })
returns a store: getSnapshot() (stable object identity between changes),
subscribe(listener), start(), stop(), refresh(). Its semantics are
owed to every caller identically:
- Visibility-gated — a hidden tab skips its ticks and refetches the
moment it becomes visible; no
document(SSR, node) counts as visible. - In-flight suppression — a slow response never stacks a second request; every request carries an abort deadline (15 s), so a stalled upstream can never park the loop.
- First interval after first response — the first timer is scheduled once the first response settles, so the interval honours the feed’s advised cadence rather than the pre-data default.
- Keep-last-on-error — a failed or unreadable poll keeps the previous
validated document and flags a structured error:
{ kind: "network", status? }with the HTTP status when a response arrived;{ kind: "contract", cause? }with the zod error (or JSON syntax error) behind an unreadable body. - Seeds refresh — an
initialsnapshot (SSR-fetched data) fills state before the first fetch; the first poll still fires, because a seed is a starting point, never a substitute for refreshing. - The consumer’s
fetchInitrides every request (headers, credentials, cache mode — pass a function to thread the latest values); the loop’s own abort signal is applied last and wins. - A url change is a new poller. Callers key on the url and construct a fresh, seed-less poller, so a held document is never served under a new address.
The stores
Section titled “The stores”createStationFeedStore(base, { pollSeconds?, fetchInit?, initial? })— polls/feed. Cadence:pollSeconds, else the fastestrecommendedPollSecondsany station in the last feed advised, else 60 s.createStationCurrentStore(base, stationId, { pollSeconds?, fetchInit?, initial? })— polls the light/currentendpoint. Cadence:pollSeconds, else the station’s ownrecommendedPollSeconds, else 15 s — this endpoint exists to be quick.createStationStore(base, stationId, options)— both at once, folded withmergeCurrentand its clock rule: a merged current response advancesreceivedAtMsto the current’s; a merge that didn’t take (station unavailable, or absent from the feed) keeps the feed’s own clock — never credit a dead station with a response it never produced. The feed is the backbone: its error outranks the light endpoint’s.refresh()fans out to both.
The fold itself is foldCurrent(feed, feedReceivedAtMs, current, currentReceivedAtMs) on @azohra/meteo.station, for callers composing
their own stores.
Display resolution — shared across bindings
Section titled “Display resolution — shared across bindings”The components’ ambient-default discipline is one exported rule,
resolveDisplay(defaults, props) on @azohra/meteo.station: explicit prop
→ ambient default → package default, for strings, unit (default
"kmh"), and formatTime. thresholds resolves explicit → ambient →
nothing — a judgment parameter ships no package default, and with none
declared, readings go ungraded. Thresholds are a trichotomy, and
the distinction is load-bearing in every binding:
- omitted (
undefined) — inherit the ambient thresholds; - a value — grade against exactly these;
null— explicitly opt this component out of ambient grading.
Station resolution for per-station components is resolveStation(feed, stationId): an explicit station always wins, then stationId looked up
in the ambient feed, then the feed’s primaryStationId, then stations[0].
A stationId that matches nothing is a wiring error, not a fallback, and
resolving nothing throws a wiring error naming the binding’s provider —
requireResolved is that rule as code, the same function every built-in
binding calls.
Freshness between polls
Section titled “Freshness between polls”Freshness itself is the wire contract’s
model,
computed by freshness() on the root. Between polls every binding re-judges
the same reading every FRESHNESS_REEVALUATE_MS (30 s), so a station that
dies visibly ages while the loop keeps returning the last observation. The
hydration rule is shared too: the initial clock is receivedAtMs — a value
both a server pass and the client render from — never Date.now(), which
differs between the passes; bindings correct to the real clock once mounted.
Words and formatting
Section titled “Words and formatting”Everything a component prints comes from the isomorphic root, so two
bindings can never disagree on a character: the strings vocabulary
(defaultStrings, resolveStrings, mergeStringOverrides,
localeFormatTime), the formatting rules (roundSpeed, optionalSpeed,
the one-decimal temperature, updatedAtText, summaryEntries,
directionCell), the air sentences (airSummary, lastStrikeWords,
airRows), and the instrument geometry (DIAL_*, ROSE_*, and the
sparkline machinery — historyRuns, bandStrips, and the scales, so a
custom sparkline draws outage gaps exactly as the built-in one does;
coordinates and path strings, never markup). All on
@azohra/meteo.station.
Stability
Section titled “Stability”Pre-1.0: the poller and store semantics documented here are stable; pin a minor version if you reach past them.