diff --git a/packages/elysia/package.json b/packages/elysia/package.json index 5389ffe..2a8be41 100644 --- a/packages/elysia/package.json +++ b/packages/elysia/package.json @@ -104,4 +104,4 @@ "elysia": ">=1.0.0" }, "packageManager": "pnpm@10.15.0" -} \ No newline at end of file +} diff --git a/packages/express/package.json b/packages/express/package.json index 6073188..7a3049e 100644 --- a/packages/express/package.json +++ b/packages/express/package.json @@ -109,4 +109,4 @@ "express": ">=4.0.0" }, "packageManager": "pnpm@10.15.0" -} \ No newline at end of file +} diff --git a/packages/hono/package.json b/packages/hono/package.json index 32887f9..40b75c6 100644 --- a/packages/hono/package.json +++ b/packages/hono/package.json @@ -107,4 +107,4 @@ "hono": ">=4.0.0" }, "packageManager": "pnpm@10.15.0" -} \ No newline at end of file +} diff --git a/packages/next/package.json b/packages/next/package.json index 59135c2..8fe8e79 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -142,4 +142,4 @@ "react-dom": ">=19.0.0" }, "packageManager": "pnpm@10.15.0" -} \ No newline at end of file +} diff --git a/packages/rate-limiter/CHANGELOG.md b/packages/rate-limiter/CHANGELOG.md index 1e96857..3b55d30 100644 --- a/packages/rate-limiter/CHANGELOG.md +++ b/packages/rate-limiter/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added +- Added support for the `sliding-window` rate-limiter algorithm via the `createSlidingWindowAlgorithm` function. The function can be used standalone or integrated with the centralized `createRateLimiter` function. [#186](https://github.com/aura-stack-ts/auth/pull/186) + - Added support for the `leaky-bucket` rate-limiter algorithm via the `createLeakyBucketAlgorithm` function. The function can be used standalone or integrated with the centralized `createRateLimiter` function. [#186](https://github.com/aura-stack-ts/auth/pull/186) - Added support for the `fixed-window` rate-limiter algorithm via the `createFixedWindowAlgorithm` function. The function can be used standalone or integrated with the centralized `createRateLimiter` function. [#185](https://github.com/aura-stack-ts/auth/pull/185) diff --git a/packages/rate-limiter/src/algorithms/index.ts b/packages/rate-limiter/src/algorithms/index.ts index 11f9c75..0af59c0 100644 --- a/packages/rate-limiter/src/algorithms/index.ts +++ b/packages/rate-limiter/src/algorithms/index.ts @@ -1,3 +1,4 @@ export { createTokenBucketAlgorithm } from "@/algorithms/token-bucket.ts" export { createFixedWindowAlgorithm } from "@/algorithms/fixed-window.ts" export { createLeakyBucketAlgorithm } from "@/algorithms/leaky-bucket.ts" +export { createSlidingWindowAlgorithm } from "@/algorithms/sliding-window.ts" diff --git a/packages/rate-limiter/src/algorithms/sliding-window.ts b/packages/rate-limiter/src/algorithms/sliding-window.ts new file mode 100644 index 0000000..9a45627 --- /dev/null +++ b/packages/rate-limiter/src/algorithms/sliding-window.ts @@ -0,0 +1,83 @@ +import { toContent } from "@/utils.ts" +import { createMemoryStorage } from "@/memory.ts" +import type { RateLimiterAlgorithm, RateLimitResult, SlidingWindowRule } from "@/types.ts" + +/** + * Sliding Window Counter + * + * Interpolates between the previous fixed window's count and the current one + * to approximate a true rolling window without storing per-request timestamps. + * + * Formula: + * weight = elapsed time in current window / windowMs + * estimate = previousCount * (1 - weight) + currentCount + * + * O(1) storage — two counters per key — with smooth, burst-free throttling. + * + * Recommended for: security-sensitive endpoints (signIn, signOut, verifyToken). + */ +export const createSlidingWindowAlgorithm = ( + rule: SlidingWindowRule +): RateLimiterAlgorithm => { + const { limit, windowMs, storage = createMemoryStorage() } = rule + + const getBoundary = (now: number) => Math.floor(now / windowMs) * windowMs + + const windowKeys = (baseKey: string, now: number) => { + const currentBoundary = getBoundary(now) + return { + current: `${baseKey}:sw:${currentBoundary}`, + previous: `${baseKey}:sw:${currentBoundary - windowMs}`, + } + } + + const estimate = async (baseKey: string, now: number): Promise<{ count: number; resetAt: number }> => { + const boundary = getBoundary(now) + const weight = (now - boundary) / windowMs + const { current, previous } = windowKeys(baseKey, now) + + const [currentEntry, previousEntry] = await Promise.all([storage.get(current), storage.get(previous)]) + + const count = (previousEntry?.value ?? 0) * (1 - weight) + (currentEntry?.value ?? 0) + return { count, resetAt: boundary + windowMs } + } + + const check = async (request: RequestInit): Promise => { + const now = Date.now() + const boundary = getBoundary(now) + const reset = boundary + windowMs + const key = rule.keyGenerator(request) + const { current, previous } = windowKeys(key, now) + + const newCount = await storage.increment(current, windowMs * 2) + const weight = (now - boundary) / windowMs + const previousEntry = await storage.get(previous) + const estimatedCount = (previousEntry?.value ?? 0) * (1 - weight) + newCount + const ok = estimatedCount <= limit + + return toContent({ + ok, + limit, + remaining: Math.max(0, Math.floor(limit - estimatedCount)), + resetAt: reset, + retryAfter: ok ? 0 : reset - now, + }) + } + + const peek = async (request: RequestInit): Promise => { + const now = Date.now() + const key = rule.keyGenerator(request) + const { count, resetAt } = await estimate(key, now) + const ok = count <= limit + + return toContent({ + ok, + limit, + remaining: Math.max(0, Math.floor(limit - count)), + resetAt, + retryAfter: ok ? 0 : resetAt - now, + }) + } + + return { check, peek } +} diff --git a/packages/rate-limiter/src/rate-limiter.ts b/packages/rate-limiter/src/rate-limiter.ts index 6a77619..b3767e6 100644 --- a/packages/rate-limiter/src/rate-limiter.ts +++ b/packages/rate-limiter/src/rate-limiter.ts @@ -1,5 +1,10 @@ import { createMemoryStorage } from "@/memory.ts" -import { createTokenBucketAlgorithm, createFixedWindowAlgorithm, createLeakyBucketAlgorithm } from "@/algorithms/index.ts" +import { + createTokenBucketAlgorithm, + createFixedWindowAlgorithm, + createLeakyBucketAlgorithm, + createSlidingWindowAlgorithm, +} from "@/algorithms/index.ts" import type { InferRules, RateLimiter, RateLimiterAlgorithm, RateLimiterConfig, RateLimiterRule } from "@/types.ts" /** @@ -14,6 +19,8 @@ const buildAlgorithm = (rule: RateLimiterRule { return [`${key}:fw`] case "leaky-bucket": return [`${key}:lb:tokens`, `${key}:lb:lastLeak`] + case "sliding-window": + const boundary = Math.floor(Date.now() / rule.windowMs) * rule.windowMs + return [`${key}:sw:${boundary}`, `${key}:sw:${boundary - rule.windowMs}`] } } diff --git a/packages/rate-limiter/src/types.ts b/packages/rate-limiter/src/types.ts index d44a25c..c46e74d 100644 --- a/packages/rate-limiter/src/types.ts +++ b/packages/rate-limiter/src/types.ts @@ -58,7 +58,7 @@ export interface RateLimiterAlgorithm { check(request: RequestInit): Promise } -export type AlgorithmType = "token-bucket" | "fixed-window" | "leaky-bucket" +export type AlgorithmType = "token-bucket" | "fixed-window" | "leaky-bucket" | "sliding-window" interface BaseRule { algorithm: AlgorithmType @@ -83,7 +83,7 @@ export type TokenBucketRule = BaseRule & { refillRate: number } -export type FixedWindowRule = BaseRule & { +export interface FixedWindowRule extends BaseRule { algorithm: "fixed-window" /** Maximum requests allowed per window. */ limit: number @@ -91,7 +91,7 @@ export type FixedWindowRule = BaseRule & { windowMs: number } -export type LeakyBucketRule = BaseRule & { +export interface LeakyBucketRule extends BaseRule { algorithm: "leaky-bucket" /** * The maximum queue size (burst capacity). When the bucket is full, @@ -106,10 +106,19 @@ export type LeakyBucketRule = BaseRule & { leakRatePerMs: number } +export interface SlidingWindowRule extends BaseRule { + algorithm: "sliding-window" + /** Maximum requests allowed per window. */ + limit: number + /** Window duration in milliseconds. */ + windowMs: number +} + export type RateLimiterRule = | TokenBucketRule | FixedWindowRule | LeakyBucketRule + | SlidingWindowRule export interface RateLimiterConfig> { storage?: RateLimiterStorage diff --git a/packages/rate-limiter/test/algorithms/sliding-window.test.ts b/packages/rate-limiter/test/algorithms/sliding-window.test.ts new file mode 100644 index 0000000..d1c605e --- /dev/null +++ b/packages/rate-limiter/test/algorithms/sliding-window.test.ts @@ -0,0 +1,286 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" + +import { createMemoryStorage } from "@/memory.ts" +import { createSlidingWindowAlgorithm } from "@/algorithms/sliding-window.ts" + +describe("createSlidingWindowAlgorithm", () => { + const limit = 10 + const windowMs = 1_000 + + let storage: ReturnType + + beforeEach(() => { + storage = createMemoryStorage() + + vi.useFakeTimers() + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const request = new Request("https://example.com/api/auth/sign-in") + + describe("peek", () => { + test("returns full capacity before any requests", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit, + windowMs, + storage, + keyGenerator: () => "user", + }) + + const result = await limiter.peek(request) + + expect(result).toMatchObject({ + ok: true, + limit, + remaining: limit, + retryAfter: 0, + }) + + expect(result.resetAt).toBe(windowMs) + }) + + test("does not consume capacity", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit, + windowMs, + storage, + keyGenerator: () => "user", + }) + + await limiter.peek(request) + await limiter.peek(request) + await limiter.peek(request) + + const result = await limiter.peek(request) + + expect(result.remaining).toBe(limit) + expect(result.ok).toBe(true) + }) + + test("returns remaining capacity after requests", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit: 5, + windowMs, + storage, + keyGenerator: () => "user", + }) + + await limiter.check(request) + + const result = await limiter.peek(request) + + expect(result.remaining).toBe(4) + }) + + test("carries part of the previous window into the current one", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit, + windowMs, + storage, + keyGenerator: () => "user", + }) + + for (let index = 0; index < limit; index++) { + await limiter.check(request) + } + + vi.setSystemTime(1_500) + + const result = await limiter.peek(request) + + expect(result.ok).toBe(true) + expect(result.remaining).toBe(5) + }) + + test("fully expires previous traffic after two windows", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit, + windowMs, + storage, + keyGenerator: () => "user", + }) + + for (let index = 0; index < limit; index++) { + await limiter.check(request) + } + + vi.setSystemTime(2_000) + + const result = await limiter.peek(request) + + expect(result.ok).toBe(true) + expect(result.remaining).toBe(limit) + }) + + test("combines previous and current window counts", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit, + windowMs, + storage, + keyGenerator: () => "user", + }) + + for (let index = 0; index < limit; index++) { + await limiter.check(request) + } + + vi.setSystemTime(1_500) + + await limiter.check(request) + await limiter.check(request) + + const result = await limiter.peek(request) + + expect(result.ok).toBe(true) + expect(result.remaining).toBe(3) + }) + + test("tracks each key independently", async () => { + const limiter = createSlidingWindowAlgorithm<{ ip: string }>({ + algorithm: "sliding-window", + limit: 5, + windowMs, + storage, + keyGenerator: (request) => request.ip, + }) + + await limiter.check({ ip: "1" }) + + const first = await limiter.peek({ ip: "1" }) + const second = await limiter.peek({ ip: "2" }) + + expect(first.remaining).toBe(4) + expect(second.remaining).toBe(5) + }) + + test("handles requests exactly at a window boundary", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit, + windowMs, + storage, + keyGenerator: () => "user", + }) + + await limiter.check(request) + + vi.setSystemTime(windowMs) + + const result = await limiter.peek(request) + + expect(result.ok).toBe(true) + expect(result.remaining).toBe(9) + }) + }) + + describe("check", () => { + test("allows requests below the limit", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit: 3, + windowMs, + storage, + keyGenerator: () => "user", + }) + + const first = await limiter.check(request) + const second = await limiter.check(request) + const third = await limiter.check(request) + + expect(first.ok).toBe(true) + expect(second.ok).toBe(true) + expect(third.ok).toBe(true) + + expect(third.remaining).toBe(0) + }) + + test("blocks requests after reaching the limit", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit: 2, + windowMs, + storage, + keyGenerator: () => "user", + }) + + await limiter.check(request) + await limiter.check(request) + + const result = await limiter.check(request) + + expect(result.ok).toBe(false) + expect(result.remaining).toBe(0) + expect(result.retryAfter).toBe(windowMs) + }) + + test("returns retryAfter until the current window boundary", async () => { + vi.setSystemTime(250) + + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit: 1, + windowMs, + storage, + keyGenerator: () => "user", + }) + + await limiter.check(request) + + const result = await limiter.check(request) + + expect(result.ok).toBe(false) + expect(result.retryAfter).toBe(750) + }) + + test("counts requests from the current window immediately", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit: 3, + windowMs, + storage, + keyGenerator: () => "user", + }) + + const first = await limiter.check(request) + const second = await limiter.check(request) + const third = await limiter.check(request) + + expect(first.remaining).toBe(2) + expect(second.remaining).toBe(1) + expect(third.remaining).toBe(0) + }) + + test("blocks when the interpolated estimate exceeds the limit", async () => { + const limiter = createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit: 10, + windowMs, + storage, + keyGenerator: () => "user", + }) + + for (let index = 0; index < 10; index++) { + await limiter.check(request) + } + + vi.setSystemTime(1_100) + + const first = await limiter.check(request) + const second = await limiter.check(request) + + expect(first.remaining).toBe(0) + expect(second.remaining).toBe(0) + expect(second.ok).toBe(false) + }) + }) +}) diff --git a/packages/react-router/package.json b/packages/react-router/package.json index 1f7ea1d..4b8f0f6 100644 --- a/packages/react-router/package.json +++ b/packages/react-router/package.json @@ -114,4 +114,4 @@ "react-router": ">=7.0.0" }, "packageManager": "pnpm@10.15.0" -} \ No newline at end of file +} diff --git a/packages/react/package.json b/packages/react/package.json index dad8503..ae6d524 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -126,4 +126,4 @@ "@types/react": ">=19.0.0" }, "packageManager": "pnpm@10.15.0" -} \ No newline at end of file +}