1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/server/src/app.ts
T

100 lines
3.9 KiB
TypeScript

/**
* 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();
});
}