SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
5.6 KB · 115 lines typescript
Raw Blame History
1import Fastify, { type FastifyReply, type FastifyRequest } from "fastify";2import cors from "@fastify/cors";3import rateLimit from "@fastify/rate-limit";4import websocket from "@fastify/websocket";5import replyFrom from "@fastify/reply-from";6import client from "prom-client";7import { newId } from "@websensor/core";8import { closeDb } from "@websensor/db";9import { config } from "./config";10import { closeLive, registerLive } from "./live";11import { registerRoutes } from "./routes";12import { registerAdminRoutes } from "./routes-admin";13import { registerUserRoutes } from "./routes-user";1415/**16 * WebSensor gateway: public entry point behind ngrok.17 *   /api/v1/*        REST + WebSocket (/api/v1/live)18 *   /api/health /api/ready /api/metrics19 *   everything else  → Next.js frontend (loopback), with forwarded headers.20 * Also enforces the canonical host (websensor.io → www.websensor.io).21 */22const registry = new client.Registry();23client.collectDefaultMetrics({ register: registry, prefix: "websensor_api_" });24const httpRequests = new client.Counter({ name: "websensor_api_requests_total", help: "API requests", labelNames: ["route", "status"], registers: [registry] });2526export async function buildServer() {27  const app = Fastify({28    logger: {29      level: config.logLevel,30      ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}),31      redact: ["req.headers.authorization", "req.headers.cookie", "req.headers['x-websensor-owner']"],32    },33    trustProxy: true,34    bodyLimit: 512 * 1024,35    genReqId: () => newId("req"),36    disableRequestLogging: config.env === "production",37  });3839  // Canonical host redirect (apex → www) for every path.40  app.addHook("onRequest", async (req, reply) => {41    const host = (req.headers.host ?? "").split(":")[0];42    if (config.redirectApexToWww && host && host !== config.canonicalHost && host === config.canonicalHost.replace(/^www\./, "")) {43      return reply.redirect(`https://${config.canonicalHost}${req.url}`, 301);44    }45  });4647  await app.register(cors, { origin: true, methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: ["content-type", "x-websensor-owner", "x-websensor-admin", "authorization"], exposedHeaders: ["x-request-id", "x-ratelimit-limit", "x-ratelimit-remaining", "x-ratelimit-reset"] });48  await app.register(rateLimit, { max: 600, timeWindow: "1 minute", allowList: (req) => !req.url.startsWith("/api/") });49  await app.register(websocket, { options: { maxPayload: 64 * 1024 } });50  // Raw YAML bodies for the admin import endpoint.51  app.addContentTypeParser(["text/yaml", "application/yaml", "application/x-yaml", "text/plain"], { parseAs: "string" }, (_req, body, done) => done(null, body));5253  app.addHook("onSend", async (req, reply) => {54    reply.header("x-request-id", req.id);55    if (req.url.startsWith("/api/")) {56      const isOwner = Boolean(req.headers["x-websensor-owner"]) || req.url.includes("/admin/");57      reply.header("cache-control", req.method === "GET" && !isOwner ? "public, max-age=5, stale-while-revalidate=30" : "no-store");58      httpRequests.inc({ route: req.routeOptions?.url ?? "unknown", status: String(reply.statusCode) });59    }60  });6162  app.setErrorHandler((err: Error & { statusCode?: number; validation?: unknown; issues?: unknown }, req, reply) => {63    if (err.name === "ZodError" || err.issues) return reply.status(400).send({ error: "invalid_request", details: err.issues });64    if (err.statusCode && err.statusCode < 500) return reply.status(err.statusCode).send({ error: err.message });65    req.log.error({ err }, "unhandled error");66    return reply.status(500).send({ error: "internal_error", request_id: req.id });67  });6869  app.get("/api/metrics", async (_req, reply) => {70    reply.header("content-type", registry.contentType);71    return registry.metrics();72  });7374  await registerRoutes(app);75  await registerUserRoutes(app);76  await registerAdminRoutes(app);77  await registerLive(app);7879  // Frontend proxy — everything that is not /api/* goes to Next.js (loopback)80  // with forwarded headers so the app knows the public host/proto and the real client IP.81  await app.register(replyFrom, { base: config.webUrl, http: { requestOptions: { timeout: 60_000 } }, undici: { connections: 64, pipelining: 1 } });82  const forward = (req: FastifyRequest, reply: FastifyReply): FastifyReply => {83    if (req.url.startsWith("/api/")) return reply.status(404).send({ error: "not_found" });84    return reply.from(req.raw.url ?? "/", {85      rewriteRequestHeaders: (r, headers) => ({ ...headers, "x-forwarded-host": String(r.headers["x-forwarded-host"] ?? r.headers.host ?? ""), "x-forwarded-proto": String(r.headers["x-forwarded-proto"] ?? "https"), "x-real-ip": r.ip, "x-forwarded-for": r.ip }),86    });87  };88  const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"] as const;89  app.route({ method: [...methods], url: "/", handler: forward });90  app.route({ method: [...methods], url: "/*", handler: forward });9192  return app;93}9495async function main(): Promise<void> {96  const app = await buildServer();97  await app.listen({ port: config.port, host: config.host });98  app.log.info({ port: config.port, web: config.webUrl, canonical: config.canonicalHost }, "websensor gateway listening");99  const shutdown = async (): Promise<void> => {100    await closeLive();101    await app.close();102    await closeDb();103    process.exit(0);104  };105  process.on("SIGINT", () => void shutdown());106  process.on("SIGTERM", () => void shutdown());107}108109if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "")) {110  main().catch((e) => {111    console.error(e);112    process.exit(1);113  });114}115