/** * earth-now.co * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai * File: apps/api/src/routes.ts * Purpose: Public REST + SSE + badge routes — read-only, CORS-open, rate-limited; ships models, never per-tick values */ import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import type { CounterModel } from "@earth-now/counter"; import type { FastifyInstance } from "fastify"; import { renderBadgeSvg } from "./badge.js"; import type { ModelStore } from "./store.js"; const SSE_HEADERS = { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-cache, no-transform", connection: "keep-alive", "x-accel-buffering": "no", } as const; function modelsSnapshot(store: ModelStore): { models: Record; generatedAt: string } { return { models: Object.fromEntries(store.models), generatedAt: new Date().toISOString(), }; } export async function registerRoutes(app: FastifyInstance, store: ModelStore): Promise { // Public read-only endpoints: open CORS, GET only. await app.register(cors, { origin: "*", methods: ["GET"] }); // 300 req/min per IP; SSE connections are long-lived and exempt. await app.register(rateLimit, { max: 300, timeWindow: "1 minute", allowList: (req) => req.url.startsWith("/sse/"), }); app.get("/api/health", () => ({ status: "ok", now: new Date().toISOString(), modelCount: store.models.size, blockedCount: store.blocked.length, uptimeSeconds: Math.round(process.uptime()), })); // Full registry (attribution included — sources+licenses are a product commitment) // plus per-metric serving status. app.get("/v1/metrics", () => store.registry.metrics.map((entry) => { const model = store.getModel(entry.id); return { ...entry, stale: model === undefined || store.isStale(entry.id), ...(model !== undefined ? { modelVersion: model.modelVersion, observedAt: model.observedAt } : {}), }; }), ); app.get<{ Params: { id: string } }>("/v1/metrics/:id/model", (req, reply) => { const { id } = req.params; if (!store.registry.byId.has(id)) { return reply.code(404).send({ error: "unknown_metric", metricId: id }); } const model = store.getModel(id); if (!model) return reply.code(503).send({ error: "stale", metricId: id }); return model; }); app.get("/v1/models", () => modelsSnapshot(store)); app.get("/v1/rt/quakes", () => store.quakes); // /badge/:id.svg — same counterValue+formatValue as the widget and dashboard. app.get<{ Params: { file: string } }>("/badge/:file", (req, reply) => { const { file } = req.params; if (!file.endsWith(".svg")) return reply.code(404).send({ error: "not_found" }); const id = file.slice(0, -".svg".length); const entry = store.registry.byId.get(id); if (!entry) return reply.code(404).send({ error: "unknown_metric", metricId: id }); return reply .header("content-type", "image/svg+xml") .header("cache-control", "public, max-age=60") .send(renderBadgeSvg(entry, store.getModel(id), Date.now())); }); // Model distribution stream: full snapshot on connect, then ONLY new models. app.get("/sse/stream", (req, reply) => { reply.hijack(); const raw = reply.raw; raw.writeHead(200, SSE_HEADERS); raw.write("retry: 5000\n\n"); raw.write(`event: models\ndata: ${JSON.stringify(modelsSnapshot(store))}\n\n`); const onModel = (model: CounterModel): void => { raw.write(`event: model\ndata: ${JSON.stringify(model)}\n\n`); }; store.onModel(onModel); const heartbeat = setInterval(() => { raw.write(": ping\n\n"); }, 15_000); heartbeat.unref(); req.raw.on("close", () => { clearInterval(heartbeat); store.offModel(onModel); raw.end(); }); }); // Deploy health check holds this open ≥ 10 s; we cap the connection at 60 s. app.get("/sse/health", (req, reply) => { reply.hijack(); const raw = reply.raw; raw.writeHead(200, SSE_HEADERS); // 2 KB padding comment: tiny comment-only chunks can sit below the flush // threshold of tunnel/proxy edges (observed with ngrok) — pad past it, then // send real data events rather than bare comments. raw.write(`: ${"p".repeat(2048)}\n\n`); raw.write(`event: hb\ndata: {"t":"${new Date().toISOString()}"}\n\n`); const heartbeat = setInterval(() => { raw.write(`event: hb\ndata: {"t":"${new Date().toISOString()}"}\n\n`); }, 2_000); heartbeat.unref(); const shutdown = setTimeout(() => { clearInterval(heartbeat); raw.end(); }, 60_000); shutdown.unref(); req.raw.on("close", () => { clearInterval(heartbeat); clearTimeout(shutdown); }); }); }