Separate app construction from server startup

buildApp() returns the configured Fastify instance without listening, so
tests can drive it with inject(). Shutdown of database, Redis and queue
connections now lives in one place shared by the server and worker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
2026-10-03 11:28:17 -04:00
co-authored by Claude Opus 5.5
parent 0122a93212
commit 95cd740e51
5 changed files with 84 additions and 60 deletions
+64
View File
@@ -0,0 +1,64 @@
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import websocket from '@fastify/websocket';
import Fastify from 'fastify';
import { ZodError } from 'zod';
import { sql } from './db';
import { env } from './env';
import { loadUser } from './lib/auth';
import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { authRoutes } from './routes/auth';
import { connectionRoutes } from './routes/connections';
import { meRoutes } from './routes/me';
import { realtimeRoutes } from './routes/realtime';
import { trackRoutes } from './routes/tracks';
import { userRoutes } from './routes/users';
import { webhookRoutes } from './routes/webhooks';
/** Builds the API without listening, so tests can drive it with app.inject(). */
export async function buildApp({ logger = true, rateLimits = true }: { logger?: boolean; rateLimits?: boolean } = {}) {
const app = Fastify({ logger, trustProxy: true });
await app.register(cors, {
origin: [env.WEB_URL],
credentials: true,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
});
await app.register(cookie);
// Rate limits are per-route (see `config.rateLimit`); the Redis store makes them global across instances.
if (rateLimits) await app.register(rateLimit, { global: false, redis });
await app.register(websocket);
app.decorateRequest('user', null);
app.decorateRequest('sessionId', null);
app.addHook('onRequest', loadUser);
app.setErrorHandler((err, req, reply) => {
if (err instanceof ZodError) {
return reply.status(400).send({ error: err.issues[0]?.message ?? 'Invalid input', issues: err.issues });
}
if (err instanceof HttpError) return reply.status(err.status).send({ error: err.message });
const status = (err as { statusCode?: number }).statusCode;
if (status && status < 500) return reply.status(status).send({ error: (err as Error).message });
req.log.error(err);
return reply.status(500).send({ error: 'Internal server error' });
});
app.get('/health', async () => {
await Promise.all([sql`select 1`, redis.ping()]);
return { ok: true };
});
await app.register(authRoutes);
await app.register(meRoutes);
await app.register(userRoutes);
await app.register(trackRoutes);
await app.register(connectionRoutes);
await app.register(realtimeRoutes);
// registered as its own plugin so its permissive body parsers stay scoped to webhook routes
await app.register(webhookRoutes);
return app;
}