import Fastify, { type FastifyInstance } from 'fastify'; import cors from '@fastify/cors'; import fastifyStatic from '@fastify/static'; import middie from '@fastify/middie'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { type LibSQLDatabase } from 'drizzle-orm/libsql'; import type * as schema from '../db/schema/index'; import { appConfig } from '../config/index'; import { authPlugin } from './middleware/auth'; import { errorHandlerPlugin } from './middleware/error-handler'; import { healthRoutes } from './routes/health'; import { systemRoutes } from './routes/system'; import { channelRoutes } from './routes/channel'; import { formatProfileRoutes } from './routes/format-profile'; import { downloadRoutes } from './routes/download'; import { queueRoutes } from './routes/queue'; import { historyRoutes } from './routes/history'; import { contentRoutes } from './routes/content'; import { notificationRoutes } from './routes/notification'; import { platformSettingsRoutes } from './routes/platform-settings'; import { scanRoutes } from './routes/scan'; import { collectRoutes } from './routes/collect'; import { playlistRoutes } from './routes/playlist'; import { adhocDownloadRoutes } from './routes/adhoc-download'; import { mediaServerRoutes } from './routes/media-server'; import { websocketRoutes } from './routes/websocket'; import type { SchedulerService } from '../services/scheduler'; import type { DownloadService } from '../services/download'; import type { QueueService } from '../services/queue'; import type { HealthService } from '../services/health'; import type { DownloadEventBus } from '../services/event-bus'; import type { MissingFileScanner } from '../services/missing-file-scanner'; import type { ViteDevServer } from 'vite'; // Extend Fastify's type system so routes can access the database and scheduler declare module 'fastify' { interface FastifyInstance { db: LibSQLDatabase; scheduler: SchedulerService | null; downloadService: DownloadService | null; queueService: QueueService | null; healthService: HealthService | null; missingFileScanner: MissingFileScanner | null; } } export interface BuildServerOptions { db: LibSQLDatabase; eventBus?: DownloadEventBus; /** Vite dev server instance for HMR in development — omit in production */ vite?: ViteDevServer; } /** * Create and configure the Fastify server instance. * Registers CORS, auth middleware, error handler, and all route plugins. * The database is decorated onto the instance so routes can access it via `fastify.db`. */ export async function buildServer(opts: BuildServerOptions): Promise { const server = Fastify({ logger: { level: appConfig.logLevel, // Redact API key from request logs serializers: { req(request) { return { method: request.method, url: sanitizeUrl(request.url), hostname: request.hostname, remoteAddress: request.ip, }; }, }, }, }); // Decorate with database instance for route access server.decorate('db', opts.db); // Decorate with scheduler (null until set by startup code) server.decorate('scheduler', null); // Decorate with download service (null until set by startup code) server.decorate('downloadService', null); // Decorate with queue service (null until set by startup code) server.decorate('queueService', null); // Decorate with health service (null until set by startup code) server.decorate('healthService', null); // Decorate with missing file scanner (null until set by startup code) server.decorate('missingFileScanner', null); // Register CORS — permissive for development, tightened later await server.register(cors, { origin: true }); // Register centralized error handler await server.register(errorHandlerPlugin); // Register API key authentication await server.register(authPlugin); // Register route modules await server.register(healthRoutes); await server.register(systemRoutes); await server.register(channelRoutes); await server.register(formatProfileRoutes); await server.register(downloadRoutes); await server.register(queueRoutes); await server.register(historyRoutes); await server.register(contentRoutes); await server.register(notificationRoutes); await server.register(platformSettingsRoutes); await server.register(scanRoutes); await server.register(collectRoutes); await server.register(playlistRoutes); await server.register(adhocDownloadRoutes); await server.register(mediaServerRoutes); // Register WebSocket route (before static file serving so /ws is handled) if (opts.eventBus) { await server.register(websocketRoutes, { eventBus: opts.eventBus }); } // ── Frontend serving ── // Dev mode: Vite middleware handles HMR, module transforms, and index.html // Production: @fastify/static serves the built frontend from dist/frontend/ if (opts.vite) { // Register @fastify/middie to support Connect-style middleware await server.register(middie); // Pipe Vite's dev middleware through Fastify (HMR websocket, module transforms, etc.) server.use(opts.vite.middlewares); // SPA catch-all: transform and serve index.html through Vite's pipeline const vite = opts.vite; server.setNotFoundHandler(async (request, reply) => { if ( request.method === 'GET' && !request.url.startsWith('/api/') && request.url !== '/ping' ) { try { const indexPath = join(vite.config.root, 'index.html'); let html = readFileSync(indexPath, 'utf-8'); html = await vite.transformIndexHtml(request.url, html); return reply.type('text/html').send(html); } catch (err) { // Let Vite fix the stack trace for better DX if (err instanceof Error) vite.ssrFixStacktrace(err); throw err; } } return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: `Route ${request.method}:${request.url} not found`, }); }); } else { // Production: serve pre-built static frontend const frontendDir = join(process.cwd(), 'dist', 'frontend'); if (existsSync(frontendDir)) { await server.register(fastifyStatic, { root: frontendDir, prefix: '/', wildcard: false, }); // SPA catch-all: serve index.html for any GET request that isn't an API route, // /ping, or a static file. API and non-GET requests get a standard 404 JSON. server.setNotFoundHandler(async (request, reply) => { if ( request.method === 'GET' && !request.url.startsWith('/api/') && request.url !== '/ping' ) { return reply.sendFile('index.html'); } return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: `Route ${request.method}:${request.url} not found`, }); }); } else { // No frontend build — standard 404 for all unknown routes server.setNotFoundHandler(async (_request, reply) => { return reply.status(404).send({ statusCode: 404, error: 'Not Found', message: 'Route not found', }); }); } } return server; } /** * Strip API key query parameters from URLs before logging. * Prevents accidental credential leakage in log output. */ function sanitizeUrl(url: string): string { try { // Replace apikey query param value with [REDACTED] return url.replace(/([?&])apikey=[^&]*/gi, '$1apikey=[REDACTED]'); } catch { return url; } }