From 718a0184804749d518248d1ab5a90c3abaf29142 Mon Sep 17 00:00:00 2001 From: Henry Date: Sun, 25 Jan 2026 21:03:34 +0000 Subject: [PATCH] feat: add withRateLimit wrapper --- apps/web/src/utils/rateLimit.ts | 99 +++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/web/src/utils/rateLimit.ts diff --git a/apps/web/src/utils/rateLimit.ts b/apps/web/src/utils/rateLimit.ts new file mode 100644 index 00000000..8170b61d --- /dev/null +++ b/apps/web/src/utils/rateLimit.ts @@ -0,0 +1,99 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { + RateLimiterRedis, + RateLimiterMemory, +} from "rate-limiter-flexible"; + +import { getRedisClient } from "./redis"; + +export interface RateLimitOptions { + points?: number; + duration?: number; + identifier?: (req: NextApiRequest) => string | Promise; + errorMessage?: string; +} + + +const defaultIdentifier = (req: NextApiRequest): string => { + // Try to identify the IP address of the request + const forwardedFor = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + const cfConnectingIp = req.headers["cf-connecting-ip"]; + + const ip = + (typeof forwardedFor === "string" + ? forwardedFor.split(",")[0]?.trim() + : null) ?? + (typeof realIp === "string" ? realIp : null) ?? + (typeof cfConnectingIp === "string" ? cfConnectingIp : null) ?? + req.socket.remoteAddress ?? + "unknown"; + + return ip; +}; + + +const DEFAULT_OPTIONS = { + points: 100, + duration: 60, + errorMessage: "Too many requests, please try again later.", + identifier: defaultIdentifier, +} as const; + + +function createRateLimiter(options: RateLimitOptions = {}) { + const redis = getRedisClient(); + const points = options.points ?? DEFAULT_OPTIONS.points; + const duration = options.duration ?? DEFAULT_OPTIONS.duration; + + // Use Redis if available, otherwise fall back to in-memory storage + if (redis) { + return new RateLimiterRedis({ + storeClient: redis, + points, + duration, + }); + } + + return new RateLimiterMemory({ + points, + duration, + }); +} + +export function withRateLimit( + options: RateLimitOptions, + handler: ( + req: NextApiRequest, + res: NextApiResponse, + ) => Promise | unknown, +) { + const rateLimiter = createRateLimiter(options); + const identifier = options.identifier ?? DEFAULT_OPTIONS.identifier; + const errorMessage = options.errorMessage ?? DEFAULT_OPTIONS.errorMessage; + + return async (req: NextApiRequest, res: NextApiResponse) => { + try { + const id = await identifier(req); + const key = `ratelimit_${id}`; + + await rateLimiter.consume(key); + + return await handler(req, res); + } catch (error) { + // rate-limiter-flexible throws an error with msBeforeNext or remainingPoints + // when limit is exceeded. Check for these properties directly. + if ( + error && + typeof error === "object" && + ("msBeforeNext" in error || "remainingPoints" in error) + ) { + return res.status(429).json({ + message: errorMessage, + }); + } + + return await handler(req, res); + } + }; +}