/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/api/src/pollers/usgs.ts * Purpose: USGS FDSN poller (5 min) — true event-driven RT for earthquakes_24h, never interpolated, never crashes the server */ import { buildStaticRtModel } from "@earth-now/models"; import { type ModelStore, type QuakesSnapshot, displayHintsOf } from "../store.js"; import { type PollerHandle, NOOP_POLLER, fetchJson, pollersEnabled } from "./poller.js"; const METRIC_ID = "earthquakes_24h"; const SOURCE_ID = "usgs_fdsn"; const BASE = "https://earthquake.usgs.gov/fdsnws/event/1"; const HOUR_MS = 3_600_000; export const USGS_POLL_INTERVAL_MS = 5 * 60_000; function readCount(data: unknown): number { const count = (data as { count?: unknown }).count; if (typeof count !== "number" || !Number.isFinite(count) || count < 0) { throw new Error("USGS count response malformed"); } return count; } function readLastMajor(data: unknown): QuakesSnapshot["lastMajor"] { const features = (data as { features?: unknown }).features; if (!Array.isArray(features) || features.length === 0) return null; const props = (features[0] as { properties?: { mag?: unknown; place?: unknown; time?: unknown } }) .properties; if (!props || typeof props.mag !== "number" || typeof props.time !== "number") return null; return { mag: props.mag, place: typeof props.place === "string" ? props.place : "unknown location", timeIso: new Date(props.time).toISOString(), }; } async function pollOnce(store: ModelStore): Promise { const nowMs = Date.now(); const nowIso = new Date(nowMs).toISOString(); const countUrl = `${BASE}/count?format=geojson&starttime=${new Date(nowMs - 24 * HOUR_MS).toISOString()}&minmagnitude=2.5`; const majorUrl = `${BASE}/query?format=geojson&starttime=${new Date(nowMs - 7 * 24 * HOUR_MS).toISOString()}&minmagnitude=5&orderby=time&limit=1`; const [countRes, majorRes] = await Promise.allSettled([fetchJson(countUrl), fetchJson(majorUrl)]); if (countRes.status === "rejected") { throw new Error(`USGS count fetch failed: ${String(countRes.reason)}`); } const count24h = readCount(countRes.value); // The "last major quake" leg is optional — keep the previous one on failure. const lastMajor = majorRes.status === "fulfilled" ? readLastMajor(majorRes.value) : store.quakes.lastMajor; const entry = store.registry.byId.get(METRIC_ID); if (!entry) throw new Error(`registry has no '${METRIC_ID}' entry`); store.updateRtModel( METRIC_ID, buildStaticRtModel( { metricId: METRIC_ID, sourceId: SOURCE_ID, observedAt: nowIso, displayHints: displayHintsOf(entry), }, { value: count24h, at: nowIso }, ), ); store.setQuakesSnapshot({ count24h, lastMajor, fetchedAt: nowIso }); } export function startUsgsPoller( store: ModelStore, intervalMs: number = USGS_POLL_INTERVAL_MS, ): PollerHandle { if (!pollersEnabled()) return NOOP_POLLER; const tick = (): void => { void pollOnce(store).catch((err: unknown) => { // Keep serving the previous model — a counter must never freeze silently or show NaN. store.markStale(METRIC_ID); console.error(`[usgs] poll failed (previous model keeps serving): ${String(err)}`); }); }; tick(); const timer = setInterval(tick, intervalMs); timer.unref(); return { stop: () => clearInterval(timer) }; }