UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

257 lines (223 loc) 7.38 kB
import { $atom, $hook, $inject, $state, type Static, z } from "alepha"; import { CacheProvider } from "alepha/cache"; import { DateTimeProvider } from "alepha/datetime"; import { $logger } from "alepha/logger"; import { HttpError, type ServerRequest, ServerRouterProvider, } from "alepha/server"; import type { RateLimitOptions } from "../index.ts"; // --------------------------------------------------------------------------------------------------------------------- export interface RateLimitResult { allowed: boolean; limit: number; remaining: number; resetTime: number; retryAfter?: number; } /** * Rate limit configuration atom (global defaults) */ export const rateLimitOptions = $atom({ name: "alepha.server.rate-limit.options", schema: z.object({ windowMs: z .number() .describe("Window duration in milliseconds") .default(15 * 60 * 1000), max: z .number() .describe("Maximum number of requests per window") .default(100), skipFailedRequests: z .boolean() .describe("Skip rate limiting for failed requests") .optional(), skipSuccessfulRequests: z .boolean() .describe("Skip rate limiting for successful requests") .optional(), }), default: { windowMs: 15 * 60 * 1000, max: 100, }, }); export type RateLimitAtomOptions = Static<typeof rateLimitOptions.schema>; declare module "alepha" { interface State { [rateLimitOptions.key]: RateLimitAtomOptions; } } export interface RateLimitRegistration extends RateLimitOptions { /** * Name identifier for this rate limit. */ name?: string; /** * Path patterns to match (supports wildcards like /api/*). */ paths?: string[]; } export class ServerRateLimitProvider { protected readonly log = $logger(); protected readonly dateTime = $inject(DateTimeProvider); protected readonly serverRouterProvider = $inject(ServerRouterProvider); protected readonly cacheProvider = $inject(CacheProvider); protected readonly globalOptions = $state(rateLimitOptions); protected static readonly CACHE_NAME = "rate-limit"; /** * Registered rate limit configurations with their path patterns */ public readonly registeredConfigs: RateLimitRegistration[] = []; /** * Register a rate limit configuration (called by primitives) */ public registerRateLimit(config: RateLimitRegistration): void { this.registeredConfigs.push(config); } protected readonly onStart = $hook({ on: "start", handler: async () => { // Apply path-specific rate limit configs to routes for (const config of this.registeredConfigs) { if (config.paths) { for (const pattern of config.paths) { const matchedRoutes = this.serverRouterProvider.getRoutes(pattern); for (const route of matchedRoutes) { route.rateLimit = this.buildRateLimitOptions(config); } } } } if (this.registeredConfigs.length > 0) { this.log.info( `Initialized with ${this.registeredConfigs.length} registered rate-limit configurations.`, ); } }, }); public readonly onRequest = $hook({ on: "server:onRequest", handler: async ({ route, request }) => { // Use route-specific rate limit if defined, otherwise use global options const rateLimitConfig = route.rateLimit ?? this.globalOptions; // Skip if no rate limiting configured if (!rateLimitConfig.max && !rateLimitConfig.windowMs) { return; } const result = await this.checkLimit(request, rateLimitConfig); this.setRateLimitHeaders(request, result); if (!result.allowed) { throw new HttpError({ status: 429, message: "Too Many Requests", }); } }, }); public readonly onActionRequest = $hook({ on: "action:onRequest", handler: async ({ action, request }) => { // Check if this action has rate limiting enabled const rateLimit = action.options?.rateLimit; if (!rateLimit) { return; // No rate limiting for this action } const result = await this.checkLimit(request, rateLimit); if (!result.allowed) { // Actions are internal - don't set HTTP headers // Only throw error to prevent action execution throw new HttpError({ status: 429, message: "Too Many Requests", }); } // Action allowed - no headers to set since actions are internal }, }); /** * Build complete rate limit options by merging with global defaults */ protected buildRateLimitOptions( config: RateLimitRegistration, ): RateLimitOptions { return { max: config.max ?? this.globalOptions.max, windowMs: config.windowMs ?? this.globalOptions.windowMs, keyGenerator: config.keyGenerator, skipFailedRequests: config.skipFailedRequests ?? this.globalOptions.skipFailedRequests, skipSuccessfulRequests: config.skipSuccessfulRequests ?? this.globalOptions.skipSuccessfulRequests, }; } /** * Set rate limit headers on the response */ public setRateLimitHeaders( request: ServerRequest, result: RateLimitResult, ): void { request.reply.setHeader("X-RateLimit-Limit", result.limit.toString()); request.reply.setHeader( "X-RateLimit-Remaining", result.remaining.toString(), ); request.reply.setHeader( "X-RateLimit-Reset", Math.ceil(result.resetTime / 1000).toString(), ); if (!result.allowed && result.retryAfter) { request.reply.setHeader("Retry-After", result.retryAfter.toString()); } } public async checkLimit( req: Pick<ServerRequest, "ip">, options: RateLimitOptions = {}, ): Promise<RateLimitResult> { const baseKey = this.generateKey(req); return this.checkLimitByKey(baseKey, options); } /** * Check rate limit by an explicit key string. * Useful when no request context is available (e.g. `$job`, `$pipeline`). */ public async checkLimitByKey( baseKey: string, options: RateLimitOptions = {}, ): Promise<RateLimitResult> { const windowMs = options.windowMs ?? this.globalOptions.windowMs; const max = options.max ?? this.globalOptions.max; const now = this.dateTime.nowMillis(); // Fixed window: round down to nearest window boundary const windowStart = Math.floor(now / windowMs) * windowMs; const resetTime = windowStart + windowMs; // Include window timestamp in key for automatic expiration of old windows const key = `${baseKey}:${windowStart}`; // Atomic increment - returns the new count after incrementing const count = await this.cacheProvider.incr( ServerRateLimitProvider.CACHE_NAME, key, 1, ); const allowed = count <= max; const remaining = Math.max(0, max - count); const result: RateLimitResult = { allowed, limit: max, remaining, resetTime, }; if (!allowed) { result.retryAfter = Math.ceil((resetTime - now) / 1000); } return result; } protected generateKey(req: Pick<ServerRequest, "ip">): string { // Use req.ip which is resolved by ServerRequestParser with proper trust proxy handling return `ip:${req.ip || "unknown"}`; } }