330 lines
9.4 KiB
TypeScript
330 lines
9.4 KiB
TypeScript
/**
|
|
* 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>
|
|
): 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<string, string>
|
|
): 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<string, string>;
|
|
}
|
|
): 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<string, string>;
|
|
}
|
|
): 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));
|
|
} |