diff --git a/lib/cors.js b/lib/cors.js index 32831e1..e314b0c 100644 --- a/lib/cors.js +++ b/lib/cors.js @@ -15,7 +15,7 @@ const DEFAULT_CORS_HEADERS = { 'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, PATCH, OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type, Authorization', 'Access-Control-Max-Age': '86400' // 24 hours -}; +} /** * Applies CORS headers to a response @@ -24,14 +24,14 @@ const DEFAULT_CORS_HEADERS = { * @param {Object} [customHeaders] - Custom CORS headers to merge with defaults * @returns {void} */ -function applyCorsHeaders(response, enabled = true, customHeaders = {}) { - if (!enabled) return; - - const headers = { ...DEFAULT_CORS_HEADERS, ...customHeaders }; - +function applyCorsHeaders (response, enabled = true, customHeaders = {}) { + if (!enabled) return + + const headers = { ...DEFAULT_CORS_HEADERS, ...customHeaders } + Object.entries(headers).forEach(([key, value]) => { - response.setHeader(key, value); - }); + response.setHeader(key, value) + }) } /** @@ -41,24 +41,24 @@ function applyCorsHeaders(response, enabled = true, customHeaders = {}) { * @param {boolean} [enabled=true] - Whether CORS is enabled * @returns {boolean} - True if this was a preflight request that was handled */ -function handlePreflight(request, response, enabled = true) { +function handlePreflight (request, response, enabled = true) { if (!enabled || request.method !== 'OPTIONS') { - return false; + return false } applyCorsHeaders(response, true, { - 'Access-Control-Allow-Methods': request.headers['access-control-request-method'] || + 'Access-Control-Allow-Methods': request.headers['access-control-request-method'] || DEFAULT_CORS_HEADERS['Access-Control-Allow-Methods'], - 'Access-Control-Allow-Headers': request.headers['access-control-request-headers'] || + 'Access-Control-Allow-Headers': request.headers['access-control-request-headers'] || DEFAULT_CORS_HEADERS['Access-Control-Allow-Headers'] - }); + }) - response.status(204).send(''); - return true; + response.status(204).send('') + return true } module.exports = { applyCorsHeaders, handlePreflight, DEFAULT_CORS_HEADERS -}; +} diff --git a/lib/httpParser.js b/lib/httpParser.js index 2ccd346..cbb29f6 100644 --- a/lib/httpParser.js +++ b/lib/httpParser.js @@ -23,7 +23,7 @@ const { findFirstBrac, HTTPbody, JSONbodyParser, queryParser } = require('./util * @returns {Promise} Parsed HTTP request object * @throws {Error} If the request is malformed */ -async function httpParser(request, connection = {}) { +async function httpParser (request, connection = {}) { try { /** @type {ParsedRequest} */ const req = { @@ -34,142 +34,142 @@ async function httpParser(request, connection = {}) { query: {}, ip: '127.0.0.1', body: undefined - }; - + } + // Convert buffer to string if necessary and handle empty requests - const requestString = (request && request.toString) ? request.toString() : ''; + const requestString = (request && request.toString) ? request.toString() : '' // Set client IP address with fallback if (connection && connection.remoteAddress) { - req.ip = connection.remoteAddress; + req.ip = connection.remoteAddress } - + // Step 1: Split the request into headers and body by finding "\r\n\r\n" // Split into headers and body parts - const headerBodySplit = requestString.split('\r\n\r\n'); + const headerBodySplit = requestString.split('\r\n\r\n') if (headerBodySplit.length === 0 || !headerBodySplit[0]) { - throw new Error('Invalid HTTP request: Missing headers'); + throw new Error('Invalid HTTP request: Missing headers') } - + const headersPart = headerBodySplit[0] // First part is the headers const bodyPart = headerBodySplit[1] || '' // Second part is the body, default to empty string if no body // Step 2: Extract the headers (the first line is the request line, e.g., "POST /path HTTP/1.1") // Split headers into lines, handling both CRLF and LF line endings - const headers = headersPart.split(/\r?\n/).filter(line => line.trim()); + const headers = headersPart.split(/\r?\n/).filter(line => line.trim()) // Parse the request line (first line of the headers) - const requestLine = (headers[0] || '').split(/\s+/); + const requestLine = (headers[0] || '').split(/\s+/) if (requestLine.length < 3 || !requestLine[0] || !requestLine[1] || !requestLine[2]) { - throw new Error('Invalid request line format'); + throw new Error('Invalid request line format') } // Parse method, path, and version with validation - const method = requestLine[0].toUpperCase(); - const path = requestLine[1] || '/'; - const version = requestLine[2]; - + const method = requestLine[0].toUpperCase() + const path = requestLine[1] || '/' + const version = requestLine[2] + // Validate HTTP method - const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']; + const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] if (!validMethods.includes(method)) { - throw new Error(`Unsupported HTTP method: ${method}`); + throw new Error(`Unsupported HTTP method: ${method}`) } - + // Validate HTTP version if (!/^HTTP\/\d+\.\d+$/.test(version)) { - throw new Error(`Invalid HTTP version: ${version}`); + throw new Error(`Invalid HTTP version: ${version}`) } - - req.method = method; - req.path = path; - req.version = version; + + req.method = method + req.path = path + req.version = version // Parse headers with validation for (let i = 1; i < headers.length; i++) { - const line = (headers[i] || '').trim(); - if (!line) continue; - - const colonIndex = line.indexOf(':'); - if (colonIndex <= 0) continue; // Skip malformed headers - - const key = line.slice(0, colonIndex).trim().toLowerCase(); - const value = line.slice(colonIndex + 1).trim(); - + const line = (headers[i] || '').trim() + if (!line) continue + + const colonIndex = line.indexOf(':') + if (colonIndex <= 0) continue // Skip malformed headers + + const key = line.slice(0, colonIndex).trim().toLowerCase() + const value = line.slice(colonIndex + 1).trim() + // Handle duplicate headers by appending with comma (per HTTP spec) if (req.headers[key]) { if (Array.isArray(req.headers[key])) { - req.headers[key].push(value); + req.headers[key].push(value) } else { - req.headers[key] = [req.headers[key], value]; + req.headers[key] = [req.headers[key], value] } } else { - req.headers[key] = value; + req.headers[key] = value } } // Parse query string and clean path try { // Handle potential URI encoding issues - const cleanPath = decodeURIComponent(req.path || '/').split('?')[0] || '/'; - req.path = cleanPath; - + const cleanPath = decodeURIComponent(req.path || '/').split('?')[0] || '/' + req.path = cleanPath + // Parse query parameters safely - const queryStart = req.path.indexOf('?'); + const queryStart = req.path.indexOf('?') if (queryStart !== -1) { - req.query = queryParser(req.path.slice(queryStart + 1)); - req.path = req.path.slice(0, queryStart); + req.query = queryParser(req.path.slice(queryStart + 1)) + req.path = req.path.slice(0, queryStart) } else { - req.query = {}; + req.query = {} } } catch (error) { - console.warn('Error parsing query string:', error); - req.query = {}; - req.path = '/'; + console.warn('Error parsing query string:', error) + req.query = {} + req.path = '/' } // Parse request body based on method and content type try { if (['POST', 'PUT', 'PATCH'].includes(req.method) && bodyPart) { - const contentType = (req.headers['content-type'] || '').toLowerCase(); - + const contentType = (req.headers['content-type'] || '').toLowerCase() + if (contentType.includes('application/json')) { try { - const bodyData = await HTTPbody(bodyPart, 0); - req.body = JSONbodyParser(bodyData) || {}; + const bodyData = await HTTPbody(bodyPart, 0) + req.body = JSONbodyParser(bodyData) || {} } catch (error) { - console.warn('Error parsing JSON body:', error); - req.body = {}; + console.warn('Error parsing JSON body:', error) + req.body = {} } } else if (contentType.includes('application/x-www-form-urlencoded')) { try { - req.body = queryParser(bodyPart); + req.body = queryParser(bodyPart) } catch (error) { - console.warn('Error parsing form data:', error); - req.body = {}; + console.warn('Error parsing form data:', error) + req.body = {} } } else { // For other content types, keep as raw string - req.body = bodyPart; + req.body = bodyPart } } else if (req.method === 'OPTIONS') { // Handle OPTIONS preflight request - req.body = {}; + req.body = {} req.cors = { - origin: req.headers['origin'], + origin: req.headers.origin, method: req.headers['access-control-request-method'] || '*', headers: req.headers['access-control-request-headers'] || '' - }; + } } else { - req.body = undefined; + req.body = undefined } } catch (error) { - console.warn('Error processing request body:', error); - req.body = {}; + console.warn('Error processing request body:', error) + req.body = {} } - return req; + return req } catch (error) { - console.error('Error parsing HTTP request:', error); + console.error('Error parsing HTTP request:', error) // Create a minimal valid request object even on error return { method: 'GET', @@ -179,7 +179,7 @@ async function httpParser(request, connection = {}) { query: {}, ip: connection.remoteAddress || '127.0.0.1', body: undefined - }; + } } } diff --git a/server/index.js b/server/index.js index f8c9e6a..5f470f1 100644 --- a/server/index.js +++ b/server/index.js @@ -1,9 +1,9 @@ -const { httpParser } = require('../lib/httpParser.js'); -const net = require('net'); -const path = require('path'); -const { URL } = require('url'); -const Response = require('./response.js'); -const { handlePreflight } = require('../lib/cors'); +const { httpParser } = require('../lib/httpParser.js') +const net = require('net') +const path = require('path') +const { URL } = require('url') +const Response = require('./response.js') +const { handlePreflight } = require('../lib/cors') /** * @typedef {Object} Route @@ -19,27 +19,27 @@ const { handlePreflight } = require('../lib/cors'); * @param {Object} context - Server context object * @returns {net.Server} - Node.js Server instance */ -function createServer(callback, context) { +function createServer (callback, context) { return net.createServer((socket) => { // Set socket timeout to prevent hanging connections - socket.setTimeout(30000); // 30 seconds - + socket.setTimeout(30000) // 30 seconds + // Handle connection errors socket.on('error', (error) => { - console.error('Socket error:', error); + console.error('Socket error:', error) if (!socket.destroyed) { - socket.destroy(); + socket.destroy() } - }); - + }) + // Handle timeout socket.on('timeout', () => { - console.warn('Socket timeout'); - socket.end('HTTP/1.1 408 Request Timeout\r\n\r\n'); - }); - - callback(socket, context); - }); + console.warn('Socket timeout') + socket.end('HTTP/1.1 408 Request Timeout\r\n\r\n') + }) + + callback(socket, context) + }) } /** @@ -47,26 +47,26 @@ function createServer(callback, context) { * @param {net.Socket} socket - The socket connection * @param {Object} context - Server context with routes and configuration */ -function handleConnection(socket, context) { - let requestData = Buffer.alloc(0); - +function handleConnection (socket, context) { + let requestData = Buffer.alloc(0) + socket.on('data', (chunk) => { - requestData = Buffer.concat([requestData, chunk]); - + requestData = Buffer.concat([requestData, chunk]) + // Check if we've received the complete request if (requestData.includes('\r\n\r\n')) { processRequest(socket, requestData, context) .catch(error => { - console.error('Error processing request:', error); - const res = new Response(socket, context.enableCors); - res.status(500).send('Internal Server Error'); - }); + console.error('Error processing request:', error) + const res = new Response(socket, context.enableCors) + res.status(500).send('Internal Server Error') + }) } - }); - + }) + socket.on('error', (error) => { - console.error('Connection error:', error); - }); + console.error('Connection error:', error) + }) } /** @@ -76,45 +76,44 @@ function handleConnection(socket, context) { * @param {Object} context - Server context * @returns {Promise} */ -async function processRequest(socket, requestData, context) { - const res = new Response(socket, context.enableCors); - +async function processRequest (socket, requestData, context) { + const res = new Response(socket, context.enableCors) + try { // Parse the HTTP request - const req = await httpParser(requestData.toString()); - + const req = await httpParser(requestData.toString()) + // Handle CORS preflight requests if (req.method === 'OPTIONS' && context.enableCors) { - handlePreflight(res); - return; + handlePreflight(res) + return } - + // Find matching route - const route = findMatchingRoute(req.method, req.path, context.routes); - + const route = findMatchingRoute(req.method, req.path, context.routes) + if (!route) { - res.status(404).send('Not Found'); - return; + res.status(404).send('Not Found') + return } - + // Extract URL parameters - req.params = extractParams(route.path, req.path); - + req.params = extractParams(route.path, req.path) + // Parse query parameters if (req.path.includes('?')) { - const url = new URL(`http://dummy${req.path}`); - req.query = Object.fromEntries(url.searchParams.entries()); + const url = new URL(`http://dummy${req.path}`) + req.query = Object.fromEntries(url.searchParams.entries()) } else { - req.query = {}; + req.query = {} } - + // Execute route handler - await route.callback(req, res); - + await route.callback(req, res) } catch (error) { - console.error('Request processing error:', error); + console.error('Request processing error:', error) if (!res.headersSent) { - res.status(400).send('Bad Request'); + res.status(400).send('Bad Request') } } } @@ -126,18 +125,18 @@ async function processRequest(socket, requestData, context) { * @param {Array} routes - Available routes * @returns {Route|undefined} - Matched route or undefined */ -function findMatchingRoute(method, path, routes) { +function findMatchingRoute (method, path, routes) { // First try exact match const exactMatch = routes.find( route => route.method === method && route.path === path - ); - - if (exactMatch) return exactMatch; - + ) + + if (exactMatch) return exactMatch + // Then try parameterized routes - return routes.find(route => + return routes.find(route => route.method === method && matchRouteWithParams(route.path, path) - ); + ) } /** @@ -146,15 +145,15 @@ function findMatchingRoute(method, path, routes) { * @param {string} urlPath - Actual URL path * @returns {boolean} - True if the route matches */ -function matchRouteWithParams(routePath, urlPath) { - const routeParts = routePath.split('/'); - const pathParts = urlPath.split('?')[0].split('/'); - - if (routeParts.length !== pathParts.length) return false; - - return routeParts.every((part, i) => +function matchRouteWithParams (routePath, urlPath) { + const routeParts = routePath.split('/') + const pathParts = urlPath.split('?')[0].split('/') + + if (routeParts.length !== pathParts.length) return false + + return routeParts.every((part, i) => part.startsWith(':') || part === pathParts[i] - ); + ) } /** @@ -163,18 +162,18 @@ function matchRouteWithParams(routePath, urlPath) { * @param {string} urlPath - Actual URL path (e.g., '/users/123') * @returns {Object} - Extracted parameters */ -function extractParams(routePath, urlPath) { - const params = {}; - const routeParts = routePath.split('/'); - const pathParts = urlPath.split('?')[0].split('/'); - +function extractParams (routePath, urlPath) { + const params = {} + const routeParts = routePath.split('/') + const pathParts = urlPath.split('?')[0].split('/') + routeParts.forEach((part, i) => { if (part.startsWith(':')) { - params[part.slice(1)] = decodeURIComponent(pathParts[i]); + params[part.slice(1)] = decodeURIComponent(pathParts[i]) } - }); - - return params; + }) + + return params } /** @@ -186,65 +185,64 @@ class Server { * Creates a new Server instance * @constructor */ - constructor() { + constructor () { /** @private */ - this.routes = []; - + this.routes = [] + /** @private */ - this.server = null; - + this.server = null + // Bind methods - this.listen = this.listen.bind(this); - this.close = this.close.bind(this); + this.listen = this.listen.bind(this) + this.close = this.close.bind(this) } - + /** * Starts the server listening on the specified port * @param {number} port - Port number to listen on * @param {Function} [callback] - Callback function when server starts * @returns {Promise} */ - listen(port, callback) { + listen (port, callback) { return new Promise((resolve, reject) => { try { this.server = createServer(handleConnection, { routes: this.routes, enableCors: this.enableCors || false - }); - + }) + this.server.on('error', (error) => { - console.error('Server error:', error); - reject(error); - }); - + console.error('Server error:', error) + reject(error) + }) + this.server.listen(port, '0.0.0.0', () => { - console.log(`Server listening on port ${port}`); - if (callback) callback(); - resolve(); - }); - + console.log(`Server listening on port ${port}`) + if (callback) callback() + resolve() + }) } catch (error) { - console.error('Failed to start server:', error); - reject(error); + console.error('Failed to start server:', error) + reject(error) } - }); + }) } - + /** * Stops the server * @returns {Promise} */ - close() { + close () { return new Promise((resolve) => { if (this.server) { this.server.close(() => { - console.log('Server stopped'); - resolve(); - }); + console.log('Server stopped') + resolve() + }) } else { - resolve(); + resolve() } - }); + }) } } @@ -257,26 +255,26 @@ class Hasty extends Server { * Creates a new Hasty server instance * @constructor */ - constructor() { - super(); - + constructor () { + super() + /** @private */ - this.enableCors = false; - + this.enableCors = false + // Bind methods - this.cors = this.cors.bind(this); + this.cors = this.cors.bind(this) } - + /** * Enables or disables CORS support * @param {boolean} [enabled=true] - Whether to enable CORS * @returns {Hasty} - The server instance for chaining */ - cors(enabled = true) { - this.enableCors = enabled; - return this; + cors (enabled = true) { + this.enableCors = enabled + return this } - + /** * Registers a route with the server * @private @@ -285,121 +283,121 @@ class Hasty extends Server { * @param {Function} callback - Route handler * @returns {void} */ - _registerRoute(method, path, callback) { + _registerRoute (method, path, callback) { // Normalize path - ensure it starts with a slash but don't modify parameter routes - let normalizedPath = path; + let normalizedPath = path if (!normalizedPath.startsWith('/')) { - normalizedPath = '/' + normalizedPath; + normalizedPath = '/' + normalizedPath } // Remove trailing slash unless it's the root path if (normalizedPath !== '/' && normalizedPath.endsWith('/')) { - normalizedPath = normalizedPath.slice(0, -1); + normalizedPath = normalizedPath.slice(0, -1) } - + this.routes.push({ method: method.toUpperCase(), path: normalizedPath, callback: async (req, res) => { try { - await callback(req, res); - + await callback(req, res) + // If headers haven't been sent and the response hasn't been ended if (!res.headersSent && !res.finished) { - res.end(); + res.end() } } catch (error) { - console.error('Route handler error:', error); + console.error('Route handler error:', error) if (!res.headersSent) { - res.status(500).send('Internal Server Error'); + res.status(500).send('Internal Server Error') } } } - }); + }) } - + // HTTP method shortcuts - + /** * Registers a GET route * @param {string} path - URL path pattern * @param {Function} callback - Route handler * @returns {void} */ - get(path, callback) { - this._registerRoute('GET', path, callback); + get (path, callback) { + this._registerRoute('GET', path, callback) } - + /** * Registers a POST route * @param {string} path - URL path pattern * @param {Function} callback - Route handler * @returns {void} */ - post(path, callback) { - this._registerRoute('POST', path, callback); + post (path, callback) { + this._registerRoute('POST', path, callback) } - + /** * Registers a PUT route * @param {string} path - URL path pattern * @param {Function} callback - Route handler * @returns {void} */ - put(path, callback) { - this._registerRoute('PUT', path, callback); + put (path, callback) { + this._registerRoute('PUT', path, callback) } - + /** * Registers a DELETE route * @param {string} path - URL path pattern * @param {Function} callback - Route handler * @returns {void} */ - delete(path, callback) { - this._registerRoute('DELETE', path, callback); + delete (path, callback) { + this._registerRoute('DELETE', path, callback) } - + /** * Registers a PATCH route * @param {string} path - URL path pattern * @param {Function} callback - Route handler * @returns {void} */ - patch(path, callback) { - this._registerRoute('PATCH', path, callback); + patch (path, callback) { + this._registerRoute('PATCH', path, callback) } - + /** * Registers a HEAD route * @param {string} path - URL path pattern * @param {Function} callback - Route handler * @returns {void} */ - head(path, callback) { - this._registerRoute('HEAD', path, callback); + head (path, callback) { + this._registerRoute('HEAD', path, callback) } - + /** * Registers an OPTIONS route * @param {string} path - URL path pattern * @param {Function} [callback] - Optional route handler * @returns {void} */ - options(path, callback) { + options (path, callback) { if (callback) { - this._registerRoute('OPTIONS', path, callback); + this._registerRoute('OPTIONS', path, callback) } else { // Auto-handle OPTIONS for CORS this._registerRoute('OPTIONS', path, (_, res) => { if (this.enableCors) { - handlePreflight(res); + handlePreflight(res) } else { - res.status(200).end(); + res.status(200).end() } - }); + }) } } - + /** * Serves static files from a directory * @param {string} root - Root directory to serve files from @@ -407,25 +405,25 @@ class Hasty extends Server { * @param {string} [options.prefix='/'] - URL prefix for the static files * @returns {void} */ - static(root, options = {}) { - const { prefix = '/' } = options; - const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/`; - + static (root, options = {}) { + const { prefix = '/' } = options + const normalizedPrefix = prefix.endsWith('/') ? prefix : `${prefix}/` + this.get(`${normalizedPrefix}*`, (req, res) => { // Remove the prefix and any leading slashes to get the relative path - const relativePath = req.path.slice(normalizedPrefix.length).replace(/^\/+/, ''); - + const relativePath = req.path.slice(normalizedPrefix.length).replace(/^\/+/, '') + // Prevent directory traversal if (relativePath.includes('../') || relativePath.includes('..\\')) { - return res.status(403).send('Forbidden'); + return res.status(403).send('Forbidden') } - + // Join with the root directory and normalize the path - const filePath = path.join(root, relativePath || 'index.html'); - + const filePath = path.join(root, relativePath || 'index.html') + // Send the file - res.sendFile(filePath); - }); + res.sendFile(filePath) + }) } } diff --git a/server/response.js b/server/response.js index f82c4e7..a267d67 100644 --- a/server/response.js +++ b/server/response.js @@ -1,7 +1,7 @@ -const fs = require('fs'); -const path = require('path'); -const { lookupMimeType } = require('../lib/utils'); -const { applyCorsHeaders } = require('../lib/cors'); +const fs = require('fs') +const path = require('path') +const { lookupMimeType } = require('../lib/utils') +const { applyCorsHeaders } = require('../lib/cors') /** * @typedef {Object} ResponseOptions @@ -16,7 +16,6 @@ const { applyCorsHeaders } = require('../lib/cors'); * @property {boolean} [end=true] - Whether to end the response after sending */ - /** @type {Object.} */ const STATUS_CODES = Object.freeze({ 100: 'Continue', @@ -81,8 +80,7 @@ const STATUS_CODES = Object.freeze({ 508: 'Loop Detected', 510: 'Not Extended', 511: 'Network Authentication Required' -}); - +}) /** * Response class for handling HTTP responses. @@ -97,36 +95,36 @@ const STATUS_CODES = Object.freeze({ */ class Response { /** @type {number} */ - statusCode = 200; - + statusCode = 200 + /** @type {boolean} */ - enableCors = false; - + enableCors = false + /** @type {Object.} */ - headers = {}; - + headers = {} + /** @type {boolean} */ - headersSent = false; - + headersSent = false + /** @type {import('net').Socket} */ - socket; + socket /** * Create a new Response instance * @param {import('net').Socket} socket - The socket object for the response * @param {boolean} [enableCors=false] - Whether to enable CORS headers */ - constructor(socket, enableCors = false) { + constructor (socket, enableCors = false) { if (!socket || typeof socket.write !== 'function') { - throw new Error('Socket is required and must be a writable stream'); + throw new Error('Socket is required and must be a writable stream') } - - this.socket = socket; - this.enableCors = Boolean(enableCors); + + this.socket = socket + this.enableCors = Boolean(enableCors) this.headers = { 'Content-Type': 'text/plain', 'X-Powered-By': 'Hasty-Server' - }; + } } /** @@ -135,12 +133,12 @@ class Response { * @returns {Response} The Response instance for chaining * @throws {Error} If status code is invalid */ - status(code) { + status (code) { if (!Number.isInteger(code) || code < 100 || code > 599) { - throw new Error(`Invalid status code: ${code}`); + throw new Error(`Invalid status code: ${code}`) } - this.statusCode = code; - return this; + this.statusCode = code + return this } /** @@ -149,19 +147,19 @@ class Response { * @param {string|number|string[]} value - Header value(s) * @returns {Response} The Response instance for chaining */ - setHeader(key, value) { + setHeader (key, value) { if (this.headersSent) { - console.warn('Cannot set header after headers have been sent'); - return this; + console.warn('Cannot set header after headers have been sent') + return this } - + if (Array.isArray(value)) { - this.headers[key] = value.join(', '); + this.headers[key] = value.join(', ') } else { - this.headers[key] = String(value); + this.headers[key] = String(value) } - - return this; + + return this } /** @@ -169,9 +167,9 @@ class Response { * @private * @returns {void} */ - _applyCorsHeaders() { + _applyCorsHeaders () { if (this.enableCors) { - applyCorsHeaders(this, true); + applyCorsHeaders(this, true) } } @@ -196,74 +194,74 @@ class Response { * @param {SendOptions} [options] - Additional options * @returns {void} */ - send(data, options = {}) { + send (data, options = {}) { if (this.headersSent) { - console.warn('Headers already sent'); - return; + console.warn('Headers already sent') + return } - const { contentType, end = true } = options; + const { contentType, end = true } = options // Set content type if provided or default to text/plain if (contentType) { - this.setHeader('Content-Type', contentType); + this.setHeader('Content-Type', contentType) } else if (!this.headers['Content-Type']) { - this.setHeader('Content-Type', 'text/plain; charset=utf-8'); + this.setHeader('Content-Type', 'text/plain; charset=utf-8') } // Apply CORS headers - this._applyCorsHeaders(); + this._applyCorsHeaders() // Handle different data types - let responseData = data; + let responseData = data if (data !== undefined && data !== null) { if (typeof data === 'object' && !Buffer.isBuffer(data)) { try { - responseData = JSON.stringify(data); + responseData = JSON.stringify(data) if (!contentType) { - this.setHeader('Content-Type', 'application/json; charset=utf-8'); + this.setHeader('Content-Type', 'application/json; charset=utf-8') } } catch (error) { - console.error('Error stringifying JSON:', error); - return this.status(500).send('Error generating response'); + console.error('Error stringifying JSON:', error) + return this.status(500).send('Error generating response') } } else if (Buffer.isBuffer(data)) { if (!contentType) { - this.setHeader('Content-Type', 'application/octet-stream'); + this.setHeader('Content-Type', 'application/octet-stream') } } else { - responseData = String(data); + responseData = String(data) } } else { - responseData = ''; + responseData = '' } // Set Content-Length header - const contentLength = responseData ? Buffer.byteLength(responseData) : 0; - this.setHeader('Content-Length', contentLength); + const contentLength = responseData ? Buffer.byteLength(responseData) : 0 + this.setHeader('Content-Length', contentLength) // Build response - const statusText = STATUS_CODES[this.statusCode] || 'Unknown Status'; + const statusText = STATUS_CODES[this.statusCode] || 'Unknown Status' const headers = Object.entries(this.headers) .map(([key, value]) => `${key}: ${value}`) - .join('\r\n'); + .join('\r\n') // Send response this.socket.write( `HTTP/1.1 ${this.statusCode} ${statusText}\r\n` + `${headers}\r\n\r\n` - ); + ) // Send response body if present if (contentLength > 0) { - this.socket.write(responseData); + this.socket.write(responseData) } - this.headersSent = true; + this.headersSent = true // End the connection if requested if (end) { - this.socket.end(); + this.socket.end() } } @@ -273,11 +271,11 @@ class Response { * @param {number} [status] - Optional status code * @returns {void} */ - json(data, status) { + json (data, status) { if (status !== undefined) { - this.status(status); + this.status(status) } - this.send(data, { contentType: 'application/json; charset=utf-8' }); + this.send(data, { contentType: 'application/json; charset=utf-8' }) } /** @@ -289,78 +287,77 @@ class Response { * @param {boolean} [options.end=true] - Whether to end the response * @returns {void} */ - download(filePath, filename, options = {}) { + download (filePath, filename, options = {}) { if (this.headersSent) { - console.warn('Headers already sent'); - return; + console.warn('Headers already sent') + return } - const { contentType, end = true } = options; + const { contentType, end = true } = options try { - const stats = fs.statSync(filePath); - + const stats = fs.statSync(filePath) + // Set content disposition for download - const contentDisposition = `attachment${filename ? `; filename="${filename}"` : ''}`; - this.setHeader('Content-Disposition', contentDisposition); - + const contentDisposition = `attachment${filename ? `; filename="${filename}"` : ''}` + this.setHeader('Content-Disposition', contentDisposition) + // Set content type if (contentType) { - this.setHeader('Content-Type', contentType); + this.setHeader('Content-Type', contentType) } else { - const mimeType = lookupMimeType(path.extname(filePath).substring(1)) || 'application/octet-stream'; - this.setHeader('Content-Type', mimeType); + const mimeType = lookupMimeType(path.extname(filePath).substring(1)) || 'application/octet-stream' + this.setHeader('Content-Type', mimeType) } - + // Set content length - this.setHeader('Content-Length', stats.size); - + this.setHeader('Content-Length', stats.size) + // Apply CORS headers - this._applyCorsHeaders(); - + this._applyCorsHeaders() + // Send headers - const statusText = STATUS_CODES[this.statusCode] || 'OK'; + const statusText = STATUS_CODES[this.statusCode] || 'OK' const headers = Object.entries(this.headers) .map(([key, value]) => `${key}: ${value}`) - .join('\r\n'); - + .join('\r\n') + this.socket.write( `HTTP/1.1 ${this.statusCode} ${statusText}\r\n` + `${headers}\r\n\r\n` - ); - - this.headersSent = true; - + ) + + this.headersSent = true + // Stream the file - const fileStream = fs.createReadStream(filePath); - fileStream.pipe(this.socket, { end }); - + const fileStream = fs.createReadStream(filePath) + fileStream.pipe(this.socket, { end }) + // Handle stream errors fileStream.on('error', (error) => { - console.error('Error streaming file:', error); + console.error('Error streaming file:', error) if (!this.headersSent) { - this.status(500).send('Error streaming file'); + this.status(500).send('Error streaming file') } else { - this.socket.end(); + this.socket.end() } - }); - + }) + if (end) { fileStream.on('end', () => { - this.socket.end(); - }); + this.socket.end() + }) } - } catch (error) { - console.error('Error serving file:', error); + console.error('Error serving file:', error) if (!this.headersSent) { if (error.code === 'ENOENT') { - this.status(404).send('File not found'); + this.status(404).send('File not found') } else { - this.status(500).send('Error serving file'); + this.status(500).send('Error serving file') } } else { - this.socket.end(); + this.socket.end() } } } @@ -373,74 +370,73 @@ class Response { * @param {boolean} [options.end=true] - Whether to end the response * @returns {void} */ - sendFile(filePath, options = {}) { + sendFile (filePath, options = {}) { if (this.headersSent) { - console.warn('Headers already sent'); - return; + console.warn('Headers already sent') + return } - const { contentType, end = true } = options; + const { contentType, end = true } = options try { - const stats = fs.statSync(filePath); - + const stats = fs.statSync(filePath) + // Set content type based on file extension if not provided if (contentType) { - this.setHeader('Content-Type', contentType); + this.setHeader('Content-Type', contentType) } else { - const mimeType = lookupMimeType(path.extname(filePath).substring(1)) || 'application/octet-stream'; - this.setHeader('Content-Type', mimeType); + const mimeType = lookupMimeType(path.extname(filePath).substring(1)) || 'application/octet-stream' + this.setHeader('Content-Type', mimeType) } - + // Set content length - this.setHeader('Content-Length', stats.size); - + this.setHeader('Content-Length', stats.size) + // Apply CORS headers - this._applyCorsHeaders(); - + this._applyCorsHeaders() + // Send headers - const statusText = STATUS_CODES[this.statusCode] || 'OK'; + const statusText = STATUS_CODES[this.statusCode] || 'OK' const headers = Object.entries(this.headers) .map(([key, value]) => `${key}: ${value}`) - .join('\r\n'); - + .join('\r\n') + this.socket.write( `HTTP/1.1 ${this.statusCode} ${statusText}\r\n` + `${headers}\r\n\r\n` - ); - - this.headersSent = true; - + ) + + this.headersSent = true + // Stream the file - const fileStream = fs.createReadStream(filePath); - fileStream.pipe(this.socket, { end }); - + const fileStream = fs.createReadStream(filePath) + fileStream.pipe(this.socket, { end }) + // Handle stream errors fileStream.on('error', (error) => { - console.error('Error streaming file:', error); + console.error('Error streaming file:', error) if (!this.headersSent) { - this.status(500).send('Error streaming file'); + this.status(500).send('Error streaming file') } else { - this.socket.end(); + this.socket.end() } - }); - + }) + if (end) { fileStream.on('end', () => { - this.socket.end(); - }); + this.socket.end() + }) } - } catch (error) { - console.error('Error serving file:', error); + console.error('Error serving file:', error) if (!this.headersSent) { if (error.code === 'ENOENT') { - this.status(404).send('File not found'); + this.status(404).send('File not found') } else { - this.status(500).send('Error serving file'); + this.status(500).send('Error serving file') } } else { - this.socket.end(); + this.socket.end() } } } @@ -451,10 +447,10 @@ class Response { * @param {string} [message] - Optional message (defaults to status text) * @returns {void} */ - sendStatus(statusCode, message) { - this.status(statusCode); - const statusText = message || STATUS_CODES[statusCode] || 'Unknown Status'; - this.send(statusText); + sendStatus (statusCode, message) { + this.status(statusCode) + const statusText = message || STATUS_CODES[statusCode] || 'Unknown Status' + this.send(statusText) } /** @@ -462,32 +458,32 @@ class Response { * @param {string|Buffer} [data] - Optional data to send before ending * @returns {void} */ - end(data) { + end (data) { if (data !== undefined) { - this.send(data, { end: true }); + this.send(data, { end: true }) } else if (!this.headersSent) { // Send headers with no body - this.setHeader('Content-Length', '0'); - this._applyCorsHeaders(); - - const statusText = STATUS_CODES[this.statusCode] || 'OK'; + this.setHeader('Content-Length', '0') + this._applyCorsHeaders() + + const statusText = STATUS_CODES[this.statusCode] || 'OK' const headers = Object.entries(this.headers) .map(([key, value]) => `${key}: ${value}`) - .join('\r\n'); - + .join('\r\n') + this.socket.write( `HTTP/1.1 ${this.statusCode} ${statusText}\r\n` + `${headers}\r\n\r\n` - ); - - this.headersSent = true; - this.socket.end(); + ) + + this.headersSent = true + this.socket.end() } else { - this.socket.end(); + this.socket.end() } } } // End of Response class -Response.STATUS_CODES = STATUS_CODES; +Response.STATUS_CODES = STATUS_CODES -module.exports = Response; +module.exports = Response