/** * Copyright (c) 2026 AzerothJS. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ /** * Request rate limiting at the edge. * * A fixed-window limiter over a small store interface. The default store is an in-memory Map * with lazy eviction - correct for a single process and the common case - and the RateStore * interface is the seam: back it with Redis for a fleet without touching a handler. The * limiter keys on the client IP by default (through the same trusted-proxy boundary as * `clientIp`, with IPv6 bucketed on its routed prefix via `ipBucket`), throws the kernel's * TooManyRequestsError with Retry-After when the window is exhausted, and stamps the standard * RateLimit-* headers on every response so a well-behaved client can pace itself before it is * ever refused. */ import { type EdgeMiddleware } from './edge.ts'; /** The outcome of counting one request against a key. */ export interface RateLimitDecision { /** True when this request pushed the key OVER its limit and must be refused. */ limited: boolean; /** The configured ceiling for the window. */ limit: number; /** Requests still allowed in the current window (0 once limited). */ remaining: number; /** Seconds until the current window resets. */ resetSeconds: number; } /** The storage seam. The default is in-memory; a Redis-backed implementation is a drop-in. */ export interface RateStore { /** Counts one hit against `key` and reports the resulting decision. */ hit(key: string, limit: number, windowMs: number): RateLimitDecision | Promise; } /** * A fixed-window counter in a Map. Buckets expire at their window end; a lazy sweep (once * per window, at most once a minute) drops expired keys, and a hard entry cap (default * 100000) bounds memory even against a deliberate churn of distinct keys. * * Eviction NEVER drops a bucket that is currently over its limit, because dropping one is * indistinguishable from forgiving it: an attacker who can mint keys (a forged * `X-Forwarded-For`, an IPv6 /64) would otherwise burn their allowance, churn the cap, and * return with a clean counter. Only under-limit or already-expired buckets are given up, and * when the store holds nothing but limited buckets it fails CLOSED - the new key is refused * rather than paid for with someone else's enforcement - and reports the state through * `onSaturation` so an operator can tell a key-space attack from ordinary limiting. * * Buckets are split across two maps by that standing, so admission never walks the store: * the evictable set is read from the front of one map in O(1) however deep the enforced * limits run, and worst-case work per hit is a constant independent of `maxEntries`. * * Single-process only - share one across a fleet and each node limits independently. */ export declare class MemoryRateStore implements RateStore { #private; /** * @param options.maxEntries - Hard cap on retained buckets (default 100000). * @param options.onSaturation - Called with the store's size when every retained bucket is * an enforced limit and a new key is refused; fired once per saturation episode, not per * refusal, and its own throws are swallowed. */ constructor(options?: { maxEntries?: number; onSaturation?: (size: number) => void; }); /** How many buckets are currently held (expired ones included until the next sweep). */ get size(): number; hit(key: string, limit: number, windowMs: number): RateLimitDecision; } /** * Options for {@link rateLimit}: the window, the budget, the key (defaults to the client IP - * behind a proxy set `trustProxy`/`trustedHops` or every client shares the proxy's bucket), * and the store ({@link MemoryRateStore} by default; swap for shared state across instances). */ export interface RateLimitOptions { /** * Maximum requests allowed per key per window. Fixed-window arithmetic: a full allowance * at the end of one window and another at the start of the next are both honored, so the * instantaneous ceiling across a window boundary is TWICE this value - size an OTP or * login limit accordingly. */ limit: number; /** Window length in milliseconds. */ windowMs: number; /** The counter store (default a fresh {@link MemoryRateStore}). */ store?: RateStore; /** Derives the bucket key from a request (default the client IP; refused loudly off-socket). */ key?: (request: Request) => string; /** * When keying on IP by default, trust the forwarding header (default false). Behind a * reverse proxy this MUST be on: left off, every client shares the proxy's address and * `limit` becomes one GLOBAL budget an attacker can exhaust for everyone. See * {@link clientIp}. */ trustProxy?: boolean; /** How many proxies sit in front of this server, direct peer included (default 1). See {@link clientIp}. */ trustedHops?: number; /** IPv6 prefix length the default key buckets on (default 64, the per-customer allocation). See {@link ipBucket}. */ ipv6Prefix?: number; } /** * Rate limiting. Counts each request against its key; once a key exceeds `limit` within * `windowMs`, further requests are refused with 429 + Retry-After until the window resets. * Every response carries RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset. A throwing * key or store fails CLOSED - a refusal, never an unmetered lane. * * The refusal is THROWN, so the chain owner's error policy answers it: composed through * `pipeline()`, a 429 carries the app's envelope, serializer and error observer, the same as a * refusal raised inside the app. Every layer between the throw and the boundary still * decorates the reply, and the adapter's own guard means a hand-composed chain still cannot * turn a refusal into a process kill. */ export declare function rateLimit(options: RateLimitOptions): EdgeMiddleware;