Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
export {
createIpRateLimiter,
createRateLimitChecker,
createRouteRateLimiter,
getRateLimitResult,
} from "./rate-limit-middleware.js";

View File

@@ -0,0 +1,99 @@
# rate-limit-middleware
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Provides framework-agnostic middleware factory helpers that wrap `createRateLimiter` for easy integration into HTTP servers, API gateways, and custom request pipelines. All middleware functions accept `req: unknown` and return a `Promise<RateLimitResult>`, keeping them decoupled from any specific framework (Hono, Express, Fastify, Bun's native server, etc.).
The three exported helpers cover the most common patterns: a simple checker function, IP-based limiting, and route-specific per-path limits. For Hono-specific wiring, consumers can use a thin adapter that reads `result.allowed` and returns a `429` response.
## Features
- `createRateLimitChecker` — returns a `(context) => Promise<RateLimitResult>` wrapper
- `getRateLimitResult` — low-level direct storage+algorithm call
- `createIpRateLimiter` — IP-key extraction with configurable `keyExtractor`, `skip`, `onLimited`
- `createRouteRateLimiter` — per-route config map with route-prefix matching
- All factories accept an optional `skip` predicate to bypass limiting for health probes
- `onLimited` callback for telemetry, logging, or 429-response generation
## Usage: Basic Example
```ts
import { createRateLimitChecker } from "@suaveplan/rate-limiter";
const check = createRateLimitChecker({
defaultConfig: { limit: 100, window: 60_000 },
});
const result = await check({ key: "user:42" });
if (!result.allowed) {
return new Response("Too Many Requests", { status: 429 });
}
```
## Usage: Advanced Example
```ts
import { createIpRateLimiter, createRouteRateLimiter, MemoryStorage } from "@suaveplan/rate-limiter";
const storage = new MemoryStorage({ maxSize: 10_000 });
// IP-based limiter that skips health checks
const ipLimiter = createIpRateLimiter(
{ limit: 200, window: 60_000 },
storage,
{
keyExtractor: (req) => (req as Request).headers.get("x-forwarded-for") ?? "unknown",
skip: (req) => (req as Request).url.endsWith("/health"),
onLimited: (result) => console.warn("IP limited", result),
}
);
// Route-specific limits
const routeLimiter = createRouteRateLimiter(
{
"/api/auth/login": { limit: 5, window: 60_000 },
"/api/search": { limit: 50, window: 10_000 },
},
storage
);
const result = await ipLimiter(request);
if (!result.allowed) {
return new Response("Too Many Requests", { status: 429 });
}
```
## API Reference
### `createRateLimitChecker(options)`
`(options: RateLimiterOptions) => (context: RateLimitContext) => Promise<RateLimitResult>`
### `getRateLimitResult(context, storage, config)`
`(context: RateLimitContext, storage: RateLimitStorage, config: RateLimitConfig) => Promise<RateLimitResult>`
Low-level helper — creates a fresh `SlidingWindow` per call, restoring persisted state from storage.
### `createIpRateLimiter(config, storage?, middlewareOptions?)`
Returns `(req: unknown) => Promise<RateLimitResult>`.
### `createRouteRateLimiter(routes, storage?, middlewareOptions?)`
Returns `(req: unknown) => Promise<RateLimitResult>`. Matches route prefixes in insertion order.
### `RateLimiterMiddlewareOptions`
| Field | Type | Description |
|---|---|---|
| `keyExtractor?` | `(req: unknown) => string` | Extract key from request |
| `onLimited?` | `(result: RateLimitResult) => void` | Called on every check (use to send 429) |
| `skip?` | `(req: unknown) => boolean` | Skip limiting for matching requests |
## Implementation Notes
`createIpRateLimiter` and `createRouteRateLimiter` each construct one `createRateLimiter` instance per route/IP config at factory time, sharing a single storage adapter. Route matching in `createRouteRateLimiter` uses `key.startsWith(route) || key.includes(route)` — for production use with complex routing rules, consumers should provide a custom `keyExtractor` that returns the matched route prefix.

View File

@@ -0,0 +1,194 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { MemoryStorage } from "../../storage/memory-storage/memory-storage.js";
import {
createIpRateLimiter,
createRateLimitChecker,
createRouteRateLimiter,
getRateLimitResult,
} from "./rate-limit-middleware.js";
describe("createRateLimitChecker", () => {
it("returns checker that allows requests", { timeout: 5000 }, async () => {
const check = createRateLimitChecker({
defaultConfig: { limit: 10, window: 1000 },
});
const result = await check({ key: "user:1" });
expect(result.allowed).toBe(true);
});
it("rejects when over limit", { timeout: 5000 }, async () => {
const check = createRateLimitChecker({
defaultConfig: { limit: 1, window: 60_000 },
});
await check({ key: "u" });
const result = await check({ key: "u" });
expect(result.allowed).toBe(false);
});
});
describe("getRateLimitResult", () => {
it("allows within limit", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const result = await getRateLimitResult({ key: "k" }, storage, {
limit: 5,
window: 1000,
});
expect(result.allowed).toBe(true);
});
it("rejects over limit", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
await getRateLimitResult({ key: "k" }, storage, {
limit: 1,
window: 60_000,
});
const result = await getRateLimitResult({ key: "k" }, storage, {
limit: 1,
window: 60_000,
});
expect(result.allowed).toBe(false);
});
it("restores state from storage", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
// Pre-seed storage with a near-limit state
const now = Date.now();
await storage.set(
"k2",
{
key: "k2",
algorithm: "sliding-window",
state: { requests: [now - 100, now - 200] },
config: { limit: 3, window: 60_000 },
updatedAt: now,
},
60_000
);
const result = await getRateLimitResult({ key: "k2" }, storage, {
limit: 3,
window: 60_000,
});
// 2 existing + 1 new = 3, which is at limit (not over)
expect(result.allowed).toBe(true);
expect(result.remaining).toBe(0);
});
});
describe("createIpRateLimiter", () => {
it("allows requests below limit", { timeout: 5000 }, async () => {
const limiter = createIpRateLimiter({ limit: 5, window: 1000 });
const result = await limiter("192.168.1.1");
expect(result.allowed).toBe(true);
});
it("uses custom keyExtractor", { timeout: 5000 }, async () => {
const limiter = createIpRateLimiter(
{ limit: 1, window: 60_000 },
undefined,
{ keyExtractor: (req) => `ip-${String(req)}` }
);
await limiter("1.2.3.4");
const result = await limiter("1.2.3.4");
expect(result.allowed).toBe(false);
});
it("skips when skip returns true", { timeout: 5000 }, async () => {
let limited = false;
const limiter = createIpRateLimiter(
{ limit: 0, window: 1000 },
undefined,
{
skip: () => true,
onLimited: () => {
limited = true;
},
}
);
const result = await limiter("anything");
expect(result.allowed).toBe(true);
expect(limited).toBe(false);
});
it("calls onLimited when provided", { timeout: 5000 }, async () => {
let calls = 0;
const limiter = createIpRateLimiter(
{ limit: 1, window: 60_000 },
undefined,
{
onLimited: () => {
calls++;
},
}
);
await limiter("ip");
await limiter("ip"); // rejected
// onLimited is called on every check
expect(calls).toBeGreaterThanOrEqual(1);
});
it("uses shared storage adapter", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const limiter = createIpRateLimiter(
{ limit: 2, window: 60_000 },
storage
);
await limiter("1.1.1.1");
expect(storage.size).toBeGreaterThan(0);
});
});
describe("createRouteRateLimiter", () => {
it("applies route-specific config", { timeout: 5000 }, async () => {
const limiter = createRouteRateLimiter(
{
"/api/login": { limit: 1, window: 60_000 },
},
undefined,
{ keyExtractor: (req) => String(req) }
);
await limiter("/api/login:user1");
const result = await limiter("/api/login:user1");
expect(result.allowed).toBe(false);
});
it("allows unmatched routes", { timeout: 5000 }, async () => {
const limiter = createRouteRateLimiter(
{ "/api/strict": { limit: 0, window: 1000 } },
undefined,
{ keyExtractor: (req) => String(req) }
);
const result = await limiter("/other:key");
expect(result.allowed).toBe(true);
});
it("skip predicate bypasses route limiter", { timeout: 5000 }, async () => {
const limiter = createRouteRateLimiter(
{ "/api": { limit: 0, window: 1000 } },
undefined,
{ skip: () => true }
);
const result = await limiter("/api/anything");
expect(result.allowed).toBe(true);
});
it("uses shared storage adapter", { timeout: 5000 }, async () => {
const storage = new MemoryStorage();
const limiter = createRouteRateLimiter(
{ "/api": { limit: 5, window: 60_000 } },
storage,
{ keyExtractor: (req) => String(req) }
);
await limiter("/api:user");
expect(storage.size).toBeGreaterThan(0);
});
it("uses default keyExtractor when none provided", {
timeout: 5000,
}, async () => {
const limiter = createRouteRateLimiter({
"/api": { limit: 5, window: 60_000 },
});
const result = await limiter("/api:user");
expect(result.allowed).toBe(true);
});
});

