Skip to content

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.

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.

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 initial snapshot (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 fetchInit rides 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.
  • createStationFeedStore(base, { pollSeconds?, fetchInit?, initial? }) — polls /feed. Cadence: pollSeconds, else the fastest recommendedPollSeconds any station in the last feed advised, else 60 s.
  • createStationCurrentStore(base, stationId, { pollSeconds?, fetchInit?, initial? }) — polls the light /current endpoint. Cadence: pollSeconds, else the station’s own recommendedPollSeconds, else 15 s — this endpoint exists to be quick.
  • createStationStore(base, stationId, options) — both at once, folded with mergeCurrent and its clock rule: a merged current response advances receivedAtMs to 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 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.

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.

Pre-1.0: the poller and store semantics documented here are stable; pin a minor version if you reach past them.