server/src/index.ts
Rama main · réplica de lectura del repositorio original.
import dotenv from 'dotenv';
import path from 'path';
// Load environment variables from .env file (check parent directory for monorepo setup)
dotenv.config({ path: path.resolve(__dirname, '../../.env') });
dotenv.config({ path: path.resolve(__dirname, '../../../.env') });
dotenv.config();
import express, { Request, Response, NextFunction } from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import config, { validateConfig } from './config';
import { initializeDatabase, closeDatabase } from './db';
import routes from './routes';
import logger, { morganStream } from './utils/logger';
import { apiAuthMiddleware, ensureApiKey } from './middleware/apiAuth';
import { initializeServices } from './services/init';
import { applyEnvSettings } from './services/envSettings';
import { getScheduler } from './scheduler';
import { sendStartupHeartbeat } from './services/telemetry';
import { refreshAnnouncementsOnStartup } from './services/announcements';
// Create Express application
const app = express();
// Validate configuration on startup
const configValidation = validateConfig();
if (!configValidation.valid) {
logger.warn('Configuration warnings:');
configValidation.errors.forEach((err) => logger.warn(` - ${err}`));
}
// Security middleware
// Disable HSTS and CSP as app runs on HTTP in home server environments
app.use(
helmet({
contentSecurityPolicy: false,
hsts: false,
})
);
// CORS configuration
app.use(
cors({
origin: config.nodeEnv === 'production'
? process.env['CORS_ORIGIN'] || true
: true,
credentials: true,
})
);
// Request logging
app.use(
morgan(config.nodeEnv === 'production' ? 'combined' : 'dev', {
stream: morganStream,
skip: (req: Request) => req.path === '/api/health/ping',
})
);
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Serve static files in production
// In Docker: /app/public (copied from client build)
// In local prod: ../../client/dist (relative to server/dist)
if (config.nodeEnv === 'production') {
const dockerPath = path.join(__dirname, '../public');
const localPath = path.join(__dirname, '../../client/dist');
const clientPath = require('fs').existsSync(dockerPath) ? dockerPath : localPath;
app.use(express.static(clientPath));
logger.info(`Serving static files from: ${clientPath}`);
}
// API key authentication middleware
app.use('/api', apiAuthMiddleware);
// API routes
app.use('/api', routes);
// Root health check redirect (convenience)
app.get('/health', (_req: Request, res: Response) => {
res.redirect('/api/health');
});
// 404 handler for API routes
app.use('/api', (_req: Request, res: Response) => {
res.status(404).json({
success: false,
error: 'Endpoint not found',
});
});
// Serve client app for any other routes in production (SPA routing)
if (config.nodeEnv === 'production') {
const dockerIndex = path.join(__dirname, '../public/index.html');
const localIndex = path.join(__dirname, '../../client/dist/index.html');
const indexPath = require('fs').existsSync(dockerIndex) ? dockerIndex : localIndex;
// Catch-all for SPA routing - must be after API routes
app.use((_req: Request, res: Response) => {
res.sendFile(indexPath);
});
}
// Global error handler
interface ErrorWithStatus extends Error {
status?: number;
statusCode?: number;
}
app.use((err: ErrorWithStatus, _req: Request, res: Response, _next: NextFunction) => {
const statusCode = err.status || err.statusCode || 500;
const message = config.nodeEnv === 'production'
? 'Internal server error'
: err.message;
logger.error('Unhandled error:', {
message: err.message,
stack: err.stack,
statusCode,
});
res.status(statusCode).json({
success: false,
error: message,
...(config.nodeEnv !== 'production' && { stack: err.stack }),
});
});
// Start server
async function startServer(): Promise<void> {
try {
// Initialize database
logger.info('Initializing database...');
initializeDatabase();
// Ensure API key exists in settings
ensureApiKey();
// Service connections given as environment variables (see CONFIG_FROM_ENV)
applyEnvSettings();
// Initialize services (DeletionService, Sonarr, Radarr, Overseerr)
await initializeServices();
// Start the scheduler
const scheduler = getScheduler();
scheduler.start();
logger.info('Scheduler started');
// Start listening
const server = app.listen(config.port, () => {
logger.info(`Server started successfully`);
logger.info(` Environment: ${config.nodeEnv}`);
logger.info(` Port: ${config.port}`);
logger.info(` API: http://localhost:${config.port}/api`);
logger.info(` Health: http://localhost:${config.port}/api/health`);
// Anonymous install-count heartbeat. Fired after the server is already
// listening and never awaited, so an unreachable endpoint costs startup
// nothing. No-ops when telemetry is off or not yet due.
sendStartupHeartbeat();
// Same rules for the "What's new" feed: after listen, never awaited.
refreshAnnouncementsOnStartup();
});
// Graceful shutdown handlers
const shutdown = async (signal: string): Promise<void> => {
logger.info(`Received ${signal}, shutting down gracefully...`);
// Stop the scheduler
scheduler.stop();
logger.info('Scheduler stopped');
server.close(() => {
logger.info('HTTP server closed');
closeDatabase();
logger.info('Database connection closed');
process.exit(0);
});
// Force shutdown after 10 seconds
setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 10000);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
} catch (error) {
logger.error('Failed to start server:', error);
process.exit(1);
}
}
// Start the server
startServer();
export default app;