bitip/src/backend/index.ts

165 lines
4.6 KiB
TypeScript

// Copyright (c) 2025 Tudor Stanciu
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import path from 'path';
import { setTimeout } from 'timers';
import apiRoutes from './routes/api';
import apiKeyAuth from './middleware/auth';
import dynamicRateLimit from './middleware/rateLimit';
import { errorHandler } from './middleware/errorHandler';
import config from './services/config';
import logger from './services/logger';
import { generateRuntimeConfig } from './services/runtimeConfig';
import { healthCheckHandler } from './handlers/healthHandler';
import { paths } from './utils/paths';
const app = express();
// Security middleware
app.use(
helmet({
contentSecurityPolicy: config.enableHttpsSecurity
? {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", 'https://unpkg.com'],
scriptSrc: ["'self'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
}
: false,
strictTransportSecurity: config.enableHttpsSecurity,
crossOriginOpenerPolicy: config.enableHttpsSecurity,
crossOriginResourcePolicy: config.enableHttpsSecurity,
originAgentCluster: false, // Disable Origin-Agent-Cluster header to avoid browser warnings
})
);
app.use(
cors({
origin: process.env.NODE_ENV === 'production' ? false : true,
credentials: true,
})
);
app.use(compression());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request logging
app.use((req, res, next) => {
logger.debug('Incoming request', {
method: req.method,
path: req.path,
ip: req.ip,
userAgent: req.headers['user-agent'],
});
next();
});
// Apply base path if configured
const basePath = config.basePath.endsWith('/')
? config.basePath.slice(0, -1)
: config.basePath;
// Serve static frontend files
const frontendPath = paths.frontendDir;
// Generate runtime configuration (env.js) at startup
generateRuntimeConfig(frontendPath, basePath);
// Health check endpoint at root (always accessible for Docker HEALTHCHECK)
app.get('/api/health', healthCheckHandler);
// API routes with authentication and rate limiting
app.use(`${basePath}/api`, apiKeyAuth, dynamicRateLimit, apiRoutes);
// Serve static frontend files
app.use(basePath, express.static(frontendPath));
// Fallback to index.html for client-side routing (Express 5 syntax)
// Named wildcard required in path-to-regexp v8: /*path matches any path
app.get(`${basePath}/*path`, (_req, res): void => {
res.sendFile(path.join(frontendPath, 'index.html'));
});
// Global error handler (must be last middleware)
app.use(errorHandler);
// Start server
const server = app.listen(config.port, () => {
logger.info('Bitip GeoIP Service started', {
port: config.port,
basePath: config.basePath,
environment: process.env.NODE_ENV || 'development',
dbPath: config.maxmindDbPath,
});
});
// Graceful shutdown
let isShuttingDown = false;
const gracefulShutdown = async (signal: string): Promise<void> => {
if (isShuttingDown) {
logger.warn(`Shutdown already in progress, ignoring ${signal}`);
return;
}
isShuttingDown = true;
logger.info(`Received ${signal}, shutting down gracefully`);
// Set timeout slightly less than Docker's default (10s)
const forceExitTimeout = setTimeout(() => {
logger.error('Forced shutdown after timeout');
process.exit(1);
}, 8000);
try {
// Stop accepting new connections
await new Promise<void>((resolve, reject) => {
server.close(err => {
if (err) {
reject(err);
} else {
logger.info('Server closed successfully');
resolve();
}
});
});
// Flush logs before exit
await logger.flush();
logger.info('Shutdown complete');
clearTimeout(forceExitTimeout);
process.exit(0);
} catch (error) {
logger.error('Error during graceful shutdown', error as Error);
clearTimeout(forceExitTimeout);
process.exit(1);
}
};
// Docker sends SIGTERM for graceful shutdown
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
// Ctrl+C in development
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
// Nodemon uses SIGUSR2 for restart
process.on('SIGUSR2', () => gracefulShutdown('SIGUSR2')); // nodemon uses SIGUSR2
// Handle server errors
server.on('error', (error: Error) => {
logger.error('Server error', error);
process.exit(1);
});
export default app;