@raven-js/wings
Version:
Zero-dependency isomorphic routing library for modern JavaScript - Server and CLI routing
1,480 lines (1,417 loc) • 60.1 kB
JavaScript
/**
* @author Anonyfox <max@anonyfox.com>
* @license MIT
* @see {@link https://github.com/Anonyfox/ravenjs}
* @see {@link https://ravenjs.dev}
* @see {@link https://anonyfox.com}
*/
/**
* @file HTTP request/response context class with middleware support, automatic parsing, and response helpers.
*
* Provides the Context class for unified HTTP request/response handling in both Node.js and browser environments.
* Includes path parsing, header management, body parsing, and convenient response methods.
*/
import { isValidHttpMethod } from "./http-methods.js";
import { HEADER_NAMES, MATH_CONSTANTS, MESSAGES, MIME_TYPES, STATUS_CODES } from "./string-pool.js";
/**
* HTTP request/response context with middleware support and response helpers.
*
* @example
* // Basic context usage in route handler
* const handler = async (ctx) => {
* const userId = ctx.params.id;
* const user = await getUser(userId);
* return ctx.json(user);
* };
*
* @example
* // Context with request data
* const handler = async (ctx) => {
* const body = await ctx.json();
* const token = ctx.requestHeaders.get('authorization');
* return ctx.status(201).json({ created: true });
* };
*/
export class Context {
#requestHeaders = new Headers();
/** @type {string} */
#origin = "";
/** @type {string} */
#protocol = "";
/** @type {string} */
#host = "";
/**
* HTTP Headers of the request.
*
* Returns the complete Headers object containing all request headers.
* All header keys are normalized to lowercase for consistent access.
*
* **Note**: While the Headers object is returned directly, modifications
* to request headers after construction are generally discouraged as they
* may affect middleware behavior and request integrity.
*
* @returns {Headers} The request headers object
*
* @example
* ```javascript
* // Access request headers
* const contentType = ctx.requestHeaders.get('content-type');
* const authToken = ctx.requestHeaders.get('authorization');
*
* // Check if header exists
* if (ctx.requestHeaders.has('x-api-key')) {
* // Handle API key authentication
* }
*
* // Iterate over all headers
* for (const [key, value] of ctx.requestHeaders.entries()) {
* console.log(`${key}: ${value}`);
* }
* ```
*/
get requestHeaders() {
return this.#requestHeaders;
}
/**
* The unparsed body of the request, if existing.
*
* Note: All keys are lowercased to make working with it easier.
*
* @type {Buffer|null}
*/
#requestBody = null;
/**
* Cached parsed body to avoid repeated parsing operations.
*
* This cache stores the result of the first requestBody() call to eliminate
* repeated content-type parsing, JSON parsing, and form data parsing.
*
* **Performance**: Avoids O(n) string operations and parsing on subsequent calls.
*
* @type {Object|Array<*>|Buffer|null|undefined}
*/
#parsedBodyCache = undefined;
/**
* Cached response body byte length to avoid repeated Buffer.byteLength calculations.
*
* This cache stores the byte length of the current response body to eliminate
* repeated Buffer.byteLength() calls in hot path response methods.
*
* **Performance**: O(1) cached lookup vs O(n) Buffer byte scanning per response.
*
* @type {number|null}
*/
#responseBodyByteLength = null;
/**
* Index counter for before callbacks to avoid expensive shift() operations.
*
* This counter tracks the current position in the before callbacks array,
* enabling O(1) iteration vs O(n) array shift operations.
*
* **Performance**: Direct array indexing vs array re-indexing on each shift.
*
* @type {number}
*/
#beforeCallbackIndex = 0;
/**
* Index counter for after callbacks to avoid expensive shift() operations.
*
* This counter tracks the current position in the after callbacks array,
* enabling O(1) iteration vs O(n) array shift operations.
*
* **Performance**: Direct array indexing vs array re-indexing on each shift.
*
* @type {number}
*/
#afterCallbackIndex = 0;
/**
* Parses and returns the request body based on the content-type header.
*
* This method automatically handles different content types:
* - `application/json`: Parses as JSON object/array
* - `application/x-www-form-urlencoded`: Parses as plain object
* - Other types: Returns raw Buffer
*
* **Performance Note**: Parsing occurs on each call. For high-performance
* scenarios, consider caching the result if the body is accessed multiple times.
*
* **Error Handling**: JSON parsing errors are thrown as SyntaxError.
* Form data parsing is lenient and handles malformed input gracefully.
*
* @returns {Object|Array<*>|Buffer|null} The parsed body or null if no body exists
*
* @throws {SyntaxError} When JSON content-type is specified but body is malformed
*
* @example
* ```javascript
* // JSON body
* // Content-Type: application/json
* // Body: {"name":"John","age":30}
* const body = ctx.requestBody();
* console.log(body.name); // "John"
* console.log(body.age); // 30
*
* // Form data body
* // Content-Type: application/x-www-form-urlencoded
* // Body: name=John&age=30&active=true
* const formData = ctx.requestBody();
* console.log(formData.name); // "John"
* console.log(formData.age); // "30" (string)
* console.log(formData.active); // "true" (string)
*
* // Raw body (other content types)
* // Content-Type: application/octet-stream
* // Body: [binary data]
* const rawBody = ctx.requestBody();
* console.log(Buffer.isBuffer(rawBody)); // true
* console.log(rawBody.toString()); // string representation
*
* // No body
* const emptyBody = ctx.requestBody();
* console.log(emptyBody); // null
* ```
*/
requestBody() {
// Return cached result if already parsed
if (this.#parsedBodyCache !== undefined) {
return this.#parsedBodyCache;
}
// Parse and cache the body
if (!this.#requestBody) {
this.#parsedBodyCache = null;
return null;
}
const ct = this.requestHeaders.get(HEADER_NAMES.CONTENT_TYPE);
if (!ct) {
this.#parsedBodyCache = this.#requestBody;
return this.#parsedBodyCache;
}
const contentType = ct.toLowerCase();
if (contentType.includes(MIME_TYPES.APPLICATION_JSON)) {
const jsonString = this.#requestBody.toString("utf8");
this.#parsedBodyCache = jsonString.trim() === "" ? null : JSON.parse(jsonString);
} else if (contentType.includes(MIME_TYPES.APPLICATION_FORM_URLENCODED)) {
const formString = this.#requestBody.toString("utf8");
this.#parsedBodyCache = formString.trim() === "" ? {} : Object.fromEntries(new URLSearchParams(formString));
} else {
this.#parsedBodyCache = this.#requestBody;
}
return this.#parsedBodyCache;
}
/**
* Named path parameters extracted from the URL path.
*
* This object contains key-value pairs where keys are parameter names
* (without the `:` prefix) and values are the actual path segments.
* Path parameters are typically set by the router when matching routes
* with parameter placeholders.
*
* **Note**: All values are strings, even if they represent numbers.
* Convert to appropriate types as needed in your handlers.
*
* @type {Object.<string, string>}
*
* @example
* ```javascript
* // Route: /users/:id/posts/:postId
* // URL: /users/123/posts/456
* console.log(ctx.pathParams.id); // "123"
* console.log(ctx.pathParams.postId); // "456"
*
* // Route: /products/:category/:productId
* // URL: /products/electronics/laptop-123
* console.log(ctx.pathParams.category); // "electronics"
* console.log(ctx.pathParams.productId); // "laptop-123"
*
* // Convert string to number if needed
* const userId = parseInt(ctx.pathParams.id, 10);
* const postId = Number(ctx.pathParams.postId);
* ```
*/
pathParams = {};
#queryParams = new URLSearchParams();
/**
* Query parameters parsed from the URL.
*
* Returns a URLSearchParams object containing all query string parameters.
* This provides a standard interface for accessing and manipulating query
* parameters with built-in URL encoding/decoding.
*
* **Note**: While the URLSearchParams object is returned directly, modifications
* to query parameters after construction are generally discouraged as they
* may affect middleware behavior and request integrity.
*
* @type {URLSearchParams}
*
* @example
* ```javascript
* // Access query parameters
* const page = ctx.queryParams.get('page'); // "1"
* const limit = ctx.queryParams.get('limit'); // "10"
*
* // Check if parameter exists
* if (ctx.queryParams.has('search')) {
* const searchTerm = ctx.queryParams.get('search');
* // Handle search functionality
* }
*
* // Get all values for a parameter (if multiple)
* const tags = ctx.queryParams.getAll('tag'); // ["js", "api", "web"]
*
* // Iterate over all parameters
* for (const [key, value] of ctx.queryParams.entries()) {
* console.log(`${key}: ${value}`);
* }
*
* // URL: /users?page=1&limit=10&search=john&tag=js&tag=api
* // Results in:
* // page: "1"
* // limit: "10"
* // search: "john"
* // tag: "js" (first occurrence)
* // tag: "api" (second occurrence)
* ```
*/
get queryParams() {
return this.#queryParams;
}
#method = "";
/**
* The HTTP method of the request in uppercase.
*
* Returns the normalized HTTP method (GET, POST, PUT, DELETE, etc.)
* as a string. The method is validated during construction to ensure
* it's a valid HTTP method.
*
* @type {string}
*
* @example
* ```javascript
* // Check request method
* if (ctx.method === 'GET') {
* // Handle GET request
* } else if (ctx.method === 'POST') {
* // Handle POST request
* }
*
* // Method validation
* const allowedMethods = ['GET', 'POST', 'PUT'];
* if (!allowedMethods.includes(ctx.method)) {
* ctx.error('Method not allowed');
* return;
* }
* ```
*/
get method() {
return this.#method;
}
#path = "";
/**
* The URL path of the request.
*
* Returns the normalized path from the request URL. The path is validated
* during construction with security limits to prevent DoS attacks:
* - Maximum length: 2048 characters
* - Maximum segments: 100 segments
*
* **Note**: Trailing slashes are preserved as they can be significant
* for routing purposes.
*
* @type {string}
*
* @example
* ```javascript
* // Access the request path
* console.log(ctx.path); // "/users/123/posts"
*
* // Path-based routing logic
* if (ctx.path.startsWith('/api/')) {
* // Handle API routes
* } else if (ctx.path.startsWith('/admin/')) {
* // Handle admin routes
* }
*
* // Path validation
* if (ctx.path.length > 100) {
* ctx.error('Path too long');
* return;
* }
* ```
*/
get path() {
return this.#path;
}
/**
* The full origin of the request (protocol + host [+ port]).
*
* Extracted from the URL at construction time for O(1) access throughout
* the request lifecycle. This avoids fragile header-based reconstruction
* in middlewares and ensures a single source of truth.
*
* @type {string}
*
* @example
* // 'https://api.example.com'
* console.log(ctx.origin);
*/
get origin() {
return this.#origin;
}
/**
* The request protocol without trailing colon (e.g. 'http'|'https').
*
* @type {string}
*
* @example
* if (ctx.protocol === 'https') {
* // HSTS/cookie secure handling
* }
*/
get protocol() {
return this.#protocol;
}
/**
* The request host (hostname[:port]).
*
* @type {string}
*
* @example
* console.log(ctx.host); // 'example.com:3000'
*/
get host() {
return this.#host;
}
/**
* HTTP Headers to send in the response.
*
* A mutable Headers object that allows setting response headers.
* All header keys are automatically normalized to lowercase for consistency.
*
* **Common Headers**: Content-Type, Content-Length, Cache-Control,
* Set-Cookie, Location (for redirects), etc.
*
* @type {Headers}
*
* @example
* ```javascript
* // Set response headers
* ctx.responseHeaders.set('content-type', 'application/json');
* ctx.responseHeaders.set('cache-control', 'no-cache');
* ctx.responseHeaders.set('x-custom-header', 'value');
*
* // Set multiple headers
* ctx.responseHeaders.set('access-control-allow-origin', '*');
* ctx.responseHeaders.set('access-control-allow-methods', 'GET, POST, PUT');
*
* // Check if header is set
* if (!ctx.responseHeaders.has('content-type')) {
* ctx.responseHeaders.set('content-type', 'text/plain');
* }
* ```
*/
responseHeaders = new Headers();
/**
* Private response body storage with automatic cache invalidation.
* @type {string|Buffer|null}
*/
#responseBody = null;
/**
* The body content to return in the HTTP response.
*
* Can be a string, Buffer, or null. The content type should be set
* via the responseHeaders to ensure proper interpretation by the client.
*
* **Note**: For large responses, consider using streams instead of
* setting the entire body in memory.
* **Cache Management**: Automatically invalidates byte length cache on assignment.
*
* @example
* ```javascript
* // Set string response body
* ctx.responseBody = "Hello, World!";
*
* // Set Buffer response body (for binary data)
* ctx.responseBody = Buffer.from([0x89, 0x50, 0x4E, 0x47]); // PNG header
*
* // Set JSON response body
* ctx.responseBody = JSON.stringify({ success: true, data: [] });
*
* // Clear response body
* ctx.responseBody = null;
* ```
*/
get responseBody() {
return this.#responseBody;
}
/**
* Sets the response body and automatically invalidates the byte length cache.
*
* This ensures Content-Length headers remain accurate when the response body
* is modified directly, preventing response truncation issues.
*
* @param {string|Buffer|null} value - The new response body
*/
set responseBody(value) {
this.#responseBody = value;
this.#responseBodyByteLength = null; // Invalidate cache
}
/**
* The HTTP status code to return in the response.
*
* Defaults to 200 (OK). Common status codes:
* - 2xx: Success (200, 201, 204)
* - 3xx: Redirection (301, 302, 304)
* - 4xx: Client Error (400, 401, 403, 404, 422)
* - 5xx: Server Error (500, 502, 503)
*
* @type {number}
*
* @example
* ```javascript
* // Set success status codes
* ctx.responseStatusCode = 200; // OK
* ctx.responseStatusCode = 201; // Created
* ctx.responseStatusCode = 204; // No Content
*
* // Set error status codes
* ctx.responseStatusCode = 400; // Bad Request
* ctx.responseStatusCode = 401; // Unauthorized
* ctx.responseStatusCode = 404; // Not Found
* ctx.responseStatusCode = 500; // Internal Server Error
*
* // Use with response helpers
* ctx.notFound(); // Sets 404
* ctx.error(); // Sets 500
* ```
*/
responseStatusCode = STATUS_CODES.OK;
/**
* Flag indicating whether the response has been finalized.
*
* When set to `true`, this prevents further processing of the request:
* - Stops execution of remaining before callbacks
* - Skips the main handler execution
* - Still allows after callbacks to run (for cleanup/logging)
*
* This flag is typically set by middleware that needs to short-circuit
* the request processing (e.g., authentication failures, rate limiting).
*
* @type {boolean}
*
* @example
* ```javascript
* // Authentication middleware
* const authMiddleware = new Middleware((ctx) => {
* const token = ctx.requestHeaders.get('authorization');
* if (!token) {
* ctx.responseStatusCode = 401;
* ctx.responseBody = 'Unauthorized';
* ctx.responseEnded = true; // Stop processing
* return;
* }
* // Continue processing...
* });
*
* // Rate limiting middleware
* const rateLimitMiddleware = new Middleware((ctx) => {
* if (isRateLimited(ctx)) {
* ctx.responseStatusCode = 429;
* ctx.responseBody = 'Too Many Requests';
* ctx.responseEnded = true; // Stop processing
* return;
* }
* });
* ```
*/
responseEnded = false;
/**
* Custom data container for storing request-scoped state.
*
* This object allows middleware and handlers to share data throughout
* the request lifecycle. Common use cases include:
* - Storing authenticated user information
* - Passing data between middleware
* - Caching parsed request data
* - Storing request metadata
*
* **Note**: This data is request-scoped and will be garbage collected
* after the request completes.
*
* **V8 optimization**: Object.create(null) eliminates prototype chain
* for faster property access and cleaner object shapes.
*
* @type {Object.<string, any>}
*
* @example
* ```javascript
* // Authentication middleware
* const authMiddleware = new Middleware(async (ctx) => {
* const token = ctx.requestHeaders.get('authorization');
* if (token) {
* ctx.data.user = await validateToken(token);
* ctx.data.isAuthenticated = true;
* }
* });
*
* // Handler using stored data
* const userHandler = (ctx) => {
* if (ctx.data.isAuthenticated) {
* ctx.json({ user: ctx.data.user, message: 'Welcome!' });
* } else {
* ctx.notFound('Please login');
* }
* };
*
* // Caching parsed data
* const cacheMiddleware = new Middleware((ctx) => {
* if (!ctx.data.parsedBody) {
* ctx.data.parsedBody = ctx.requestBody();
* }
* });
*
* // Logging middleware
* const loggingMiddleware = new Middleware((ctx) => {
* ctx.data.startTime = Date.now();
* ctx.data.requestId = generateRequestId();
* });
* ```
*/
data = Object.create(null);
/**
* Collection of errors that occurred during the request lifecycle.
*
* This array stores any errors that occur during middleware execution,
* route handler execution, or after-callback execution. Errors are
* collected instead of immediately thrown, allowing the complete
* request lifecycle (including after callbacks like logging) to run.
*
* **Error Collection Flow**:
* 1. Errors from before callbacks are collected here
* 2. Errors from route handlers are collected here
* 3. After callbacks always run (for logging, cleanup, etc.)
* 4. Middleware (like logger) can consume errors by clearing this array
* 5. Any remaining errors are printed to console.error as fallback
*
* **Use Cases**:
* - Logging errors via after-callback middleware
* - Collecting multiple validation errors
* - Ensuring cleanup code runs even when handlers fail
* - Providing detailed error context for debugging
*
* @type {Error[]}
*
* @example
* ```javascript
* // Middleware can check for and consume errors
* const loggingMiddleware = new Middleware((ctx) => {
* if (ctx.errors.length > 0) {
* console.error(`Request failed with ${ctx.errors.length} error(s):`);
* ctx.errors.forEach((error, index) => {
* console.error(`Error ${index + 1}:`, error.message);
* });
* // Consume the errors after logging them
* ctx.errors.length = 0;
* }
* });
*
* // Handlers can add custom errors
* const validationHandler = (ctx) => {
* const data = ctx.requestBody();
* if (!data.email) {
* ctx.errors.push(new Error('Email is required'));
* }
* if (!data.password) {
* ctx.errors.push(new Error('Password is required'));
* }
* };
* ```
*/
errors = [];
/**
* Checks if the current response represents a "not found" condition.
*
* This helper method determines if the request resulted in a 404 Not Found
* response, which is useful for conditional logic in middleware (like logging)
* that needs to handle 404s differently from other responses.
*
* **Use Cases**:
* - Logging middleware formatting 404s differently (e.g., yellow vs red)
* - Analytics middleware tracking 404 patterns
* - Custom error pages or redirect logic
* - Conditional response transformation
*
* @returns {boolean} True if the response status code is 404
*
* @example
* ```javascript
* // In logging middleware
* const loggingMiddleware = new Middleware((ctx) => {
* if (ctx.isNotFound()) {
* console.log(`⚠️ 404 Not Found: ${ctx.method} ${ctx.path}`);
* } else if (ctx.errors.length > 0) {
* console.log(`❌ Error: ${ctx.method} ${ctx.path}`);
* } else {
* console.log(`✅ Success: ${ctx.method} ${ctx.path}`);
* }
* });
*
* // In analytics middleware
* const analyticsMiddleware = new Middleware((ctx) => {
* if (ctx.isNotFound()) {
* trackMissingRoute(ctx.path);
* }
* });
*
* // In custom error page middleware
* const errorPageMiddleware = new Middleware((ctx) => {
* if (ctx.isNotFound()) {
* ctx.html('<h1>Page Not Found</h1><p>The requested page does not exist.</p>');
* }
* });
* ```
*/
isNotFound() {
return this.responseStatusCode === STATUS_CODES.NOT_FOUND;
}
/**
* Internal list of middleware instances to execute before the main handler.
*
* These middleware are executed in FIFO order (first added, first executed).
* Each middleware receives the current context instance and can modify it directly.
*
* **Design Note**: Using a mutable context object instead of creating new instances
* for each middleware step provides better performance by reducing memory allocation
* and garbage collection pressure in high-throughput scenarios.
*
* @type {import('./middleware.js').Middleware[]}
*/
#beforeCallbacks = [];
/**
* Adds a middleware instance to the before-callback queue.
*
* The middleware will be executed before the main handler in FIFO order.
* If the `responseEnded` flag is already set to `true`, the middleware
* will not be executed when `runBeforeCallbacks()` is called.
*
* **Execution Order**: Middleware added first will be executed first.
*
* @param {import('./middleware.js').Middleware} middleware - The middleware instance to add
*
* @example
* ```javascript
* // Add authentication middleware
* const authMiddleware = new Middleware(async (ctx) => {
* const token = ctx.requestHeaders.get('authorization');
* if (!token) {
* ctx.notFound('Unauthorized');
* return;
* }
* ctx.data.user = await validateToken(token);
* });
*
* ctx.addBeforeCallback(authMiddleware);
*
* // Add logging middleware
* const loggingMiddleware = new Middleware((ctx) => {
* console.log(`${ctx.method} ${ctx.path} - ${new Date().toISOString()}`);
* });
*
* ctx.addBeforeCallback(loggingMiddleware);
*
* // Execute all before callbacks
* await ctx.runBeforeCallbacks();
* ```
*/
addBeforeCallback(middleware) {
this.#beforeCallbacks.push(middleware);
}
/**
* Adds multiple middleware instances to the before-callback queue.
*
* Middleware are added in the order provided and will be executed
* in FIFO order when `runBeforeCallbacks()` is called.
*
* **Performance Note**: This is more efficient than calling `addBeforeCallback()`
* multiple times as it avoids repeated array operations.
*
* @param {import('./middleware.js').Middleware[]} middlewares - Array of middleware instances to add
*
* @example
* ```javascript
* // Create multiple middleware
* const authMiddleware = new Middleware(async (ctx) => {
* // Authentication logic
* });
*
* const loggingMiddleware = new Middleware((ctx) => {
* // Logging logic
* });
*
* const corsMiddleware = new Middleware((ctx) => {
* // CORS logic
* });
*
* // Add all middleware at once
* ctx.addBeforeCallbacks([corsMiddleware, loggingMiddleware, authMiddleware]);
*
* // Execute in order: cors -> logging -> auth
* await ctx.runBeforeCallbacks();
* ```
*/
addBeforeCallbacks(middlewares) {
for (const middleware of middlewares) {
this.addBeforeCallback(middleware);
}
}
/**
* Executes all before-callbacks in FIFO order.
*
* This method processes each middleware in the queue sequentially, waiting
* for each to complete before proceeding to the next. Middleware are removed
* from the queue as they are executed (consumed).
*
* **Execution Flow**:
* 1. Process middleware in order they were added
* 2. Wait for each middleware to complete (supports async middleware)
* 3. Remove middleware from queue after execution
* 4. Stop if `responseEnded` becomes `true`
*
* **Dynamic Middleware**: Middleware can add more middleware during execution,
* enabling complex conditional logic and dynamic request processing.
*
* **Error Handling**: If any middleware throws an error, execution stops
* and the error is propagated to the caller.
*
* @returns {Promise<void>} Resolves when all before-callbacks complete
*
* @example
* ```javascript
* // Basic usage
* await ctx.runBeforeCallbacks();
*
* // With error handling
* try {
* await ctx.runBeforeCallbacks();
* } catch (error) {
* console.error('Middleware error:', error);
* ctx.error('Internal server error');
* }
*
* // Dynamic middleware example
* const conditionalMiddleware = new Middleware((ctx) => {
* if (ctx.requestHeaders.get('x-debug')) {
* // Add debug middleware dynamically
* ctx.addBeforeCallback(new Middleware((ctx) => {
* console.log('Debug info:', ctx.data);
* }));
* }
* });
*
* ctx.addBeforeCallback(conditionalMiddleware);
* await ctx.runBeforeCallbacks(); // May execute additional middleware
* ```
*/
async runBeforeCallbacks() {
while (this.#beforeCallbackIndex < this.#beforeCallbacks.length) {
if (this.responseEnded) break;
const middleware = this.#beforeCallbacks[this.#beforeCallbackIndex++];
await middleware.execute(this);
}
}
/**
* Internal list of middleware instances to execute after the main handler.
*
* These middleware are executed in FIFO order (first added, first executed).
* Each middleware receives the current context instance and can modify it directly.
*
* **Use Cases**: After-callbacks are typically used for:
* - Response logging and metrics
* - Response transformation
* - Cleanup operations
* - Response caching
*
* **Design Note**: Using a mutable context object instead of creating new instances
* for each middleware step provides better performance by reducing memory allocation
* and garbage collection pressure in high-throughput scenarios.
*
* @type {import('./middleware.js').Middleware[]}
*/
#afterCallbacks = [];
/**
* Adds a middleware instance to the after-callback queue.
*
* The middleware will be executed after the main handler in FIFO order.
* After-callbacks are typically used for logging, metrics, response
* transformation, and cleanup operations.
*
* **Execution Order**: Middleware added first will be executed first.
*
* @param {import('./middleware.js').Middleware} middleware - The middleware instance to add
*
* @example
* ```javascript
* // Add response logging middleware
* const loggingMiddleware = new Middleware((ctx) => {
* const duration = Date.now() - ctx.data.startTime;
* console.log(`${ctx.method} ${ctx.path} - ${ctx.responseStatusCode} - ${duration}ms`);
* });
*
* ctx.addAfterCallback(loggingMiddleware);
*
* // Add response transformation middleware
* const transformMiddleware = new Middleware((ctx) => {
* if (ctx.responseHeaders.get('content-type')?.includes('application/json')) {
* const body = JSON.parse(ctx.responseBody);
* body.timestamp = new Date().toISOString();
* ctx.responseBody = JSON.stringify(body);
* }
* });
*
* ctx.addAfterCallback(transformMiddleware);
*
* // Execute all after callbacks
* await ctx.runAfterCallbacks();
* ```
*/
addAfterCallback(middleware) {
this.#afterCallbacks.push(middleware);
}
/**
* Adds multiple middleware instances to the after-callback queue.
*
* Middleware are added in the order provided and will be executed
* in FIFO order when `runAfterCallbacks()` is called.
*
* **Performance Note**: This is more efficient than calling `addAfterCallback()`
* multiple times as it avoids repeated array operations.
*
* @param {import('./middleware.js').Middleware[]} middlewares - Array of middleware instances to add
*
* @example
* ```javascript
* // Create multiple after-callbacks
* const loggingMiddleware = new Middleware((ctx) => {
* // Logging logic
* });
*
* const metricsMiddleware = new Middleware((ctx) => {
* // Metrics collection
* });
*
* const cleanupMiddleware = new Middleware((ctx) => {
* // Cleanup operations
* });
*
* // Add all after-callbacks at once
* ctx.addAfterCallbacks([loggingMiddleware, metricsMiddleware, cleanupMiddleware]);
*
* // Execute in order: logging -> metrics -> cleanup
* await ctx.runAfterCallbacks();
* ```
*/
addAfterCallbacks(middlewares) {
for (const middleware of middlewares) {
this.addAfterCallback(middleware);
}
}
/**
* Executes all after-callbacks in FIFO order.
*
* This method processes each middleware in the queue sequentially, waiting
* for each to complete before proceeding to the next. Middleware are removed
* from the queue as they are executed (consumed).
*
* **Execution Flow**:
* 1. Process middleware in order they were added
* 2. Wait for each middleware to complete (supports async middleware)
* 3. Remove middleware from queue after execution
* 4. Stop if `responseEnded` becomes `true`
*
* **Dynamic Middleware**: Middleware can add more middleware during execution,
* enabling complex conditional logic and dynamic response processing.
*
* **Error Handling**: If any middleware throws an error, execution stops
* and the error is propagated to the caller.
*
* @returns {Promise<void>} Resolves when all after-callbacks complete
*
* @example
* ```javascript
* // Basic usage
* await ctx.runAfterCallbacks();
*
* // With error handling
* try {
* await ctx.runAfterCallbacks();
* } catch (error) {
* console.error('After-callback error:', error);
* // Note: Response may already be sent, so error handling is limited
* }
*
* // Dynamic after-callback example
* const conditionalMiddleware = new Middleware((ctx) => {
* if (ctx.responseStatusCode >= 400) {
* // Add error logging middleware dynamically
* ctx.addAfterCallback(new Middleware((ctx) => {
* console.error('Error response:', ctx.responseStatusCode, ctx.responseBody);
* }));
* }
* });
*
* ctx.addAfterCallback(conditionalMiddleware);
* await ctx.runAfterCallbacks(); // May execute additional middleware
* ```
*/
async runAfterCallbacks() {
while (this.#afterCallbackIndex < this.#afterCallbacks.length) {
if (this.responseEnded) break;
const middleware = this.#afterCallbacks[this.#afterCallbackIndex++];
await middleware.execute(this);
}
}
/**
* Creates a new Context instance from HTTP request information.
*
* The constructor validates all input parameters and sets up the context
* for request processing. Invalid parameters will throw descriptive errors.
*
* **Validation Rules**:
* - Method must be a valid HTTP method (GET, POST, PUT, DELETE, etc.)
* - Path must be a non-empty string with length ≤ 2048 characters
* - Path must have ≤ 100 segments to prevent CPU exhaustion attacks
* - URL must be a valid URL object with pathname and searchParams
*
* **Security Features**:
* - Path length limits prevent DoS attacks
* - Segment count limits prevent CPU exhaustion
* - Method validation prevents invalid HTTP methods
*
* @param {string} method - The HTTP method (GET, POST, PUT, DELETE, etc.)
* @param {URL} url - The URL object containing pathname and searchParams
* @param {Headers} headers - The request headers
* @param {Buffer|undefined} [body] - Optional request body as Buffer
*
* @throws {Error} When method is empty or invalid
* @throws {Error} When path is empty, not a string, or too long
* @throws {Error} When path has too many segments
*
* @example
* ```javascript
* // Create context from request
* const url = new URL('https://api.example.com/users/123?page=1&limit=10');
* const headers = new Headers({
* 'content-type': 'application/json',
* 'authorization': 'Bearer token123'
* });
* const body = Buffer.from('{"name":"John","age":30}');
*
* const ctx = new Context('POST', url, headers, body);
*
* // Access validated properties
* console.log(ctx.method); // 'POST'
* console.log(ctx.path); // '/users/123'
* console.log(ctx.requestBody()); // { name: 'John', age: 30 }
* console.log(ctx.queryParams.get('page')); // '1'
*
* // Error cases
* try {
* new Context('INVALID', url, headers); // Throws: Invalid HTTP method
* } catch (error) {
* console.error(error.message);
* }
*
* try {
* const longPathUrl = new URL('https://example.com/' + 'a'.repeat(2049));
* new Context('GET', longPathUrl, headers); // Throws: Path too long
* } catch (error) {
* console.error(error.message);
* }
* ```
*/
constructor(method, url, headers, body) {
// validate and set #method properly
if (!method) throw new Error(`Method is required, got: ${method}`);
if (!isValidHttpMethod(method)) {
throw new Error(`Invalid HTTP method: ${method}`);
}
this.#method = method;
// validate and set #path properly
const path = url.pathname;
if (!path) throw new Error(`Path is required, got: ${path}`);
if (typeof path !== "string") throw new Error("Path must be a string");
if (path.length > 2048) throw new Error("Path too long");
// security check to prevent CPU exhaustion attacks
const pathSegments = path.split("/");
if (pathSegments.length > MATH_CONSTANTS.MAX_PATH_SEGMENTS) throw new Error("Path too long");
this.#path = path;
// set the query params and headers
this.#queryParams = url.searchParams;
this.#requestHeaders = headers;
// cache origin components for fast access across middlewares
// url.protocol includes trailing ':' -> strip for protocol getter
this.#origin = url.origin;
this.#protocol = url.protocol.endsWith(":") ? url.protocol.slice(0, -1) : url.protocol;
this.#host = url.host;
// set the body if given
if (body) this.#requestBody = body;
}
/**
* Gets the cached byte length of the response body, calculating if necessary.
*
* This method provides O(1) cached access to response body byte length,
* eliminating repeated Buffer.byteLength() calculations in hot paths.
*
* **Performance**: Cached result vs O(n) Buffer byte scanning per call.
* **Hot Path**: Called by every response method (text, html, json, etc.)
*
* @returns {string} The byte length as a string for Content-Length header
*/
#getResponseBodyByteLength() {
if (this.#responseBodyByteLength === null) {
this.#responseBodyByteLength = Buffer.byteLength(this.#responseBody || "");
}
return this.#responseBodyByteLength.toString();
}
/**
* Updates response body and invalidates byte length cache.
*
* This method ensures the byte length cache stays synchronized with
* response body changes for optimal performance.
*
* **Performance**: Cache invalidation prevents stale byte length values.
*
* @param {string|Buffer} body - The new response body
*/
#setResponseBodyWithCache(body) {
this.#responseBody = body || "";
this.#responseBodyByteLength = null; // Invalidate cache
}
/**
* Sends a 200 OK response with text/plain content type.
*
* This is a convenience method for sending plain text responses.
* It automatically sets the content-type header and calculates the
* content-length for proper HTTP compliance.
*
* **Promise Support**: If data is a promise, it will be awaited and the resolved
* value will be used as the response body. This enables seamless async content
* generation without manual await calls in handler code.
*
* **Note**: Falsy values (null, undefined, 0, false) are converted
* to empty strings to ensure consistent behavior.
*
* @param {string|Promise<string>} data - The text content to send or a promise that resolves to text content
* @returns {Promise<Context>} The Context instance for method chaining
*
* @example
* ```javascript
* // Send simple text response
* ctx.text('Hello, World!');
* // Result: 200 OK, Content-Type: text/plain, Content-Length: 13
*
* // Send async text response
* await ctx.text(fetchDataFromAPI());
* // Automatically awaits the promise and sends the result
*
* // Send empty text response
* ctx.text('');
* // Result: 200 OK, Content-Type: text/plain, Content-Length: 0
*
* // Handle falsy values
* ctx.text(null); // Sends empty string
* ctx.text(0); // Sends empty string
*
* // Method chaining with promises
* await ctx.text(Promise.resolve('Success'));
* ctx.responseHeaders.set('x-custom', 'value');
* ```
*/
async text(data) {
// Await data if it's a promise
const resolvedData = await data;
this.responseStatusCode = STATUS_CODES.OK;
this.responseHeaders.set(HEADER_NAMES.CONTENT_TYPE, MIME_TYPES.TEXT_PLAIN);
this.#setResponseBodyWithCache(resolvedData);
this.responseHeaders.set(HEADER_NAMES.CONTENT_LENGTH, this.#getResponseBodyByteLength());
return this;
}
/**
* Sends a 200 OK response with text/html content type.
*
* This is a convenience method for sending HTML responses.
* It automatically sets the content-type header and calculates the
* content-length for proper HTTP compliance.
*
* **Promise Support**: If data is a promise, it will be awaited and the resolved
* value will be used as the response body. This enables seamless async HTML
* generation for templates and dynamic content.
*
* **Note**: Falsy values (null, undefined, 0, false) are converted
* to empty strings to ensure consistent behavior.
*
* @param {string|Promise<string>} data - The HTML content to send or a promise that resolves to HTML content
* @returns {Promise<Context>} The Context instance for method chaining
*
* @example
* ```javascript
* // Send HTML response
* ctx.html('<h1>Welcome</h1><p>Hello, World!</p>');
* // Result: 200 OK, Content-Type: text/html, Content-Length: 35
*
* // Send async HTML response
* await ctx.html(renderTemplate('homepage', { user }));
* // Automatically awaits the template rendering
*
* // Send empty HTML response
* ctx.html('');
* // Result: 200 OK, Content-Type: text/html, Content-Length: 0
*
* // Method chaining with promises
* await ctx.html(Promise.resolve('<html><body>Success</body></html>'));
* ctx.responseHeaders.set('cache-control', 'no-cache');
* ```
*/
async html(data) {
// Await data if it's a promise
const resolvedData = await data;
this.responseStatusCode = STATUS_CODES.OK;
this.responseHeaders.set(HEADER_NAMES.CONTENT_TYPE, MIME_TYPES.TEXT_HTML);
this.#setResponseBodyWithCache(resolvedData);
this.responseHeaders.set(HEADER_NAMES.CONTENT_LENGTH, this.#getResponseBodyByteLength());
return this;
}
/**
* Sends a 200 OK response with application/xml content type.
*
* This is a convenience method for sending XML responses.
* It automatically sets the content-type header and calculates the
* content-length for proper HTTP compliance.
*
* **Promise Support**: If data is a promise, it will be awaited and the resolved
* value will be used as the response body. This enables seamless async XML
* generation for feeds, API responses, and dynamic XML content.
*
* **Note**: Falsy values (null, undefined, 0, false) are converted
* to empty strings to ensure consistent behavior.
*
* @param {string|Promise<string>} data - The XML content to send or a promise that resolves to XML content
* @returns {Promise<Context>} The Context instance for method chaining
*
* @example
* ```javascript
* // Send XML response
* ctx.xml('<root><item>value</item></root>');
* // Result: 200 OK, Content-Type: application/xml, Content-Length: 28
*
* // Send async XML response
* await ctx.xml(generateRSSFeed(articles));
* // Automatically awaits the feed generation
*
* // Send RSS feed
* const rss = `<?xml version="1.0"?>
* <rss version="2.0">
* <channel>
* <title>My Feed</title>
* <item><title>Article 1</title></item>
* </channel>
* </rss>`;
* ctx.xml(rss);
*
* // Method chaining with promises
* await ctx.xml(Promise.resolve('<response>success</response>'));
* ctx.responseHeaders.set('x-api-version', '1.0');
* ```
*/
async xml(data) {
// Await data if it's a promise
const resolvedData = await data;
this.responseStatusCode = STATUS_CODES.OK;
this.responseHeaders.set(HEADER_NAMES.CONTENT_TYPE, MIME_TYPES.APPLICATION_XML);
this.#setResponseBodyWithCache(resolvedData);
this.responseHeaders.set(HEADER_NAMES.CONTENT_LENGTH, this.#getResponseBodyByteLength());
return this;
}
/**
* Sends a 200 OK response with application/json content type.
*
* This is a convenience method for sending JSON responses.
* It automatically serializes the data to JSON, sets the content-type
* header, and calculates the content-length for proper HTTP compliance.
*
* **Promise Support**: If data is a promise, it will be awaited and the resolved
* value will be used for JSON serialization. This enables seamless async API
* responses from database queries, external API calls, and computed data.
*
* **Error Handling**: If the data contains circular references or
* other non-serializable values, JSON.stringify() will throw a TypeError.
*
* **Note**: Undefined values in objects are omitted from the JSON output
* as per JSON specification.
*
* @param {Object|Array<*>|Promise<Object|Array<*>>} data - The data to serialize as JSON or a promise that resolves to serializable data
* @returns {Promise<Context>} The Context instance for method chaining
*
* @throws {TypeError} When data contains circular references or non-serializable values
*
* @example
* ```javascript
* // Send simple JSON object
* ctx.json({ success: true, message: 'Hello World' });
* // Result: 200 OK, Content-Type: application/json
* // Body: {"success":true,"message":"Hello World"}
*
* // Send async JSON response
* await ctx.json(fetchUserFromDatabase(userId));
* // Automatically awaits the database query
*
* // Send JSON array
* ctx.json([1, 2, 3, 4, 5]);
* // Result: 200 OK, Content-Type: application/json
* // Body: [1,2,3,4,5]
*
* // Send complex nested object
* ctx.json({
* user: {
* id: 123,
* name: 'John Doe',
* email: 'john@example.com'
* },
* metadata: {
* timestamp: new Date().toISOString(),
* version: '1.0.0'
* }
* });
*
* // Handle undefined values (omitted from JSON)
* ctx.json({ name: 'John', age: undefined, active: true });
* // Result: {"name":"John","active":true}
*
* // Method chaining with promises
* await ctx.json(Promise.resolve({ status: 'success' }));
* ctx.responseHeaders.set('x-api-version', '1.0');
*
* // Error case - circular reference
* const obj = { name: 'test' };
* obj.self = obj;
* try {
* ctx.json(obj); // Throws TypeError
* } catch (error) {
* console.error('JSON serialization error:', error.message);
* }
* ```
*/
async json(data) {
// Await data if it's a promise
const resolvedData = await data;
this.responseStatusCode = STATUS_CODES.OK;
this.responseHeaders.set(HEADER_NAMES.CONTENT_TYPE, MIME_TYPES.APPLICATION_JSON);
this.#setResponseBodyWithCache(JSON.stringify(resolvedData));
this.responseHeaders.set(HEADER_NAMES.CONTENT_LENGTH, this.#getResponseBodyByteLength());
return this;
}
/**
* Sends a 200 OK response with application/javascript content type.
*
* This is a convenience method for sending JavaScript responses.
* It automatically sets the content-type header and calculates the
* content-length for proper HTTP compliance.
*
* **Promise Support**: If data is a promise, it will be awaited and the resolved
* value will be used as the response body. This enables seamless async JavaScript
* generation for JSONP callbacks, dynamic modules, and computed scripts.
*
* **Note**: Falsy values (null, undefined, 0, false) are converted
* to empty strings to ensure consistent behavior.
*
* @param {string|Promise<string>} data - The JavaScript code to send or a promise that resolves to JavaScript code
* @returns {Promise<Context>} The Context instance for method chaining
*
* @example
* ```javascript
* // Send JavaScript response
* ctx.js('console.log("Hello from server!");');
* // Result: 200 OK, Content-Type: application/javascript, Content-Length: 32
*
* // Send async JavaScript response
* await ctx.js(generateDynamicScript(userPreferences));
* // Automatically awaits the script generation
*
* // Send JSONP response
* const callback = ctx.queryParams.get('callback') || 'callback';
* const data = { message: 'Hello World' };
* ctx.js(`${callback}(${JSON.stringify(data)});`);
*
* // Send module response
* ctx.js('export const version = "1.0.0";');
*
* // Method chaining with promises
* await ctx.js(Promise.resolve('alert("Success!");'));
* ctx.responseHeaders.set('cache-control', 'no-cache');
* ```
*/
async js(data) {
// Await data if it's a promise
const resolvedData = await data;
this.responseStatusCode = STATUS_CODES.OK;
this.responseHeaders.set(HEADER_NAMES.CONTENT_TYPE, MIME_TYPES.APPLICATION_JAVASCRIPT);
this.#setResponseBodyWithCache(resolvedData);
this.responseHeaders.set(HEADER_NAMES.CONTENT_LENGTH, this.#getResponseBodyByteLength());
return this;
}
/**
* Sends a 200 OK response with arbitrary binary or text content and custom MIME type.
*
* This is a low-level method for sending raw responses with explicit content types.
* It handles any content type including images, PDFs, fonts, audio, video, and
* other binary or text formats. The content can be provided as a string or Buffer.
*
* **Promise Support**: If data is a promise, it will be awaited and the resolved
* value will be used as the response body. This enables seamless async content
* generation for images from S3, dynamically generated PDFs, processed media, etc.
*
* **Use Cases**:
* - Images (PNG, JPEG, WebP, GIF, SVG, etc.)
* - Documents (PDF, Word, Excel, etc.)
* - Fonts (WOFF, WOFF2, TTF, OTF, etc.)
* - Audio/Video (MP3, MP4, WebM, etc.)
* - Raw binary data (protobuf, msgpack, etc.)
* - Custom text formats with specific MIME types
*
* **Note**: This method provides no automatic content type detection or validation.
* You must explicitly specify the correct MIME type for the content.
*
* @param {string|Buffer|Promise<string|Buffer>} data - The raw content to send or a promise that resolves to content
* @param {string} mimeType - The MIME type for the Content-Type header (e.g., 'image/png', 'application/pdf')
* @returns {Promise<Context>} The Context instance for method chaining
*
* @example
* ```javascript
* // Send PNG image
* const pngBuffer = await fs.readFile('image.png');
* await ctx.raw(pngBuffer, 'image/png');
* // Result: 200 OK, Content-Type: image/png, Content-Length: [buffer size]
*
* // Send PDF document
* const pdfBuffer = await generatePDF(data);
* await ctx.raw(pdfBuffer, 'application/pdf');
* // Result: 200 OK, Content-Type: application/pdf
*
* // Send SVG image (as string)
* const svg = '<svg><circle cx="50" cy="50" r="40"/></svg>';
* await ctx.raw(svg, 'image/svg+xml');
* // Result: 200 OK, Content-Type: image/svg+xml
*
* // Send async image from S3
* await ctx.raw(fetchImageFromS3(key), 'image/webp');
* // Automatically awaits the S3 fetch
*
* // Send WOFF2 font
* const fontBuffer = await fs.readFile('font.woff2');
* await ctx.raw(fontBuffer, 'font/woff2');
*
* // Send audio file
* const mp3Buffer = await fs.readFile('audio.mp3');
* await ctx.raw(mp3Buffer, 'audio/mpeg');
*
* // Send custom binary format
* const protobufData = encodeProtobuf(message);