tubearr/src/server/index.ts
jlightner a11c4c56c5 test: Added missing-scan API (trigger + status) and content requeue end…
- "src/server/routes/system.ts"
- "src/server/index.ts"
- "src/index.ts"
- "src/__tests__/missing-scan-api.test.ts"

GSD-Task: S06/T02
2026-04-04 06:35:58 +00:00

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;
}
}