UNPKG

@oxog/spark

Version:

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

756 lines (694 loc) 20.7 kB
/** * @fileoverview Router class for handling HTTP routes in Spark Framework * @author Spark Framework Team * @since 1.0.0 * @version 1.0.0 */ const Layer = require('./layer'); const Route = require('./route'); const { SafeRegexCache } = require('../utils/regex-validator'); /** * Router class for managing HTTP routes and middleware * * The Router class provides route registration, path matching, and middleware execution. * It supports dynamic route parameters, nested routers, route grouping, and various * HTTP methods. Routes are matched using optimized regular expressions with caching. * * @class Router * @since 1.0.0 * * @example * // Basic usage * const router = new Router(); * * router.get('/users', (ctx) => { * ctx.json({ users: [] }); * }); * * router.post('/users', (ctx) => { * const user = ctx.body; * ctx.status(201).json({ id: 123, ...user }); * }); * * @example * // With parameters and middleware * router.get('/users/:id', authenticate, (ctx) => { * const userId = ctx.params.id; * ctx.json({ id: userId, name: 'User' }); * }); * * @example * // Route grouping * router.group('/api/v1', (api) => { * api.get('/users', getUsersHandler); * api.post('/users', createUserHandler); * }); */ class Router { /** * Create a new Router instance * * @param {Object} [options={}] - Router configuration options * @param {boolean} [options.caseSensitive=false] - Enable case-sensitive routing * @param {boolean} [options.strict=false] - Enable strict routing (trailing slash matters) * * @since 1.0.0 * * @example * // Default router * const router = new Router(); * * @example * // Case-sensitive router * const router = new Router({ caseSensitive: true }); * * @example * // Strict routing (trailing slash matters) * const router = new Router({ strict: true }); */ constructor(options = {}) { /** * Router configuration options * @type {Object} * @private */ this.options = { caseSensitive: false, strict: false, ...options }; /** * Array of route layers (middleware and routes) * @type {Array} * @private */ this.stack = []; /** * Parameter handlers for route parameters * @type {Object} * @private */ this.params = {}; /** * Cache for compiled regular expressions * @type {SafeRegexCache} * @private */ this.regexCache = new SafeRegexCache(); } /** * Add middleware to the router * * Middleware functions are executed for routes that match the specified path. * If no path is provided, middleware applies to all routes. * * @param {string|Function} pathOrMiddleware - Path pattern or middleware function * @param {...Function} middlewares - Additional middleware functions * @returns {Router} The router instance for method chaining * * @since 1.0.0 * * @example * // Global middleware * router.use((ctx, next) => { * console.log(`${ctx.method} ${ctx.path}`); * return next(); * }); * * @example * // Path-specific middleware * router.use('/api', authenticateMiddleware); * * @example * // Multiple middleware * router.use('/admin', authenticate, authorize, logAccess); */ use(pathOrMiddleware, ...middlewares) { let path = '/'; let handlers = []; if (typeof pathOrMiddleware === 'string') { path = pathOrMiddleware; handlers = middlewares; } else { handlers = [pathOrMiddleware, ...middlewares]; } for (const handler of handlers) { const layer = new Layer(path, { sensitive: this.options.caseSensitive, strict: this.options.strict, end: false }, handler, this); this.stack.push(layer); } return this; } /** * Register a GET route * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.get('/', (ctx) => { * ctx.json({ message: 'Hello World!' }); * }); * * @example * // With parameters * router.get('/users/:id', (ctx) => { * const id = ctx.params.id; * ctx.json({ id, name: 'User' }); * }); */ get(path, ...handlers) { return this.route(path, 'GET', ...handlers); } /** * Register a POST route * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.post('/users', (ctx) => { * const user = ctx.body; * ctx.status(201).json({ id: 123, ...user }); * }); */ post(path, ...handlers) { return this.route(path, 'POST', ...handlers); } /** * Register a PUT route * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.put('/users/:id', (ctx) => { * const id = ctx.params.id; * const updates = ctx.body; * ctx.json({ id, ...updates }); * }); */ put(path, ...handlers) { return this.route(path, 'PUT', ...handlers); } /** * Register a DELETE route * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.delete('/users/:id', (ctx) => { * const id = ctx.params.id; * // Delete user logic * ctx.status(204).end(); * }); */ delete(path, ...handlers) { return this.route(path, 'DELETE', ...handlers); } /** * Register a PATCH route * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.patch('/users/:id', (ctx) => { * const id = ctx.params.id; * const updates = ctx.body; * ctx.json({ id, ...updates }); * }); */ patch(path, ...handlers) { return this.route(path, 'PATCH', ...handlers); } /** * Register a HEAD route * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.head('/users/:id', (ctx) => { * // Check if user exists * ctx.status(200).end(); * }); */ head(path, ...handlers) { return this.route(path, 'HEAD', ...handlers); } /** * Register an OPTIONS route * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.options('/api/*', (ctx) => { * ctx.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE'); * ctx.status(200).end(); * }); */ options(path, ...handlers) { return this.route(path, 'OPTIONS', ...handlers); } /** * Register a route for all HTTP methods * * @param {string} path - Route path pattern * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @since 1.0.0 * * @example * router.all('/api/*', (ctx, next) => { * ctx.set('X-API-Version', '1.0'); * return next(); * }); */ all(path, ...handlers) { return this.route(path, null, ...handlers); } /** * Create a new route for the specified path and method * * This is an internal method used by the HTTP method functions (get, post, etc.). * It creates a new Route instance and wraps it in a Layer for the router stack. * * @param {string} path - Route path pattern * @param {string|null} method - HTTP method or null for all methods * @param {...Function} handlers - Route handler functions * @returns {Route} The created route object * * @private * @since 1.0.0 */ route(path, method, ...handlers) { const route = new Route(path); if (method) { route[method.toLowerCase()](...handlers); } else { route.all(...handlers); } const layer = new Layer(path, { sensitive: this.options.caseSensitive, strict: this.options.strict, end: true }, (ctx, next) => { return route.dispatch(ctx, next); }, this); layer.route = route; this.stack.push(layer); return route; } /** * Register a parameter handler * * Parameter handlers are called when a route parameter is matched. * They can be used for validation, transformation, or loading related data. * * @param {string} name - Parameter name * @param {Function} handler - Handler function (ctx, next, value, name) => {} * @returns {Router} The router instance for method chaining * * @since 1.0.0 * * @example * // Load user for :userId parameter * router.param('userId', async (ctx, next, id) => { * ctx.user = await User.findById(id); * if (!ctx.user) { * ctx.status(404).json({ error: 'User not found' }); * return; * } * return next(); * }); * * router.get('/users/:userId', (ctx) => { * ctx.json(ctx.user); // User already loaded by param handler * }); */ param(name, handler) { this.params[name] = handler; return this; } /** * Handle an incoming request through the router * * Processes the request through all layers (middleware and routes) in the router stack. * Matches paths, extracts parameters, and executes appropriate handlers. * * @param {Context} ctx - The request context * @param {Function} next - The next function to call if no routes match * @returns {Promise<void>} * * @since 1.0.0 */ async handle(ctx, next) { let layerError = null; let layerIndex = 0; const nextLayer = async (error) => { if (error) { layerError = error; } if (layerIndex >= this.stack.length) { return next(layerError); } const layer = this.stack[layerIndex++]; const match = layer.match(ctx.path); if (!match) { return nextLayer(layerError); } ctx.params = { ...ctx.params, ...match.params }; if (match.path !== undefined) { ctx.path = match.path; } try { await this.processParams(ctx, layer, match.params); if (layer.route) { if (layer.route.handles_method(ctx.method)) { await layer.handle(ctx, nextLayer); } else { nextLayer(); } } else { await layer.handle(ctx, nextLayer); } } catch (err) { nextLayer(err); } }; await nextLayer(); } /** * Process route parameters through registered parameter handlers * * Calls any registered parameter handlers for the matched route parameters. * Parameter handlers can validate, transform, or load data based on parameter values. * * @param {Context} ctx - The request context * @param {Layer} layer - The matched layer * @param {Object} params - The extracted route parameters * @returns {Promise<void>} * * @private * @since 1.0.0 */ async processParams(ctx, layer, params) { if (!params || Object.keys(params).length === 0) { return; } for (const [key, value] of Object.entries(params)) { if (this.params[key]) { try { await this.params[key](ctx, () => {}, value, key); } catch (error) { throw error; } } } } /** * Convert a path string to a regular expression * * Converts route path patterns to regular expressions for matching URLs. * Supports parameter patterns (:param) and wildcard patterns (*wild). * * @param {string|RegExp|Array} path - Path pattern(s) to convert * @param {Object} [options={}] - Conversion options * @param {boolean} [options.sensitive=false] - Case-sensitive matching * @param {boolean} [options.strict=false] - Strict mode (trailing slash matters) * @param {boolean} [options.end=true] - Match to end of string * @returns {Object} Object with regexp and keys properties * * @static * @since 1.0.0 * * @example * // Simple path * Router.pathToRegExp('/users'); // { regexp: /^\/users\/?$/, keys: [] } * * @example * // With parameters * Router.pathToRegExp('/users/:id'); // { regexp: /^\/users\/([^/]+)\/?$/, keys: [{ name: 'id', optional: false }] } */ static pathToRegExp(path, options = {}) { const { sensitive = false, strict = false, end = true } = options; if (path instanceof RegExp) { return { regexp: path, keys: [] }; } if (Array.isArray(path)) { const regexps = path.map(p => Router.pathToRegExp(p, options)); return { regexp: new RegExp(`(?:${regexps.map(r => r.regexp.source).join('|')})`, getFlags(options)), keys: regexps.reduce((keys, r) => keys.concat(r.keys), []) }; } const keys = []; let regexp = path; regexp = regexp.replace(/\\\//g, '/'); regexp = regexp.replace(/:([^(/\\]+)/g, (match, key) => { keys.push({ name: key, optional: false }); return '([^/]+)'; }); regexp = regexp.replace(/\*([^(/\\]*)/g, (match, key) => { keys.push({ name: key || 'wild', optional: false }); return '(.*)'; }); if (end) { regexp += strict ? '' : '/?'; } regexp += end ? '$' : ''; const flags = getFlags(options); return { regexp: new RegExp(`^${regexp}`, flags), keys }; } /** * Match a path against a regular expression and extract parameters * * Tests if a path matches the given regular expression and extracts * parameter values based on the provided keys. * * @param {string} path - The path to match * @param {RegExp} regexp - The regular expression to test against * @param {Array} keys - Array of parameter key objects * @returns {Object|false} Match object with path and params, or false if no match * * @throws {Error} When URL parameters cannot be decoded * * @static * @since 1.0.0 * * @example * const { regexp, keys } = Router.pathToRegExp('/users/:id'); * const match = Router.match('/users/123', regexp, keys); * // { path: '/users/123', params: { id: '123' } } */ static match(path, regexp, keys) { const match = regexp.exec(path); if (!match) return false; const params = {}; for (let i = 1; i < match.length; i++) { const key = keys[i - 1]; const value = match[i]; if (value !== undefined) { try { params[key.name] = decodeURIComponent(value); } catch (error) { // Handle malformed URL parameters throw new Error('Invalid URL parameter'); } } } return { path: match[0], params }; } /** * Create a route group with a common prefix * * Groups routes under a common path prefix. The callback function receives * a new router instance to define routes within the group. * * @param {string} prefix - Common path prefix for the group * @param {Function} callback - Function that receives the group router * @returns {Router} The router instance for method chaining * * @since 1.0.0 * * @example * router.group('/api', (api) => { * api.get('/users', getUsersHandler); * api.post('/users', createUserHandler); * api.get('/users/:id', getUserHandler); * }); * // Creates routes: GET /api/users, POST /api/users, GET /api/users/:id */ group(prefix, callback) { const router = new Router(this.options); callback(router); this.use(prefix, (ctx, next) => { const originalPath = ctx.path; if (ctx.path.startsWith(prefix)) { ctx.path = ctx.path.slice(prefix.length) || '/'; } return router.handle(ctx, () => { ctx.path = originalPath; return next(); }); }); return this; } /** * Create a versioned API group * * Convenience method for creating API version groups with /v{version} prefix. * * @param {string|number} version - API version number * @param {Function} callback - Function that receives the version router * @returns {Router} The router instance for method chaining * * @since 1.0.0 * * @example * router.version('1', (v1) => { * v1.get('/users', getUsersV1); * v1.post('/users', createUserV1); * }); * * router.version('2', (v2) => { * v2.get('/users', getUsersV2); * v2.post('/users', createUserV2); * }); * // Creates: GET /v1/users, POST /v1/users, GET /v2/users, POST /v2/users */ version(version, callback) { const versionPrefix = `/v${version}`; return this.group(versionPrefix, callback); } /** * Mount another router at the specified path * * Mounts a sub-router at the given path, allowing for modular route organization. * * @param {string} path - Path prefix where the router should be mounted * @param {Router} router - Router instance to mount * @returns {Router} The router instance for method chaining * * @since 1.0.0 * * @example * const userRouter = new Router(); * userRouter.get('/', getUsersHandler); * userRouter.get('/:id', getUserHandler); * * const apiRouter = new Router(); * apiRouter.mount('/users', userRouter); * // Creates routes: GET /users/, GET /users/:id */ mount(path, router) { this.use(path, (ctx, next) => { return router.handle(ctx, next); }); return this; } /** * Get all routes (layers with route objects) * * Returns an array of layers that contain route objects, filtering out * middleware-only layers. * * @returns {Array} Array of route layers * * @since 1.0.0 * * @example * const routes = router.routes; * console.log(`Router has ${routes.length} routes`); */ get routes() { return this.stack.filter(layer => layer.route); } /** * Returns router middleware function * * @returns {Function} Middleware function for this router * @since 1.0.0 * * @example * // Mount router on app * const router = new Router(); * router.get('/users', handler); * app.use('/api', router.routes()); */ routes() { const router = this; return function routerMiddleware(ctx, next) { return router.handle(ctx, next); }; } /** * Alias for routes() method * @returns {Function} Middleware function */ middleware(prefix) { const router = this; return function routerMiddleware(ctx, next) { // If prefix is provided, adjust the path if (prefix && ctx.path.startsWith(prefix)) { const originalPath = ctx.path; ctx.path = ctx.path.slice(prefix.length) || '/'; return router.handle(ctx, next).then(() => { ctx.path = originalPath; }).catch(err => { ctx.path = originalPath; throw err; }); } return router.handle(ctx, next); }; } } /** * Get regular expression flags based on options * * @param {Object} options - Router options * @param {boolean} options.sensitive - Case sensitivity flag * @returns {string} Regular expression flags * * @private * @since 1.0.0 */ function getFlags(options) { return options.sensitive ? '' : 'i'; } module.exports = Router;