spb/earth-now Public License
earth-now.co — real-time planetary dashboard: live world metrics modeled, not streamed.
TypeScript 93%
Shell 2.3%
SQL 1.4%
JavaScript 1.3%
Dockerfile 1.2%
CSS 0.8%
1/**2 * earth-now.co3 * Author: Simon-Pierre Boucher4 * Contact: contact@spboucher.ai5 * File: apps/api/src/routes.ts6 * Purpose: Public REST + SSE + badge routes — read-only, CORS-open, rate-limited; ships models, never per-tick values7 */89import cors from "@fastify/cors";10import rateLimit from "@fastify/rate-limit";11import type { CounterModel } from "@earth-now/counter";12import type { FastifyInstance } from "fastify";13import { renderBadgeSvg } from "./badge.js";14import type { ModelStore } from "./store.js";1516const SSE_HEADERS = {17 "content-type": "text/event-stream; charset=utf-8",18 "cache-control": "no-cache, no-transform",19 connection: "keep-alive",20 "x-accel-buffering": "no",21} as const;2223function modelsSnapshot(store: ModelStore): { models: Record<string, CounterModel>; generatedAt: string } {24 return {25 models: Object.fromEntries(store.models),26 generatedAt: new Date().toISOString(),27 };28}2930export async function registerRoutes(app: FastifyInstance, store: ModelStore): Promise<void> {31 // Public read-only endpoints: open CORS, GET only.32 await app.register(cors, { origin: "*", methods: ["GET"] });33 // 300 req/min per IP; SSE connections are long-lived and exempt.34 await app.register(rateLimit, {35 max: 300,36 timeWindow: "1 minute",37 allowList: (req) => req.url.startsWith("/sse/"),38 });3940 app.get("/api/health", () => ({41 status: "ok",42 now: new Date().toISOString(),43 modelCount: store.models.size,44 blockedCount: store.blocked.length,45 uptimeSeconds: Math.round(process.uptime()),46 }));4748 // Full registry (attribution included — sources+licenses are a product commitment)49 // plus per-metric serving status.50 app.get("/v1/metrics", () =>51 store.registry.metrics.map((entry) => {52 const model = store.getModel(entry.id);53 return {54 ...entry,55 stale: model === undefined || store.isStale(entry.id),56 ...(model !== undefined57 ? { modelVersion: model.modelVersion, observedAt: model.observedAt }58 : {}),59 };60 }),61 );6263 app.get<{ Params: { id: string } }>("/v1/metrics/:id/model", (req, reply) => {64 const { id } = req.params;65 if (!store.registry.byId.has(id)) {66 return reply.code(404).send({ error: "unknown_metric", metricId: id });67 }68 const model = store.getModel(id);69 if (!model) return reply.code(503).send({ error: "stale", metricId: id });70 return model;71 });7273 app.get("/v1/models", () => modelsSnapshot(store));7475 app.get("/v1/rt/quakes", () => store.quakes);7677 // /badge/:id.svg — same counterValue+formatValue as the widget and dashboard.78 app.get<{ Params: { file: string } }>("/badge/:file", (req, reply) => {79 const { file } = req.params;80 if (!file.endsWith(".svg")) return reply.code(404).send({ error: "not_found" });81 const id = file.slice(0, -".svg".length);82 const entry = store.registry.byId.get(id);83 if (!entry) return reply.code(404).send({ error: "unknown_metric", metricId: id });84 return reply85 .header("content-type", "image/svg+xml")86 .header("cache-control", "public, max-age=60")87 .send(renderBadgeSvg(entry, store.getModel(id), Date.now()));88 });8990 // Model distribution stream: full snapshot on connect, then ONLY new models.91 app.get("/sse/stream", (req, reply) => {92 reply.hijack();93 const raw = reply.raw;94 raw.writeHead(200, SSE_HEADERS);95 raw.write("retry: 5000\n\n");96 raw.write(`event: models\ndata: ${JSON.stringify(modelsSnapshot(store))}\n\n`);9798 const onModel = (model: CounterModel): void => {99 raw.write(`event: model\ndata: ${JSON.stringify(model)}\n\n`);100 };101 store.onModel(onModel);102103 const heartbeat = setInterval(() => {104 raw.write(": ping\n\n");105 }, 15_000);106 heartbeat.unref();107108 req.raw.on("close", () => {109 clearInterval(heartbeat);110 store.offModel(onModel);111 raw.end();112 });113 });114115 // Deploy health check holds this open ≥ 10 s; we cap the connection at 60 s.116 app.get("/sse/health", (req, reply) => {117 reply.hijack();118 const raw = reply.raw;119 raw.writeHead(200, SSE_HEADERS);120 // 2 KB padding comment: tiny comment-only chunks can sit below the flush121 // threshold of tunnel/proxy edges (observed with ngrok) — pad past it, then122 // send real data events rather than bare comments.123 raw.write(`: ${"p".repeat(2048)}\n\n`);124 raw.write(`event: hb\ndata: {"t":"${new Date().toISOString()}"}\n\n`);125126 const heartbeat = setInterval(() => {127 raw.write(`event: hb\ndata: {"t":"${new Date().toISOString()}"}\n\n`);128 }, 2_000);129 heartbeat.unref();130 const shutdown = setTimeout(() => {131 clearInterval(heartbeat);132 raw.end();133 }, 60_000);134 shutdown.unref();135136 req.raw.on("close", () => {137 clearInterval(heartbeat);138 clearTimeout(shutdown);139 });140 });141}142