import { join } from 'node:path'; import formbody from '@fastify/formbody'; import rateLimit from '@fastify/rate-limit'; import secureSession from '@fastify/secure-session'; import fastifyStatic from '@fastify/static'; import Fastify from 'fastify'; import { HOST, PORT, RATE_LIMIT_PER_SEC, ROOT, SESSION_SECRET } from './config.js'; import { startWatchers } from './lib/dataReload.js'; import { preloadMappings } from './lib/imdbMapping.js'; import { getRatings } from './lib/imdbRatings.js'; import { httpDuration, httpRequests, imdbRatingsCount, searchWorkers } from './lib/metrics.js'; import { getPool } from './lib/searchEngine.js'; import adminRoutes from './routes/admin.js'; import apiRoutes from './routes/api.js'; import healthRoutes from './routes/health.js'; import searchRoutes from './routes/search.js'; const fastify = Fastify({ logger: true, trustProxy: true }); await fastify.register(rateLimit, { max: RATE_LIMIT_PER_SEC, timeWindow: '1 second', // Skip rate limiting for /health and /metrics so monitoring is never throttled skipOnError: true, allowList: (req) => req.url === '/health' || req.url === '/metrics', }); await fastify.register(formbody); await fastify.register(secureSession, { secret: SESSION_SECRET.padEnd(32, '0').slice(0, 32), salt: 'proxytmdb-salt-1', cookieName: 'session', cookie: { path: '/', httpOnly: true, sameSite: 'lax', secure: 'auto' }, }); // Per-request timing for Prometheus. fastify.addHook('onRequest', (_req, reply, done) => { reply.startTime = process.hrtime.bigint(); done(); }); fastify.addHook('onResponse', (req, reply, done) => { const route = req.routeOptions?.url || req.url.split('?')[0] || 'unknown'; const labels = { method: req.method, route, status: String(reply.statusCode) }; httpRequests.inc(labels); if (reply.startTime) { const seconds = Number(process.hrtime.bigint() - reply.startTime) / 1e9; httpDuration.observe(labels, seconds); } done(); }); // Public static UI at / await fastify.register(fastifyStatic, { root: join(ROOT, 'public'), serve: true, index: 'index.html', prefix: '/', }); await fastify.register(adminRoutes); await fastify.register(apiRoutes); await fastify.register(searchRoutes); await fastify.register(healthRoutes); // Serve raw project files only under /admin/files (still session-protected // at the listing level because the listing page itself requires auth). await fastify.register(fastifyStatic, { root: ROOT, serve: true, index: false, list: false, decorateReply: false, prefix: '/admin/files/', }); fastify.ready().then(async () => { try { const ratings = await getRatings(); imdbRatingsCount.set(ratings.size); const movie = getPool('movie'); const tv = getPool('tv'); searchWorkers.set({ type: 'movie' }, movie.workers.length); searchWorkers.set({ type: 'tv' }, tv.workers.length); const maps = await preloadMappings(); startWatchers(); fastify.log.info( { ratings: ratings.size, imdb_movie: maps.movie, imdb_tv: maps.tv }, 'Warmup complete (data hot-reload watchers started)', ); } catch (err) { fastify.log.warn({ err }, 'Warmup failed'); } }); fastify.listen({ port: PORT, host: HOST }).catch((err) => { fastify.log.error(err); process.exit(1); });