From 5ddc8e6484f962b7253ef82bfc2cf05dceb12803 Mon Sep 17 00:00:00 2001 From: Danijel Martinek Date: Wed, 20 May 2026 08:30:35 +0000 Subject: [PATCH] feat(core-shared): add NoopRateLimit and InMemoryRateLimit implementations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NoopRateLimit always allows with Infinity remaining — zero-overhead default for apps without a wired rate-limit impl. InMemoryRateLimit uses a Map-backed fixed-window with check-at-read expiry and an injected clock for testability. Both exported from the core-shared barrel. Co-Authored-By: Claude Sonnet 4.6 --- coverage/summary.json | 44 ++--- packages/core-shared/src/index.ts | 1 + .../rate-limit/in-memory-rate-limit.test.ts | 150 ++++++++++++++++++ .../src/rate-limit/in-memory-rate-limit.ts | 67 ++++++++ packages/core-shared/src/rate-limit/index.ts | 2 + .../src/rate-limit/noop-rate-limit.test.ts | 36 +++++ .../src/rate-limit/noop-rate-limit.ts | 15 ++ 7 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 packages/core-shared/src/rate-limit/in-memory-rate-limit.test.ts create mode 100644 packages/core-shared/src/rate-limit/in-memory-rate-limit.ts create mode 100644 packages/core-shared/src/rate-limit/noop-rate-limit.test.ts create mode 100644 packages/core-shared/src/rate-limit/noop-rate-limit.ts diff --git a/coverage/summary.json b/coverage/summary.json index 1f7d879..38b78de 100644 --- a/coverage/summary.json +++ b/coverage/summary.json @@ -1,18 +1,18 @@ { - "generatedAt": "2026-05-20T08:21:18.294Z", - "commit": "a633561", + "generatedAt": "2026-05-20T08:30:19.042Z", + "commit": "e2a5278", "repo": { - "statements": 97.38, - "branches": 92.2, - "functions": 97.11, - "lines": 97.38, + "statements": 97.41, + "branches": 92.31, + "functions": 97.18, + "lines": 97.41, "counts": { - "lf": 5807, - "lh": 5655, - "brf": 1166, - "brh": 1075, - "fnf": 346, - "fnh": 336 + "lf": 5867, + "lh": 5715, + "brf": 1184, + "brh": 1093, + "fnf": 354, + "fnh": 344 } }, "byPackage": { @@ -101,17 +101,17 @@ } }, "@repo/core-shared": { - "statements": 98.04, - "branches": 95.9, - "functions": 92.16, - "lines": 98.04, + "statements": 98.15, + "branches": 96.12, + "functions": 92.73, + "lines": 98.15, "counts": { - "lf": 1073, - "lh": 1052, - "brf": 317, - "brh": 304, - "fnf": 102, - "fnh": 94 + "lf": 1133, + "lh": 1112, + "brf": 335, + "brh": 322, + "fnf": 110, + "fnh": 102 } }, "@repo/core-ui": { diff --git a/packages/core-shared/src/index.ts b/packages/core-shared/src/index.ts index e7701ac..a46e861 100644 --- a/packages/core-shared/src/index.ts +++ b/packages/core-shared/src/index.ts @@ -3,3 +3,4 @@ export { toIsoString } from "./lib/date"; export * from "./audit"; export * from "./di"; export * from "./instrumentation/index"; +export * from "./rate-limit/index"; diff --git a/packages/core-shared/src/rate-limit/in-memory-rate-limit.test.ts b/packages/core-shared/src/rate-limit/in-memory-rate-limit.test.ts new file mode 100644 index 0000000..42c3a30 --- /dev/null +++ b/packages/core-shared/src/rate-limit/in-memory-rate-limit.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from "vitest"; +import { InMemoryRateLimit } from "@/rate-limit/in-memory-rate-limit"; +import type { IRateLimit } from "@/rate-limit/rate-limit.interface"; + +const BUDGET = { name: "signin", window: "1m", budget: 3 }; + +function makeClock(start = 0) { + let now = start; + return { + tick: (ms: number) => { + now += ms; + }, + fn: () => now, + }; +} + +describe("InMemoryRateLimit", () => { + it("satisfies IRateLimit", () => { + const rl: IRateLimit = new InMemoryRateLimit([BUDGET]); + expect(rl).toBeDefined(); + }); + + it("allows consume up to budget", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + for (let i = 0; i < 3; i++) { + const result = await rl.consume("signin", "u1"); + expect(result.allowed).toBe(true); + } + }); + + it("decrements remaining on each consume", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + const r1 = await rl.consume("signin", "u1"); + expect(r1.remaining).toBe(2); + const r2 = await rl.consume("signin", "u1"); + expect(r2.remaining).toBe(1); + const r3 = await rl.consume("signin", "u1"); + expect(r3.remaining).toBe(0); + }); + + it("denies consume beyond budget", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + for (let i = 0; i < 3; i++) await rl.consume("signin", "u1"); + const result = await rl.consume("signin", "u1"); + expect(result.allowed).toBe(false); + expect(result.remaining).toBe(0); + }); + + it("tracks per-key buckets independently", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + for (let i = 0; i < 3; i++) await rl.consume("signin", "u1"); + const result = await rl.consume("signin", "u2"); + expect(result.allowed).toBe(true); + }); + + it("resets bucket at window expiry (check-at-read)", async () => { + const clock = makeClock(1000); + const rl = new InMemoryRateLimit([BUDGET], clock.fn); + for (let i = 0; i < 3; i++) await rl.consume("signin", "u1"); + // advance past the 1-minute window + clock.tick(61_000); + const result = await rl.consume("signin", "u1"); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(2); + }); + + it("explicit reset restores budget to full", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + for (let i = 0; i < 3; i++) await rl.consume("signin", "u1"); + await rl.reset("signin", "u1"); + const result = await rl.consume("signin", "u1"); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(2); + }); + + it("reset for unknown key is a no-op", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + await expect(rl.reset("signin", "never-seen")).resolves.toBeUndefined(); + }); + + it("throws for unknown budget name", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + await expect(rl.consume("unknown-budget", "u1")).rejects.toThrow( + /Unknown rate-limit budget/, + ); + }); + + it("throws for malformed window string when consume is called", async () => { + const rl = new InMemoryRateLimit([{ name: "x", window: "bad", budget: 5 }]); + await expect(rl.consume("x", "k")).rejects.toThrow( + /Invalid rate-limit window/, + ); + }); + + it("resetAt is set to window boundary", async () => { + const clock = makeClock(10_000); + const rl = new InMemoryRateLimit([BUDGET], clock.fn); + const result = await rl.consume("signin", "u1"); + expect(result.resetAt.getTime()).toBe(10_000 + 60_000); + }); + + it("parses ms window unit", async () => { + const clock = makeClock(0); + const rl = new InMemoryRateLimit( + [{ name: "fast", window: "500ms", budget: 1 }], + clock.fn, + ); + const r = await rl.consume("fast", "u1"); + expect(r.resetAt.getTime()).toBe(500); + }); + + it("parses s window unit", async () => { + const clock = makeClock(0); + const rl = new InMemoryRateLimit( + [{ name: "second", window: "30s", budget: 1 }], + clock.fn, + ); + const r = await rl.consume("second", "u1"); + expect(r.resetAt.getTime()).toBe(30_000); + }); + + it("parses h window unit", async () => { + const clock = makeClock(0); + const rl = new InMemoryRateLimit( + [{ name: "hourly", window: "2h", budget: 1 }], + clock.fn, + ); + const r = await rl.consume("hourly", "u1"); + expect(r.resetAt.getTime()).toBe(7_200_000); + }); + + it("parses d window unit", async () => { + const clock = makeClock(0); + const rl = new InMemoryRateLimit( + [{ name: "daily", window: "1d", budget: 1 }], + clock.fn, + ); + const r = await rl.consume("daily", "u1"); + expect(r.resetAt.getTime()).toBe(86_400_000); + }); + + it("respects weight parameter", async () => { + const rl = new InMemoryRateLimit([BUDGET]); + const r1 = await rl.consume("signin", "u1", 2); + expect(r1.allowed).toBe(true); + expect(r1.remaining).toBe(1); + const r2 = await rl.consume("signin", "u1", 2); + expect(r2.allowed).toBe(false); + }); +}); diff --git a/packages/core-shared/src/rate-limit/in-memory-rate-limit.ts b/packages/core-shared/src/rate-limit/in-memory-rate-limit.ts new file mode 100644 index 0000000..5960410 --- /dev/null +++ b/packages/core-shared/src/rate-limit/in-memory-rate-limit.ts @@ -0,0 +1,67 @@ +import type { + IRateLimit, + RateLimitBudget, + RateLimitDecision, +} from "./rate-limit.interface"; + +const UNIT_MS: Record = { + ms: 1, + s: 1_000, + m: 60_000, + h: 3_600_000, + d: 86_400_000, +}; + +function parseWindowMs(window: string): number { + const match = /^(\d+)(ms|s|m|h|d)$/.exec(window); + if (!match) throw new Error(`Invalid rate-limit window: "${window}"`); + return parseInt(match[1] as string, 10) * UNIT_MS[match[2] as string]!; +} + +type Bucket = { count: number; resetAt: number }; + +export class InMemoryRateLimit implements IRateLimit { + private readonly budgets: Map; + private readonly buckets = new Map(); + private readonly clock: () => number; + + constructor( + budgets: RateLimitBudget[], + clock: () => number = () => Date.now(), + ) { + this.budgets = new Map(budgets.map((b) => [b.name, b])); + this.clock = clock; + } + + async consume( + budgetName: string, + key: string, + weight = 1, + ): Promise { + const budget = this.budgets.get(budgetName); + if (!budget) throw new Error(`Unknown rate-limit budget: "${budgetName}"`); + + const windowMs = parseWindowMs(budget.window); + const now = this.clock(); + const bucketKey = `${budgetName}:${key}`; + + let bucket = this.buckets.get(bucketKey); + if (!bucket || now >= bucket.resetAt) { + bucket = { count: 0, resetAt: now + windowMs }; + this.buckets.set(bucketKey, bucket); + } + + const resetAt = new Date(bucket.resetAt); + + if (bucket.count + weight > budget.budget) { + return { allowed: false, remaining: 0, resetAt }; + } + + bucket.count += weight; + return { allowed: true, remaining: budget.budget - bucket.count, resetAt }; + } + + async reset(budgetName: string, key: string): Promise { + this.buckets.delete(`${budgetName}:${key}`); + } +} diff --git a/packages/core-shared/src/rate-limit/index.ts b/packages/core-shared/src/rate-limit/index.ts index 290e9d9..639f991 100644 --- a/packages/core-shared/src/rate-limit/index.ts +++ b/packages/core-shared/src/rate-limit/index.ts @@ -3,3 +3,5 @@ export type { RateLimitBudget, RateLimitDecision, } from "./rate-limit.interface"; +export { NoopRateLimit } from "./noop-rate-limit"; +export { InMemoryRateLimit } from "./in-memory-rate-limit"; diff --git a/packages/core-shared/src/rate-limit/noop-rate-limit.test.ts b/packages/core-shared/src/rate-limit/noop-rate-limit.test.ts new file mode 100644 index 0000000..12f7d74 --- /dev/null +++ b/packages/core-shared/src/rate-limit/noop-rate-limit.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect } from "vitest"; +import { NoopRateLimit } from "@/rate-limit/noop-rate-limit"; +import type { IRateLimit } from "@/rate-limit/rate-limit.interface"; + +describe("NoopRateLimit", () => { + it("satisfies IRateLimit", () => { + const rl: IRateLimit = new NoopRateLimit(); + expect(rl).toBeDefined(); + }); + + it("consume always resolves allowed with Infinity remaining", async () => { + const rl = new NoopRateLimit(); + const result = await rl.consume("signin", "user-1"); + expect(result.allowed).toBe(true); + expect(result.remaining).toBe(Infinity); + }); + + it("consume always resolves resetAt as epoch", async () => { + const rl = new NoopRateLimit(); + const result = await rl.consume("signin", "user-1", 5); + expect(result.resetAt).toEqual(new Date(0)); + }); + + it("reset is a no-op and resolves", async () => { + const rl = new NoopRateLimit(); + await expect(rl.reset("signin", "user-1")).resolves.toBeUndefined(); + }); + + it("consume remains allowed after many calls", async () => { + const rl = new NoopRateLimit(); + for (let i = 0; i < 100; i++) { + const result = await rl.consume("signin", "user-1"); + expect(result.allowed).toBe(true); + } + }); +}); diff --git a/packages/core-shared/src/rate-limit/noop-rate-limit.ts b/packages/core-shared/src/rate-limit/noop-rate-limit.ts new file mode 100644 index 0000000..c34a90b --- /dev/null +++ b/packages/core-shared/src/rate-limit/noop-rate-limit.ts @@ -0,0 +1,15 @@ +import type { IRateLimit, RateLimitDecision } from "./rate-limit.interface"; + +const EPOCH = new Date(0); + +export class NoopRateLimit implements IRateLimit { + async consume( + _budgetName: string, + _key: string, + _weight?: number, + ): Promise { + return { allowed: true, remaining: Infinity, resetAt: EPOCH }; + } + + async reset(_budgetName: string, _key: string): Promise {} +}