/** * 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 FastifyError, type FastifyInstance } from "fastify"; import { createBirdsService } from "./birds/index.ts"; import { registerCachePolicy } from "./cache.ts"; import { loadConfig, type Config } from "./config.ts"; import { createRadarService } from "./radar/index.ts"; import { registerBirds } from "./routes/birds.ts"; import { registerDevices } from "./routes/devices.ts"; import { registerFires } from "./routes/fires.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 { registerRadar } from "./routes/radar.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); registerFires(app, services); // The two sky feeds are constructed here rather than on `Services`. They own // their own cache and their own failure behaviour exactly as every other // service does, and neither can throw at a route. `sources.radar` and // `sources.birds` are on the health body — `routes/health.ts` reads them off // the config rather than off a service, which is what every line in that route // does and is why it still touches no upstream. registerRadar(app, createRadarService(config, app.log)); registerBirds(app, createBirdsService(config, app.log)); registerWeather(app, services); registerMarkers(app, services); registerMedia(app, services); registerOffices(app, services); registerPresence(app, services); registerDevices(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); }); /** * The one error handler, and the one place a client's mistake is told apart * from ours. * * It used to answer 500 to everything, which was right for the only thing * that reached it at the time — a route that threw. It stopped being right * the moment a route accepted a body: Fastify raises its own errors for a * payload that is not JSON, a content type it was not offered and a body over * a route's `bodyLimit`, and every one of those is the caller's mistake, * carries a 4xx `statusCode`, and was being reported as "something went wrong * on the server". An operator watching error rates cannot tell a broken box * from somebody POSTing nonsense at it, and the caller is told to retry * something that will never work. * * So a 4xx from the framework is passed through with its own status and the * `ErrorBody` shape every other refusal here uses; anything else is still a * 500 with nothing in it, because the inside of an exception is not a thing * to hand to the internet. */ app.setErrorHandler(async (err: FastifyError, req, reply) => { const status = typeof err.statusCode === "number" ? err.statusCode : 500; if (status >= 400 && status < 500) { req.log.info({ err: err.message, status }, "refused a malformed request"); const body: ErrorBody = status === 404 ? { error: "not_found", message: "No such route." } : { error: "bad_request", message: "That request could not be read." }; return reply.code(status).send(body); } 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(); }); }