/** * The one server. * * One Fastify instance on `127.0.0.1:8431` serving `/api/v1/*`, behind Caddy, * with one systemd unit and one Caddy snippet. Three separate backends were * designed for this box โ€” three ports, three frameworks, three deploy files that * would have overwritten each other โ€” and this is the one that replaces them. * Weather, flights, markers and offices are route modules here, not services on * ports of their own. CONTRACT.md ยง5. * * `buildApp` never listens. Tests build one and use `inject()`; `index.ts` builds * one and binds it. */ import Fastify, { type FastifyInstance } from "fastify"; import { registerCachePolicy } from "./cache.ts"; import { loadConfig, type Config } from "./config.ts"; import { registerFlights } from "./routes/flights.ts"; import { registerHealth } from "./routes/health.ts"; import { registerMarkers } from "./routes/markers.ts"; import { registerMedia } from "./routes/media.ts"; import { registerOffices } from "./routes/offices.ts"; import { registerPresence } from "./routes/presence.ts"; import { registerRealtime } from "./routes/realtime.ts"; import { registerSatellites } from "./routes/satellites.ts"; import { registerSession } from "./routes/session.ts"; import { registerWeather } from "./routes/weather.ts"; import { createServices } from "./services.ts"; import type { ErrorBody } from "../../src/server/wire.ts"; export function buildApp(config: Config = loadConfig()): FastifyInstance { const app = Fastify({ logger: { level: config.logLevel }, // Caddy is the only thing that talks to this socket, so its X-Forwarded-For // is the client address. Nothing else can reach the port to forge one. trustProxy: true, }); // One loud line per demotion, at boot, before anything can be served. The // health body carries the same list for anyone without log access. for (const line of config.degraded) { app.log.warn(`TERA DEGRADED: ${line}`); } registerCachePolicy(app); registerCors(app, config.corsOrigins); const services = createServices(config, app.log); registerHealth(app, services); registerFlights(app, services); registerSatellites(app, services); registerWeather(app, services); registerMarkers(app, services); registerMedia(app, services); registerOffices(app, services); registerPresence(app, services); registerRealtime(app, services); registerSession(app, services); app.setNotFoundHandler(async (_req, reply) => { const body: ErrorBody = { error: "not_found", message: "No such route." }; return reply.code(404).send(body); }); app.setErrorHandler(async (err, _req, reply) => { app.log.error({ err }, "unhandled error"); const body: ErrorBody = { error: "internal", message: "Something went wrong." }; return reply.code(500).send(body); }); return app; } /** * CORS, by hand. * * The default is an empty allowlist โ€” the browser build is served from the same * origin through Caddy, so nothing needs this until somebody runs the dev server * on a different port and sets `TERA_CORS_ORIGIN`. A plugin whose entire job is * fifteen lines of header-setting is not worth the dependency, and the fifteen * lines being visible here is worth something on a route that decides who may * read the API from where. */ function registerCors(app: FastifyInstance, allowed: string[]): void { if (allowed.length === 0) return; app.addHook("onRequest", async (req, reply) => { const origin = req.headers.origin; if (typeof origin !== "string" || !allowed.includes(origin)) return; reply.header("access-control-allow-origin", origin); reply.header("access-control-allow-headers", "authorization, content-type"); reply.header("access-control-allow-methods", "GET, POST, OPTIONS"); // Whatever the response ends up being, it depended on the Origin header. reply.header("vary", "Origin"); if (req.method === "OPTIONS") await reply.code(204).send(); }); }