/** * Fastify application: plugins (CORS, rate limit, swagger, under-pressure), error handling, metrics / * telemetry hooks, public /api/v1 routes, admin /api/admin routes, system routes. */ import Fastify, { type FastifyInstance } from "fastify"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import swagger from "@fastify/swagger"; import swaggerUi from "@fastify/swagger-ui"; import underPressure from "@fastify/under-pressure"; import { getEnv } from "./env.js"; import { HttpError } from "./lib/http.js"; import { metrics } from "./metrics.js"; import { recordRequest } from "./telemetry.js"; import { systemRoutes } from "./routes/system.js"; import { facilityRoutes } from "./routes/public/facilities.js"; import { graphRoutes } from "./routes/public/graph.js"; import { activityRoutes } from "./routes/public/activity.js"; import { discoveryRoutes } from "./routes/public/discovery.js"; import { marketRoutes } from "./routes/public/markets.js"; import { intelligenceRoutes } from "./routes/public/intelligence.js"; import { downloadRoutes } from "./routes/public/download.js"; import { watchlistRoutes } from "./routes/public/watchlist.js"; import { docsMetaRoutes } from "./routes/public/docs-meta.js"; import { adminRoutes } from "./routes/admin/index.js"; export interface BuildOptions { logger?: boolean; underPressure?: boolean } export async function buildApp(opts: BuildOptions = {}): Promise { const env = getEnv(); const app = Fastify({ logger: opts.logger === false ? false : { level: env.logLevel, ...(process.stdout.isTTY ? {} : {}) }, trustProxy: env.trustedProxies, disableRequestLogging: true, ignoreTrailingSlash: true, bodyLimit: 8 * 1024 * 1024, ajv: { customOptions: { removeAdditional: false, coerceTypes: false } }, }); // zod does the validation inside handlers; schemas are for documentation only app.setValidatorCompiler(() => () => true); app.setSerializerCompiler(() => (data) => JSON.stringify(data)); await app.register(cors, { origin: true, credentials: true, methods: ["GET", "HEAD", "OPTIONS", "POST", "PATCH", "DELETE"], allowedHeaders: ["content-type", "x-dci-admin-token", "if-none-match", "authorization"], exposedHeaders: ["etag", "x-cache", "x-route", "x-ratelimit-limit", "x-ratelimit-remaining", "content-disposition"], maxAge: 86400 }); await app.register(rateLimit, { global: true, max: env.publicRateLimit, timeWindow: "1 minute", keyGenerator: (req) => req.ip, allowList: (req) => req.url === "/api/health" || req.url === "/api/ready" || req.url === "/api/metrics", addHeadersOnExceeding: { "x-ratelimit-limit": true, "x-ratelimit-remaining": true }, errorResponseBuilder: (_req, ctx) => ({ error: "rate limit exceeded", statusCode: 429, limit: ctx.max, retryAfterMs: ctx.ttl }), }); if (opts.underPressure !== false) { await app.register(underPressure, { maxEventLoopDelay: 2000, maxEventLoopUtilization: 0.98, message: "service under pressure", retryAfter: 5, exposeStatusRoute: false }); } await app.register(swagger, { openapi: { openapi: "3.1.0", info: { title: "DataCenterIndex API", version: "2.0.0", description: "Public read API (`/api/v1`, envelope `{ data, meta, sources }`, weak ETags, `s-maxage` caching) and token-protected admin API (`/api/admin`, header `x-dci-admin-token`). Response `data` types are named after packages/core/src/api-types.ts (the contract); every 200 response documents the envelope and an `x-example`. Figures are published values only — coverage and methodology are stated in `meta.methodology`." }, servers: [{ url: env.siteUrl }, { url: `http://127.0.0.1:${env.port}` }], tags: [{ name: "facilities" }, { name: "operators" }, { name: "countries" }, { name: "metros" }, { name: "cloud-regions" }, { name: "ixps" }, { name: "projects" }, { name: "events" }, { name: "map" }, { name: "search" }, { name: "rankings" }, { name: "dashboard" }, { name: "stats" }, { name: "sources" }, { name: "sitemap" }, { name: "nearby" }, { name: "pulse" }, { name: "compare" }, { name: "explore" }, { name: "coverage" }, { name: "ai" }, { name: "power" }, { name: "connectivity" }, { name: "time-machine" }, { name: "download" }, { name: "watchlist" }, { name: "docs" }, { name: "system" }, { name: "admin" }], components: { securitySchemes: { adminToken: { type: "apiKey", in: "header", name: "x-dci-admin-token" } } }, }, }); await app.register(swaggerUi, { routePrefix: "/api/v1/docs", uiConfig: { docExpansion: "list", deepLinking: true }, staticCSP: true }); // ---- error handling: consistent JSON app.setNotFoundHandler((req, reply) => { reply.code(404).header("cache-control", "no-store").send({ error: `route ${req.method} ${req.url.split("?")[0]} not found`, statusCode: 404 }); }); app.setErrorHandler((err, req, reply) => { if (err instanceof HttpError) { reply.code(err.statusCode).header("cache-control", "no-store").send({ error: err.message, statusCode: err.statusCode, ...(err.details !== undefined ? { details: err.details } : {}) }); return; } const e = err as { statusCode?: number; message?: string }; const status = e.statusCode ?? 500; if (status >= 500) req.log.error({ err, url: req.url }, "request failed"); reply.code(status).header("cache-control", "no-store").send({ error: status >= 500 ? "internal error" : e.message ?? "error", statusCode: status, ...(status >= 500 && process.env.NODE_ENV !== "production" ? { detail: e.message } : {}) }); }); // ---- metrics + ClickHouse request log app.addHook("onResponse", (req, reply, done) => { const route = req.routeOptions?.url ?? req.url.split("?")[0] ?? "unknown"; const ms = reply.elapsedTime; metrics.observe(route, reply.statusCode, ms / 1000); if (route !== "/api/metrics" && route !== "/api/health") recordRequest(route, reply.statusCode, ms, String(reply.getHeader("x-cache") ?? "") === "HIT"); done(); }); // ---- routes await app.register(systemRoutes); app.get("/api/v1/openapi.json", { schema: { hide: true } }, async (_req, reply) => { reply.header("cache-control", "public, max-age=300"); return app.swagger(); }); await app.register( async (v1) => { await v1.register(facilityRoutes); await v1.register(graphRoutes); await v1.register(activityRoutes); await v1.register(discoveryRoutes); await v1.register(marketRoutes); await v1.register(intelligenceRoutes); await v1.register(downloadRoutes); await v1.register(watchlistRoutes); await v1.register(docsMetaRoutes); }, { prefix: "/api/v1" }, ); await app.register(adminRoutes, { prefix: "/api/admin" }); app.get("/", { schema: { hide: true } }, async () => ({ service: "DataCenterIndex API", docs: "/api/v1/docs", openapi: "/api/v1/openapi.json", health: "/api/health" })); return app; }