import fs from 'fs' import { Server as HTTPServer, createServer } from 'http' import cors from 'cors' import express from 'express' import { rateLimit } from 'express-rate-limit' import { RedisStore } from 'rate-limit-redis' import type { RedisClientType, RedisClusterType } from 'redis' import Server from './../Server.js' import GenericHandler from './GenericHandler.js' import HealthHandler from './HealthHandler.js' import { HttpMethod } from '../../../API/Request.js' import Redis from '../../../Cache/Redis.js' import Globals from '../../../Globals.js' import Logger from '../../../Logger/Logger.js' import Utils from '../../../Util/Utils.js' import { GlobalRateLimitConfig, RateLimitConfig, RouterConfig } from '../../Router.js' import RouteResolver from '../../RouteResolver.js' /* Get package.json version from Wapi on ESM */ const { version: appVersion } = JSON.parse(fs.readFileSync('package.json').toString()) /** * Represents a Proxy class that handles routing and server functionality. */ export default class Proxy { /** * A boolean flag indicating whether the process is currently stopping or not. */ private stopping: boolean /** * The configuration object for the router. */ private readonly config: RouterConfig /** * The logger instance for structured logging. * @readonly * @type {Logger} */ private readonly logger: Logger /** * The Express application instance for the server. * @readonly * @type {express.Express} */ private readonly app: express.Express /** * The handler function for serverless events in the Server class. * @param {ServerlessEvent} event - The serverless event object. * @returns None */ private readonly serverlessHandler: Server['handleServerlessEvent'] /** * Route resolver used to identify per-route rate limit configuration. * @private * @readonly */ private readonly routeResolver: RouteResolver /** * Represents a listener for an HTTP server. * @private * @type {HTTPServer} */ private listener!: HTTPServer /** * Shared Redis client resolved from the WAPI cache configuration when * rate limiting is configured to use Redis. * @private */ private rateLimitRedisClient?: RedisClientType | RedisClusterType /** * Constructs a new instance of the Router class. * @param {RouterConfig} config - The configuration object for the router. * @param {Server['handleServerlessEvent']} serverlessHandler - The handler function for serverless events. * @returns None */ constructor(config: RouterConfig, serverlessHandler: Server['handleServerlessEvent']) { this.stopping = false this.config = config this.serverlessHandler = serverlessHandler this.routeResolver = new RouteResolver(this.config) this.logger = new Logger({ logLevel: 'INFO' }, 'proxy-container') this.app = express() // Trust the first proxy hop so req.ip resolves to the real client IP // (not the load balancer's IP) when running behind ALB or similar. this.app.set('trust proxy', 1) /* Opinionated Express configs */ this.app.use( express.json({ verify(req, res, buf) { req['rawBody'] = buf }, }) ) // apply cors config const corsConfig = this.config.cors || Utils.parseObjectNullIfEmpty(process.env.CORS) this.app.use( cors( corsConfig ? { origin: corsConfig.origin, allowedHeaders: corsConfig.headers, credentials: !!corsConfig.allowCredentials, } : {} ) ) // //This supposedly fix some 502 codes where nodejs socket would hang during // //a request and if behind ALB, it would cause 502 codes. Had experiencied this // //and 502 codes reduced dramastically, but still some appearances. Maybe this // //is just a palliative work-around for the real issue; TODO: need to investigate // //in the future. // this.listener.listener.keepAliveTimeout = 120e3 // this.listener.listener.headersTimeout = 120e3 } /** * Loads the necessary components and initializes the application. * @returns None */ public async load() { await this.initializeRateLimiting() await this.startListeners() this.installRoutes() } /** * Initializes global rate limiting and resolves any shared Redis client * required by the configured store. * @returns {Promise} * @private */ private async initializeRateLimiting(): Promise { if (!this.config.rateLimit || this.config.rateLimit.enabled === false) return if (this.config.rateLimit.store === 'redis') { if (!this.config.cache) { throw new Error( '[Proxy] - [RATE-LIMIT] - RouterConfig.cache is required when rateLimit.store is set to redis' ) } this.rateLimitRedisClient = await Redis.connection(this.config.cache) } this.logger.info('[Proxy] - [RATE-LIMIT] - Global rate limiting enabled') const globalConfig: GlobalRateLimitConfig = { ...this.config.rateLimit, skip: (req: express.Request) => { const route = this.routeResolver.resolveRoute(req.method as HttpMethod, req.path) if (route && route.rateLimit !== undefined) return true return this.config.rateLimit!.skip?.(req) ?? false }, } this.app.use(this.createRateLimitMiddleware(globalConfig)) } /** * Unloads the current module, stopping any active listeners. * @param {any} [err] - Optional error object to pass to the stopListeners method. * @returns {Promise} - A promise that resolves once the listeners have been stopped. */ public async unload(err?: any) { await this.stopListeners(err) } /** * Starts the listeners for the proxy server. * @returns {Promise} A promise that resolves when the listeners have started. */ private async startListeners(): Promise { // eslint-disable-next-line no-async-promise-executor return new Promise(async resolve => { const port = this.config.port || Globals.Listener_HTTP_DefaultPort this.logger.info(`[Proxy] - [STARTING] - v.${appVersion} - :${port}`) // Create Server this.listener = createServer(this.app) // Set defaults this.listener.setTimeout(this.config.timeout || Globals.Listener_HTTP_DefaultTimeout) //This supposedly fix some 502 codes where nodejs socket would hang during //a request and if behind ALB, this.listener.keepAliveTimeout = 120e3 this.listener.headersTimeout = 120e3 // Call hook if available if (this.config.containerSetupHook) await this.config.containerSetupHook(this.listener, this.app) // Start Server this.listener.listen(port, () => { console.log(`[Proxy] - [STARTED]`) resolve() }) }) } /** * Stops the listeners and exits the process. * @param {any} [err] - Optional error object. * @returns {Promise} - A promise that resolves when the listeners are stopped and the process is exited. */ private async stopListeners(err?: any) { if (this.stopping) return this.stopping = true this.logger.info('[Proxy] - [STOPPING]') return new Promise(resolve => { this.listener.close(_err => { const err2 = err || _err if (err2) this.logger.error('[Proxy] - exit output:', err2) this.logger.info('[Proxy] - [STOPPED]') process.exit(err2 ? 1 : 0) resolve(null) }) }) } /** * Installs the routes for the proxy server. * @returns None */ private installRoutes() { //Health check route -- This is a bypass route to only check if //runtime proxy is working and responding to calls. console.log( `[Proxy] - [HEALTH-ROUTE] - ${ this.config.healthCheckRoute || Globals.Listener_HTTP_DefaultHealthCheckRoute }` ) this.app .route(this.config.healthCheckRoute || Globals.Listener_HTTP_DefaultHealthCheckRoute) .get(HealthHandler) // Register individual routes that declare their own rateLimit config. // These are installed BEFORE the wildcard so Express matches them first, // giving each route an independent rate limit bucket. for (const route of this.config.routes) { if (!route.rateLimit) continue const rlMiddleware = this.createRouteRateLimitMiddleware(route.rateLimit) const paths = Array.isArray(route.path) ? route.path : [route.path] for (const path of paths) { ;(this.app.route(path) as any)[route.method.toLowerCase()]( rlMiddleware, GenericHandler(this.serverlessHandler) ) } } //Main route -- We use a wildcard route because is not the job of the runtime and neither //the task to deny/constrain routes that invoked this task; all the job is done by the //load balancer and we just foward everything we have to the function. this.app.route(Globals.Listener_HTTP_ProxyRoute).all(GenericHandler(this.serverlessHandler)) } /** * Creates rate limiting middleware from a per-route {@link RateLimitConfig}, * inheriting the global Redis store configuration when available so all * rate-limit counters share the same Redis connection. * @param {RateLimitConfig} config - The per-route rate limit configuration * @returns {express.RequestHandler} Express middleware for rate limiting * @private */ private createRouteRateLimitMiddleware(config: RateLimitConfig): express.RequestHandler { // Resolve keyGenerator string shorthands to concrete functions. let keyGenerator: GlobalRateLimitConfig['keyGenerator'] if (config.keyGenerator === 'ip') { keyGenerator = (req: express.Request) => req.ip || req.socket.remoteAddress || 'unknown' } else if (config.keyGenerator === 'userId') { keyGenerator = (req: express.Request) => { const authHeader = req.headers['authorization'] if (authHeader?.startsWith('Bearer ')) { const parts = authHeader.slice(7).split('.') if (parts.length === 3) { try { const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) const userId = payload.sub || payload.id || payload.userId if (userId) return String(userId) } catch { // malformed token — fall through to IP } } } return req.ip || req.socket.remoteAddress || 'unknown' } } else { keyGenerator = config.keyGenerator } // Inherit the global Redis store (if configured) so per-route limiters // share the same connection; distinguish them with a unique key prefix. const globalRl = this.config.rateLimit const globalConfig: GlobalRateLimitConfig = { windowMs: config.windowMs, limit: config.limit, keyGenerator, skip: config.skip, store: globalRl?.store, redis: globalRl?.redis ? { prefix: `${globalRl.redis.prefix || 'wapi:rl:'}route:`, } : undefined, } return this.createRateLimitMiddleware(globalConfig) } /** * Creates rate limiting middleware based on the provided configuration. * @param {GlobalRateLimitConfig} config - The rate limit configuration * @returns {express.RequestHandler} Express middleware for rate limiting * @private */ private createRateLimitMiddleware(config: GlobalRateLimitConfig): express.RequestHandler { const store = this.createRateLimitStore(config) return rateLimit({ windowMs: config.windowMs || 60000, // Default: 1 minute limit: config.limit || 60, // Default: 60 requests per windowMs standardHeaders: true, // Return rate limit info in `RateLimit-*` headers legacyHeaders: false, // Disable `X-RateLimit-*` headers // Key generator - how to identify unique clients keyGenerator: config.keyGenerator || ((req: express.Request) => { // For authenticated requests, use the stable user ID from the JWT // payload so that multiple users behind the same IP are bucketed // independently and token rotation doesn't change a user's bucket. const authHeader = req.headers['authorization'] if (authHeader?.startsWith('Bearer ')) { const parts = authHeader.slice(7).split('.') if (parts.length === 3) { try { const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')) const userId = payload.sub || payload.id || payload.userId if (userId) return String(userId) } catch { // malformed token — fall through to IP } } } // Unauthenticated: fall back to real client IP. // trust proxy is set so req.ip reflects x-forwarded-for correctly. return req.ip || req.socket.remoteAddress || 'unknown' }), // Custom handler when rate limit is exceeded handler: config.handler || ((req: express.Request, res: express.Response) => { // Log rate limit violation this.logger.info('[Proxy] - [RATE-LIMIT] - Limit exceeded', { ip: req.ip, path: req.path, method: req.method, timestamp: new Date().toISOString(), }) res.status(429).json({ error: 'rate_limit_exceeded', message: 'Too many requests. Please try again later.', }) }), // Skip function - allows bypassing rate limiting for certain requests skip: config.skip, // Store - use Redis if configured, otherwise in-memory store: store, }) } /** * Creates the appropriate store for rate limiting based on configuration. * * Cluster mode note: `rate-limit-redis` v4 uses EVALSHA (Lua script). In cluster * mode a plain `SCRIPT LOAD` only reaches one master node, so any subsequent * EVALSHA that routes to a different node gets a NOSCRIPT error — and if the * SHA1 is still `undefined` at the time of the call (async loading race) the * Redis encoder crashes with `Cannot read properties of undefined (reading 'length')`. * We detect a cluster client and broadcast SCRIPT LOAD to every master node so * the script is resident everywhere before the first EVALSHA arrives. * * @param {GlobalRateLimitConfig} config - The rate limit configuration * @returns {RedisStore | undefined} Redis store if configured, undefined for in-memory * @private */ private createRateLimitStore(config: GlobalRateLimitConfig): any { if (config.store === 'redis') { if (!this.rateLimitRedisClient) { throw new Error( '[Proxy] - [RATE-LIMIT] - Redis rate limit store was requested before the WAPI Redis client was initialized' ) } const client = this.rateLimitRedisClient as any // WAPI's bundled cluster client exposes `getMasters()` or a `masters` // array; standalone clients do not. const isCluster = typeof client.getMasters === 'function' || Array.isArray(client.masters) const isReadonlyCommand = (args: string[], providedIsReadonly?: boolean): boolean => { if (typeof providedIsReadonly === 'boolean') return providedIsReadonly const command = args[0]?.toUpperCase() return command === 'GET' || command === 'MGET' } const getFirstKey = (args: string[], providedKey?: string): string | undefined => { if (providedKey) return providedKey const command = args[0]?.toUpperCase() if (!command) return undefined if (command === 'EVALSHA' || command === 'EVAL') { const keyCount = Number.parseInt(args[2] || '0', 10) if (keyCount > 0) return args[3] return undefined } if (command === 'GET' || command === 'DECR' || command === 'DEL' || command === 'SET') { return args[1] } return undefined } const getClusterNodeClient = async (node: any) => { if (typeof client.nodeClient === 'function') return await client.nodeClient(node) if (node?.client) return await node.client return node } const loadedScriptsBySha = new Map() const reloadScriptPromises = new Map>() const getClusterMasters = () => typeof client.getMasters === 'function' ? client.getMasters() : (client.masters ?? []) const broadcastScriptLoad = async (script: string): Promise => { const results = await Promise.all( getClusterMasters().map(async (node: any) => { const nodeClient = await getClusterNodeClient(node) return nodeClient.sendCommand(['SCRIPT', 'LOAD', script]) }) ) const sha = String(results[0]) loadedScriptsBySha.set(sha, script) return sha } const reloadScriptIfNeeded = async (sha: string) => { if (!loadedScriptsBySha.has(sha)) return const existingReload = reloadScriptPromises.get(sha) if (existingReload) { await existingReload return } const reloadPromise = broadcastScriptLoad(loadedScriptsBySha.get(sha)!).then( () => undefined ) reloadScriptPromises.set(sha, reloadPromise) try { await reloadPromise } finally { reloadScriptPromises.delete(sha) } } if (isCluster) { return new RedisStore({ sendCommandCluster: async ({ key, isReadOnly, command }) => { if (command[0] === 'SCRIPT' && command[1]?.toUpperCase() === 'LOAD') { return broadcastScriptLoad(command[2]) } try { return await client.sendCommand( getFirstKey(command, key), isReadonlyCommand(command, isReadOnly), command ) } catch (err) { const isEvalSha = command[0]?.toUpperCase() === 'EVALSHA' const errorMessage = err instanceof Error ? err.message : String(err) const isNoScript = errorMessage.toUpperCase().includes('NOSCRIPT') if (!isEvalSha || !isNoScript) throw err const sha = command[1] if (!sha) throw err await reloadScriptIfNeeded(sha) return client.sendCommand( getFirstKey(command, key), isReadonlyCommand(command, isReadOnly), command ) } }, prefix: config.redis?.prefix || 'wapi:rl:', }) } return new RedisStore({ sendCommand: (...args: string[]) => client.sendCommand(args), prefix: config.redis?.prefix || 'wapi:rl:', }) } this.logger.info('[Proxy] - [RATE-LIMIT] - Using in-memory store') return undefined // express-rate-limit uses MemoryStore by default } }