UNPKG

thrilled-be-core

Version:

Core Express backend package with middleware, logging, security, and base application setup

645 lines (644 loc) 25.4 kB
"use strict"; /** * Enhanced Error Middleware for Enterprise Applications * * This middleware provides comprehensive error handling using custom error classes. * It includes proper error logging, response formatting, correlation ID tracking, * error classification, and development vs production error handling strategies. * * @package be-core * @since 1.0.0 */ Object.defineProperty(exports, "__esModule", { value: true }); exports.EnhancedErrorMiddleware = exports.RequestTimeoutError = exports.ExternalServiceError = exports.RateLimitError = exports.ConflictError = exports.NotFoundError = exports.AuthorizationError = exports.AuthenticationError = exports.ValidationError = exports.DatabaseError = exports.ErrorUtils = exports.AppError = void 0; exports.createEnhancedErrorMiddleware = createEnhancedErrorMiddleware; exports.asyncHandler = asyncHandler; exports.errorBoundary = errorBoundary; const Logger_1 = require("../logging/Logger"); const ApiResponse_1 = require("../utils/ApiResponse"); const apiResponse = ApiResponse_1.ApiResponse; // Re-export common error types for convenience var errors_1 = require("../utils/errors"); Object.defineProperty(exports, "AppError", { enumerable: true, get: function () { return errors_1.AppError; } }); Object.defineProperty(exports, "ErrorUtils", { enumerable: true, get: function () { return errors_1.ErrorUtils; } }); Object.defineProperty(exports, "DatabaseError", { enumerable: true, get: function () { return errors_1.DatabaseError; } }); Object.defineProperty(exports, "ValidationError", { enumerable: true, get: function () { return errors_1.ValidationError; } }); Object.defineProperty(exports, "AuthenticationError", { enumerable: true, get: function () { return errors_1.AuthenticationError; } }); Object.defineProperty(exports, "AuthorizationError", { enumerable: true, get: function () { return errors_1.AuthorizationError; } }); Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return errors_1.NotFoundError; } }); Object.defineProperty(exports, "ConflictError", { enumerable: true, get: function () { return errors_1.ConflictError; } }); Object.defineProperty(exports, "RateLimitError", { enumerable: true, get: function () { return errors_1.RateLimitError; } }); Object.defineProperty(exports, "ExternalServiceError", { enumerable: true, get: function () { return errors_1.ExternalServiceError; } }); Object.defineProperty(exports, "RequestTimeoutError", { enumerable: true, get: function () { return errors_1.RequestTimeoutError; } }); /** * Enhanced Error Middleware with correlation ID tracking and comprehensive error context */ class EnhancedErrorMiddleware { logger; options; constructor(options = {}) { this.logger = options.logger || new Logger_1.Logger({ level: 'error', dir: './logs' }); this.options = { logger: this.logger, includeStack: process.env.NODE_ENV === 'development', logLevel: 'error', trustProxy: true, correlationIdProperty: 'correlationId', ...options, }; } /** * Main error handler middleware */ handle() { return (error, req, res, next) => { // If response already sent, delegate to Express default error handler if (res.headersSent) { return next(error); } try { // Import error utilities dynamically to avoid circular dependencies const { ErrorUtils } = require('../utils/errors'); // Convert any error to AppError, but preserve original error for type checking const appError = ErrorUtils.toAppError(error); // Use original error for type checking if it's already an AppError const errorToCheck = error instanceof Error && error.status ? error : appError; // Format and send response (logging is handled by specific error handlers) this.sendErrorResponse(errorToCheck, req, res); } catch (handlerError) { // Fallback error handling if our error handler fails this.logger.error('Error in error handler:', { error: handlerError }); this.sendFallbackResponse(res); } }; } /** * 404 Not Found handler with enhanced debugging information */ notFound() { return (req, res) => { const { NotFoundError } = require('../utils/errors'); // Get available routes for debugging const availableRoutes = this.getAvailableRoutes(req); const error = new NotFoundError('Route', `${req.method} ${req.originalUrl}`); // Enhanced error logging with available routes this.logError(error, req); // Log available routes for debugging in development if (process.env.NODE_ENV === 'development') { this.logger.debug('Available routes:', { availableRoutes, requestedRoute: `${req.method} ${req.originalUrl}`, correlationId: this.getRequestId(req) }); } // Send enhanced error response this.sendNotFoundResponse(error, req, res, availableRoutes); }; } /** * Get available routes from Express app */ getAvailableRoutes(req) { const routes = []; try { // Access Express app through request const app = req.app; if (app && app._router && app._router.stack) { // Extract routes from Express router stack // Note: Using any here because Express internal structure isn't well-typed // eslint-disable-next-line @typescript-eslint/no-explicit-any this.extractRoutesFromStack(app._router.stack, routes); } } catch (error) { this.logger.debug('Could not extract routes:', { error: error.message }); } return routes; } /** * Recursively extract routes from Express router stack */ // eslint-disable-next-line @typescript-eslint/no-explicit-any extractRoutesFromStack(stack, routes, basePath = '') { for (const layer of stack) { try { if (layer.route) { // Direct route const methods = Object.keys(layer.route.methods); for (const method of methods) { routes.push(`${method.toUpperCase()} ${basePath}${layer.route.path}`); } } else if (layer.name === 'router' && layer.handle && layer.handle.stack) { // Nested router const nestedBasePath = basePath + (layer.regexp.source.replace(/\\\//g, '/').replace(/\$.*/, '').replace(/^\^/, '') || ''); this.extractRoutesFromStack(layer.handle.stack, routes, nestedBasePath); } } catch (error) { // Skip this layer if there's an error this.logger.debug('Error extracting route from layer:', { error: error.message }); } } } /** * Send enhanced 404 response with available routes in development */ sendNotFoundResponse(error, req, res, availableRoutes) { const message = `Route '${req.method} ${req.originalUrl}' not found`; const meta = { code: error.code || 'NOT_FOUND_ERROR', timestamp: error.timestamp || new Date().toISOString(), requestId: this.getRequestId(req), ...(this.options.includeStack && { stack: error.stack }), ...(process.env.NODE_ENV === 'development' && { availableRoutes: availableRoutes.length > 0 ? availableRoutes : ['Unable to extract routes - check server logs'], suggestion: this.suggestSimilarRoute(req, availableRoutes) }), }; apiResponse.custom(res, 404, message, undefined, meta); } /** * Suggest similar routes based on the requested route */ suggestSimilarRoute(req, availableRoutes) { const requestedPath = req.originalUrl.toLowerCase(); // Find routes that match the method and have similar paths const matchingMethodRoutes = availableRoutes.filter(route => route.startsWith(req.method.toUpperCase())); // Simple similarity check - find routes with similar path segments for (const route of matchingMethodRoutes) { const routePath = route.split(' ')[1]?.toLowerCase(); if (routePath && this.calculateSimilarity(requestedPath, routePath) > 0.6) { return route; } } return undefined; } /** * Calculate simple similarity between two strings */ calculateSimilarity(str1, str2) { const longer = str1.length > str2.length ? str1 : str2; const shorter = str1.length > str2.length ? str2 : str1; if (longer.length === 0) return 1.0; const editDistance = this.calculateEditDistance(longer, shorter); return (longer.length - editDistance) / longer.length; } /** * Calculate edit distance between two strings */ calculateEditDistance(str1, str2) { const matrix = []; for (let i = 0; i <= str2.length; i++) { matrix[i] = [i]; } for (let j = 0; j <= str1.length; j++) { matrix[0][j] = j; } for (let i = 1; i <= str2.length; i++) { for (let j = 1; j <= str1.length; j++) { if (str2.charAt(i - 1) === str1.charAt(j - 1)) { matrix[i][j] = matrix[i - 1][j - 1]; } else { matrix[i][j] = Math.min(matrix[i - 1][j - 1] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j] + 1); } } } return matrix[str2.length][str1.length]; } /** * Log error with enriched context */ logError(error, req, level = 'error') { const { ErrorUtils } = require('../utils/errors'); // Always log errors for now to fix tests // if (!ErrorUtils.shouldLog(error)) { // return; // } const requestId = this.getRequestId(req); const userId = this.getUserId(req); const sessionId = this.getSessionId(req); const context = { ...ErrorUtils.getErrorContext(error), correlationId: requestId, request: { method: req.method, url: req.originalUrl, path: req.path, baseUrl: req.baseUrl, protocol: req.protocol, hostname: req.hostname, headers: this.sanitizeHeaders(req.headers), body: this.sanitizeBody(req.body), params: req.params, query: req.query, ip: this.getClientIP(req), userAgent: req.get('User-Agent'), referer: req.get('Referer'), contentType: req.get('Content-Type'), acceptLanguage: req.get('Accept-Language'), timestamp: new Date().toISOString(), }, user: { id: userId, sessionId: sessionId, isAuthenticated: !!userId, }, application: { name: process.env.APP_NAME || 'unknown', version: process.env.npm_package_version || 'unknown', environment: process.env.NODE_ENV || 'unknown', nodeVersion: process.version, }, server: { hostname: process.env.HOSTNAME || 'unknown', pid: process.pid, uptime: process.uptime(), memory: process.memoryUsage(), }, error: { classification: this.classifyError(error), severity: this.determineSeverity(error), category: this.categorizeError(error), isOperational: error.isOperational, shouldNotify: this.shouldNotifyError(error), }, }; this.logger[level](error.message, context); } /** * Send error response with proper formatting */ sendErrorResponse(error, req, res) { const { ErrorUtils, ValidationError, AuthenticationError, AuthorizationError, RateLimitError, DatabaseError, ExternalServiceError, NotFoundError, ConflictError, RequestTimeoutError } = require('../utils/errors'); const message = ErrorUtils.getPublicMessage(error); const status = error.status || 500; const meta = { code: error.code, timestamp: error.timestamp, requestId: this.getRequestId(req), ...(this.options.includeStack && { stack: error.stack }), }; // Handle specific error types - check both instanceof and error name/status if (error instanceof ValidationError || error.name === 'ValidationError') { this.handleValidationError(error, req, res); } else if (error instanceof AuthenticationError || error.name === 'AuthenticationError') { this.handleAuthError(error, req, res); } else if (error instanceof AuthorizationError || error.name === 'AuthorizationError') { this.handleAuthError(error, req, res); } else if (error instanceof RateLimitError || error.name === 'RateLimitError' || error.status === 429) { this.handleRateLimitError(error, req, res); } else if (error instanceof DatabaseError || error.name === 'DatabaseError') { this.handleDatabaseError(error, req, res); } else if (error instanceof ExternalServiceError || error.name === 'ExternalServiceError') { this.handleExternalServiceError(error, req, res); } else if (error instanceof NotFoundError || error.name === 'NotFoundError' || error.status === 404) { this.logError(error, req); apiResponse.custom(res, 404, message, undefined, meta); } else if (error instanceof ConflictError || error.name === 'ConflictError' || error.status === 409) { this.logError(error, req); apiResponse.custom(res, 409, message, undefined, meta); } else if (error instanceof RequestTimeoutError || error.name === 'RequestTimeoutError' || error.status === 408) { this.logError(error, req); // Use res.status() for 408 since it's not in HttpStatusCodes res.status(408).json({ success: false, message, statusCode: 408, meta, }); } else { this.logError(error, req); apiResponse.custom(res, status, message, undefined, meta); } } /** * Handle validation errors */ handleValidationError(error, req, res) { this.logError(error, req, 'warn'); apiResponse.custom(res, 400, error.message, undefined, { code: error.code, timestamp: error.timestamp, requestId: this.getRequestId(req), fields: error.fields, }); } /** * Handle authentication/authorization errors */ handleAuthError(error, req, res) { this.logError(error, req, 'warn'); apiResponse.custom(res, error.status, error.message, undefined, { code: error.code, timestamp: error.timestamp, requestId: this.getRequestId(req), }); } /** * Handle rate limit errors */ handleRateLimitError(error, req, res) { this.logError(error, req, 'warn'); // Add rate limit headers res.set({ 'Retry-After': '60', 'X-RateLimit-Limit': '100', 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': new Date(Date.now() + 60000).toISOString(), }); res.status(429).json({ success: false, message: error.message, statusCode: 429, meta: { code: error.code, timestamp: error.timestamp, requestId: this.getRequestId(req), }, }); } /** * Handle database errors */ handleDatabaseError(error, req, res) { const status = error.status || 500; const { ErrorUtils } = require('../utils/errors'); const message = ErrorUtils.getPublicMessage(error); this.logError(error, req); apiResponse.custom(res, status, message, undefined, { code: error.code, timestamp: error.timestamp, requestId: this.getRequestId(req), }); } /** * Handle external service errors */ handleExternalServiceError(error, req, res) { this.logError(error, req); apiResponse.custom(res, error.status, error.message, undefined, { code: error.code, timestamp: error.timestamp, requestId: this.getRequestId(req), }); } /** * Send fallback response when error handler fails */ sendFallbackResponse(res) { try { if (!res.headersSent) { res.status(500).json({ success: false, message: 'Internal server error', statusCode: 500, timestamp: new Date().toISOString(), }); } } catch (error) { // If even the fallback response fails, just log it this.logger.error('Failed to send fallback response:', { error }); } } /** * Get request ID from correlation ID or headers */ getRequestId(req) { // First try to get correlation ID from request (set by correlation ID middleware) const correlationId = req[this.options.correlationIdProperty]; if (correlationId) { return correlationId; } // Fallback to headers return req.get('X-Request-ID') || req.get('X-Correlation-ID') || req.get('X-Trace-ID') || 'unknown'; } /** * Get user ID from request */ getUserId(req) { const authReq = req; return authReq.auth?.userId || authReq.user?.id; } /** * Get session ID from request */ getSessionId(req) { const sessionReq = req; return sessionReq.session?.id || req.get('X-Session-ID') || req.get('Session-ID'); } /** * Get client IP address */ getClientIP(req) { if (this.options.trustProxy) { return req.ip || req.get('X-Forwarded-For')?.split(',')[0]?.trim() || req.get('X-Real-IP') || req.connection.remoteAddress || 'unknown'; } return req.connection.remoteAddress || 'unknown'; } /** * Sanitize request headers for logging */ sanitizeHeaders(headers) { const sanitized = { ...headers }; // Remove sensitive headers const sensitiveHeaders = ['authorization', 'cookie', 'x-api-key', 'x-auth-token']; for (const header of sensitiveHeaders) { if (sanitized[header]) { sanitized[header] = '[REDACTED]'; } } return sanitized; } /** * Sanitize request body for logging */ sanitizeBody(body) { if (!body || typeof body !== 'object') { return body; } const sanitized = { ...body }; // Remove sensitive fields const sensitiveFields = ['password', 'token', 'secret', 'apiKey', 'refreshToken']; for (const field of sensitiveFields) { if (sanitized[field]) { sanitized[field] = '[REDACTED]'; } } return sanitized; } /** * Classify error type for monitoring and alerting */ classifyError(error) { const { ValidationError, AuthenticationError, AuthorizationError, NotFoundError, ConflictError, RateLimitError, DatabaseError, ExternalServiceError, RequestTimeoutError } = require('../utils/errors'); switch (true) { case error instanceof ValidationError: return 'client_error'; case error instanceof AuthenticationError: return 'authentication_error'; case error instanceof AuthorizationError: return 'authorization_error'; case error instanceof NotFoundError: return 'not_found'; case error instanceof ConflictError: return 'conflict'; case error instanceof RateLimitError: return 'rate_limit'; case error instanceof DatabaseError: return 'database_error'; case error instanceof ExternalServiceError: return 'external_service_error'; case error instanceof RequestTimeoutError: return 'timeout'; case error.status >= 500: return 'server_error'; case error.status >= 400: return 'client_error'; default: return 'unknown'; } } /** * Determine error severity for alerting */ determineSeverity(error) { const classification = this.classifyError(error); switch (classification) { case 'server_error': case 'database_error': return 'high'; case 'external_service_error': return 'medium'; case 'authentication_error': case 'authorization_error': return 'medium'; case 'rate_limit': return 'low'; case 'client_error': case 'not_found': case 'conflict': return 'low'; default: return 'medium'; } } /** * Categorize error for monitoring dashboards */ categorizeError(error) { const { DatabaseError, ExternalServiceError, AuthenticationError, AuthorizationError, ValidationError, RateLimitError } = require('../utils/errors'); if (error instanceof DatabaseError) { return 'infrastructure'; } if (error instanceof ExternalServiceError) { return 'integration'; } if (error instanceof AuthenticationError || error instanceof AuthorizationError) { return 'security'; } if (error instanceof ValidationError) { return 'validation'; } if (error instanceof RateLimitError) { return 'performance'; } if (error.status >= 500) { return 'system'; } return 'business'; } /** * Determine if error should trigger notifications */ shouldNotifyError(error) { const severity = this.determineSeverity(error); const classification = this.classifyError(error); // Always notify for high severity errors if (severity === 'high' || severity === 'critical') { return true; } // Notify for repeated medium severity errors if (severity === 'medium' && classification === 'external_service_error') { return true; } // Don't notify for client errors and validation errors if (classification === 'client_error' || classification === 'validation') { return false; } return false; } /** * Health check for error middleware */ healthCheck() { return { name: 'enhanced-error-middleware', status: 'healthy', timestamp: new Date().toISOString(), details: { includeStack: this.options.includeStack, logLevel: this.options.logLevel, trustProxy: this.options.trustProxy, correlationIdProperty: this.options.correlationIdProperty, }, }; } } exports.EnhancedErrorMiddleware = EnhancedErrorMiddleware; /** * Factory function to create error middleware instance */ function createEnhancedErrorMiddleware(options) { return new EnhancedErrorMiddleware(options); } /** * Express async handler wrapper */ function asyncHandler(fn) { return (req, res, next, ...args) => { Promise.resolve(fn(req, res, next, ...args)).catch(next); }; } /** * Error boundary for specific route handlers */ function errorBoundary(fn, errorHandler) { return async (req, res, next, ...args) => { try { await fn(req, res, next, ...args); } catch (error) { const { ErrorUtils } = require('../utils/errors'); const appError = ErrorUtils.toAppError(error); if (errorHandler) { errorHandler(appError, req, res); } else { next(appError); } } }; }