From c5f1d2c993dea5fdbc4def52790a61f66eaf2ee1 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:54:51 -0500 Subject: [PATCH] feat: add in-memory sliding window rate limiter --- src/lib/server/rate-limit.ts | 33 +++++++++++++++++++++++++++++++++ tests/unit/rate-limit.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+) create mode 100644 src/lib/server/rate-limit.ts create mode 100644 tests/unit/rate-limit.test.ts diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts new file mode 100644 index 0000000..3eb7ed0 --- /dev/null +++ b/src/lib/server/rate-limit.ts @@ -0,0 +1,33 @@ +export class RateLimiter { + private windows = new Map(); + + constructor( + private maxRequests: number, + private windowMs: number + ) {} + + check(key: string): boolean { + const now = Date.now(); + const timestamps = this.windows.get(key) ?? []; + + const valid = timestamps.filter((t) => now - t < this.windowMs); + + if (valid.length >= this.maxRequests) { + this.windows.set(key, valid); + return false; + } + + valid.push(now); + this.windows.set(key, valid); + return true; + } + + reset(key: string): void { + this.windows.delete(key); + } +} + +export const loginLimiter = new RateLimiter(5, 15 * 60 * 1000); +export const registrationLimiter = new RateLimiter(3, 60 * 60 * 1000); +export const apiLimiter = new RateLimiter(60, 60 * 1000); +export const nameGeneratorLimiter = new RateLimiter(10, 5 * 60 * 1000); diff --git a/tests/unit/rate-limit.test.ts b/tests/unit/rate-limit.test.ts new file mode 100644 index 0000000..6ea59e2 --- /dev/null +++ b/tests/unit/rate-limit.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from 'vitest'; +import { RateLimiter } from '$lib/server/rate-limit'; + +describe('rate-limit', () => { + it('allows requests under the limit', () => { + const limiter = new RateLimiter(3, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + }); + + it('blocks requests over the limit', () => { + const limiter = new RateLimiter(2, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip1')).toBe(false); + }); + + it('tracks different keys independently', () => { + const limiter = new RateLimiter(1, 60000); + expect(limiter.check('ip1')).toBe(true); + expect(limiter.check('ip2')).toBe(true); + expect(limiter.check('ip1')).toBe(false); + }); +});