thrilled-be-core
Version:
Core Express backend package with middleware, logging, security, and base application setup
645 lines (644 loc) • 25.4 kB
JavaScript
"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);
}
}
};
}