import Fastify, { type FastifyInstance } from "fastify"; import { createAuth } from "./auth"; import { type Clock, createOffsetClock, systemClock } from "./clock"; import type { ServerConfig } from "./config"; import { createDatabase, type Database } from "./db/client"; import { registerErrorHandler } from "./http/errors"; import { requirePlayerId } from "./http/session"; import { appJobs } from "./jobs/definitions"; import { createPgBossQueue, type JobQueue } from "./jobs/queue"; import { registerBoardRoutes } from "./routes/boards"; import type { AppContext } from "./routes/context"; import { registerDispatchRoutes } from "./routes/dispatches"; import { registerFleetRoutes } from "./routes/fleet"; import { registerPlayerRoutes } from "./routes/player"; import { registerTestClockRoute } from "./routes/test-clock"; export interface AppDeps { clock?: Clock; /** An existing database (tests). Otherwise the app opens one and closes it on shutdown. */ database?: Database; /** Background job queue. Defaults to pg-boss on the app's database. */ jobs?: JobQueue; } /** Builds the Fastify app without listening, so tests can start it on any port. */ export function buildApp(config: ServerConfig, deps: AppDeps = {}): FastifyInstance { const database = deps.database ?? createDatabase(config.databaseUrl); const { db } = database; const auth = createAuth(db, { secret: config.authSecret, baseUrl: config.authUrl }); const clock = createOffsetClock(deps.clock ?? systemClock); const jobs = deps.jobs ?? createPgBossQueue(config.databaseUrl, { workers: config.workersEnabled, jobs: appJobs({ db, clock: clock.now }), }); const ctx: AppContext = { db, auth, clock: clock.now, // pg-boss timers run on the real clock, not the (possibly offset) game clock. toBaseTime: clock.toBase, jobs, options: { requirePlayer: (request) => requirePlayerId(request, auth, db), validateResponses: config.env !== "production", }, }; const app = Fastify({ // Structured JSON logs (pino); silenced in tests unless LOG_LEVEL says otherwise. logger: { level: config.logLevel }, }); // The queue starts with the app and stops before the database closes. app.addHook("onReady", () => jobs.start()); app.addHook("onClose", async () => { await jobs.stop(); if (!deps.database) await database.close(); }); registerErrorHandler(app); // Under /api so it is reachable through the same-domain proxy (decision 0.5.20). app.get("/api/health", async () => ({ status: "ok" })); registerPlayerRoutes(app, ctx); registerBoardRoutes(app, ctx); registerDispatchRoutes(app, ctx); registerFleetRoutes(app, ctx); if (config.env !== "production") registerTestClockRoute(app, clock); return app; }