import * as z from 'zod/mini' import * as Store from './Store.js' /** Zod schemas owned by the rate-limit module. */ export namespace schema { /** A rate-limit policy; the source of truth for {@link Limit}. */ export const Limit = z.object({ limit: z .number() .check(z.int(), z.nonnegative(), z.describe('Number of requests allowed per period.')), period: z .union([z.literal('minute'), z.literal('second')]) .check( z.describe('Fixed window used to enforce the request limit.'), z.meta({ examples: ['minute'] }), ), }) } /** Rate-limit policy. */ export type Limit = z.output /** Result returned by a rate-limit store. */ export type Result = { /** Whether the request is within quota. */ allowed: boolean /** Limit applied to the request. */ limit: number /** Requests remaining in the active window. */ remaining: number /** Reset timestamp in Unix seconds. */ reset: number } /** Rate-limit store. */ export type RateLimitStore = { /** Consumes one request from the active quota window. */ consume(options: RateLimitStore.ConsumeOptions): Promise } export namespace RateLimitStore { /** Options for consuming one request from a rate-limit store. */ export type ConsumeOptions = { /** Quota key. */ key: string /** Rate-limit policy. */ limit: Limit } } /** * Creates a fixed-window rate-limit store over a counter {@link Store.Store}. * * Each consume is a single `Store.increment`, so a backend with a native * counter (e.g. a Durable Object) is one atomic round trip; backends without * one fall back to get + put, acceptable for dev/in-process stores only. * The storage key includes the fixed-window bucket, so backend latency cannot * carry a previous count into a later window. */ export function memory(options: memory.Options = {}): RateLimitStore { const store = options.store ?? Store.memory() const now = options.now ?? (() => new Date()) return { async consume(options) { const time = now().getTime() const window = options.limit.period === 'second' ? 1_000 : 60_000 const bucket = Math.floor(time / window) // The bucket isolates window counts if the backing store applies the TTL // after the next window starts. const count = await Store.increment( store, `ratelimit:${options.limit.period}:${bucket}:${options.key}`, { ttl: (bucket + 1) * window - time, }, ) const limit = options.limit.limit return { allowed: count <= limit, limit, remaining: Math.max(limit - count, 0), reset: Math.ceil(((bucket + 1) * window) / 1_000), } }, } } export declare namespace memory { /** Options for creating an in-memory fixed-window rate-limit store. */ type Options = { /** Store used to persist counters. */ store?: Store.Store | undefined /** Clock used to calculate fixed windows. */ now?: (() => Date) | undefined } } /** Cloudflare rate-limit binding (per-colo counters). */ export type Binding = { /** Consumes one request; `success: false` when the key is over quota. */ limit(options: { key: string }): Promise<{ success: boolean }> } /** * Layered edge rate limit over Cloudflare rate-limit bindings. Returns a 429 * when any configured layer denies; fails open when a binding errors. */ export async function edge(request: Request, options: edge.Options): Promise { try { const checks: Promise<{ success: boolean }>[] = [] if (options.ip) checks.push(options.ip.limit({ key: clientKey(request) })) const asn = (request as Request & { cf?: CfProperties }).cf?.asn if (options.asn && asn !== undefined) checks.push(options.asn.limit({ key: `asn:${asn}` })) if (options.global) checks.push(options.global.limit({ key: 'global' })) const results = await Promise.all(checks) if (results.some((result) => !result.success)) return new Response('Rate limit exceeded', { headers: { 'retry-after': '10' }, status: 429, }) } catch (error) { console.error('Edge rate limit check failed:', error) } return undefined } export declare namespace edge { /** Layers applied by {@link edge}; omitted layers are skipped. */ type Options = { /** Per-network backstop; catches clients rotating addresses within one ASN. */ asn?: Binding | undefined /** Fixed-key circuit breaker capping total throughput per colo. */ global?: Binding | undefined /** Per-client limit, keyed by IPv4 address or IPv6 /64 prefix. */ ip?: Binding | undefined } } /** Cloudflare-enriched request properties; absent outside Workers (tests, service bindings). */ type CfProperties = { asn?: number | undefined } /** IPv4 clients key by address; IPv6 clients by /64 prefix so they cannot rotate within their allocation. */ function clientKey(request: Request): string { const ip = request.headers.get('cf-connecting-ip') if (!ip) return 'unknown' return normalizeIp(ip) } /** Normalizes an IPv4 address or IPv6 /64 prefix for per-client quotas. */ export function normalizeIp(ip: string): string { if (ip.includes('.')) return ip const [address = ''] = ip.split('%') const [head = '', tail = ''] = address.split('::') const left = head ? head.split(':') : [] const right = tail ? tail.split(':') : [] const groups = [ ...left, ...Array.from({ length: Math.max(8 - left.length - right.length, 0) }, () => '0'), ...right, ] return groups .slice(0, 4) .map((group) => Number.parseInt(group || '0', 16).toString(16)) .join(':') } export { type RateLimitStore as Store }