import Fastify, { type FastifyReply, type FastifyRequest } from "fastify"; import cors from "@fastify/cors"; import rateLimit from "@fastify/rate-limit"; import websocket from "@fastify/websocket"; import replyFrom from "@fastify/reply-from"; import client from "prom-client"; import { newId } from "@websensor/core"; import { closeDb } from "@websensor/db"; import { config } from "./config"; import { closeLive, registerLive } from "./live"; import { registerRoutes } from "./routes"; import { registerAdminRoutes } from "./routes-admin"; import { registerUserRoutes } from "./routes-user"; /** * WebSensor gateway: public entry point behind ngrok. * /api/v1/* REST + WebSocket (/api/v1/live) * /api/health /api/ready /api/metrics * everything else → Next.js frontend (loopback), with forwarded headers. * Also enforces the canonical host (websensor.io → www.websensor.io). */ const registry = new client.Registry(); client.collectDefaultMetrics({ register: registry, prefix: "websensor_api_" }); const httpRequests = new client.Counter({ name: "websensor_api_requests_total", help: "API requests", labelNames: ["route", "status"], registers: [registry] }); export async function buildServer() { const app = Fastify({ logger: { level: config.logLevel, ...(config.env !== "production" ? { transport: { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss" } } } : {}), redact: ["req.headers.authorization", "req.headers.cookie", "req.headers['x-websensor-owner']"], }, trustProxy: true, bodyLimit: 512 * 1024, genReqId: () => newId("req"), disableRequestLogging: config.env === "production", }); // Canonical host redirect (apex → www) for every path. app.addHook("onRequest", async (req, reply) => { const host = (req.headers.host ?? "").split(":")[0]; if (config.redirectApexToWww && host && host !== config.canonicalHost && host === config.canonicalHost.replace(/^www\./, "")) { return reply.redirect(`https://${config.canonicalHost}${req.url}`, 301); } }); 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"] }); await app.register(rateLimit, { max: 600, timeWindow: "1 minute", allowList: (req) => !req.url.startsWith("/api/") }); await app.register(websocket, { options: { maxPayload: 64 * 1024 } }); // Raw YAML bodies for the admin import endpoint. app.addContentTypeParser(["text/yaml", "application/yaml", "application/x-yaml", "text/plain"], { parseAs: "string" }, (_req, body, done) => done(null, body)); app.addHook("onSend", async (req, reply) => { reply.header("x-request-id", req.id); if (req.url.startsWith("/api/")) { const isOwner = Boolean(req.headers["x-websensor-owner"]) || req.url.includes("/admin/"); reply.header("cache-control", req.method === "GET" && !isOwner ? "public, max-age=5, stale-while-revalidate=30" : "no-store"); httpRequests.inc({ route: req.routeOptions?.url ?? "unknown", status: String(reply.statusCode) }); } }); app.setErrorHandler((err: Error & { statusCode?: number; validation?: unknown; issues?: unknown }, req, reply) => { if (err.name === "ZodError" || err.issues) return reply.status(400).send({ error: "invalid_request", details: err.issues }); if (err.statusCode && err.statusCode < 500) return reply.status(err.statusCode).send({ error: err.message }); req.log.error({ err }, "unhandled error"); return reply.status(500).send({ error: "internal_error", request_id: req.id }); }); app.get("/api/metrics", async (_req, reply) => { reply.header("content-type", registry.contentType); return registry.metrics(); }); await registerRoutes(app); await registerUserRoutes(app); await registerAdminRoutes(app); await registerLive(app); // Frontend proxy — everything that is not /api/* goes to Next.js (loopback) // with forwarded headers so the app knows the public host/proto and the real client IP. await app.register(replyFrom, { base: config.webUrl, http: { requestOptions: { timeout: 60_000 } }, undici: { connections: 64, pipelining: 1 } }); const forward = (req: FastifyRequest, reply: FastifyReply): FastifyReply => { if (req.url.startsWith("/api/")) return reply.status(404).send({ error: "not_found" }); return reply.from(req.raw.url ?? "/", { 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 }), }); }; const methods = ["GET", "HEAD", "POST", "PUT", "DELETE", "PATCH"] as const; app.route({ method: [...methods], url: "/", handler: forward }); app.route({ method: [...methods], url: "/*", handler: forward }); return app; } async function main(): Promise { const app = await buildServer(); await app.listen({ port: config.port, host: config.host }); app.log.info({ port: config.port, web: config.webUrl, canonical: config.canonicalHost }, "websensor gateway listening"); const shutdown = async (): Promise => { await closeLive(); await app.close(); await closeDb(); process.exit(0); }; process.on("SIGINT", () => void shutdown()); process.on("SIGTERM", () => void shutdown()); } if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop() ?? "")) { main().catch((e) => { console.error(e); process.exit(1); }); }