TypeScript 55.4%
Python 43.2%
SQL 1.2%
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