Move docs/ package mirror into document-navigator plugin
Relocate the ~6k-file docs tree from the repo root into plugins/document-navigator/docs/ so the plugin is self-contained and portable. docs/superpowers/ (specs and plans) stays at the repo root. Moved via cp+rm rather than git mv because directory rename() fails with EPERM on this WSL /mnt DrvFs mount; git rename-detection pairs the identical-content files at commit time. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -0,0 +1,68 @@
|
||||
# fixed-window
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the fixed-window rate-limiting algorithm. Time is divided into equal-length buckets of `windowMs` milliseconds. A counter per key tracks requests in the current bucket and resets to zero when a new bucket starts. This is the simplest algorithm — O(1) memory per key — suitable for coarse-grained limits where occasional boundary spikes (a burst at the end of one window plus the start of the next) are acceptable.
|
||||
|
||||
## Features
|
||||
|
||||
- O(1) counter per key with automatic window boundary reset
|
||||
- Injectable `Clock` for deterministic tests
|
||||
- `reset(key)` for admin and test operations
|
||||
- `retryAfter` equals time until the current window ends
|
||||
- `resetIn` reflects exact milliseconds until the next window
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { FixedWindow } from "@suaveplan/rate-limiter";
|
||||
|
||||
const fw = new FixedWindow({ limit: 100, windowMs: 60_000 });
|
||||
const result = fw.check("tenant:acme");
|
||||
console.log(`${result.remaining} requests remaining`);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { FixedWindow } from "@suaveplan/rate-limiter";
|
||||
|
||||
let t = 0;
|
||||
const clock = () => t;
|
||||
const fw = new FixedWindow({ limit: 2, windowMs: 1000, clock });
|
||||
|
||||
fw.check("k"); // count=1
|
||||
fw.check("k"); // count=2
|
||||
expect(fw.check("k").allowed).toBe(false);
|
||||
|
||||
// Advance to next window
|
||||
t = 1000;
|
||||
expect(fw.check("k").allowed).toBe(true); // counter reset
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `FixedWindow`
|
||||
|
||||
```ts
|
||||
class FixedWindow {
|
||||
constructor(options: FixedWindowOptions)
|
||||
check(key: string): RateLimitResult
|
||||
reset(key: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### `FixedWindowOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `limit` | `number` | required | Max requests per window |
|
||||
| `windowMs` | `number` | required | Window duration in milliseconds |
|
||||
| `clock` | `Clock` | `Date.now` | Injectable time source |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Window boundaries are computed as `Math.floor(now / windowMs) * windowMs`, so all callers sharing the same clock see synchronised window resets. The counter map retains stale entries indefinitely until `reset(key)` is called or the key reappears in a new window (at which point the stale entry is overwritten). This is safe because the window boundary check is always evaluated on access, never on eviction.
|
||||
@@ -0,0 +1,96 @@
|
||||
# rate-limiter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`createRateLimiter` is the top-level orchestrator for `@suaveplan/rate-limiter`. It accepts a `RateLimiterOptions` object and returns a `RateLimiterInstance` that applies rule-based algorithm routing, tenant-specific multipliers, persistent state via a pluggable `RateLimitStorage` adapter, OTel counters and histograms, and optional event-bus integration.
|
||||
|
||||
Rules are evaluated in descending priority order — the first matching rule wins. A bypass rule returns an `allowed: true` result immediately without touching storage. If no rule matches, the `defaultConfig` is applied. Tenant multipliers scale the effective limit for matching `tenantId` values.
|
||||
|
||||
## Features
|
||||
|
||||
- Rule-based routing with priority ordering and five condition operators
|
||||
- Bypass rules for privileged keys (admin accounts, monitoring agents)
|
||||
- Tenant multipliers: `effectiveLimit = baseLimit * multiplier`
|
||||
- Pluggable `RateLimitStorage` (defaults to in-memory LRU)
|
||||
- `rate:limit:exceeded` and `rate:limit:reset` event bus emissions
|
||||
- Structured logging via `LoggerLike`
|
||||
- OTel: `rate_limiter.checks_total`, `rate_limiter.check_duration_ms`, `rate_limiter.exceeded_total`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { createRateLimiter } from "@suaveplan/rate-limiter";
|
||||
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 100, window: 60_000 },
|
||||
});
|
||||
|
||||
const result = await limiter.check({ key: "user:42" });
|
||||
if (!result.allowed) {
|
||||
console.log(`Rate limited. Retry after ${result.retryAfter}ms`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createRateLimiter, MemoryStorage } from "@suaveplan/rate-limiter";
|
||||
import { EventBus } from "@suaveplan/events";
|
||||
|
||||
const bus = new EventBus();
|
||||
bus.on("rate:limit:exceeded", ({ key, resetIn }) => {
|
||||
console.warn(`Key ${key} exceeded. Reset in ${resetIn}ms`);
|
||||
});
|
||||
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 100, window: 60_000 },
|
||||
storage: new MemoryStorage({ maxSize: 50_000 }),
|
||||
rules: [
|
||||
{
|
||||
id: "admin-bypass",
|
||||
limit: 0,
|
||||
window: 60_000,
|
||||
priority: 100,
|
||||
bypass: true,
|
||||
conditions: [{ field: "userId", operator: "in", value: ["admin-1"] }],
|
||||
},
|
||||
{
|
||||
id: "strict-api",
|
||||
limit: 10,
|
||||
window: 1000,
|
||||
priority: 50,
|
||||
conditions: [{ field: "path", operator: "matches", value: "^/api/sensitive" }],
|
||||
},
|
||||
],
|
||||
enableTenantLimits: true,
|
||||
tenantMultipliers: { enterprise: 5 },
|
||||
bus,
|
||||
keyPrefix: "rl",
|
||||
});
|
||||
|
||||
await limiter.reset("user:42");
|
||||
const state = await limiter.getState("user:42");
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `createRateLimiter(options)`
|
||||
|
||||
Returns a `RateLimiterInstance`.
|
||||
|
||||
**Parameters**: `RateLimiterOptions` — see `rate-limiter.types.md`.
|
||||
|
||||
### `RateLimiterInstance`
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `check` | `(context: RateLimitContext) => Promise<RateLimitResult>` | Evaluate and record a request |
|
||||
| `reset` | `(key: string) => Promise<void>` | Clear state and emit reset event |
|
||||
| `getState` | `(key: string) => Promise<RateLimitState \| null>` | Read current persisted state |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
State synchronisation between the in-memory `SlidingWindow` instances and the storage adapter is done by loading persisted timestamps on each `check` and writing back after evaluation. This is correct for single-process deployments but not atomic for distributed Redis-backed setups — a Redis Lua script or atomic transaction would be required for strict distributed correctness. For in-memory (single-process) use, the `SlidingWindow` is the authoritative source and the storage adapter provides durability across restarts.
|
||||
@@ -0,0 +1,73 @@
|
||||
# sliding-window
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the sliding-window rate-limiting algorithm. Each key maintains a sorted array of request timestamps. On every `check`, timestamps older than `now - windowMs` are pruned and the remaining count is compared against `limit`. This produces a smooth rate distribution without the periodic boundary spikes that plague fixed-window counters.
|
||||
|
||||
The sliding window trades memory (O(limit) timestamps per key) for accuracy. It is the default algorithm used by `createRateLimiter`.
|
||||
|
||||
## Features
|
||||
|
||||
- Per-key timestamp arrays with automatic expiry pruning
|
||||
- Injectable `Clock` for deterministic tests
|
||||
- `reset(key)` for admin and test operations
|
||||
- `retryAfter` equals time until the oldest request in the window expires
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { SlidingWindow } from "@suaveplan/rate-limiter";
|
||||
|
||||
const sw = new SlidingWindow({ limit: 5, windowMs: 1000 });
|
||||
const result = sw.check("user:1");
|
||||
if (!result.allowed) {
|
||||
console.log(`Retry in ${result.retryAfter}ms`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { SlidingWindow } from "@suaveplan/rate-limiter";
|
||||
|
||||
let t = 1_000_000;
|
||||
const clock = () => t;
|
||||
const sw = new SlidingWindow({ limit: 3, windowMs: 1000, clock });
|
||||
|
||||
// Fill limit
|
||||
sw.check("user:1");
|
||||
sw.check("user:1");
|
||||
sw.check("user:1");
|
||||
expect(sw.check("user:1").allowed).toBe(false);
|
||||
|
||||
// Advance past window — all old entries expire
|
||||
t += 1001;
|
||||
expect(sw.check("user:1").allowed).toBe(true);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SlidingWindow`
|
||||
|
||||
```ts
|
||||
class SlidingWindow {
|
||||
constructor(options: SlidingWindowOptions)
|
||||
check(key: string): RateLimitResult
|
||||
reset(key: string): void
|
||||
}
|
||||
```
|
||||
|
||||
### `SlidingWindowOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `limit` | `number` | required | Max requests in `windowMs` |
|
||||
| `windowMs` | `number` | required | Window duration in milliseconds |
|
||||
| `clock` | `Clock` | `Date.now` | Injectable time source |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Timestamps are stored in chronological order. The prune step uses a linear scan from the start — acceptable because the splice is O(k) where k is the number of expired entries. For very high-frequency keys or very large windows, a circular-buffer implementation would be preferable. The `check` call only records a timestamp when `allowed === true`, so rejected requests do not consume capacity.
|
||||
@@ -0,0 +1,75 @@
|
||||
# token-bucket
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Implements the token bucket rate-limiting algorithm. Tokens accumulate continuously at `refillRate` tokens per millisecond up to `capacity`. Each `consume(n)` call checks whether enough tokens are available, deducts them if so, and returns a `RateLimitResult` indicating whether the request was allowed. Requests that arrive when the bucket is empty are rejected without penalising the bucket state.
|
||||
|
||||
The token bucket is ideal for workloads that benefit from burst tolerance: a burst of requests up to `capacity` is permitted as long as the long-run rate does not exceed `refillRate * 1000` requests per second.
|
||||
|
||||
## Features
|
||||
|
||||
- Continuous token refill based on elapsed wall-clock time
|
||||
- Injectable `Clock` for deterministic tests without `sleep`
|
||||
- `available` and `capacity` read-only getters
|
||||
- Manual `refill(tokens?)` for test setup and admin operations
|
||||
- `RateLimitResult` with `retryAfter` computed from deficit tokens
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { TokenBucket } from "@suaveplan/rate-limiter";
|
||||
|
||||
const bucket = new TokenBucket({ capacity: 10, refillRate: 0.1 }); // 100 req/s
|
||||
const result = bucket.consume();
|
||||
if (!result.allowed) {
|
||||
console.log(`Retry in ${result.retryAfter}ms`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { TokenBucket } from "@suaveplan/rate-limiter";
|
||||
|
||||
let t = Date.now();
|
||||
const clock = () => t;
|
||||
const bucket = new TokenBucket({ capacity: 5, refillRate: 0.005, clock });
|
||||
|
||||
// Drain the bucket
|
||||
for (let i = 0; i < 5; i++) bucket.consume();
|
||||
expect(bucket.available).toBe(0);
|
||||
|
||||
// Advance 1 second — refillRate 0.005 * 1000ms = 5 tokens
|
||||
t += 1000;
|
||||
expect(bucket.available).toBe(5);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `TokenBucket`
|
||||
|
||||
```ts
|
||||
class TokenBucket {
|
||||
constructor(options: TokenBucketOptions)
|
||||
consume(tokens?: number): RateLimitResult
|
||||
refill(tokens?: number): void
|
||||
readonly available: number
|
||||
readonly capacity: number
|
||||
}
|
||||
```
|
||||
|
||||
### `TokenBucketOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `capacity` | `number` | required | Max tokens the bucket can hold |
|
||||
| `refillRate` | `number` | required | Tokens added per millisecond |
|
||||
| `initialTokens` | `number` | `capacity` | Starting token count |
|
||||
| `clock` | `Clock` | `Date.now` | Injectable time source |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Refill is performed lazily on every `consume()` and on the `available` getter. The `performRefill` helper uses `Math.min(capacity, available + elapsed * rate)` to clamp at capacity. The `retryAfter` in a rejected result is the ceiling of `deficit / refillRate`, giving the caller a precise time to retry.
|
||||
@@ -0,0 +1,97 @@
|
||||
# rate-limiter-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the structured error class and stable error-code registry for `@suaveplan/rate-limiter`. All runtime failures in this package throw a `RateLimiterError`, which extends `SuaveplanError` from `@suaveplan/error`, ensuring a consistent error shape with code, context, cause-chain traversal, OTel span-event adapter, and i18n message resolution across the entire Suaveplan.
|
||||
|
||||
Using `defineCodes("RATE", [...])` pins every error identifier to a fully-qualified string such as `"RATE.STORAGE_ERROR"` that survives tree-shaking, minification, and code-split boundaries — callers can discriminate errors by `error.code` without importing the original source file.
|
||||
|
||||
## Features
|
||||
|
||||
- Four stable error codes: `STORAGE_ERROR`, `INVALID_CONFIG`, `KEY_MISSING`, `STATE_CORRUPT`
|
||||
- Full TypeScript narrowing via the `RateLimiterErrorCode` union type
|
||||
- `instanceof RateLimiterError` works across ESM / CJS boundaries
|
||||
- Cause-chain support via `options.cause`
|
||||
- Structured context attachment via `options.context`
|
||||
- Injectable clock for deterministic `timestamp` in tests
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RateLimiterError, RATE_CODES } from "@suaveplan/rate-limiter/errors";
|
||||
|
||||
throw new RateLimiterError(
|
||||
RATE_CODES.STORAGE_ERROR,
|
||||
"rate.storage.failed",
|
||||
{ context: { key: "user:123" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { RateLimiterError, RATE_CODES } from "@suaveplan/rate-limiter/errors";
|
||||
import { walkCauses } from "@suaveplan/error";
|
||||
|
||||
try {
|
||||
await storage.get("user:123");
|
||||
} catch (err) {
|
||||
throw new RateLimiterError(
|
||||
RATE_CODES.STORAGE_ERROR,
|
||||
"rate.storage.failed",
|
||||
{
|
||||
context: { key: "user:123", operation: "get" },
|
||||
...(err instanceof Error ? { cause: err } : {}),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Discriminate by code at a higher layer:
|
||||
try {
|
||||
await rateLimiter.check(ctx);
|
||||
} catch (err) {
|
||||
if (err instanceof RateLimiterError && err.code === RATE_CODES.KEY_MISSING) {
|
||||
// handle missing key
|
||||
}
|
||||
for (const cause of walkCauses(err)) {
|
||||
console.error(cause.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RATE_CODES`
|
||||
|
||||
`Record` mapping reason names to fully-qualified code strings:
|
||||
|
||||
| Key | Value |
|
||||
|---|---|
|
||||
| `STORAGE_ERROR` | `"RATE.STORAGE_ERROR"` |
|
||||
| `INVALID_CONFIG` | `"RATE.INVALID_CONFIG"` |
|
||||
| `KEY_MISSING` | `"RATE.KEY_MISSING"` |
|
||||
| `STATE_CORRUPT` | `"RATE.STATE_CORRUPT"` |
|
||||
|
||||
### `RateLimiterErrorCode`
|
||||
|
||||
Union type of all values in `RATE_CODES`.
|
||||
|
||||
### `RateLimiterError`
|
||||
|
||||
Extends `SuaveplanError<RateLimiterErrorCode>`.
|
||||
|
||||
**Constructor**: `(code: RateLimiterErrorCode, messageKey: string, options?: SuaveplanErrorOptions) => RateLimiterError`
|
||||
|
||||
- `code` — one of the `RATE_CODES` values
|
||||
- `messageKey` — i18n key resolved by the global message resolver
|
||||
- `options.context` — structured key-value context attached to the error
|
||||
- `options.cause` — underlying error preserved in the cause chain
|
||||
- `options.traceId` — OTel trace correlation id
|
||||
- `options.clock` — injectable time source (defaults to `Date.now`)
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`defineCodes` returns a frozen object, so `RATE_CODES` is immutable at runtime. The `RateLimiterError` constructor sets `this.name = "RateLimiterError"` explicitly so that serialised stack traces identify the class correctly even when the prototype chain is obscured by bundlers.
|
||||
@@ -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,64 @@
|
||||
# rate-limiter.schemas
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides Zod v4 validation schemas for all configuration objects in `@suaveplan/rate-limiter`. These schemas enable runtime validation of user-supplied configuration, safe parsing of stored state from external sources, and documentation of valid value ranges. They are tree-shakeable — import only the schemas you need.
|
||||
|
||||
Non-serialisable fields (`clock`, `bus`, `logger`, `storage`) are intentionally omitted from `RateLimiterOptionsSchema` since they cannot be represented in Zod.
|
||||
|
||||
## Features
|
||||
|
||||
- `RateLimitConfigSchema` — validates `limit`, `window`, optional `id` and `description`
|
||||
- `RateLimitConditionSchema` — validates five-operator condition objects
|
||||
- `RateLimitRuleSchema` — extends config with `priority`, `conditions`, `bypass`
|
||||
- `RateLimitContextSchema` — validates per-request context objects
|
||||
- `RateLimiterOptionsSchema` — partial options schema for serialisable fields
|
||||
- `TokenBucketOptionsSchema`, `SlidingWindowOptionsSchema`, `FixedWindowOptionsSchema` — algorithm options
|
||||
- `MemoryStorageOptionsSchema` — storage options
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { RateLimitConfigSchema } from "@suaveplan/rate-limiter/schemas";
|
||||
|
||||
const result = RateLimitConfigSchema.safeParse({ limit: 100, window: 60_000 });
|
||||
if (!result.success) {
|
||||
console.error(result.error.issues);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
RateLimiterOptionsSchema,
|
||||
RateLimitRuleSchema,
|
||||
} from "@suaveplan/rate-limiter/schemas";
|
||||
|
||||
const raw = JSON.parse(configJson);
|
||||
const opts = RateLimiterOptionsSchema.parse(raw);
|
||||
|
||||
// Validate individual rules from an API payload:
|
||||
const rules = z.array(RateLimitRuleSchema).parse(payload.rules);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
| Schema | Validates |
|
||||
|---|---|
|
||||
| `RateLimitConfigSchema` | `{ limit, window, id?, description? }` |
|
||||
| `RateLimitConditionSchema` | `{ field, operator, value }` |
|
||||
| `RateLimitRuleSchema` | `RateLimitConfig + { priority?, conditions?, bypass? }` |
|
||||
| `RateLimitContextSchema` | `{ key, tenantId?, userId?, ip?, path?, method?, metadata? }` |
|
||||
| `RateLimiterOptionsSchema` | Serialisable subset of `RateLimiterOptions` |
|
||||
| `TokenBucketOptionsSchema` | `{ capacity, refillRate, initialTokens? }` |
|
||||
| `SlidingWindowOptionsSchema` | `{ limit, windowMs }` |
|
||||
| `FixedWindowOptionsSchema` | `{ limit, windowMs }` |
|
||||
| `MemoryStorageOptionsSchema` | `{ maxSize? }` |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`z.record(z.string(), z.unknown())` is used for `metadata` and `tenantMultipliers` fields, which requires both key and value type arguments in Zod v4. All integer fields use `.int()` to prevent floating-point surprises in window calculations.
|
||||
@@ -0,0 +1,65 @@
|
||||
# storage-adapter
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Defines the `RateLimitStorage` interface — the pluggable persistence contract that decouples rate-limit state from any specific backend. All algorithm implementations and the `RateLimiterInstance` orchestrator depend only on this interface, making it possible to swap in Redis, SurrealDB, Postgres, or any key-value store without modifying core logic.
|
||||
|
||||
The interface is intentionally minimal: four async methods covering read, write, delete, and clear. The `ttl` parameter on `set` is advisory: conformant backends SHOULD honour it, but the algorithms are defensive against stale state.
|
||||
|
||||
## Features
|
||||
|
||||
- `get(key)` — returns `RateLimitState | null` (null means absent or expired)
|
||||
- `set(key, state, ttl)` — persist with advisory TTL in milliseconds
|
||||
- `delete(key)` — idempotent removal
|
||||
- `clear()` — full eviction (tests and admin reset)
|
||||
- Zero runtime code — pure interface definition
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { RateLimitStorage } from "@suaveplan/rate-limiter";
|
||||
|
||||
class RedisStorage implements RateLimitStorage {
|
||||
async get(key: string) { /* ... */ return null; }
|
||||
async set(key: string, state, ttl: number) { /* ... */ }
|
||||
async delete(key: string) { /* ... */ }
|
||||
async clear() { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { createRateLimiter } from "@suaveplan/rate-limiter";
|
||||
import type { RateLimitStorage } from "@suaveplan/rate-limiter";
|
||||
|
||||
// Provide a Redis-backed storage for distributed rate limiting:
|
||||
const storage: RateLimitStorage = new RedisStorage(redisClient);
|
||||
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 1000, window: 60_000 },
|
||||
storage,
|
||||
});
|
||||
|
||||
const result = await limiter.check({ key: "tenant:acme:user:7" });
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RateLimitStorage`
|
||||
|
||||
Interface with four methods:
|
||||
|
||||
| Method | Signature | Description |
|
||||
|---|---|---|
|
||||
| `get` | `(key: string) => Promise<RateLimitState \| null>` | Read state or null |
|
||||
| `set` | `(key: string, state: RateLimitState, ttl: number) => Promise<void>` | Write with TTL |
|
||||
| `delete` | `(key: string) => Promise<void>` | Remove key |
|
||||
| `clear` | `() => Promise<void>` | Remove all entries |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `ttl` passed to `set` is derived by the caller from the remaining window duration. Backends that do not support native TTL (e.g. in-process Maps) should store the expiry timestamp alongside the state and filter it in `get`. The built-in `MemoryStorage` follows this pattern.
|
||||
@@ -0,0 +1,78 @@
|
||||
# memory-storage
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
`MemoryStorage` is the built-in, zero-dependency `RateLimitStorage` adapter for `@suaveplan/rate-limiter`. It stores rate-limit state in a `Map` within the current process, using insertion-order iteration for LRU eviction and lazy TTL expiry checks on `get`. It is ideal for single-process servers, testing environments, and local development where distributed coordination is not required.
|
||||
|
||||
The injectable `Clock` enables fully deterministic unit tests by advancing time without sleeping.
|
||||
|
||||
## Features
|
||||
|
||||
- LRU eviction: when `maxSize` is exceeded the oldest key is removed
|
||||
- Lazy TTL enforcement: expired entries are evicted on next `get` access
|
||||
- Injectable `Clock` for deterministic time in tests
|
||||
- `size` getter for observability
|
||||
- Implements full `RateLimitStorage` interface (`get`, `set`, `delete`, `clear`)
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MemoryStorage } from "@suaveplan/rate-limiter";
|
||||
|
||||
const storage = new MemoryStorage({ maxSize: 5000 });
|
||||
await storage.set("user:1", state, 60_000);
|
||||
const loaded = await storage.get("user:1");
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MemoryStorage } from "@suaveplan/rate-limiter";
|
||||
import { createRateLimiter } from "@suaveplan/rate-limiter";
|
||||
|
||||
// Use a controlled clock for deterministic TTL tests:
|
||||
let t = 1_700_000_000_000;
|
||||
const clock = () => t;
|
||||
const storage = new MemoryStorage({ maxSize: 100, clock });
|
||||
|
||||
const limiter = createRateLimiter({
|
||||
defaultConfig: { limit: 10, window: 1000 },
|
||||
storage,
|
||||
clock,
|
||||
});
|
||||
|
||||
// Advance time past TTL to verify state expiry:
|
||||
await limiter.check({ key: "user:1" });
|
||||
t += 2000; // advance past window
|
||||
const state = await storage.get("rl:user:1");
|
||||
// state is null — TTL expired
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MemoryStorage`
|
||||
|
||||
```ts
|
||||
class MemoryStorage implements RateLimitStorage {
|
||||
constructor(options?: MemoryStorageOptions)
|
||||
get(key: string): Promise<RateLimitState | null>
|
||||
set(key: string, state: RateLimitState, ttl: number): Promise<void>
|
||||
delete(key: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
readonly size: number
|
||||
}
|
||||
```
|
||||
|
||||
### `MemoryStorageOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `maxSize` | `number` | `10000` | Maximum keys before LRU eviction |
|
||||
| `clock` | `Clock` | `Date.now` | Injectable time source |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
LRU is implemented via `Map` deletion-then-reinsertion on both `get` and `set`, exploiting the specification guarantee that `Map` iterates in insertion order. The `set` path evicts exactly one entry when the map exceeds `maxSize`, keeping the operation O(1). Expired entries are not proactively swept — they accumulate until accessed, which keeps the hot path simple but means `size` may temporarily include expired keys.
|
||||
@@ -0,0 +1,88 @@
|
||||
# rate-limiter.types
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines all shared TypeScript interfaces and type aliases used across the `@suaveplan/rate-limiter` package. Centralising types here avoids circular import cycles between implementation modules and ensures that every consumer — storage adapters, algorithm classes, middleware helpers, and Zod schemas — shares a single source of truth for type definitions.
|
||||
|
||||
The types are deliberately kept as pure interfaces with no runtime footprint. Every field that is optional uses the `readonly` modifier so accidental mutation at the call site is caught by the TypeScript compiler.
|
||||
|
||||
## Features
|
||||
|
||||
- `RateLimitResult` — the universal check result (allowed, remaining, resetIn, retryAfter)
|
||||
- `RateLimitConfig` and `RateLimitRule` — static configuration shapes for algorithms and rule routing
|
||||
- `RateLimitCondition` — field-level predicate for rule matching with five operators
|
||||
- `RateLimitContext` — per-request context used for rule evaluation and key construction
|
||||
- `RateLimiterOptions` — top-level orchestrator configuration
|
||||
- Serialisable state interfaces: `TokenBucketState`, `SlidingWindowState`, `CounterState`, `RateLimitState`
|
||||
- Event payload types: `RateLimitedPayload`, `RateLimitResetPayload`, `RateLimiterEvents`
|
||||
- `RateLimiterInstance` — the returned facade from `createRateLimiter`
|
||||
- `MiddlewareFn` and `RateLimiterMiddlewareOptions` — framework-agnostic middleware contracts
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { RateLimitResult, RateLimitContext } from "@suaveplan/rate-limiter";
|
||||
|
||||
const context: RateLimitContext = {
|
||||
key: "user:42",
|
||||
tenantId: "acme",
|
||||
ip: "1.2.3.4",
|
||||
};
|
||||
|
||||
const result: RateLimitResult = await rateLimiter.check(context);
|
||||
if (!result.allowed) {
|
||||
console.log(`Retry after ${result.retryAfter}ms`);
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type {
|
||||
RateLimitRule,
|
||||
RateLimitCondition,
|
||||
RateLimiterOptions,
|
||||
} from "@suaveplan/rate-limiter";
|
||||
|
||||
const adminBypassRule: RateLimitRule = {
|
||||
limit: 0,
|
||||
window: 60_000,
|
||||
priority: 100,
|
||||
bypass: true,
|
||||
conditions: [
|
||||
{ field: "userId", operator: "in", value: ["admin-1", "admin-2"] },
|
||||
],
|
||||
};
|
||||
|
||||
const opts: RateLimiterOptions = {
|
||||
defaultConfig: { limit: 100, window: 60_000 },
|
||||
rules: [adminBypassRule],
|
||||
enableTenantLimits: true,
|
||||
tenantMultipliers: { enterprise: 10 },
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `RateLimitResult`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `allowed` | `boolean` | Whether the request should proceed |
|
||||
| `remaining` | `number` | Remaining capacity in the current window |
|
||||
| `limit` | `number` | Effective limit applied |
|
||||
| `resetIn` | `number` | Milliseconds until window/bucket resets |
|
||||
| `retryAfter?` | `number` | Milliseconds to wait (only when `allowed === false`) |
|
||||
| `metadata?` | `Record<string, unknown>` | Optional algorithm-specific metadata |
|
||||
|
||||
### `RateLimitConditionOperator`
|
||||
|
||||
`"eq" | "neq" | "in" | "nin" | "matches"` — five operators for field-based predicate evaluation.
|
||||
|
||||
### `RateLimitAlgorithm`
|
||||
|
||||
`"token-bucket" | "sliding-window" | "fixed-window"` — the three built-in algorithms.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `RateLimiterEvents` interface is intentionally typed with string literal keys (`"rate:limit:exceeded"`) so it can be passed directly to `EventBusLike<RateLimiterEvents>`. The storage adapter interface (`RateLimitStorage`) is defined in the `storage-adapter` module to avoid a circular dependency back into this types file.
|
||||
Reference in New Issue
Block a user