import * as http from 'http'; import * as url from 'url'; import * as tsclass from '@tsclass/tsclass'; /** * Optional path pattern configuration that can be added to proxy configs */ export interface IPathPatternConfig { pathPattern?: string; } /** * Interface for router result with additional metadata */ export interface IRouterResult { config: tsclass.network.IReverseProxyConfig; pathMatch?: string; pathParams?: Record; pathRemainder?: string; } export class ProxyRouter { // Store original configs for reference private reverseProxyConfigs: tsclass.network.IReverseProxyConfig[] = []; // Default config to use when no match is found (optional) private defaultConfig?: tsclass.network.IReverseProxyConfig; // Store path patterns separately since they're not in the original interface private pathPatterns: Map = new Map(); // Logger interface private logger: { error: (message: string, data?: any) => void; warn: (message: string, data?: any) => void; info: (message: string, data?: any) => void; debug: (message: string, data?: any) => void; }; constructor( configs?: tsclass.network.IReverseProxyConfig[], logger?: { error: (message: string, data?: any) => void; warn: (message: string, data?: any) => void; info: (message: string, data?: any) => void; debug: (message: string, data?: any) => void; } ) { this.logger = logger || console; if (configs) { this.setNewProxyConfigs(configs); } } /** * Sets a new set of reverse configs to be routed to * @param reverseCandidatesArg Array of reverse proxy configurations */ public setNewProxyConfigs(reverseCandidatesArg: tsclass.network.IReverseProxyConfig[]): void { this.reverseProxyConfigs = [...reverseCandidatesArg]; // Find default config if any (config with "*" as hostname) this.defaultConfig = this.reverseProxyConfigs.find(config => config.hostName === '*'); this.logger.info(`Router initialized with ${this.reverseProxyConfigs.length} configs (${this.getHostnames().length} unique hosts)`); } /** * Routes a request based on hostname and path * @param req The incoming HTTP request * @returns The matching proxy config or undefined if no match found */ public routeReq(req: http.IncomingMessage): tsclass.network.IReverseProxyConfig { const result = this.routeReqWithDetails(req); return result ? result.config : undefined; } /** * Routes a request with detailed matching information * @param req The incoming HTTP request * @returns Detailed routing result including matched config and path information */ public routeReqWithDetails(req: http.IncomingMessage): IRouterResult | undefined { // Extract and validate host header const originalHost = req.headers.host; if (!originalHost) { this.logger.error('No host header found in request'); return this.defaultConfig ? { config: this.defaultConfig } : undefined; } // Parse URL for path matching const parsedUrl = url.parse(req.url || '/'); const urlPath = parsedUrl.pathname || '/'; // Extract hostname without port const hostWithoutPort = originalHost.split(':')[0].toLowerCase(); // First try exact hostname match const exactConfig = this.findConfigForHost(hostWithoutPort, urlPath); if (exactConfig) { return exactConfig; } // Try wildcard subdomain if (hostWithoutPort.includes('.')) { const domainParts = hostWithoutPort.split('.'); if (domainParts.length > 2) { const wildcardDomain = `*.${domainParts.slice(1).join('.')}`; const wildcardConfig = this.findConfigForHost(wildcardDomain, urlPath); if (wildcardConfig) { return wildcardConfig; } } } // Fall back to default config if available if (this.defaultConfig) { this.logger.warn(`No specific config found for host: ${hostWithoutPort}, using default`); return { config: this.defaultConfig }; } this.logger.error(`No config found for host: ${hostWithoutPort}`); return undefined; } /** * Find a config for a specific host and path */ private findConfigForHost(hostname: string, path: string): IRouterResult | undefined { // Find all configs for this hostname const configs = this.reverseProxyConfigs.filter( config => config.hostName.toLowerCase() === hostname.toLowerCase() ); if (configs.length === 0) { return undefined; } // First try configs with path patterns const configsWithPaths = configs.filter(config => this.pathPatterns.has(config)); // Sort by path pattern specificity - more specific first configsWithPaths.sort((a, b) => { const aPattern = this.pathPatterns.get(a) || ''; const bPattern = this.pathPatterns.get(b) || ''; // Exact patterns come before wildcard patterns const aHasWildcard = aPattern.includes('*'); const bHasWildcard = bPattern.includes('*'); if (aHasWildcard && !bHasWildcard) return 1; if (!aHasWildcard && bHasWildcard) return -1; // Longer patterns are considered more specific return bPattern.length - aPattern.length; }); // Check each config with path pattern for (const config of configsWithPaths) { const pathPattern = this.pathPatterns.get(config); if (pathPattern) { const pathMatch = this.matchPath(path, pathPattern); if (pathMatch) { return { config, pathMatch: pathMatch.matched, pathParams: pathMatch.params, pathRemainder: pathMatch.remainder }; } } } // If no path pattern matched, use the first config without a path pattern const configWithoutPath = configs.find(config => !this.pathPatterns.has(config)); if (configWithoutPath) { return { config: configWithoutPath }; } return undefined; } /** * Matches a URL path against a pattern * Supports: * - Exact matches: /users/profile * - Wildcards: /api/* (matches any path starting with /api/) * - Path parameters: /users/:id (captures id as a parameter) * * @param path The URL path to match * @param pattern The pattern to match against * @returns Match result with params and remainder, or null if no match */ private matchPath(path: string, pattern: string): { matched: string; params: Record; remainder: string; } | null { // Handle exact match if (path === pattern) { return { matched: pattern, params: {}, remainder: '' }; } // Handle wildcard match if (pattern.endsWith('/*')) { const prefix = pattern.slice(0, -2); if (path === prefix || path.startsWith(`${prefix}/`)) { return { matched: prefix, params: {}, remainder: path.slice(prefix.length) }; } return null; } // Handle path parameters const patternParts = pattern.split('/').filter(p => p); const pathParts = path.split('/').filter(p => p); // Too few path parts to match if (pathParts.length < patternParts.length) { return null; } const params: Record = {}; // Compare each part for (let i = 0; i < patternParts.length; i++) { const patternPart = patternParts[i]; const pathPart = pathParts[i]; // Handle parameter if (patternPart.startsWith(':')) { const paramName = patternPart.slice(1); params[paramName] = pathPart; continue; } // Handle wildcard at the end if (patternPart === '*' && i === patternParts.length - 1) { break; } // Handle exact match for this part if (patternPart !== pathPart) { return null; } } // Calculate the remainder - the unmatched path parts const remainderParts = pathParts.slice(patternParts.length); const remainder = remainderParts.length ? '/' + remainderParts.join('/') : ''; // Calculate the matched path const matchedParts = patternParts.map((part, i) => { return part.startsWith(':') ? pathParts[i] : part; }); const matched = '/' + matchedParts.join('/'); return { matched, params, remainder }; } /** * Gets all currently active proxy configurations * @returns Array of all active configurations */ public getProxyConfigs(): tsclass.network.IReverseProxyConfig[] { return [...this.reverseProxyConfigs]; } /** * Gets all hostnames that this router is configured to handle * @returns Array of hostnames */ public getHostnames(): string[] { const hostnames = new Set(); for (const config of this.reverseProxyConfigs) { if (config.hostName !== '*') { hostnames.add(config.hostName.toLowerCase()); } } return Array.from(hostnames); } /** * Adds a single new proxy configuration * @param config The configuration to add * @param pathPattern Optional path pattern for route matching */ public addProxyConfig( config: tsclass.network.IReverseProxyConfig, pathPattern?: string ): void { this.reverseProxyConfigs.push(config); // Store path pattern if provided if (pathPattern) { this.pathPatterns.set(config, pathPattern); } } /** * Sets a path pattern for an existing config * @param config The existing configuration * @param pathPattern The path pattern to set * @returns Boolean indicating if the config was found and updated */ public setPathPattern( config: tsclass.network.IReverseProxyConfig, pathPattern: string ): boolean { const exists = this.reverseProxyConfigs.includes(config); if (exists) { this.pathPatterns.set(config, pathPattern); return true; } return false; } /** * Removes a proxy configuration by hostname * @param hostname The hostname to remove * @returns Boolean indicating whether any configs were removed */ public removeProxyConfig(hostname: string): boolean { const initialCount = this.reverseProxyConfigs.length; // Find configs to remove const configsToRemove = this.reverseProxyConfigs.filter( config => config.hostName === hostname ); // Remove them from the patterns map for (const config of configsToRemove) { this.pathPatterns.delete(config); } // Filter them out of the configs array this.reverseProxyConfigs = this.reverseProxyConfigs.filter( config => config.hostName !== hostname ); return this.reverseProxyConfigs.length !== initialCount; } }