From 408762e2d6b97c7106a51c35d05a314b8f07b2e0 Mon Sep 17 00:00:00 2001 From: Till JS Date: Wed, 1 Apr 2026 14:55:22 +0200 Subject: [PATCH] feat(shared-hono): add rate limiting middleware In-memory sliding window rate limiter with per-IP tracking, configurable limits, and automatic stale entry cleanup. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/shared-hono/package.json | 3 +- packages/shared-hono/src/index.ts | 1 + packages/shared-hono/src/rate-limit.ts | 70 ++++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/shared-hono/src/rate-limit.ts diff --git a/packages/shared-hono/package.json b/packages/shared-hono/package.json index f5718bf05..b461614f2 100644 --- a/packages/shared-hono/package.json +++ b/packages/shared-hono/package.json @@ -13,7 +13,8 @@ "./health": "./src/health.ts", "./admin": "./src/admin.ts", "./error": "./src/error.ts", - "./credits": "./src/credits.ts" + "./credits": "./src/credits.ts", + "./rate-limit": "./src/rate-limit.ts" }, "scripts": { "type-check": "tsc --noEmit" diff --git a/packages/shared-hono/src/index.ts b/packages/shared-hono/src/index.ts index 961f04e66..65f02784d 100644 --- a/packages/shared-hono/src/index.ts +++ b/packages/shared-hono/src/index.ts @@ -40,4 +40,5 @@ export { adminRoutes } from './admin'; export { errorHandler, notFoundHandler } from './error'; export { getBalance, validateCredits, consumeCredits, refundCredits } from './credits'; export type { CreditBalance, CreditValidationResult } from './credits'; +export { rateLimitMiddleware } from './rate-limit'; export type { CurrentUserData, AuthVariables } from './types'; diff --git a/packages/shared-hono/src/rate-limit.ts b/packages/shared-hono/src/rate-limit.ts new file mode 100644 index 000000000..6796b52de --- /dev/null +++ b/packages/shared-hono/src/rate-limit.ts @@ -0,0 +1,70 @@ +/** + * Simple in-memory rate limiting middleware for Hono servers. + * + * Uses a sliding window counter per IP address. + * Suitable for single-instance deployments (Mac Mini). + * + * Usage: + * ```ts + * import { rateLimitMiddleware } from '@manacore/shared-hono/rate-limit'; + * app.use('/api/*', rateLimitMiddleware({ max: 100, windowMs: 60_000 })); + * ``` + */ + +import type { Context, Next } from 'hono'; + +interface RateLimitOptions { + /** Maximum requests per window (default: 100) */ + max?: number; + /** Window duration in milliseconds (default: 60_000 = 1 minute) */ + windowMs?: number; + /** Key extractor — defaults to IP address */ + keyFn?: (c: Context) => string; +} + +interface WindowEntry { + count: number; + resetAt: number; +} + +const store = new Map(); + +// Cleanup stale entries every 5 minutes +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of store) { + if (entry.resetAt <= now) store.delete(key); + } +}, 5 * 60_000); + +export function rateLimitMiddleware(options: RateLimitOptions = {}) { + const { max = 100, windowMs = 60_000, keyFn } = options; + + return async (c: Context, next: Next) => { + const key = keyFn + ? keyFn(c) + : c.req.header('x-forwarded-for')?.split(',')[0]?.trim() || + c.req.header('x-real-ip') || + 'unknown'; + + const now = Date.now(); + let entry = store.get(key); + + if (!entry || entry.resetAt <= now) { + entry = { count: 0, resetAt: now + windowMs }; + store.set(key, entry); + } + + entry.count++; + + c.header('X-RateLimit-Limit', String(max)); + c.header('X-RateLimit-Remaining', String(Math.max(0, max - entry.count))); + c.header('X-RateLimit-Reset', String(Math.ceil(entry.resetAt / 1000))); + + if (entry.count > max) { + return c.json({ error: 'Too many requests' }, 429); + } + + await next(); + }; +}