import * as plugins from '../../plugins.js'; import type { IRouteConfig } from '../../proxies/smart-proxy/models/route-types.js'; import { DomainMatcher, PathMatcher } from '../../core/routing/matchers/index.js'; /** * Interface for router result with additional metadata */ export interface RouterResult { route: IRouteConfig; pathMatch?: string; pathParams?: Record; pathRemainder?: string; } /** * Logger interface for HttpRouter */ export interface ILogger { debug?: (message: string, data?: any) => void; info: (message: string, data?: any) => void; warn: (message: string, data?: any) => void; error: (message: string, data?: any) => void; } /** * Unified HTTP Router for reverse proxy requests * * Domain matching patterns: * - Exact matches: "example.com" * - Wildcard subdomains: "*.example.com" (matches any subdomain of example.com) * - TLD wildcards: "example.*" (matches example.com, example.org, etc.) * - Complex wildcards: "*.lossless*" (matches any subdomain of any lossless domain) * - Default fallback: "*" (matches any unmatched domain) * * Path pattern matching: * - Exact path: "/api/users" * - Wildcard paths: "/api/*" * - Path parameters: "/users/:id/profile" */ export class HttpRouter { // Store routes sorted by priority private routes: IRouteConfig[] = []; // Default route to use when no match is found (optional) private defaultRoute?: IRouteConfig; // Logger interface private logger: ILogger; constructor( routes?: IRouteConfig[], logger?: ILogger ) { this.logger = logger || { error: console.error.bind(console), warn: console.warn.bind(console), info: console.info.bind(console), debug: console.debug?.bind(console) }; if (routes) { this.setRoutes(routes); } } /** * Sets a new set of routes * @param routes Array of route configurations */ public setRoutes(routes: IRouteConfig[]): void { this.routes = [...routes]; // Sort routes by priority (higher priority first) this.routes.sort((a, b) => { const priorityA = a.priority ?? 0; const priorityB = b.priority ?? 0; return priorityB - priorityA; }); // Find default route if any (route with "*" as domain) this.defaultRoute = this.routes.find(route => { const domains = Array.isArray(route.match.domains) ? route.match.domains : route.match.domains ? [route.match.domains] : []; return domains.includes('*'); }); const uniqueDomains = this.getHostnames(); this.logger.info(`HttpRouter initialized with ${this.routes.length} routes (${uniqueDomains.length} unique hosts)`); } /** * Routes a request based on hostname and path * @param req The incoming HTTP request * @returns The matching route or undefined if no match found */ public routeReq(req: plugins.http.IncomingMessage): IRouteConfig | undefined { const result = this.routeReqWithDetails(req); return result ? result.route : undefined; } /** * Routes a request with detailed matching information * @param req The incoming HTTP request * @returns Detailed routing result including matched route and path information */ public routeReqWithDetails(req: plugins.http.IncomingMessage): RouterResult | undefined { // Extract and validate host header const originalHost = req.headers.host; if (!originalHost) { this.logger.error('No host header found in request'); return this.defaultRoute ? { route: this.defaultRoute } : undefined; } // Parse URL for path matching const parsedUrl = plugins.url.parse(req.url || '/'); const urlPath = parsedUrl.pathname || '/'; // Extract hostname without port const hostWithoutPort = originalHost.split(':')[0].toLowerCase(); // Find matching route const matchingRoute = this.findMatchingRoute(hostWithoutPort, urlPath); if (matchingRoute) { return matchingRoute; } // Fall back to default route if available if (this.defaultRoute) { this.logger.warn(`No specific route found for host: ${hostWithoutPort}, using default`); return { route: this.defaultRoute }; } this.logger.error(`No route found for host: ${hostWithoutPort}`); return undefined; } /** * Find the best matching route for a given hostname and path */ private findMatchingRoute(hostname: string, path: string): RouterResult | undefined { // Try each route in priority order for (const route of this.routes) { // Skip disabled routes if (route.enabled === false) { continue; } // Check domain match if (route.match.domains) { const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; // Check if any domain pattern matches const domainMatches = domains.some(domain => DomainMatcher.match(domain, hostname) ); if (!domainMatches) { continue; } } // Check path match if specified if (route.match.path) { const pathResult = PathMatcher.match(route.match.path, path); if (pathResult.matches) { return { route, pathMatch: path, pathParams: pathResult.params, pathRemainder: pathResult.pathRemainder }; } } else { // No path specified, so domain match is sufficient return { route }; } } return undefined; } /** * Gets all currently active route configurations * @returns Array of all active routes */ public getRoutes(): IRouteConfig[] { return [...this.routes]; } /** * Gets all hostnames that this router is configured to handle * @returns Array of unique hostnames */ public getHostnames(): string[] { const hostnames = new Set(); for (const route of this.routes) { if (!route.match.domains) continue; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; for (const domain of domains) { if (domain !== '*') { hostnames.add(domain.toLowerCase()); } } } return Array.from(hostnames); } /** * Adds a single new route configuration * @param route The route configuration to add */ public addRoute(route: IRouteConfig): void { this.routes.push(route); // Re-sort routes by priority this.routes.sort((a, b) => { const priorityA = a.priority ?? 0; const priorityB = b.priority ?? 0; return priorityB - priorityA; }); } /** * Removes routes by domain pattern * @param domain The domain pattern to remove routes for * @returns Boolean indicating whether any routes were removed */ public removeRoutesByDomain(domain: string): boolean { const initialCount = this.routes.length; // Filter out routes that match the domain this.routes = this.routes.filter(route => { if (!route.match.domains) return true; const domains = Array.isArray(route.match.domains) ? route.match.domains : [route.match.domains]; return !domains.includes(domain); }); return this.routes.length !== initialCount; } /** * Remove a specific route by reference * @param route The route to remove * @returns Boolean indicating if the route was found and removed */ public removeRoute(route: IRouteConfig): boolean { const index = this.routes.indexOf(route); if (index !== -1) { this.routes.splice(index, 1); return true; } return false; } }