SPB Git

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%
4.7 KB · 142 lines typescript
Raw Blame History
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