spb/datacenterindex
Public
HTML 53.9%
TypeScript 44.5%
JavaScript 0.6%
SQL 0.5%
1/**2 * Fastify application: plugins (CORS, rate limit, swagger, under-pressure), error handling, metrics /3 * telemetry hooks, public /api/v1 routes, admin /api/admin routes, system routes.4 */5import Fastify, { type FastifyInstance } from "fastify";6import cors from "@fastify/cors";7import rateLimit from "@fastify/rate-limit";8import swagger from "@fastify/swagger";9import swaggerUi from "@fastify/swagger-ui";10import underPressure from "@fastify/under-pressure";11import { getEnv } from "./env.js";12import { HttpError } from "./lib/http.js";13import { metrics } from "./metrics.js";14import { recordRequest } from "./telemetry.js";15import { systemRoutes } from "./routes/system.js";16import { facilityRoutes } from "./routes/public/facilities.js";17import { graphRoutes } from "./routes/public/graph.js";18import { activityRoutes } from "./routes/public/activity.js";19import { discoveryRoutes } from "./routes/public/discovery.js";20import { marketRoutes } from "./routes/public/markets.js";21import { intelligenceRoutes } from "./routes/public/intelligence.js";22import { downloadRoutes } from "./routes/public/download.js";23import { watchlistRoutes } from "./routes/public/watchlist.js";24import { docsMetaRoutes } from "./routes/public/docs-meta.js";25import { adminRoutes } from "./routes/admin/index.js";2627export interface BuildOptions { logger?: boolean; underPressure?: boolean }2829export async function buildApp(opts: BuildOptions = {}): Promise<FastifyInstance> {30 const env = getEnv();31 const app = Fastify({32 logger: opts.logger === false ? false : { level: env.logLevel, ...(process.stdout.isTTY ? {} : {}) },33 trustProxy: env.trustedProxies,34 disableRequestLogging: true,35 ignoreTrailingSlash: true,36 bodyLimit: 8 * 1024 * 1024,37 ajv: { customOptions: { removeAdditional: false, coerceTypes: false } },38 });3940 // zod does the validation inside handlers; schemas are for documentation only41 app.setValidatorCompiler(() => () => true);42 app.setSerializerCompiler(() => (data) => JSON.stringify(data));4344 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 });4546 await app.register(rateLimit, {47 global: true,48 max: env.publicRateLimit,49 timeWindow: "1 minute",50 keyGenerator: (req) => req.ip,51 allowList: (req) => req.url === "/api/health" || req.url === "/api/ready" || req.url === "/api/metrics",52 addHeadersOnExceeding: { "x-ratelimit-limit": true, "x-ratelimit-remaining": true },53 errorResponseBuilder: (_req, ctx) => ({ error: "rate limit exceeded", statusCode: 429, limit: ctx.max, retryAfterMs: ctx.ttl }),54 });5556 if (opts.underPressure !== false) {57 await app.register(underPressure, { maxEventLoopDelay: 2000, maxEventLoopUtilization: 0.98, message: "service under pressure", retryAfter: 5, exposeStatusRoute: false });58 }5960 await app.register(swagger, {61 openapi: {62 openapi: "3.1.0",63 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`." },64 servers: [{ url: env.siteUrl }, { url: `http://127.0.0.1:${env.port}` }],65 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" }],66 components: { securitySchemes: { adminToken: { type: "apiKey", in: "header", name: "x-dci-admin-token" } } },67 },68 });69 await app.register(swaggerUi, { routePrefix: "/api/v1/docs", uiConfig: { docExpansion: "list", deepLinking: true }, staticCSP: true });7071 // ---- error handling: consistent JSON72 app.setNotFoundHandler((req, reply) => {73 reply.code(404).header("cache-control", "no-store").send({ error: `route ${req.method} ${req.url.split("?")[0]} not found`, statusCode: 404 });74 });75 app.setErrorHandler((err, req, reply) => {76 if (err instanceof HttpError) {77 reply.code(err.statusCode).header("cache-control", "no-store").send({ error: err.message, statusCode: err.statusCode, ...(err.details !== undefined ? { details: err.details } : {}) });78 return;79 }80 const e = err as { statusCode?: number; message?: string };81 const status = e.statusCode ?? 500;82 if (status >= 500) req.log.error({ err, url: req.url }, "request failed");83 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 } : {}) });84 });8586 // ---- metrics + ClickHouse request log87 app.addHook("onResponse", (req, reply, done) => {88 const route = req.routeOptions?.url ?? req.url.split("?")[0] ?? "unknown";89 const ms = reply.elapsedTime;90 metrics.observe(route, reply.statusCode, ms / 1000);91 if (route !== "/api/metrics" && route !== "/api/health") recordRequest(route, reply.statusCode, ms, String(reply.getHeader("x-cache") ?? "") === "HIT");92 done();93 });9495 // ---- routes96 await app.register(systemRoutes);97 app.get("/api/v1/openapi.json", { schema: { hide: true } }, async (_req, reply) => { reply.header("cache-control", "public, max-age=300"); return app.swagger(); });98 await app.register(99 async (v1) => {100 await v1.register(facilityRoutes);101 await v1.register(graphRoutes);102 await v1.register(activityRoutes);103 await v1.register(discoveryRoutes);104 await v1.register(marketRoutes);105 await v1.register(intelligenceRoutes);106 await v1.register(downloadRoutes);107 await v1.register(watchlistRoutes);108 await v1.register(docsMetaRoutes);109 },110 { prefix: "/api/v1" },111 );112 await app.register(adminRoutes, { prefix: "/api/admin" });113 app.get("/", { schema: { hide: true } }, async () => ({ service: "DataCenterIndex API", docs: "/api/v1/docs", openapi: "/api/v1/openapi.json", health: "/api/health" }));114115 return app;116}117