- "src/server/routes/system.ts" - "src/server/index.ts" - "src/index.ts" - "src/__tests__/missing-scan-api.test.ts" GSD-Task: S06/T02
216 lines
7.6 KiB
TypeScript
216 lines
7.6 KiB
TypeScript
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<typeof schema>;
|
|
scheduler: SchedulerService | null;
|
|
downloadService: DownloadService | null;
|
|
queueService: QueueService | null;
|
|
healthService: HealthService | null;
|
|
missingFileScanner: MissingFileScanner | null;
|
|
}
|
|
}
|
|
|
|
export interface BuildServerOptions {
|
|
db: LibSQLDatabase<typeof schema>;
|
|
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<FastifyInstance> {
|
|
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;
|
|
}
|
|
}
|