UNPKG

@oxog/spark

Version:

Ultra-fast, zero-dependency Node.js web framework with security hardening, memory leak protection, and enhanced error handling

831 lines (744 loc) 21.3 kB
/** * @fileoverview Context class for handling HTTP requests and responses in Spark Framework * @author Spark Framework Team * @since 1.0.0 * @version 1.0.0 */ const { URL } = require('url'); const querystring = require('querystring'); /** * Context class that wraps HTTP request and response objects * * The Context class provides a unified interface for handling HTTP requests and responses. * It parses incoming request data, provides helper methods for setting headers and status codes, * and includes convenience methods for sending various types of responses. * * @class Context * @since 1.0.0 * * @example * // In a middleware or route handler * app.get('/users/:id', (ctx) => { * const userId = ctx.params.id; * const userAgent = ctx.get('user-agent'); * * ctx.set('X-Response-Time', '10ms'); * ctx.json({ id: userId, userAgent }); * }); * * @example * // Working with cookies * app.post('/login', (ctx) => { * const { username, password } = ctx.body; * * if (authenticate(username, password)) { * ctx.setCookie('session', 'abc123', { * httpOnly: true, * secure: true, * maxAge: 3600000 * }); * ctx.json({ success: true }); * } else { * ctx.status(401).json({ error: 'Invalid credentials' }); * } * }); */ class Context { /** * Create a new Context instance * * @param {http.IncomingMessage} req - The HTTP request object * @param {http.ServerResponse} res - The HTTP response object * @param {Application} app - The Spark application instance * * @since 1.0.0 * * @example * // Context is typically created automatically by the framework * const ctx = new Context(req, res, app); */ constructor(req, res, app) { /** * The HTTP request object * @type {http.IncomingMessage} * @readonly */ this.req = req; /** * The HTTP response object * @type {http.ServerResponse} * @readonly */ this.res = res; /** * The Spark application instance * @type {Application} * @readonly */ this.app = app; /** * Whether a response has been sent * @type {boolean} * @readonly */ this.responded = false; /** * The HTTP status code for the response * @type {number} */ this.statusCode = 200; this.initializeUrl(); this.initializeHeaders(); this.initializeQuery(); this.initializeCookies(); /** * Route parameters extracted from the URL * @type {Object} * @example * // For route '/users/:id' with URL '/users/123' * ctx.params.id // '123' */ this.params = {}; /** * Parsed request body * @type {*} */ this.body = null; /** * Uploaded files (when using multipart/form-data) * @type {Object|null} */ this.files = null; /** * Session data (when session middleware is used) * @type {Object|null} */ this.session = null; /** * Application state for storing custom data * @type {Object} */ this.state = {}; } /** * Initialize URL-related properties from the request * * Parses the request URL and extracts path, method, and other URL components. * Handles malformed URLs by throwing an appropriate error. * * @private * @throws {Error} When the URL cannot be parsed * @since 1.0.0 */ initializeUrl() { const protocol = this.req.connection.encrypted ? 'https' : 'http'; const host = this.req.headers.host || 'localhost'; try { /** * Parsed URL object * @type {URL|null} * @readonly */ this.url = new URL(this.req.url, `${protocol}://${host}`); /** * Request path (pathname without query string) * @type {string} * @readonly */ this.path = this.url.pathname; } catch (error) { // Handle malformed URLs - throw proper error instead of just logging this.url = null; this.path = '/'; this.statusCode = 400; this.body = { error: 'Invalid URL format' }; throw new Error(`Failed to parse URL: ${error.message}`); } /** * HTTP method (GET, POST, PUT, etc.) * @type {string} * @readonly */ this.method = this.req.method ? this.req.method.toUpperCase() : 'GET'; /** * Original request URL including query string * @type {string} * @readonly */ this.originalUrl = this.req.url; } /** * Initialize request and response headers * * Copies request headers and initializes response headers object. * * @private * @since 1.0.0 */ initializeHeaders() { /** * Request headers (lowercase keys) * @type {Object} * @readonly */ this.headers = { ...this.req.headers }; /** * Response headers to be sent * @type {Object} * @private */ this.responseHeaders = {}; } /** * Initialize query string parameters * * Parses the URL query string into an object. * * @private * @since 1.0.0 */ initializeQuery() { /** * Parsed query string parameters * @type {Object} * @readonly * @example * // For URL '/search?q=hello&limit=10' * ctx.query.q // 'hello' * ctx.query.limit // '10' */ this.query = {}; if (this.url.search) { this.query = querystring.parse(this.url.search.slice(1)); } } /** * Initialize cookies from the request * * Parses the Cookie header and decodes cookie values. * Malformed cookies are skipped and logged. * * @private * @since 1.0.0 */ initializeCookies() { /** * Parsed cookies from the request * @type {Object} * @readonly * @example * // For Cookie header 'session=abc123; theme=dark' * ctx.cookies.session // 'abc123' * ctx.cookies.theme // 'dark' */ this.cookies = {}; const cookieHeader = this.req.headers.cookie; if (cookieHeader) { cookieHeader.split(';').forEach(cookie => { const [name, value] = cookie.trim().split('='); if (name && value) { try { this.cookies[name] = decodeURIComponent(value); } catch (error) { // Skip malformed cookie values console.error(`Failed to decode cookie ${name}: ${error.message}`); } } }); } } /** * Get a request header value * * @param {string} headerName - The header name (case-insensitive) * @returns {string|undefined} The header value or undefined if not present * * @since 1.0.0 * * @example * const userAgent = ctx.get('User-Agent'); * const contentType = ctx.get('content-type'); */ get(headerName) { return this.headers[headerName.toLowerCase()]; } /** * Set a response header * * Sets a response header with validation for security and RFC compliance. * Header names and values are validated to prevent injection attacks. * * @param {string} headerName - The header name * @param {string|number} value - The header value * @returns {Context} The context instance for method chaining * * @throws {Error} When header name or value is invalid * * @since 1.0.0 * * @example * ctx.set('Content-Type', 'application/json'); * ctx.set('X-Response-Time', '10ms'); * * @example * // Method chaining * ctx.set('Content-Type', 'text/html') * .set('Cache-Control', 'no-cache'); */ set(headerName, value) { // Validate header name if (!headerName || typeof headerName !== 'string') { throw new Error('Header name must be a non-empty string'); } // Validate header name format (RFC 7230) if (!/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(headerName)) { throw new Error(`Invalid header name: ${headerName}`); } // Validate header value if (value !== undefined && value !== null) { const stringValue = String(value); // Check for invalid characters in header value (CRLF and null byte) if (/[\r\n\x00]/.test(stringValue)) { throw new Error('Header value cannot contain CRLF or null characters'); } // Check header value length if (stringValue.length > 8192) { throw new Error('Header value too long (max 8192 characters)'); } this.responseHeaders[headerName.toLowerCase()] = stringValue; } return this; } /** * Alias for set() method * * @param {string} name - The header name * @param {string|number} value - The header value * @returns {Context} The context instance for method chaining * * @since 1.0.0 * @see {@link Context#set} */ setHeader(name, value) { return this.set(name, value); } /** * Get a response header value * * @param {string} name - The header name (case-insensitive) * @returns {string|undefined} The header value or undefined if not set * * @since 1.0.0 * * @example * ctx.set('Content-Type', 'application/json'); * const contentType = ctx.getHeader('content-type'); // 'application/json' */ getHeader(name) { return this.responseHeaders[name.toLowerCase()]; } /** * Remove a response header * * @param {string} name - The header name (case-insensitive) * @returns {Context} The context instance for method chaining * * @since 1.0.0 * * @example * ctx.removeHeader('X-Powered-By'); */ removeHeader(name) { delete this.responseHeaders[name.toLowerCase()]; return this; } /** * Set the HTTP status code * * @param {number|string} code - HTTP status code (100-599) * @returns {Context} The context instance for method chaining * * @throws {Error} When status code is invalid * * @since 1.0.0 * * @example * ctx.status(200); // OK * ctx.status(404); // Not Found * ctx.status(500); // Internal Server Error * * @example * // Method chaining * ctx.status(201).json({ id: 123, name: 'User' }); */ status(code) { // Validate status code const statusCode = parseInt(code); if (isNaN(statusCode) || statusCode < 100 || statusCode > 599) { throw new Error(`Invalid status code: ${code}. Must be between 100 and 599`); } this.statusCode = statusCode; return this; } /** * Redirect to a URL * * Sets the Location header and status code, then ends the response. * * @param {string} url - The URL to redirect to * @param {number} [status=302] - HTTP status code for redirect * @returns {Context} The context instance for method chaining * * @since 1.0.0 * * @example * // Temporary redirect (302) * ctx.redirect('/login'); * * @example * // Permanent redirect (301) * ctx.redirect('/new-path', 301); */ redirect(url, status = 302) { this.status(status); this.set('Location', url); this.end(); return this; } /** * Send a JSON response * * Sets the Content-Type header to application/json and sends the data as JSON. * * @param {*} data - The data to send as JSON * @returns {Context} The context instance for method chaining * * @since 1.0.0 * * @example * ctx.json({ message: 'Hello World' }); * * @example * // With status code * ctx.status(201).json({ id: 123, name: 'User' }); */ json(data) { this.set('Content-Type', 'application/json'); this.send(JSON.stringify(data)); return this; } /** * Send a plain text response * * Sets the Content-Type header to text/plain and sends the data as text. * * @param {string} data - The text data to send * @returns {Context} The context instance for method chaining * * @since 1.0.0 * * @example * ctx.text('Hello World'); * * @example * // With status code * ctx.status(200).text('Success'); */ text(data) { this.set('Content-Type', 'text/plain'); this.send(data); return this; } /** * Send an HTML response * * Sets the Content-Type header to text/html and sends the data as HTML. * * @param {string} data - The HTML data to send * @returns {Context} The context instance for method chaining * * @since 1.0.0 * * @example * ctx.html('<h1>Hello World</h1>'); * * @example * // With status code * ctx.status(200).html('<p>Welcome!</p>'); */ html(data) { this.set('Content-Type', 'text/html'); this.send(data); return this; } /** * Send a response with data * * Sends the response with the specified data. This method is called by other * response methods like json(), text(), and html(). Once called, no further * response methods can be used. * * @param {*} data - The data to send * @returns {Context} The context instance for method chaining * * @since 1.0.0 * * @example * ctx.send('Hello World'); * ctx.send(Buffer.from('binary data')); */ send(data) { if (this.responded) { return this; } this.responded = true; this.res.statusCode = this.statusCode; Object.keys(this.responseHeaders).forEach(name => { this.res.setHeader(name, this.responseHeaders[name]); }); if (data === null || data === undefined) { this.res.end(); } else if (typeof data === 'string' || Buffer.isBuffer(data)) { this.res.end(data); } else { this.res.end(String(data)); } return this; } /** * End the response * * Ends the response optionally with data. This is an alias for send(). * * @param {*} [data] - Optional data to send before ending * @returns {Context} The context instance for method chaining * * @since 1.0.0 * * @example * ctx.status(204).end(); // No content * ctx.end('Final message'); */ end(data) { if (data) { this.send(data); } else { this.send(); } return this; } setCookie(name, value, options = {}) { // Validate cookie name if (!name || typeof name !== 'string') { throw new Error('Cookie name must be a non-empty string'); } // Validate cookie name format (RFC 6265) if (!/^[!#$%&'*+\-.0-9A-Z^_`a-z|~]+$/.test(name)) { throw new Error(`Invalid cookie name: ${name}`); } // Check for control characters in cookie name if (/[\x00-\x1F\x7F]/.test(name)) { throw new Error('Cookie name cannot contain control characters'); } // Validate cookie value if (value === null || value === undefined) { value = ''; } // Convert value to string and check for invalid characters const stringValue = String(value); if (/[\x00-\x1F\x7F]/.test(stringValue)) { throw new Error('Cookie value cannot contain control characters'); } let cookieString = `${name}=${encodeURIComponent(value)}`; if (options.domain) { cookieString += `; Domain=${options.domain}`; } if (options.path) { cookieString += `; Path=${options.path}`; } if (options.expires) { cookieString += `; Expires=${options.expires.toUTCString()}`; } if (options.maxAge) { cookieString += `; Max-Age=${options.maxAge}`; } if (options.httpOnly) { cookieString += '; HttpOnly'; } if (options.secure) { cookieString += '; Secure'; } if (options.sameSite) { const validSameSite = ['Strict', 'Lax', 'None']; const sameSiteValue = options.sameSite.charAt(0).toUpperCase() + options.sameSite.slice(1).toLowerCase(); if (!validSameSite.includes(sameSiteValue)) { throw new Error(`Invalid SameSite value: ${options.sameSite}. Must be 'Strict', 'Lax', or 'None'`); } cookieString += `; SameSite=${sameSiteValue}`; } const existingCookies = this.responseHeaders['set-cookie'] || []; if (Array.isArray(existingCookies)) { existingCookies.push(cookieString); this.responseHeaders['set-cookie'] = existingCookies; } else if (existingCookies) { this.responseHeaders['set-cookie'] = [existingCookies, cookieString]; } else { this.responseHeaders['set-cookie'] = [cookieString]; } return this; } clearCookie(name, options = {}) { const clearOptions = { ...options, expires: new Date(0), maxAge: 0 }; return this.setCookie(name, '', clearOptions); } is(type) { const contentType = this.get('content-type'); if (!contentType) return false; const mimeTypes = { json: 'application/json', form: 'application/x-www-form-urlencoded', multipart: 'multipart/form-data', text: 'text/plain', html: 'text/html' }; const targetType = mimeTypes[type] || type; return contentType.includes(targetType); } accepts(types) { const accept = this.get('accept') || '*/*'; if (typeof types === 'string') { types = [types]; } for (const type of types) { if (accept.includes(type) || accept.includes('*/*')) { return type; } } return false; } fresh() { const method = this.method; const status = this.statusCode; if (method !== 'GET' && method !== 'HEAD') return false; if ((status >= 200 && status < 300) || status === 304) return true; return false; } stale() { return !this.fresh(); } secure() { return this.req.connection.encrypted; } xhr() { return this.get('x-requested-with') === 'XMLHttpRequest'; } ip() { return this.get('x-forwarded-for') || this.get('x-real-ip') || this.req.connection.remoteAddress || this.req.socket.remoteAddress; } ips() { const forwarded = this.get('x-forwarded-for'); return forwarded ? forwarded.split(/\s*,\s*/) : [this.ip()]; } protocol() { return this.req.connection.encrypted ? 'https' : 'http'; } host() { return this.get('host') || 'localhost'; } hostname() { return this.host().split(':')[0]; } port() { const host = this.host(); const portMatch = host.match(/:(\d+)$/); return portMatch ? parseInt(portMatch[1]) : (this.secure() ? 443 : 80); } subdomains() { const hostname = this.hostname(); const parts = hostname.split('.'); return parts.length > 2 ? parts.slice(0, -2).reverse() : []; } type() { const contentType = this.get('content-type'); return contentType ? contentType.split(';')[0] : ''; } charset() { const contentType = this.get('content-type'); if (!contentType) return ''; const match = contentType.match(/charset=([^;]+)/); return match ? match[1] : ''; } length() { const contentLength = this.get('content-length'); return contentLength ? parseInt(contentLength) : 0; } toString() { return `${this.method} ${this.originalUrl}`; } toJSON() { return { method: this.method, url: this.originalUrl, path: this.path, query: this.query, params: this.params, headers: this.headers, cookies: this.cookies, statusCode: this.statusCode, responded: this.responded }; } /** * Initialize context with new request/response (for object pooling) * @param {IncomingMessage} req - HTTP request * @param {ServerResponse} res - HTTP response * @param {Application} app - Spark application */ init(req, res, app) { this.req = req; this.res = res; this.app = app; this.responded = false; this.statusCode = 200; this.initializeUrl(); this.initializeHeaders(); this.initializeQuery(); this.initializeCookies(); this.params = {}; this.body = null; this.files = null; this.session = null; this.state = {}; } /** * Reset context for object pooling */ reset() { this.req = null; this.res = null; this.app = null; this.responded = false; this.statusCode = 200; this.url = null; this.path = null; this.method = null; this.originalUrl = null; this.headers = {}; this.responseHeaders = {}; this.query = {}; this.cookies = {}; this.params = {}; this.body = null; this.files = null; this.session = null; this.state = {}; } } module.exports = Context;