feat(core-shared): add NoopRateLimit and InMemoryRateLimit implementations

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-20 08:30:35 +00:00
parent e2a5278899
commit 5ddc8e6484
7 changed files with 293 additions and 22 deletions

View File

@@ -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": {

View File

@@ -3,3 +3,4 @@ export { toIsoString } from "./lib/date";
export * from "./audit";
export * from "./di";
export * from "./instrumentation/index";
export * from "./rate-limit/index";

View File

@@ -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);
});
});

View File

@@ -0,0 +1,67 @@
import type {
IRateLimit,
RateLimitBudget,
RateLimitDecision,
} from "./rate-limit.interface";
const UNIT_MS: Record<string, number> = {
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<string, RateLimitBudget>;
private readonly buckets = new Map<string, Bucket>();
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<RateLimitDecision> {
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<void> {
this.buckets.delete(`${budgetName}:${key}`);
}
}

View File

@@ -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";

View File

@@ -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);
}
});
});

View File

@@ -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<RateLimitDecision> {
return { allowed: true, remaining: Infinity, resetAt: EPOCH };
}
async reset(_budgetName: string, _key: string): Promise<void> {}
}