Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

rate-limit.tsBlame76 lines · 1 contributor
3ef4c9dClaude1/**
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
8import { createMiddleware } from "hono/factory";
9
10interface Bucket {
11 count: number;
12 windowStart: number;
13}
14
15const store = new Map<string, Bucket>();
16
17function 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
30export function rateLimit(opts: { windowMs: number; max: number; prefix?: string }) {
31 const prefix = opts.prefix || "rl";
5cc5d95Claude32 // In test mode we don't enforce limits — the in-memory bucket leaks across
33 // tests in the same process and different routes share the default prefix,
34 // which causes false 429s on endpoints that have only been hit once. Headers
35 // are still written so tests asserting their presence keep passing.
36 const enforce = process.env.NODE_ENV !== "test";
3ef4c9dClaude37 return createMiddleware(async (c, next) => {
38 const key = clientKey(c, prefix);
39 const now = Date.now();
40 const bucket = store.get(key);
41 let count = 1;
42 if (!bucket || now - bucket.windowStart > opts.windowMs) {
43 store.set(key, { count: 1, windowStart: now });
44 } else {
45 bucket.count++;
46 count = bucket.count;
5cc5d95Claude47 if (enforce && bucket.count > opts.max) {
3ef4c9dClaude48 const retryMs = opts.windowMs - (now - bucket.windowStart);
49 return c.json(
50 { error: "Too many requests", retryAfterMs: retryMs },
51 429,
52 {
53 "Retry-After": String(Math.ceil(retryMs / 1000)),
54 "X-RateLimit-Limit": String(opts.max),
55 "X-RateLimit-Remaining": "0",
56 }
57 );
58 }
59 }
60 // Periodic sweep (every 5 min worth of requests)
61 if (store.size > 10_000) {
62 for (const [k, b] of store) {
63 if (now - b.windowStart > opts.windowMs * 2) store.delete(k);
64 }
65 }
66 await next();
67 // Set rate-limit headers on the final response (persists even if handler errored)
68 if (c.res) {
69 c.res.headers.set("X-RateLimit-Limit", String(opts.max));
70 c.res.headers.set(
71 "X-RateLimit-Remaining",
72 String(Math.max(0, opts.max - count))
73 );
74 }
75 });
76}