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:
@@ -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": {
|
||||
|
||||
@@ -3,3 +3,4 @@ export { toIsoString } from "./lib/date";
|
||||
export * from "./audit";
|
||||
export * from "./di";
|
||||
export * from "./instrumentation/index";
|
||||
export * from "./rate-limit/index";
|
||||
|
||||
150
packages/core-shared/src/rate-limit/in-memory-rate-limit.test.ts
Normal file
150
packages/core-shared/src/rate-limit/in-memory-rate-limit.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
67
packages/core-shared/src/rate-limit/in-memory-rate-limit.ts
Normal file
67
packages/core-shared/src/rate-limit/in-memory-rate-limit.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
36
packages/core-shared/src/rate-limit/noop-rate-limit.test.ts
Normal file
36
packages/core-shared/src/rate-limit/noop-rate-limit.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
15
packages/core-shared/src/rate-limit/noop-rate-limit.ts
Normal file
15
packages/core-shared/src/rate-limit/noop-rate-limit.ts
Normal 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> {}
|
||||
}
|
||||
Reference in New Issue
Block a user