View File

@@ -0,0 +1,151 @@
import { createRateLimiter } from "../../core/rate-limiter/rate-limiter.js";
import { SlidingWindow } from "../../core/sliding-window/sliding-window.js";
import type { RateLimitStorage } from "../../storage/adapter/adapter.js";
import type {
RateLimitConfig,
RateLimitContext,
RateLimiterMiddlewareOptions,
RateLimiterOptions,
RateLimitResult,
} from "../../types/rate-limiter.types.js";
/**
* Create a simple checker function that wraps `createRateLimiter`.
*
* Suitable for cases where you want to call the rate limiter with a
* `RateLimitContext` directly without going through middleware abstraction.
*/
export function createRateLimitChecker(
options: RateLimiterOptions
): (context: RateLimitContext) => Promise<RateLimitResult> {
const limiter = createRateLimiter(options);
return (context: RateLimitContext) => limiter.check(context);
}
/**
* Evaluate a single `RateLimitContext` against a storage adapter and config,
* using a sliding-window algorithm.
*
* Lower-level helper — callers manage the storage lifecycle.
*/
export async function getRateLimitResult(
context: RateLimitContext,
storage: RateLimitStorage,
config: RateLimitConfig
): Promise<RateLimitResult> {
const sw = new SlidingWindow({
limit: config.limit,
windowMs: config.window,
});
const existing = await storage.get(context.key);
if (
existing?.algorithm === "sliding-window" &&
"requests" in existing.state
) {
const cutoff = Date.now() - config.window;
const live = existing.state.requests.filter((ts) => ts >= cutoff);
const internalWindow = (
sw as unknown as { windows: Map<string, number[]> }
).windows;
internalWindow.set(context.key, live);
}
const result = sw.check(context.key);
const internalWindow = (sw as unknown as { windows: Map<string, number[]> })
.windows;
// sw.check() always sets the key; cast as non-null
const requests = internalWindow.get(context.key) as number[];
await storage.set(
context.key,
{
key: context.key,
algorithm: "sliding-window",
state: { requests: [...requests] },
config,
updatedAt: Date.now(),
},
config.window
);
return result;
}
/**
* Create a framework-agnostic IP-based rate limiter middleware factory.
*
* The returned function extracts an IP string from `req` using `keyExtractor`
* (or `(req) => String(req)` fallback), then runs the rate-limit check.
* The caller is responsible for reading the result and responding.
*/
export function createIpRateLimiter(
config: RateLimitConfig,
storage?: RateLimitStorage,
middlewareOptions?: RateLimiterMiddlewareOptions
): (req: unknown) => Promise<RateLimitResult> {
const limiter = createRateLimiter({
defaultConfig: config,
...(storage !== undefined ? { storage } : {}),
});
const keyExtractor =
middlewareOptions?.keyExtractor ?? ((req: unknown) => String(req));
const skip = middlewareOptions?.skip;
return async (req: unknown): Promise<RateLimitResult> => {
if (skip?.(req)) {
return {
allowed: true,
remaining: config.limit,
limit: config.limit,
resetIn: 0,
};
}
const key = keyExtractor(req);
const result = await limiter.check({ key });
middlewareOptions?.onLimited?.(result);
return result;
};
}
/**
* Create a framework-agnostic route-specific rate limiter middleware factory.
*
* Each key in `routes` maps a route pattern string to a `RateLimitConfig`.
* The `keyExtractor` receives `req` and returns a `"{route}:{identifier}"`
* string used as the rate-limit key. Unmatched routes fall through with
* `allowed: true`.
*/
export function createRouteRateLimiter(
routes: Record<string, RateLimitConfig>,
storage?: RateLimitStorage,
middlewareOptions?: RateLimiterMiddlewareOptions
): (req: unknown) => Promise<RateLimitResult> {
const limiters = new Map<string, ReturnType<typeof createRateLimiter>>();
for (const [route, config] of Object.entries(routes)) {
limiters.set(
route,
createRateLimiter({
defaultConfig: config,
keyPrefix: `route:${route}`,
...(storage !== undefined ? { storage } : {}),
})
);
}
const keyExtractor =
middlewareOptions?.keyExtractor ?? ((req: unknown) => String(req));
const skip = middlewareOptions?.skip;
return async (req: unknown): Promise<RateLimitResult> => {
if (skip?.(req)) {
return { allowed: true, remaining: 0, limit: 0, resetIn: 0 };
}
const key = keyExtractor(req);
// Check all matching routes
for (const [route, limiter] of limiters) {
if (key.startsWith(route) || key.includes(route)) {
const result = await limiter.check({ key });
middlewareOptions?.onLimited?.(result);
return result;
}
}
return { allowed: true, remaining: 0, limit: 0, resetIn: 0 };
};
}

View File

@@ -0,0 +1,4 @@
export type {
MiddlewareFn,
RateLimiterMiddlewareOptions,
} from "../../types/rate-limiter.types.js";