CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
rate-limit.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 3ef4c9d | 1 | /** |
| 2 | * Rate limiter — in-memory fixed-window counter keyed by IP (or token). | |
| 3 | * | |
| 4 | * Simple on purpose. For multi-node deployments swap the Map for the | |
| 5 | * `rate_limit_buckets` Postgres table (schema is already there). | |
| 6 | */ | |
| 7 | ||
| 8 | import { createMiddleware } from "hono/factory"; | |
| 9 | ||
| 10 | interface Bucket { | |
| 11 | count: number; | |
| 12 | windowStart: number; | |
| 13 | } | |
| 14 | ||
| 15 | const store = new Map<string, Bucket>(); | |
| 16 | ||
| 17 | function clientKey(c: any, prefix: string): string { | |
| 18 | const auth = c.req.header("authorization") || ""; | |
| 19 | if (auth.startsWith("Bearer ")) { | |
| 20 | return `${prefix}:token:${auth.slice(7, 20)}`; | |
| 21 | } | |
| 22 | const ip = | |
| 23 | c.req.header("x-forwarded-for")?.split(",")[0]?.trim() || | |
| 24 | c.req.header("x-real-ip") || | |
| 25 | c.env?.ip || | |
| 26 | "unknown"; | |
| 27 | return `${prefix}:ip:${ip}`; | |
| 28 | } | |
| 29 | ||
| 30 | export function rateLimit(opts: { windowMs: number; max: number; prefix?: string }) { | |
| 31 | const prefix = opts.prefix || "rl"; | |
| 32 | return createMiddleware(async (c, next) => { | |
| 33 | const key = clientKey(c, prefix); | |
| 34 | const now = Date.now(); | |
| 35 | const bucket = store.get(key); | |
| 36 | let count = 1; | |
| 37 | if (!bucket || now - bucket.windowStart > opts.windowMs) { | |
| 38 | store.set(key, { count: 1, windowStart: now }); | |
| 39 | } else { | |
| 40 | bucket.count++; | |
| 41 | count = bucket.count; | |
| 42 | if (bucket.count > opts.max) { | |
| 43 | const retryMs = opts.windowMs - (now - bucket.windowStart); | |
| 44 | return c.json( | |
| 45 | { error: "Too many requests", retryAfterMs: retryMs }, | |
| 46 | 429, | |
| 47 | { | |
| 48 | "Retry-After": String(Math.ceil(retryMs / 1000)), | |
| 49 | "X-RateLimit-Limit": String(opts.max), | |
| 50 | "X-RateLimit-Remaining": "0", | |
| 51 | } | |
| 52 | ); | |
| 53 | } | |
| 54 | } | |
| 55 | // Periodic sweep (every 5 min worth of requests) | |
| 56 | if (store.size > 10_000) { | |
| 57 | for (const [k, b] of store) { | |
| 58 | if (now - b.windowStart > opts.windowMs * 2) store.delete(k); | |
| 59 | } | |
| 60 | } | |
| 61 | await next(); | |
| 62 | // Set rate-limit headers on the final response (persists even if handler errored) | |
| 63 | if (c.res) { | |
| 64 | c.res.headers.set("X-RateLimit-Limit", String(opts.max)); | |
| 65 | c.res.headers.set( | |
| 66 | "X-RateLimit-Remaining", | |
| 67 | String(Math.max(0, opts.max - count)) | |
| 68 | ); | |
| 69 | } | |
| 70 | }); | |
| 71 | } |