TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1import 'server-only';2import { sql } from '@/lib/db';3import { db } from '@/lib/db';45/**6 * Fixed-window counter stored in Postgres so limits hold across PM2 instances.7 * Returns whether the call is allowed and the seconds until the window resets.8 */9export async function rateLimit(key: string, limit: number, windowSeconds: number): Promise<{ ok: boolean; remaining: number; retryAfterSeconds: number }> {10 const now = new Date();11 const resetAt = new Date(now.getTime() + windowSeconds * 1000);12 const nowIso = now.toISOString();13 const resetIso = resetAt.toISOString();14 const rows = (await db().execute(sql`15 insert into rate_limits (key, count, reset_at) values (${key}, 1, ${resetIso}::timestamptz)16 on conflict (key) do update set17 count = case when rate_limits.reset_at <= ${nowIso}::timestamptz then 1 else rate_limits.count + 1 end,18 reset_at = case when rate_limits.reset_at <= ${nowIso}::timestamptz then ${resetIso}::timestamptz else rate_limits.reset_at end19 returning count, reset_at20 `)) as unknown as Array<{ count: number; reset_at: Date | string }>;21 const row = rows[0]!;22 const count = Number(row.count);23 const reset = new Date(row.reset_at);24 return { ok: count <= limit, remaining: Math.max(0, limit - count), retryAfterSeconds: Math.max(1, Math.ceil((reset.getTime() - now.getTime()) / 1000)) };25}2627export class RateLimited extends Error {28 constructor(public retryAfterSeconds: number) {29 super(`Too many attempts. Try again in ${retryAfterSeconds}s.`);30 }31}3233export async function enforce(key: string, limit: number, windowSeconds: number): Promise<void> {34 const r = await rateLimit(key, limit, windowSeconds);35 if (!r.ok) throw new RateLimited(r.retryAfterSeconds);36}37