Initial Commit
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
createIpRateLimiter,
|
||||
createRateLimitChecker,
|
||||
createRouteRateLimiter,
|
||||
getRateLimitResult,
|
||||
} from "./rate-limit-middleware.js";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export type {
|
||||
MiddlewareFn,
|
||||
RateLimiterMiddlewareOptions,
|
||||
} from "../../types/rate-limiter.types.js";
|
||||
Reference in New Issue
Block a user