SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
3 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
6.8 KB · 117 lines typescript
Raw Blame History
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