/** * Route Utilities * * This file provides utility functions for working with route configurations, * including merging, finding, and managing route collections. */ import type { IRouteConfig, IRouteMatch } from '../models/route-types.js'; import { validateRouteConfig } from './route-validators.js'; /** * Merge two route configurations * The second route's properties will override the first route's properties where they exist * @param baseRoute The base route configuration * @param overrideRoute The route configuration with overriding properties * @returns A new merged route configuration */ export function mergeRouteConfigs( baseRoute: IRouteConfig, overrideRoute: Partial ): IRouteConfig { // Create deep copies to avoid modifying original objects const mergedRoute: IRouteConfig = JSON.parse(JSON.stringify(baseRoute)); // Apply overrides at the top level if (overrideRoute.id) mergedRoute.id = overrideRoute.id; if (overrideRoute.name) mergedRoute.name = overrideRoute.name; if (overrideRoute.enabled !== undefined) mergedRoute.enabled = overrideRoute.enabled; if (overrideRoute.priority !== undefined) mergedRoute.priority = overrideRoute.priority; // Merge match configuration if (overrideRoute.match) { mergedRoute.match = { ...mergedRoute.match }; if (overrideRoute.match.ports !== undefined) { mergedRoute.match.ports = overrideRoute.match.ports; } if (overrideRoute.match.domains !== undefined) { mergedRoute.match.domains = overrideRoute.match.domains; } if (overrideRoute.match.path !== undefined) { mergedRoute.match.path = overrideRoute.match.path; } if (overrideRoute.match.headers !== undefined) { mergedRoute.match.headers = overrideRoute.match.headers; } } // Merge action configuration if (overrideRoute.action) { // If action types are different, replace the entire action if (overrideRoute.action.type && overrideRoute.action.type !== mergedRoute.action.type) { mergedRoute.action = JSON.parse(JSON.stringify(overrideRoute.action)); } else { // Otherwise merge the action properties mergedRoute.action = { ...mergedRoute.action }; // Merge target if (overrideRoute.action.target) { mergedRoute.action.target = { ...mergedRoute.action.target, ...overrideRoute.action.target }; } // Merge TLS options if (overrideRoute.action.tls) { mergedRoute.action.tls = { ...mergedRoute.action.tls, ...overrideRoute.action.tls }; } // Merge redirect options if (overrideRoute.action.redirect) { mergedRoute.action.redirect = { ...mergedRoute.action.redirect, ...overrideRoute.action.redirect }; } // Merge static options if (overrideRoute.action.static) { mergedRoute.action.static = { ...mergedRoute.action.static, ...overrideRoute.action.static }; } } } return mergedRoute; } /** * Check if a route matches a domain * @param route The route to check * @param domain The domain to match against * @returns True if the route matches the domain, false otherwise */ export function routeMatchesDomain(route: IRouteConfig, domain: string): boolean { if (!route.match?.domains) { return false; } const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; return domains.some(d => { // Handle wildcard domains if (d.startsWith('*.')) { const suffix = d.substring(2); return domain.endsWith(suffix) && domain.split('.').length > suffix.split('.').length; } return d.toLowerCase() === domain.toLowerCase(); }); } /** * Check if a route matches a port * @param route The route to check * @param port The port to match against * @returns True if the route matches the port, false otherwise */ export function routeMatchesPort(route: IRouteConfig, port: number): boolean { if (!route.match?.ports) { return false; } if (typeof route.match.ports === 'number') { return route.match.ports === port; } if (Array.isArray(route.match.ports)) { // Simple case - array of numbers if (typeof route.match.ports[0] === 'number') { return (route.match.ports as number[]).includes(port); } // Complex case - array of port ranges if (typeof route.match.ports[0] === 'object') { return (route.match.ports as Array<{ from: number; to: number }>).some( range => port >= range.from && port <= range.to ); } } return false; } /** * Check if a route matches a path * @param route The route to check * @param path The path to match against * @returns True if the route matches the path, false otherwise */ export function routeMatchesPath(route: IRouteConfig, path: string): boolean { if (!route.match?.path) { return true; // No path specified means it matches any path } // Handle exact path if (route.match.path === path) { return true; } // Handle path prefix with trailing slash (e.g., /api/) if (route.match.path.endsWith('/') && path.startsWith(route.match.path)) { return true; } // Handle exact path match without trailing slash if (!route.match.path.endsWith('/') && path === route.match.path) { return true; } // Handle wildcard paths (e.g., /api/*) if (route.match.path.endsWith('*')) { const prefix = route.match.path.slice(0, -1); return path.startsWith(prefix); } return false; } /** * Check if a route matches headers * @param route The route to check * @param headers The headers to match against * @returns True if the route matches the headers, false otherwise */ export function routeMatchesHeaders( route: IRouteConfig, headers: Record ): boolean { if (!route.match?.headers || Object.keys(route.match.headers).length === 0) { return true; // No headers specified means it matches any headers } // Check each header in the route's match criteria return Object.entries(route.match.headers).every(([key, value]) => { // If the header isn't present in the request, it doesn't match if (!headers[key]) { return false; } // Handle exact match if (typeof value === 'string') { return headers[key] === value; } // Handle regex match if (value instanceof RegExp) { return value.test(headers[key]); } return false; }); } /** * Find all routes that match the given criteria * @param routes Array of routes to search * @param criteria Matching criteria * @returns Array of matching routes sorted by priority */ export function findMatchingRoutes( routes: IRouteConfig[], criteria: { domain?: string; port?: number; path?: string; headers?: Record; } ): IRouteConfig[] { // Filter routes that are enabled and match all provided criteria const matchingRoutes = routes.filter(route => { // Skip disabled routes if (route.enabled === false) { return false; } // Check domain match if specified if (criteria.domain && !routeMatchesDomain(route, criteria.domain)) { return false; } // Check port match if specified if (criteria.port !== undefined && !routeMatchesPort(route, criteria.port)) { return false; } // Check path match if specified if (criteria.path && !routeMatchesPath(route, criteria.path)) { return false; } // Check headers match if specified if (criteria.headers && !routeMatchesHeaders(route, criteria.headers)) { return false; } return true; }); // Sort matching routes by priority (higher priority first) return matchingRoutes.sort((a, b) => { const priorityA = a.priority || 0; const priorityB = b.priority || 0; return priorityB - priorityA; // Higher priority first }); } /** * Find the best matching route for the given criteria * @param routes Array of routes to search * @param criteria Matching criteria * @returns The best matching route or undefined if no match */ export function findBestMatchingRoute( routes: IRouteConfig[], criteria: { domain?: string; port?: number; path?: string; headers?: Record; } ): IRouteConfig | undefined { const matchingRoutes = findMatchingRoutes(routes, criteria); return matchingRoutes.length > 0 ? matchingRoutes[0] : undefined; } /** * Create a route ID based on route properties * @param route Route configuration * @returns Generated route ID */ export function generateRouteId(route: IRouteConfig): string { // Create a deterministic ID based on route properties const domains = Array.isArray(route.match?.domains) ? route.match.domains.join('-') : route.match?.domains || 'any'; let portsStr = 'any'; if (route.match?.ports) { if (Array.isArray(route.match.ports)) { portsStr = route.match.ports.join('-'); } else if (typeof route.match.ports === 'number') { portsStr = route.match.ports.toString(); } } const path = route.match?.path || 'any'; const action = route.action?.type || 'unknown'; return `route-${domains}-${portsStr}-${path}-${action}`.replace(/[^a-zA-Z0-9-]/g, '-'); } /** * Clone a route configuration * @param route Route to clone * @returns Deep copy of the route */ export function cloneRoute(route: IRouteConfig): IRouteConfig { return JSON.parse(JSON.stringify(route)); }