CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | /**
* Rate limiter — in-memory fixed-window counter keyed by IP (or token).
*
* Simple on purpose. For multi-node deployments swap the Map for the
* `rate_limit_buckets` Postgres table (schema is already there).
*/
import { createMiddleware } from "hono/factory";
interface Bucket {
count: number;
windowStart: number;
}
const store = new Map<string, Bucket>();
function clientKey(c: any, prefix: string): string {
const auth = c.req.header("authorization") || "";
if (auth.startsWith("Bearer ")) {
return `${prefix}:token:${auth.slice(7, 20)}`;
}
const ip =
c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ||
c.req.header("x-real-ip") ||
c.env?.ip ||
"unknown";
return `${prefix}:ip:${ip}`;
}
export function rateLimit(opts: { windowMs: number; max: number; prefix?: string }) {
const prefix = opts.prefix || "rl";
// In test mode we don't enforce limits — the in-memory bucket leaks across
// tests in the same process and different routes share the default prefix,
// which causes false 429s on endpoints that have only been hit once. Headers
// are still written so tests asserting their presence keep passing.
const enforce = process.env.NODE_ENV !== "test";
return createMiddleware(async (c, next) => {
const key = clientKey(c, prefix);
const now = Date.now();
const bucket = store.get(key);
let count = 1;
if (!bucket || now - bucket.windowStart > opts.windowMs) {
store.set(key, { count: 1, windowStart: now });
} else {
bucket.count++;
count = bucket.count;
if (enforce && bucket.count > opts.max) {
const retryMs = opts.windowMs - (now - bucket.windowStart);
return c.json(
{ error: "Too many requests", retryAfterMs: retryMs },
429,
{
"Retry-After": String(Math.ceil(retryMs / 1000)),
"X-RateLimit-Limit": String(opts.max),
"X-RateLimit-Remaining": "0",
}
);
}
}
// Periodic sweep (every 5 min worth of requests)
if (store.size > 10_000) {
for (const [k, b] of store) {
if (now - b.windowStart > opts.windowMs * 2) store.delete(k);
}
}
await next();
// Set rate-limit headers on the final response (persists even if handler errored)
if (c.res) {
c.res.headers.set("X-RateLimit-Limit", String(opts.max));
c.res.headers.set(
"X-RateLimit-Remaining",
String(Math.max(0, opts.max - count))
);
}
});
}
|