@oxog/spark
Version:
Ultra-fast, zero-dependency Node.js web framework with security hardening, memory leak protection, and enhanced error handling
957 lines (868 loc) • 28 kB
JavaScript
/**
* @fileoverview Core Application class for the Spark Web Framework
* @author Spark Framework Team
* @since 1.0.0
* @version 1.0.0
*/
const http = require('http');
const https = require('https');
const cluster = require('cluster');
const os = require('os');
const EventEmitter = require('events');
const { URL } = require('url');
const Context = require('./context');
const Router = require('../router/router');
const { createMiddleware } = require('./middleware');
const { errorHandler } = require('../utils/async-handler');
/**
* Core Application class that extends EventEmitter to provide a web server framework
*
* The Application class is the foundation of the Spark framework, providing HTTP/HTTPS server
* capabilities, middleware support, routing, error handling, and graceful shutdown mechanisms.
* It supports both single-process and cluster modes for high-performance applications.
*
* @class Application
* @extends EventEmitter
* @since 1.0.0
*
* @fires Application#error - Emitted when an application error occurs
* @fires Application#listening - Emitted when the server starts listening
* @fires Application#close - Emitted when the server closes
* @fires Application#uncaughtException - Emitted when an uncaught exception occurs
* @fires Application#unhandledRejection - Emitted when an unhandled promise rejection occurs
*
* @example
* // Basic HTTP server
* const app = new Application({ port: 3000 });
*
* app.use((ctx, next) => {
* console.log(`${ctx.method} ${ctx.path}`);
* return next();
* });
*
* app.get('/', (ctx) => {
* ctx.json({ message: 'Hello World!' });
* });
*
* app.listen();
*
* @example
* // HTTPS server with security options
* const fs = require('fs');
* const app = new Application({
* port: 443,
* https: {
* key: fs.readFileSync('private-key.pem'),
* cert: fs.readFileSync('certificate.pem')
* },
* security: {
* cors: { origin: 'https://example.com' },
* rateLimit: { max: 100, window: 60000 }
* }
* });
*
* app.listen();
*
* @example
* // Cluster mode for multi-core systems
* const app = new Application({
* port: 8080,
* cluster: true
* });
*
* app.listen();
*/
class Application extends EventEmitter {
/**
* Create a new Application instance
*
* @param {Object} [options={}] - Configuration options for the application
* @param {number} [options.port=3000] - Port number to listen on (also reads from PORT env var)
* @param {string} [options.host='127.0.0.1'] - Host address to bind to (also reads from HOST env var)
* @param {boolean} [options.cluster=false] - Enable cluster mode for multi-core systems
* @param {boolean} [options.compression=true] - Enable response compression
* @param {Object} [options.https] - HTTPS configuration with key and cert properties
* @param {Object} [options.security] - Security configuration options
* @param {Object} [options.security.cors] - CORS configuration
* @param {Object} [options.security.rateLimit] - Rate limiting configuration
* @param {boolean} [options.security.csrf=true] - Enable CSRF protection
* @param {boolean} [options.security.helmet=true] - Enable security headers
* @param {boolean} [options.exitOnUncaughtException=true] - Exit process on uncaught exceptions
* @param {boolean} [options.exitOnUnhandledRejection=false] - Exit process on unhandled rejections
*
* @since 1.0.0
*
* @example
* // Basic configuration
* const app = new Application({
* port: 8080,
* host: '0.0.0.0'
* });
*
* @example
* // With security options
* const app = new Application({
* port: 443,
* security: {
* cors: { origin: ['https://example.com'] },
* rateLimit: { max: 200, window: 60000 },
* csrf: true
* }
* });
*/
constructor(options = {}) {
super();
// Set max event listeners to prevent warning
this.setMaxListeners(50);
this.options = {
port: process.env.PORT || 3000,
host: process.env.HOST || '127.0.0.1', // Secure default: localhost only
cluster: false,
compression: true,
security: {
cors: {
origin: false, // Secure default: CORS disabled
credentials: true,
maxAge: 86400
},
rateLimit: { max: 100, window: 60000 }, // More restrictive default
csrf: true, // Secure default: CSRF protection enabled
helmet: true, // Enable security headers by default
contentSecurityPolicy: true,
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
},
...options
};
/**
* Array of middleware functions
* @type {Function[]}
* @private
*/
this.middlewares = [];
/**
* Router instance for handling routes
* @type {Router}
* @private
*/
this.router = new Router();
/**
* HTTP/HTTPS server instance
* @type {http.Server|https.Server|null}
* @private
*/
this.server = null;
/**
* Whether the server is currently listening
* @type {boolean}
* @private
*/
this.listening = false;
/**
* Array of cleanup handler functions
* @type {Function[]}
* @private
*/
this.cleanupHandlers = [];
/**
* Middleware factory instance
* @type {Object}
* @private
*/
this.middleware = createMiddleware(this);
this.setupErrorHandling();
this.setupShutdownHandlers();
}
/**
* Set up error handling for the application
*
* Configures event listeners for application errors, uncaught exceptions,
* and unhandled promise rejections. Provides graceful error handling
* and optional automatic shutdown on critical errors.
*
* @private
* @since 1.0.0
*/
setupErrorHandling() {
this.on('error', (error) => {
console.error('Application error:', error);
});
// Handle uncaught exceptions with recovery option
process.on('uncaughtException', (error) => {
console.error('Uncaught Exception:', error);
// Emit error event for application-level handling
this.emit('uncaughtException', error);
// Only shutdown if explicitly configured or error is critical
if (this.options.exitOnUncaughtException !== false) {
console.error('Shutting down due to uncaught exception...');
this.gracefulShutdown();
} else {
console.error('Continuing after uncaught exception (not recommended for production)');
}
});
process.on('unhandledRejection', (reason, promise) => {
console.error('Unhandled Rejection at:', promise, 'reason:', reason);
// Emit error event for application-level handling
this.emit('unhandledRejection', reason, promise);
// Only shutdown if explicitly configured
if (this.options.exitOnUnhandledRejection === true) {
console.error('Shutting down due to unhandled rejection...');
this.gracefulShutdown();
}
});
}
/**
* Set up graceful shutdown handlers for process signals
*
* Configures signal handlers for SIGTERM, SIGINT, and SIGBREAK (Windows)
* to enable graceful shutdown of the application when receiving termination signals.
*
* @private
* @since 1.0.0
*/
setupShutdownHandlers() {
// Graceful shutdown on SIGTERM and SIGINT
const shutdownHandler = (signal) => {
console.log(`\nReceived ${signal}, starting graceful shutdown...`);
this.gracefulShutdown();
};
process.once('SIGTERM', () => shutdownHandler('SIGTERM'));
process.once('SIGINT', () => shutdownHandler('SIGINT'));
// Windows-specific shutdown handling
if (process.platform === 'win32') {
process.once('SIGBREAK', () => shutdownHandler('SIGBREAK'));
}
}
/**
* Add middleware to the application
*
* Middleware functions are executed in the order they are added. Each middleware
* function receives the context object and a next function to continue to the next middleware.
*
* @param {string|Function|Router} pathOrMiddleware - Path prefix, middleware function, or router instance
* @param {Function} [middleware] - Middleware function when first parameter is a path
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* // Global middleware
* app.use((ctx, next) => {
* console.log(`${ctx.method} ${ctx.path}`);
* return next();
* });
*
* @example
* // Path-specific middleware
* app.use('/api', (ctx, next) => {
* ctx.set('X-API-Version', '1.0');
* return next();
* });
*
* @example
* // Router middleware
* const router = new Router();
* router.get('/users', (ctx) => ctx.json({ users: [] }));
* app.use('/api', router);
*/
use(pathOrMiddleware, middleware) {
if (typeof pathOrMiddleware === 'function') {
this.middlewares.push(pathOrMiddleware);
// Register cleanup handler if middleware has one
if (pathOrMiddleware.cleanup) {
this.cleanupHandlers.push(pathOrMiddleware.cleanup);
}
} else if (typeof middleware === 'function') {
const wrappedMiddleware = async (ctx, next) => {
if (ctx.path.startsWith(pathOrMiddleware)) {
// Store the original path and prefix
const originalPath = ctx.path;
const strippedPath = ctx.path.slice(pathOrMiddleware.length) || '/';
// Set the path for the middleware
ctx.path = strippedPath;
ctx.mountpath = pathOrMiddleware;
try {
await middleware(ctx, next);
} finally {
// Restore the original state
ctx.path = originalPath;
delete ctx.mountpath;
}
} else {
await next();
}
};
this.middlewares.push(wrappedMiddleware);
// Register cleanup handler if middleware has one
if (middleware.cleanup) {
this.cleanupHandlers.push(middleware.cleanup);
}
} else if (typeof pathOrMiddleware === 'string' && middleware && typeof middleware === 'object' && middleware.handle) {
// This is a router being mounted with a path prefix
const mountPath = pathOrMiddleware;
const router = middleware;
this.middlewares.push((ctx, next) => {
if (ctx.path.startsWith(mountPath)) {
// Temporarily modify the path for the router
const originalPath = ctx.path;
ctx.path = ctx.path.slice(mountPath.length) || '/';
return router.handle(ctx, next).finally(() => {
// Restore original path
ctx.path = originalPath;
});
}
return next();
});
} else if (pathOrMiddleware && typeof pathOrMiddleware === 'object' && pathOrMiddleware.handle) {
// This is a router being mounted without path prefix
const router = pathOrMiddleware;
this.middlewares.push((ctx, next) => {
return router.handle(ctx, next);
});
}
return this;
}
/**
* Register a GET route handler
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.get('/', (ctx) => {
* ctx.json({ message: 'Hello World!' });
* });
*
* @example
* // With middleware
* app.get('/protected', authenticate, (ctx) => {
* ctx.json({ user: ctx.user });
* });
*/
get(path, ...handlers) {
this.router.get(path, ...handlers);
return this;
}
/**
* Register a POST route handler
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.post('/users', (ctx) => {
* const userData = ctx.body;
* ctx.json({ id: 123, ...userData });
* });
*/
post(path, ...handlers) {
this.router.post(path, ...handlers);
return this;
}
/**
* Register a PUT route handler
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.put('/users/:id', (ctx) => {
* const id = ctx.params.id;
* const userData = ctx.body;
* ctx.json({ id, ...userData });
* });
*/
put(path, ...handlers) {
this.router.put(path, ...handlers);
return this;
}
/**
* Register a DELETE route handler
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.delete('/users/:id', (ctx) => {
* const id = ctx.params.id;
* // Delete user logic here
* ctx.status(204).end();
* });
*/
delete(path, ...handlers) {
this.router.delete(path, ...handlers);
return this;
}
/**
* Register a PATCH route handler
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.patch('/users/:id', (ctx) => {
* const id = ctx.params.id;
* const updates = ctx.body;
* ctx.json({ id, ...updates });
* });
*/
patch(path, ...handlers) {
this.router.patch(path, ...handlers);
return this;
}
/**
* Register a HEAD route handler
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.head('/users/:id', (ctx) => {
* // Check if user exists
* ctx.status(200).end();
* });
*/
head(path, ...handlers) {
this.router.head(path, ...handlers);
return this;
}
/**
* Register an OPTIONS route handler
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.options('/api/*', (ctx) => {
* ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
* ctx.status(200).end();
* });
*/
options(path, ...handlers) {
this.router.options(path, ...handlers);
return this;
}
/**
* Register a route handler for all HTTP methods
*
* @param {string} path - Route path pattern
* @param {...Function} handlers - One or more handler functions
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* app.all('/api/*', (ctx, next) => {
* ctx.set('X-API-Version', '1.0');
* return next();
* });
*/
all(path, ...handlers) {
this.router.all(path, ...handlers);
return this;
}
/**
* Handle an incoming HTTP request
*
* Creates a new context object and executes the middleware chain.
* Catches and handles any errors that occur during request processing.
*
* @param {http.IncomingMessage} req - The HTTP request object
* @param {http.ServerResponse} res - The HTTP response object
* @returns {Promise<void>}
*
* @private
* @since 1.0.0
*/
async handleRequest(req, res) {
const ctx = new Context(req, res, this);
try {
await this.executeMiddleware(ctx);
} catch (error) {
await this.handleError(error, ctx);
}
}
/**
* Execute the middleware chain for a request
*
* Runs all registered middleware functions in order, followed by the router.
* If no middleware handles the request, returns a 404 Not Found response.
*
* @param {Context} ctx - The request context object
* @returns {Promise<void>}
*
* @private
* @since 1.0.0
*/
async executeMiddleware(ctx) {
const middlewares = [...this.middlewares];
middlewares.push((ctx, next) => {
return this.router.handle(ctx, next);
});
let index = 0;
const next = async (error) => {
// If an error is passed, handle it
if (error) {
await this.handleError(error, ctx);
return;
}
if (index >= middlewares.length) {
if (!ctx.responded) {
ctx.status(404).json({ error: 'Not Found' });
}
return;
}
const middleware = middlewares[index++];
await middleware(ctx, next);
};
await next();
}
/**
* Handle errors that occur during request processing
*
* Emits an 'error' event and sends an appropriate error response to the client.
* In development mode, includes the error message in the response.
*
* @param {Error} error - The error that occurred
* @param {Context} ctx - The request context object
* @returns {Promise<void>}
*
* @private
* @since 1.0.0
*/
async handleError(error, ctx) {
// Only emit error event if not in test mode
if (!process.env.TEST_MODE) {
this.emit('error', error, ctx);
}
if (!ctx.responded) {
try {
// Get status code from error or default to 500
const statusCode = error.status || error.statusCode || 500;
// Prepare error response
const errorResponse = {
error: error.message || 'Internal Server Error',
status: statusCode
};
// Add additional error properties if they exist
if (error.code) {
errorResponse.code = error.code;
}
if (error.details) {
errorResponse.details = error.details;
}
// Add stack trace in development
if (process.env.NODE_ENV === 'development') {
errorResponse.stack = error.stack;
}
ctx.status(statusCode).json(errorResponse);
} catch (responseError) {
console.error('Error sending error response:', responseError);
}
}
}
/**
* Create an HTTP or HTTPS server instance
*
* Creates either an HTTP or HTTPS server based on the configuration.
* Sets up error handling for server and client errors.
*
* @returns {http.Server|https.Server} The created server instance
*
* @private
* @since 1.0.0
*/
createServer() {
if (this.options.https) {
this.server = https.createServer(this.options.https, (req, res) => {
this.handleRequest(req, res);
});
} else {
this.server = http.createServer((req, res) => {
this.handleRequest(req, res);
});
}
this.server.on('error', (error) => {
this.emit('error', error);
});
this.server.on('clientError', (error, socket) => {
if (error.code === 'ECONNRESET' || !socket.writable) {
return;
}
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});
return this.server;
}
/**
* Start listening for incoming requests
*
* Starts the server in either cluster mode (if configured) or single-process mode.
* Supports various parameter combinations for flexibility.
*
* @param {number|Function} [port] - Port number or callback function
* @param {string|Function} [host] - Host address or callback function
* @param {Function} [callback] - Callback function called when server starts listening
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* // Use default port and host
* app.listen();
*
* @example
* // Specify port
* app.listen(8080);
*
* @example
* // Specify port and host
* app.listen(8080, '0.0.0.0');
*
* @example
* // With callback
* app.listen(3000, () => {
* console.log('Server started on port 3000');
* });
*/
listen(port, host, callback) {
if (typeof port === 'function') {
callback = port;
port = this.options.port;
host = this.options.host;
} else if (typeof host === 'function') {
callback = host;
host = this.options.host;
}
port = port !== undefined ? port : this.options.port;
host = host || this.options.host;
if (this.options.cluster && cluster.isMaster) {
return this.startCluster(port, host, callback);
}
return this.startServer(port, host, callback);
}
/**
* Start the application in cluster mode
*
* Creates worker processes equal to the number of CPU cores.
* Automatically restarts workers if they die.
*
* @param {number} port - Port number to listen on
* @param {string} host - Host address to bind to
* @param {Function} [callback] - Callback function called when cluster starts
* @returns {Application} The application instance for method chaining
*
* @private
* @since 1.0.0
*/
startCluster(port, host, callback) {
const numCPUs = os.cpus().length;
console.log(`Starting cluster with ${numCPUs} workers...`);
for (let i = 0; i < numCPUs; i++) {
cluster.fork();
}
cluster.on('exit', (worker, code, signal) => {
console.log(`Worker ${worker.process.pid} died. Starting a new worker...`);
cluster.fork();
});
if (callback) {
callback();
}
return this;
}
/**
* Start the server in single-process mode
*
* Creates the server and starts listening on the specified port and host.
* Emits a 'listening' event when the server is ready.
*
* @param {number} port - Port number to listen on
* @param {string} host - Host address to bind to
* @param {Function} [callback] - Callback function called when server starts listening
* @returns {Application} The application instance for method chaining
*
* @throws {Error} Throws an error if the server is already listening
*
* @private
* @since 1.0.0
*/
startServer(port, host, callback) {
if (this.listening) {
throw new Error('Server is already listening');
}
this.createServer();
this.server.listen(port, host, () => {
this.listening = true;
const address = this.server.address();
console.log(`Server listening on ${address.address}:${address.port}`);
if (callback) {
callback();
}
this.emit('listening');
});
return this;
}
/**
* Close the server and clean up resources
*
* Runs all registered cleanup handlers and gracefully closes the server.
* Forces connection termination after a timeout to prevent hanging.
*
* @returns {Promise<void>} Promise that resolves when the server is closed
*
* @since 1.0.0
*
* @example
* // Graceful shutdown
* await app.close();
* console.log('Server closed successfully');
*/
async close() {
if (!this.server) {
return;
}
// Run all cleanup handlers
await this.runCleanupHandlers();
return new Promise((resolve, reject) => {
// Set a timeout for server close
const closeTimeout = setTimeout(() => {
console.error('Server close timeout, forcing shutdown...');
resolve();
}, 30000); // 30 seconds timeout
this.server.close((error) => {
clearTimeout(closeTimeout);
if (error) {
reject(error);
} else {
this.listening = false;
this.emit('close');
resolve();
}
});
// Force close all connections after 10 seconds
setTimeout(() => {
if (this.server && this.server.listening) {
console.log('Force closing remaining connections...');
this.server.unref();
}
}, 10000);
});
}
/**
* Run all registered cleanup handlers
*
* Executes cleanup functions registered by middleware in the order they were added.
* Errors in cleanup handlers are logged but do not prevent other handlers from running.
*
* @returns {Promise<void>}
*
* @private
* @since 1.0.0
*/
async runCleanupHandlers() {
console.log('Running cleanup handlers...');
for (const handler of this.cleanupHandlers) {
try {
await handler();
} catch (error) {
console.error('Error in cleanup handler:', error);
}
}
// Clear the handlers array
this.cleanupHandlers = [];
}
/**
* Perform a graceful shutdown of the application
*
* Stops accepting new connections, waits for existing requests to complete,
* runs cleanup handlers, and exits the process. Prevents multiple shutdown attempts.
*
* @returns {Promise<void>}
*
* @since 1.0.0
*
* @example
* // Trigger graceful shutdown
* process.on('SIGTERM', () => {
* app.gracefulShutdown();
* });
*/
async gracefulShutdown() {
console.log('Graceful shutdown initiated...');
// Prevent multiple shutdown attempts
if (this.shuttingDown) {
console.log('Shutdown already in progress...');
return;
}
this.shuttingDown = true;
try {
// Stop accepting new connections immediately
if (this.server && this.server.listening) {
this.server.close();
}
// Give ongoing requests time to complete
await Promise.race([
this.close(),
new Promise(resolve => setTimeout(resolve, 30000)) // 30 seconds max
]);
console.log('Server closed successfully');
process.exit(0);
} catch (error) {
console.error('Error during shutdown:', error);
process.exit(1);
}
}
/**
* Register a cleanup handler for application shutdown
*
* Cleanup handlers are called during graceful shutdown to clean up resources
* like database connections, file handles, or external service connections.
*
* @param {Function} handler - Async function to call during shutdown
* @returns {Application} The application instance for method chaining
*
* @since 1.0.0
*
* @example
* // Register database cleanup
* app.onShutdown(async () => {
* await database.close();
* console.log('Database connection closed');
* });
*
* @example
* // Register multiple cleanup handlers
* app.onShutdown(() => cache.disconnect())
* .onShutdown(() => logger.flush());
*/
onShutdown(handler) {
if (typeof handler === 'function') {
this.cleanupHandlers.push(handler);
}
return this;
}
}
module.exports = Application;