From ad2fc3c7793247da556dbfc1df58410f037acd3b Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:49:52 +0100 Subject: [PATCH 01/10] feat(ratelimit): add core package --- README.md | 1 + packages/ratelimit/LICENSE | 21 + packages/ratelimit/README.md | 710 +++++++++++++++ packages/ratelimit/package.json | 64 ++ packages/ratelimit/spec/algorithms.test.ts | 214 +++++ packages/ratelimit/spec/api.test.ts | 140 +++ packages/ratelimit/spec/engine.test.ts | 56 ++ packages/ratelimit/spec/helpers.ts | 159 ++++ packages/ratelimit/spec/plugin.test.ts | 347 +++++++ packages/ratelimit/spec/setup.ts | 10 + packages/ratelimit/src/api.ts | 298 ++++++ packages/ratelimit/src/augmentation.ts | 11 + packages/ratelimit/src/configure.ts | 82 ++ packages/ratelimit/src/constants.ts | 13 + .../src/directive/use-ratelimit-directive.ts | 27 + .../ratelimit/src/directive/use-ratelimit.ts | 152 ++++ .../ratelimit/src/engine/RateLimitEngine.ts | 144 +++ .../src/engine/algorithms/fixed-window.ts | 210 +++++ .../src/engine/algorithms/leaky-bucket.ts | 124 +++ .../src/engine/algorithms/sliding-window.ts | 136 +++ .../src/engine/algorithms/token-bucket.ts | 126 +++ packages/ratelimit/src/engine/violations.ts | 95 ++ packages/ratelimit/src/errors.ts | 18 + packages/ratelimit/src/index.ts | 38 + packages/ratelimit/src/plugin.ts | 854 ++++++++++++++++++ packages/ratelimit/src/providers/fallback.ts | 6 + packages/ratelimit/src/providers/memory.ts | 5 + packages/ratelimit/src/providers/redis.ts | 6 + packages/ratelimit/src/runtime.ts | 52 ++ packages/ratelimit/src/storage/fallback.ts | 223 +++++ packages/ratelimit/src/storage/memory.ts | 252 ++++++ packages/ratelimit/src/storage/redis.ts | 202 +++++ packages/ratelimit/src/types.ts | 369 ++++++++ packages/ratelimit/src/utils/config.ts | 114 +++ packages/ratelimit/src/utils/keys.ts | 312 +++++++ packages/ratelimit/src/utils/locking.ts | 42 + packages/ratelimit/src/utils/time.ts | 55 ++ packages/ratelimit/tsconfig.json | 15 + packages/ratelimit/vitest.config.ts | 15 + 39 files changed, 5718 insertions(+) create mode 100644 packages/ratelimit/LICENSE create mode 100644 packages/ratelimit/README.md create mode 100644 packages/ratelimit/package.json create mode 100644 packages/ratelimit/spec/algorithms.test.ts create mode 100644 packages/ratelimit/spec/api.test.ts create mode 100644 packages/ratelimit/spec/engine.test.ts create mode 100644 packages/ratelimit/spec/helpers.ts create mode 100644 packages/ratelimit/spec/plugin.test.ts create mode 100644 packages/ratelimit/spec/setup.ts create mode 100644 packages/ratelimit/src/api.ts create mode 100644 packages/ratelimit/src/augmentation.ts create mode 100644 packages/ratelimit/src/configure.ts create mode 100644 packages/ratelimit/src/constants.ts create mode 100644 packages/ratelimit/src/directive/use-ratelimit-directive.ts create mode 100644 packages/ratelimit/src/directive/use-ratelimit.ts create mode 100644 packages/ratelimit/src/engine/RateLimitEngine.ts create mode 100644 packages/ratelimit/src/engine/algorithms/fixed-window.ts create mode 100644 packages/ratelimit/src/engine/algorithms/leaky-bucket.ts create mode 100644 packages/ratelimit/src/engine/algorithms/sliding-window.ts create mode 100644 packages/ratelimit/src/engine/algorithms/token-bucket.ts create mode 100644 packages/ratelimit/src/engine/violations.ts create mode 100644 packages/ratelimit/src/errors.ts create mode 100644 packages/ratelimit/src/index.ts create mode 100644 packages/ratelimit/src/plugin.ts create mode 100644 packages/ratelimit/src/providers/fallback.ts create mode 100644 packages/ratelimit/src/providers/memory.ts create mode 100644 packages/ratelimit/src/providers/redis.ts create mode 100644 packages/ratelimit/src/runtime.ts create mode 100644 packages/ratelimit/src/storage/fallback.ts create mode 100644 packages/ratelimit/src/storage/memory.ts create mode 100644 packages/ratelimit/src/storage/redis.ts create mode 100644 packages/ratelimit/src/types.ts create mode 100644 packages/ratelimit/src/utils/config.ts create mode 100644 packages/ratelimit/src/utils/keys.ts create mode 100644 packages/ratelimit/src/utils/locking.ts create mode 100644 packages/ratelimit/src/utils/time.ts create mode 100644 packages/ratelimit/tsconfig.json create mode 100644 packages/ratelimit/vitest.config.ts diff --git a/README.md b/README.md index 431fbd1c..23947044 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ If you are looking for support or want to provide suggestions, check out the [Di - [@commandkit/queue](./packages/queue) - [@commandkit/redis](./packages/redis) - [@commandkit/tasks](./packages/tasks) +- [@commandkit/ratelimit](./packages/ratelimit) ## Contributing diff --git a/packages/ratelimit/LICENSE b/packages/ratelimit/LICENSE new file mode 100644 index 00000000..b4151c1e --- /dev/null +++ b/packages/ratelimit/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Neplex + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md new file mode 100644 index 00000000..1ab46d5b --- /dev/null +++ b/packages/ratelimit/README.md @@ -0,0 +1,710 @@ +# @commandkit/ratelimit + +Advanced rate limiting for CommandKit with multiple algorithms, queueing, +role limits, multi-window policies, and temporary exemptions. + +## Installation + +```bash +npm install @commandkit/ratelimit +``` + +## Quick start + +Create the auto-loaded `ratelimit.ts`/`ratelimit.js` file and call +`configureRatelimit(...)` there so runtime settings are available before the +plugin evaluates any commands: + +```ts +// ratelimit.ts +import { configureRatelimit } from '@commandkit/ratelimit'; + +configureRatelimit({ + defaultLimiter: { + maxRequests: 5, + interval: '1m', + scope: 'user', + algorithm: 'fixed-window', + }, +}); +``` + +```ts +// commandkit.config.ts +import { defineConfig } from 'commandkit'; +import { ratelimit } from '@commandkit/ratelimit'; + +export default defineConfig({ + plugins: [ratelimit()], +}); +``` + +The runtime plugin auto-loads `ratelimit.ts`/`ratelimit.js` on startup. + +Enable rate limiting on a command: + +```ts +export const metadata = { + ratelimit: { + maxRequests: 3, + interval: '10s', + scope: 'user', + algorithm: 'sliding-window', + }, +}; +``` + +## `ratelimit()` options + +The `ratelimit()` factory returns the compiler and runtime plugins and accepts: + +- `compiler`: Options for the `"use ratelimit"` directive transformer. + +Runtime options are configured via `configureRatelimit()`. + +Example: + +```ts +ratelimit({ + compiler: { enabled: true }, +}); +``` + +## How keys and scopes work + +Scopes determine how keys are generated: + +- `user` -> `rl:user:{userId}:{commandName}` +- `guild` -> `rl:guild:{guildId}:{commandName}` +- `channel` -> `rl:channel:{channelId}:{commandName}` +- `global` -> `rl:global:{commandName}` +- `user-guild` -> `rl:user:{userId}:guild:{guildId}:{commandName}` +- `custom` -> `keyResolver(ctx, command, source)` + +If `keyPrefix` is provided, it is prepended before the `rl:` prefix: + +- `keyPrefix: 'prod:'` -> `prod:rl:user:{userId}:{commandName}` + +Multi-window limits append a suffix: + +- `:w:{windowId}` (for example `rl:user:123:ping:w:short`) + +For `custom` scope you must provide `keyResolver`: + +```ts +import type { RateLimitKeyResolver } from '@commandkit/ratelimit'; + +const keyResolver: RateLimitKeyResolver = (ctx, command, source) => { + return `custom:${ctx.commandName}:${source.user?.id ?? 'unknown'}`; +}; +``` + +If `keyResolver` returns a falsy value, the limiter is skipped for that scope. + +Exemption keys use: + +- `rl:exempt:{scope}:{id}` (plus optional `keyPrefix`). + +## Plugin options (RateLimitPluginOptions) + +- `defaultLimiter`: Default limiter settings used when a command does not specify a limiter. +- `limiters`: Named limiter presets referenced by command metadata using `limiter: 'name'`. +- `storage`: Storage driver or `{ driver }` wrapper used for rate limit state. +- `keyPrefix`: Optional prefix prepended to all keys. +- `keyResolver`: Resolver used for `custom` scope keys. +- `bypass`: Bypass rules for users, roles, guilds, or a custom check. +- `hooks`: Lifecycle hooks for allowed, limited, reset, violation, and storage error events. +- `onRateLimited`: Custom response handler that replaces the default reply. +- `queue`: Queue settings for retrying instead of rejecting. +- `roleLimits`: Role-specific limiter overrides. +- `roleLimitStrategy`: `highest`, `lowest`, or `first` to resolve matching role limits. +- `initializeDefaultStorage`: When true, initializes in-memory storage if no storage is set. +- `initializeDefaultDriver`: Alias for `initializeDefaultStorage`. + +## Limiter options (RateLimitLimiterConfig) + +- `maxRequests`: Requests allowed per interval (default 10). +- `interval`: Duration for the limit window (number in ms or string). +- `scope`: Single scope or list of scopes. +- `algorithm`: `fixed-window`, `sliding-window`, `token-bucket`, `leaky-bucket`. +- `burst`: Capacity for token/leaky bucket (defaults to `maxRequests`). +- `refillRate`: Tokens per second for token bucket (defaults to `maxRequests / intervalSeconds`). +- `leakRate`: Tokens per second for leaky bucket (defaults to `maxRequests / intervalSeconds`). +- `keyResolver`: Custom key resolver for `custom` scope. +- `keyPrefix`: Prefix override for this limiter. +- `storage`: Storage override for this limiter. +- `violations`: Escalation settings for repeated limits. +- `queue`: Queue override for this limiter. +- `windows`: Multi-window configuration. +- `roleLimits`: Role-specific overrides scoped to this limiter. +- `roleLimitStrategy`: Role limit resolution strategy scoped to this limiter. + +## Command metadata options (RateLimitCommandConfig) + +Command metadata extends limiter options and adds: + +- `limiter`: Name of a limiter defined in `limiters`. + +## Resolution order + +Limiter resolution order (later overrides earlier): + +- Built-in defaults (`DEFAULT_LIMITER`). +- `defaultLimiter`. +- Named limiter (if `metadata.ratelimit.limiter` is set). +- Command metadata overrides. +- Role limit overrides (when matched). + +## Algorithms + +### Fixed window + +- Uses a counter per interval. +- Required: `maxRequests`, `interval`. +- Storage: `consumeFixedWindow` or `incr`, otherwise falls back to `get/set`. + +### Sliding window log + +- Tracks timestamps in a sorted set. +- Required: `maxRequests`, `interval`. +- Storage: `consumeSlidingWindowLog` or `zAdd`, `zRemRangeByScore`, `zCard` and optional `zRangeByScore`. +- If sorted-set ops are missing, it throws an error. +- The non-atomic sorted-set fallback can race under concurrency; implement `consumeSlidingWindowLog` for strict enforcement. + +### Token bucket + +- Refills tokens continuously. +- Required: `burst` (capacity), `refillRate` (tokens/sec). +- Storage: `get/set`. +- `refillRate` must be greater than 0. + +### Leaky bucket + +- Leaks tokens continuously. +- Required: `burst` (capacity), `leakRate` (tokens/sec). +- Storage: `get/set`. +- `leakRate` must be greater than 0. + +## Storage drivers + +### MemoryRateLimitStorage + +- In-memory store with TTL support. +- Implements `consumeFixedWindow`, `consumeSlidingWindowLog`, sorted-set ops, + prefix/pattern deletes, and key listing. +- Not shared across processes (single-node only). + +### RedisRateLimitStorage + +- Uses Redis with Lua scripts for fixed and sliding windows. +- Stores values as JSON. +- Supports `deleteByPattern`, `deleteByPrefix`, and `keysByPrefix` via `SCAN`. +- `@commandkit/ratelimit/redis` also re-exports `RedisOptions` from `ioredis`. + +### FallbackRateLimitStorage + +- Wraps a primary and secondary storage. +- On failure, falls back to the secondary and logs at most once per cooldown window. +- Options: `cooldownMs` (default 30s). + +Disable the default memory storage: + +```ts +configureRatelimit({ + initializeDefaultStorage: false, + // or: initializeDefaultDriver: false +}); +``` + +## Storage interface and requirements + +`storage` accepts either a `RateLimitStorage` instance or `{ driver }`. + +Required methods: + +- `get`, `set`, `delete`. + +Optional methods used by features: + +- `incr` and `consumeFixedWindow` for fixed-window efficiency. +- `zAdd`, `zRemRangeByScore`, `zCard`, `zRangeByScore`, `consumeSlidingWindowLog` for sliding window. +- `ttl`, `expire` for expiry visibility. +- `deleteByPrefix`, `deleteByPattern`, `keysByPrefix` for resets and exemption listing. + +## Queue mode + +Queue mode retries commands instead of rejecting immediately: + +```ts +configureRatelimit({ + queue: { + enabled: true, + maxSize: 3, + timeout: '30s', + deferInteraction: true, + ephemeral: true, + concurrency: 1, + }, +}); +``` + +Queue options: + +- `enabled` +- `maxSize` +- `timeout` +- `deferInteraction` +- `ephemeral` +- `concurrency` + +If any queue config is provided and `enabled` is unset, it defaults to `true`. + +Queue size counts pending plus running tasks. If the queue is full, the plugin +falls back to immediate rate-limit handling. + +Queue defaults: + +- `maxSize`: 3 +- `timeout`: 30s +- `deferInteraction`: true +- `ephemeral`: true +- `concurrency`: 1 + +`deferInteraction` only applies to interactions (messages are ignored). + +`maxSize`, `timeout`, and `concurrency` are clamped to a minimum of 1. + +Queue resolution order is (later overrides earlier): + +- `queue` +- `defaultLimiter.queue` +- `named limiter queue` +- `command metadata queue` +- `role limit queue` + +## Role limits + +Role limits override the base limiter if the user has a matching role: + +```ts +configureRatelimit({ + roleLimits: { + 'ROLE_ID_1': { maxRequests: 30, interval: '1m' }, + 'ROLE_ID_2': { maxRequests: 5, interval: '1m' }, + }, + roleLimitStrategy: 'highest', +}); +``` + +If no strategy is provided, `roleLimitStrategy` defaults to `highest`. + +Role scoring is based on `maxRequests / intervalMs` (minimum across windows). + +## Multi-window limits + +Use `windows` to enforce multiple windows at the same time: + +```ts +configureRatelimit({ + defaultLimiter: { + scope: 'user', + algorithm: 'sliding-window', + windows: [ + { id: 'short', maxRequests: 10, interval: '1m' }, + { id: 'long', maxRequests: 1000, interval: '1d' }, + ], + }, +}); +``` + +If a window `id` is omitted, it auto-generates `w1`, `w2`, and so on. + +## Violations and escalation + +Escalate cooldowns after repeated rate limit violations: + +```ts +configureRatelimit({ + defaultLimiter: { + maxRequests: 1, + interval: '10s', + violations: { + maxViolations: 5, + escalationMultiplier: 2, + resetAfter: '1h', + }, + }, +}); +``` + +If an escalation cooldown extends beyond the normal reset, the plugin +uses the longer cooldown. + +Violation defaults and flags: + +- `escalate`: Defaults to true when `violations` is set. Set `false` to disable escalation. +- `maxViolations`: Default 5. +- `escalationMultiplier`: Default 2. +- `resetAfter`: Default 1h. + +## Hooks + +```ts +configureRatelimit({ + hooks: { + onAllowed: ({ key, result }) => { + console.log('allowed', key, result.remaining); + }, + onRateLimited: ({ key, result }) => { + console.log('limited', key, result.retryAfter); + }, + onViolation: (key, count) => { + console.log('violation', key, count); + }, + onReset: (key) => { + console.log('reset', key); + }, + onStorageError: (error, fallbackUsed) => { + console.error('storage error', error, fallbackUsed); + }, + }, +}); +``` + +## Analytics events + +The runtime plugin emits analytics events (if analytics is configured): + +- `ratelimit_allowed` +- `ratelimit_hit` +- `ratelimit_violation` + +## Events + +Listen to runtime rate-limit events via CommandKit events: + +```ts +commandkit.events + .to('ratelimits') + .on('ratelimited', ({ key, result, source, aggregate, commandName, queued }) => { + console.log('ratelimited', key, commandName, queued, aggregate.retryAfter); + }); +``` + +In CommandKit apps, you can register the listener via the events router by +placing a handler under `src/app/events/(ratelimits)/ratelimited/` (for example +`logger.ts`). + +## Bypass rules + +```ts +configureRatelimit({ + bypass: { + userIds: ['USER_ID'], + guildIds: ['GUILD_ID'], + roleIds: ['ROLE_ID'], + check: (source) => source.channelId === 'ALLOWLIST_CHANNEL', + }, +}); +``` + +## Custom rate-limited response + +Override the default ephemeral cooldown reply: + +```ts +import type { RateLimitStoreValue } from '@commandkit/ratelimit'; + +configureRatelimit({ + onRateLimited: async (ctx, info: RateLimitStoreValue) => { + await ctx.reply(`Cooldown: ${Math.ceil(info.retryAfter / 1000)}s`); + }, +}); +``` + +## Temporary exemptions + +```ts +import { + grantRateLimitExemption, + revokeRateLimitExemption, + listRateLimitExemptions, +} from '@commandkit/ratelimit'; + +await grantRateLimitExemption({ + scope: 'user', + id: 'USER_ID', + duration: '1h', +}); + +await revokeRateLimitExemption({ + scope: 'user', + id: 'USER_ID', +}); + +const exemptions = await listRateLimitExemptions({ + scope: 'user', + id: 'USER_ID', +}); +``` + +All exemption helpers accept an optional `keyPrefix`. + +Listing notes: + +- `listRateLimitExemptions({ scope, id })` checks a single key directly. +- `listRateLimitExemptions({ scope })` scans by prefix if supported. +- `limit` caps the number of results. +- `expiresInMs` is `null` if the storage does not support `ttl`. + +Supported exemption scopes: + +- `user` +- `guild` +- `role` +- `channel` +- `category` + +## Runtime helpers and API + +### Runtime configuration + +```ts +import { configureRatelimit } from '@commandkit/ratelimit'; + +configureRatelimit({ + defaultLimiter: { maxRequests: 5, interval: '1m' }, +}); +``` + +Use `getRateLimitConfig()` to read the active configuration and +`isRateLimitConfigured()` to guard flows that depend on runtime setup. + +### Storage helpers + +```ts +import { + setRateLimitStorage, + getRateLimitStorage, + setDriver, + getDriver, +} from '@commandkit/ratelimit'; +``` + +### Runtime access + +```ts +import { getRateLimitRuntime, setRateLimitRuntime } from '@commandkit/ratelimit'; +``` + +### Accessing results inside commands + +```ts +import { getRateLimitInfo } from '@commandkit/ratelimit'; + +export const chatInput = async (ctx) => { + const info = getRateLimitInfo(ctx); + if (info?.limited) { + console.log(info.retryAfter); + } +}; +``` + +### Result shape + +`RateLimitStoreValue` includes: + +- `limited`: Whether any limiter hit. +- `remaining`: Minimum remaining across all results. +- `resetAt`: Latest reset timestamp across all results. +- `retryAfter`: Max retry delay when limited. +- `results`: Array of `RateLimitResult` entries. + +Each `RateLimitResult` includes: + +- `key`, `scope`, `algorithm`, `windowId?`. +- `limited`, `remaining`, `resetAt`, `retryAfter`, `limit`. + +### Reset helpers + +```ts +import { resetRateLimit, resetAllRateLimits } from '@commandkit/ratelimit'; + +await resetRateLimit({ key: 'rl:user:USER_ID:ping' }); + +await resetAllRateLimits({ commandName: 'ping' }); + +await resetAllRateLimits({ scope: 'guild', guildId: 'GUILD_ID' }); +``` + +Reset parameter notes: + +- `resetRateLimit` accepts either `key` or (`scope` + `commandName` + required IDs). +- `resetAllRateLimits` accepts `pattern`, `prefix`, `commandName`, or `scope` + IDs. +- `keyPrefix` can be passed to both reset helpers. + +## Directive: `use ratelimit` + +Use the directive in async functions to rate-limit function execution: + +```ts +import { RateLimitError } from '@commandkit/ratelimit'; + +const heavy = async () => { + 'use ratelimit'; + return 'ok'; +}; + +try { + await heavy(); +} catch (error) { + if (error instanceof RateLimitError) { + console.log(error.result.retryAfter); + } +} +``` + +The compiler plugin injects `$ckitirl` at build time. The runtime +wrapper uses a per-function key and the runtime default limiter. + +The directive is applied only to async functions. + +## RateLimitEngine reset + +`RateLimitEngine.reset(key)` removes both the main key and the +`violation:{key}` entry. + +## HMR reset behavior + +When a command file is hot-reloaded, the runtime plugin clears that command's +rate-limit keys using `deleteByPattern` (including `violation:` and `:w:` variants). +If the storage does not support pattern deletes, nothing is cleared. + +## Behavior details and edge cases + +- `ratelimit()` returns `[UseRateLimitDirectivePlugin, RateLimitPlugin]` in that order. +- If required IDs are missing for a scope (for example no guild in DMs), that scope is skipped. +- `interval` is clamped to at least 1ms when resolving limiter config. +- `RateLimitResult.limit` is `burst` for token/leaky buckets and `maxRequests` for fixed/sliding windows. +- Default rate-limit response uses an embed titled `:hourglass_flowing_sand: You are on cooldown` with a relative timestamp. Interactions reply ephemerally (or follow up if already replied/deferred). Non-repliable interactions are skipped. Messages reply only if the channel is sendable. +- Queue behavior: queue size is pending + running; if `maxSize` is reached, the command is not queued and falls back to immediate rate-limit handling. Queued tasks stop after `timeout` and log a warning. After the initial delay, retries wait at least 250ms between checks. When queued, `ctx.capture()` and `onRateLimited`/`onViolation` hooks still run. +- Bypass order is user/guild/role lists, then temporary exemptions, then `bypass.check`. +- `roleLimitStrategy: 'first'` respects object insertion order. Role limits merge in this order: plugin `roleLimits` -> `defaultLimiter.roleLimits` -> named limiter `roleLimits` -> command overrides. +- `resetRateLimit` triggers `hooks.onReset` for the key; `resetAllRateLimits` does not. +- `onStorageError` is invoked with `fallbackUsed = false` from runtime plugin calls. +- `grantRateLimitExemption` uses the runtime `keyPrefix` by default unless `keyPrefix` is provided. +- `RateLimitError` defaults to message `Rate limit exceeded`. +- If no storage is configured and default storage is disabled, the plugin logs once and stores an empty `RateLimitStoreValue` without limiting. +- `FallbackRateLimitStorage` throws if either storage does not support an optional operation. +- `MemoryRateLimitStorage.deleteByPattern` supports `*` wildcards (simple glob). + +## Constants + +- `RATELIMIT_STORE_KEY`: `ratelimit` (store key for aggregated results). +- `DEFAULT_KEY_PREFIX`: `rl:` (prefix used in generated keys). + +## Type reference (exported) + +- `RateLimitScope` and `RATE_LIMIT_SCOPES`: Scope values used in keys. +- `RateLimitExemptionScope` and `RATE_LIMIT_EXEMPTION_SCOPES`: Exemption scopes. +- `RateLimitAlgorithmType` and `RATE_LIMIT_ALGORITHMS`: Algorithm identifiers. +- `DurationLike`: Number in ms or duration string. +- `RateLimitQueueOptions`: Queue settings for retries. +- `RateLimitRoleLimitStrategy`: `highest`, `lowest`, or `first`. +- `RateLimitResult`: Result for a single limiter/window. +- `RateLimitAlgorithm`: Interface for algorithm implementations. +- `FixedWindowConsumeResult` and `SlidingWindowConsumeResult`: Storage consume return types. +- `RateLimitStorage` and `RateLimitStorageConfig`: Storage interface and wrapper. +- `ViolationOptions`: Escalation controls. +- `RateLimitWindowConfig`: Per-window limiter config. +- `RateLimitKeyResolver`: Custom scope key resolver signature. +- `RateLimitLimiterConfig`: Base limiter configuration. +- `RateLimitCommandConfig`: Limiter config plus `limiter` name. +- `RateLimitBypassOptions`: Bypass lists and optional `check`. +- `RateLimitExemptionGrantParams`, `RateLimitExemptionRevokeParams`, `RateLimitExemptionListParams`: Exemption helper params. +- `RateLimitExemptionInfo`: Exemption listing entry shape. +- `RateLimitHookContext` and `RateLimitHooks`: Hook payloads and callbacks. +- `RateLimitResponseHandler`: `onRateLimited` handler signature. +- `RateLimitPluginOptions`: Runtime plugin options. +- `RateLimitStoreValue`: Aggregated results stored in `env.store`. +- `ResolvedLimiterConfig`: Resolved limiter config with defaults and `intervalMs`. +- `RateLimitRuntimeContext`: Active runtime state. + +## Exports + +- `ratelimit` plugin factory (compiler + runtime). +- `RateLimitPlugin` and `UseRateLimitDirectivePlugin`. +- `RateLimitEngine`, algorithm classes, and `ViolationTracker`. +- Storage implementations: `MemoryRateLimitStorage`, `RedisRateLimitStorage`, `FallbackRateLimitStorage`. +- Runtime helpers: `configureRatelimit`, `setRateLimitStorage`, `getRateLimitStorage`, `setDriver`, `getDriver`, `getRateLimitRuntime`, `setRateLimitRuntime`. +- API helpers: `getRateLimitInfo`, `resetRateLimit`, `resetAllRateLimits`, `grantRateLimitExemption`, `revokeRateLimitExemption`, `listRateLimitExemptions`. +- Errors: `RateLimitError`. + +## Defaults + +- `maxRequests`: 10 +- `interval`: 60s +- `algorithm`: `fixed-window` +- `scope`: `user` +- `keyPrefix`: none (but keys always include `rl:`) +- `initializeDefaultStorage`: true + +## Duration units + +String durations support `ms`, `s`, `m`, `h`, `d` via `ms`, plus: + +- `w`, `week`, `weeks` +- `mo`, `month`, `months` + +## Subpath exports + +- `@commandkit/ratelimit/redis` +- `@commandkit/ratelimit/memory` +- `@commandkit/ratelimit/fallback` + +## Source map (packages/ratelimit/src) + +- `src/index.ts`: Package entrypoint that re-exports the public API. +- `src/augmentation.ts`: Extends `CommandMetadata` with `metadata.ratelimit`. +- `src/configure.ts`: `configureRatelimit`, `getRateLimitConfig`, `isRateLimitConfigured`, and runtime updates. +- `src/runtime.ts`: Process-wide storage/runtime accessors, plus `setDriver`/`getDriver` aliases. +- `src/plugin.ts`: Runtime plugin: config resolution, queueing, hooks, analytics/events, responses, and HMR resets. +- `src/directive/use-ratelimit-directive.ts`: Compiler plugin for the `"use ratelimit"` directive. +- `src/directive/use-ratelimit.ts`: Runtime directive wrapper; uses `RateLimitEngine` and throws `RateLimitError`. +- `src/api.ts`: Public helpers for `getRateLimitInfo`, resets, and exemptions, plus param types. +- `src/types.ts`: Public config/result/storage types. +- `src/constants.ts`: `RATELIMIT_STORE_KEY` and `DEFAULT_KEY_PREFIX`. +- `src/errors.ts`: `RateLimitError` type. +- `src/engine/RateLimitEngine.ts`: Algorithm selection plus violation escalation. +- `src/engine/violations.ts`: `ViolationTracker` and escalation state. +- `src/engine/algorithms/fixed-window.ts`: Fixed-window algorithm. +- `src/engine/algorithms/sliding-window.ts`: Sliding-window log algorithm. +- `src/engine/algorithms/token-bucket.ts`: Token-bucket algorithm. +- `src/engine/algorithms/leaky-bucket.ts`: Leaky-bucket algorithm. +- `src/storage/memory.ts`: In-memory storage with TTL and sorted-set helpers. +- `src/storage/redis.ts`: Redis storage with Lua scripts for atomic windows. +- `src/storage/fallback.ts`: Fallback storage wrapper with cooldown logging. +- `src/providers/memory.ts`: Subpath export for memory storage. +- `src/providers/redis.ts`: Subpath export for Redis storage. +- `src/providers/fallback.ts`: Subpath export for fallback storage. +- `src/utils/config.ts`: Defaults, normalization, multi-window resolution, and role-limit merging. +- `src/utils/keys.ts`: Key building and parsing for scopes/exemptions. +- `src/utils/time.ts`: Duration parsing and clamp helpers. +- `src/utils/locking.ts`: Per-storage keyed mutex for fallback algorithm serialization. + +## Spec map (packages/ratelimit/spec) + +- `spec/setup.ts`: Shared test setup for vitest. +- `spec/helpers.ts`: Test helpers and stubs. +- `spec/algorithms.test.ts`: Algorithm integration tests. +- `spec/engine.test.ts`: Engine + violation behavior tests. +- `spec/api.test.ts`: API helper tests (resets, exemptions, info). +- `spec/plugin.test.ts`: Runtime plugin behavior tests. + +## Manual testing + +- Configure `maxRequests: 1` and `interval: '5s'`. +- Call the command twice and verify the cooldown response. +- Enable queue mode and confirm the second call is deferred and executes later. +- Grant an exemption and verify the user bypasses limits. +- Reset the command and verify the cooldown clears immediately. diff --git a/packages/ratelimit/package.json b/packages/ratelimit/package.json new file mode 100644 index 00000000..f1473058 --- /dev/null +++ b/packages/ratelimit/package.json @@ -0,0 +1,64 @@ +{ + "name": "@commandkit/ratelimit", + "version": "0.0.0", + "description": "CommandKit plugin that provides advanced rate limiting", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./redis": { + "import": "./dist/providers/redis.js", + "types": "./dist/providers/redis.d.ts" + }, + "./memory": { + "import": "./dist/providers/memory.js", + "types": "./dist/providers/memory.d.ts" + }, + "./fallback": { + "import": "./dist/providers/fallback.js", + "types": "./dist/providers/fallback.d.ts" + } + }, + "scripts": { + "check-types": "tsc --noEmit", + "build": "tsc", + "test": "vitest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/neplextech/commandkit.git", + "directory": "packages/ratelimit" + }, + "keywords": [ + "commandkit", + "ratelimit", + "rate limiting" + ], + "contributors": [ + "Twilight ", + "Avraj " + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/neplextech/commandkit/issues" + }, + "homepage": "https://commandkit.dev", + "dependencies": { + "ioredis": "^5.10.0", + "ms": "^2.1.3" + }, + "devDependencies": { + "@types/ms": "^2.1.0", + "commandkit": "workspace:*", + "discord.js": "catalog:discordjs", + "tsconfig": "workspace:*", + "typescript": "catalog:build", + "vitest": "^4.0.18" + } +} diff --git a/packages/ratelimit/spec/algorithms.test.ts b/packages/ratelimit/spec/algorithms.test.ts new file mode 100644 index 00000000..25fa3615 --- /dev/null +++ b/packages/ratelimit/spec/algorithms.test.ts @@ -0,0 +1,214 @@ +// Algorithm integration tests. +// +// Fake timers keep limiter math deterministic and avoid flakiness. + +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { MemoryRateLimitStorage } from '../src/storage/memory'; +import { FixedWindowAlgorithm } from '../src/engine/algorithms/fixed-window'; +import { SlidingWindowLogAlgorithm } from '../src/engine/algorithms/sliding-window'; +import { TokenBucketAlgorithm } from '../src/engine/algorithms/token-bucket'; +import { LeakyBucketAlgorithm } from '../src/engine/algorithms/leaky-bucket'; +import type { RateLimitStorage } from '../src/types'; + +const scope = 'user' as const; +const delay = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); + +class DelayedSlidingWindowStorage implements RateLimitStorage { + private readonly kv = new Map(); + private readonly zset = new MemoryRateLimitStorage(); + + async get(key: string): Promise { + return (this.kv.get(key) as T) ?? null; + } + + async set(key: string, value: T): Promise { + this.kv.set(key, value); + } + + async delete(key: string): Promise { + this.kv.delete(key); + await this.zset.delete(key); + } + + async zAdd(key: string, score: number, member: string): Promise { + await delay(); + await this.zset.zAdd!(key, score, member); + } + + async zRemRangeByScore(key: string, min: number, max: number): Promise { + await delay(); + await this.zset.zRemRangeByScore!(key, min, max); + } + + async zCard(key: string): Promise { + await delay(); + return this.zset.zCard!(key); + } + + async zRangeByScore( + key: string, + min: number, + max: number, + ): Promise { + await delay(); + return this.zset.zRangeByScore!(key, min, max); + } + + async expire(key: string, ttlMs: number): Promise { + await delay(); + await this.zset.expire!(key, ttlMs); + } +} + +class DelayedFixedWindowStorage implements RateLimitStorage { + private readonly kv = new Map(); + + async get(key: string): Promise { + await delay(); + return (this.kv.get(key) as T) ?? null; + } + + async set(key: string, value: T): Promise { + await delay(); + this.kv.set(key, value); + } + + async delete(key: string): Promise { + this.kv.delete(key); + } +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('FixedWindowAlgorithm', () => { + test('limits after max requests and resets after interval', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const storage = new MemoryRateLimitStorage(); + const algorithm = new FixedWindowAlgorithm(storage, { + maxRequests: 2, + intervalMs: 1000, + scope, + }); + + const r1 = await algorithm.consume('key'); + const r2 = await algorithm.consume('key'); + const r3 = await algorithm.consume('key'); + + expect(r1.limited).toBe(false); + expect(r2.limited).toBe(false); + expect(r3.limited).toBe(true); + expect(r3.retryAfter).toBeGreaterThan(0); + + vi.advanceTimersByTime(1000); + const r4 = await algorithm.consume('key'); + expect(r4.limited).toBe(false); + }); + + test('serializes fallback consumes per key', async () => { + const storage = new DelayedFixedWindowStorage(); + const algorithm = new FixedWindowAlgorithm(storage, { + maxRequests: 1, + intervalMs: 1000, + scope, + }); + + const results = await Promise.all([ + algorithm.consume('key'), + algorithm.consume('key'), + ]); + + const limitedCount = results.filter((result) => result.limited).length; + expect(limitedCount).toBe(1); + }); +}); + +describe('SlidingWindowLogAlgorithm', () => { + test('enforces window and allows after it passes', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const storage = new MemoryRateLimitStorage(); + const algorithm = new SlidingWindowLogAlgorithm(storage, { + maxRequests: 2, + intervalMs: 1000, + scope, + }); + + expect((await algorithm.consume('key')).limited).toBe(false); + expect((await algorithm.consume('key')).limited).toBe(false); + expect((await algorithm.consume('key')).limited).toBe(true); + + await vi.advanceTimersByTimeAsync(1000); + + expect((await algorithm.consume('key')).limited).toBe(false); + }); + + test('serializes fallback log consumes per key', async () => { + const storage = new DelayedSlidingWindowStorage(); + const algorithm = new SlidingWindowLogAlgorithm(storage, { + maxRequests: 1, + intervalMs: 1000, + scope, + }); + + const results = await Promise.all([ + algorithm.consume('key'), + algorithm.consume('key'), + ]); + + const limitedCount = results.filter((result) => result.limited).length; + expect(limitedCount).toBe(1); + }); +}); + +describe('TokenBucketAlgorithm', () => { + test('refills over time', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const storage = new MemoryRateLimitStorage(); + const algorithm = new TokenBucketAlgorithm(storage, { + capacity: 2, + refillRate: 1, + scope, + }); + + expect((await algorithm.consume('key')).limited).toBe(false); + expect((await algorithm.consume('key')).limited).toBe(false); + const limited = await algorithm.consume('key'); + expect(limited.limited).toBe(true); + expect(limited.retryAfter).toBeGreaterThan(0); + + await vi.advanceTimersByTimeAsync(1000); + + expect((await algorithm.consume('key')).limited).toBe(false); + }); +}); + +describe('LeakyBucketAlgorithm', () => { + test('drains over time', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const storage = new MemoryRateLimitStorage(); + const algorithm = new LeakyBucketAlgorithm(storage, { + capacity: 2, + leakRate: 1, + scope, + }); + + expect((await algorithm.consume('key')).limited).toBe(false); + expect((await algorithm.consume('key')).limited).toBe(false); + const limited = await algorithm.consume('key'); + expect(limited.limited).toBe(true); + expect(limited.retryAfter).toBeGreaterThan(0); + + await vi.advanceTimersByTimeAsync(1000); + + expect((await algorithm.consume('key')).limited).toBe(false); + }); +}); diff --git a/packages/ratelimit/spec/api.test.ts b/packages/ratelimit/spec/api.test.ts new file mode 100644 index 00000000..f741d73d --- /dev/null +++ b/packages/ratelimit/spec/api.test.ts @@ -0,0 +1,140 @@ +// API helper tests. +// +// Uses in-memory storage to keep exemption/reset tests isolated. + +import { afterEach, describe, expect, test } from 'vitest'; +import { MemoryRateLimitStorage } from '../src/storage/memory'; +import { + grantRateLimitExemption, + listRateLimitExemptions, + resetAllRateLimits, + resetRateLimit, + revokeRateLimitExemption, +} from '../src/api'; +import { setRateLimitRuntime, setRateLimitStorage } from '../src/runtime'; +import type { RateLimitRuntimeContext, RateLimitStorage } from '../src/types'; +import { buildExemptionKey } from '../src/utils/keys'; + +/** + * Configure runtime + storage for API helpers under test. + */ +function setRuntime(storage: RateLimitStorage) { + setRateLimitStorage(storage); + const runtime: RateLimitRuntimeContext = { + storage, + defaultLimiter: {}, + }; + setRateLimitRuntime(runtime); +} + +afterEach(() => { + setRateLimitRuntime(null); + setRateLimitStorage(null as unknown as RateLimitStorage); +}); + +describe('ratelimit API', () => { + test('grant/list/revoke exemptions', async () => { + const storage = new MemoryRateLimitStorage(); + setRuntime(storage); + + await grantRateLimitExemption({ + scope: 'user', + id: 'user-1', + duration: '1h', + }); + + const list = await listRateLimitExemptions({ scope: 'user', id: 'user-1' }); + expect(list).toHaveLength(1); + expect(list[0]?.id).toBe('user-1'); + + await revokeRateLimitExemption({ scope: 'user', id: 'user-1' }); + const after = await listRateLimitExemptions({ + scope: 'user', + id: 'user-1', + }); + expect(after).toHaveLength(0); + }); + + test('resetRateLimit removes violations and window variants', async () => { + const storage = new MemoryRateLimitStorage(); + setRuntime(storage); + + const key = 'rl:user:user-1:ping'; + await storage.set(key, { count: 1 }, 1000); + await storage.set(`violation:${key}`, { count: 1 }, 1000); + await storage.set(`${key}:w:short`, { count: 1 }, 1000); + await storage.set(`violation:${key}:w:short`, { count: 1 }, 1000); + + await resetRateLimit({ key }); + + expect(await storage.get(key)).toBeNull(); + expect(await storage.get(`violation:${key}`)).toBeNull(); + expect(await storage.get(`${key}:w:short`)).toBeNull(); + expect(await storage.get(`violation:${key}:w:short`)).toBeNull(); + }); + + test('resetAllRateLimits supports commandName pattern deletes', async () => { + const storage = new MemoryRateLimitStorage(); + setRuntime(storage); + + const keys = [ + 'rl:user:user-1:ping', + 'rl:user:user-2:ping', + 'rl:user:user-3:pong', + ]; + + for (const key of keys) { + await storage.set(key, { count: 1 }, 1000); + } + + await resetAllRateLimits({ commandName: 'ping' }); + + expect(await storage.get('rl:user:user-1:ping')).toBeNull(); + expect(await storage.get('rl:user:user-2:ping')).toBeNull(); + expect(await storage.get('rl:user:user-3:pong')).not.toBeNull(); + }); + + test('resetAllRateLimits throws when pattern deletes are unsupported', async () => { + const storage: RateLimitStorage = { + get: async () => null, + set: async () => undefined, + delete: async () => undefined, + }; + + setRuntime(storage); + + await expect(resetAllRateLimits({ commandName: 'ping' })).rejects.toThrow( + 'Storage does not support pattern deletes', + ); + }); + + test('throws when storage is missing', async () => { + setRateLimitRuntime(null); + setRateLimitStorage(null as unknown as RateLimitStorage); + + await expect( + grantRateLimitExemption({ + scope: 'user', + id: 'user-1', + duration: '1h', + }), + ).rejects.toThrow('Rate limit storage not configured'); + }); + + test('listRateLimitExemptions uses prefix listing', async () => { + const storage = new MemoryRateLimitStorage(); + setRuntime(storage); + + const keyPrefix = 'custom:'; + const userKey = buildExemptionKey('user', 'user-1', keyPrefix); + const guildKey = buildExemptionKey('guild', 'guild-1', keyPrefix); + + await storage.set(userKey, true, 1000); + await storage.set(guildKey, true, 1000); + + const list = await listRateLimitExemptions({ keyPrefix }); + expect(list.map((entry) => entry.key).sort()).toEqual( + [guildKey, userKey].sort(), + ); + }); +}); diff --git a/packages/ratelimit/spec/engine.test.ts b/packages/ratelimit/spec/engine.test.ts new file mode 100644 index 00000000..bf1a7a16 --- /dev/null +++ b/packages/ratelimit/spec/engine.test.ts @@ -0,0 +1,56 @@ +// Engine escalation tests. +// +// Fake timers keep violation cooldowns deterministic. + +import { afterEach, describe, expect, test, vi } from 'vitest'; +import { RateLimitEngine } from '../src/engine/RateLimitEngine'; +import { MemoryRateLimitStorage } from '../src/storage/memory'; +import type { ResolvedLimiterConfig } from '../src/types'; + +const scope = 'user' as const; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('RateLimitEngine violations', () => { + test('escalates cooldown when violations repeat', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const storage = new MemoryRateLimitStorage(); + const engine = new RateLimitEngine(storage); + + const config: ResolvedLimiterConfig = { + maxRequests: 1, + intervalMs: 1000, + algorithm: 'fixed-window', + scope, + burst: 1, + refillRate: 1, + leakRate: 1, + violations: { + maxViolations: 3, + escalationMultiplier: 2, + resetAfter: 60_000, + }, + }; + + const first = await engine.consume('key', config); + expect(first.result.limited).toBe(false); + + const second = await engine.consume('key', config); + expect(second.result.limited).toBe(true); + expect(second.violationCount).toBe(1); + + await vi.advanceTimersByTimeAsync(1000); + + const third = await engine.consume('key', config); + expect(third.result.limited).toBe(false); + + const fourth = await engine.consume('key', config); + expect(fourth.result.limited).toBe(true); + expect(fourth.violationCount).toBe(2); + expect(fourth.result.retryAfter).toBeGreaterThanOrEqual(2000); + }); +}); diff --git a/packages/ratelimit/spec/helpers.ts b/packages/ratelimit/spec/helpers.ts new file mode 100644 index 00000000..555fd0a8 --- /dev/null +++ b/packages/ratelimit/spec/helpers.ts @@ -0,0 +1,159 @@ +// Test helpers for ratelimit specs. +// +// Provides lightweight stubs for Discord and CommandKit so tests stay focused +// on rate limit behavior without a live client. + +import { Collection, Message } from 'discord.js'; +import { vi } from 'vitest'; +import type { Interaction } from 'discord.js'; + +export interface InteractionStubOptions { + userId?: string; + guildId?: string | null; + channelId?: string | null; + parentId?: string | null; + replied?: boolean; + deferred?: boolean; + roleIds?: string[]; +} + +/** + * Build an Interaction-like stub with only the fields the plugin reads. + * Keeps tests fast without a live Discord client. + */ +export function createInteractionStub(options: InteractionStubOptions = {}) { + const interaction = { + reply: vi.fn(async () => undefined), + followUp: vi.fn(async () => undefined), + deferReply: vi.fn(async () => undefined), + isRepliable: vi.fn(() => true), + replied: options.replied ?? false, + deferred: options.deferred ?? false, + user: { id: options.userId ?? 'user-1' }, + guildId: options.guildId ?? 'guild-1', + channelId: options.channelId ?? 'channel-1', + channel: { parentId: options.parentId ?? 'category-1' }, + member: options.roleIds ? { roles: options.roleIds } : null, + } as Interaction & { + reply: ReturnType; + followUp: ReturnType; + deferReply: ReturnType; + isRepliable: ReturnType; + replied: boolean; + deferred: boolean; + user: { id: string } | null; + guildId: string | null; + channelId: string | null; + channel: { parentId: string | null } | null; + member: { roles: string[] } | null; + }; + + return interaction; +} + +export interface MessageStubOptions { + userId?: string; + guildId?: string | null; + channelId?: string | null; + parentId?: string | null; + roleIds?: string[]; +} + +/** + * Build a Message-like stub with minimal fields used by rate limit logic. + */ +export function createMessageStub(options: MessageStubOptions = {}) { + const message = Object.create(Message.prototype) as Message & { + reply: ReturnType; + author: { id: string } | null; + guildId: string | null; + channelId: string | null; + channel: { parentId: string | null; isSendable: () => boolean } | null; + member: { roles: string[] } | null; + }; + + message.reply = vi.fn(async () => undefined); + message.author = { id: options.userId ?? 'user-1' }; + message.guildId = options.guildId ?? 'guild-1'; + message.channelId = options.channelId ?? 'channel-1'; + message.channel = { + parentId: options.parentId ?? 'category-1', + isSendable: () => true, + }; + message.member = options.roleIds ? { roles: options.roleIds } : null; + + return message; +} + +/** + * Create a minimal CommandKit env with a store for plugin results. + */ +export function createEnv(commandName = 'ping') { + return { + context: { commandName }, + store: new Collection(), + } as const; +} + +/** + * Create a runtime context with stubbed analytics and capture hooks. + */ +export function createRuntimeContext( + overrides: { + commands?: any[]; + } = {}, +) { + const analyticsTrack = vi.fn(async () => undefined); + const capture = vi.fn(); + const eventsEmit = vi.fn(); + const eventsTo = vi.fn(() => ({ emit: eventsEmit })); + + const commandkit = { + analytics: { track: analyticsTrack }, + commandHandler: { + getCommandsArray: () => overrides.commands ?? [], + }, + events: { + to: eventsTo, + }, + }; + + return { + ctx: { commandkit, capture }, + analyticsTrack, + capture, + eventsEmit, + eventsTo, + }; +} + +/** + * Build a prepared command shape for plugin tests. + */ +export function createPreparedCommand(options: { + name?: string; + metadata?: any; + path?: string; +}) { + const name = options.name ?? 'ping'; + return { + command: { + discordId: null, + command: { + id: 'cmd-1', + name, + path: options.path ?? 'C:/commands/ping.ts', + relativePath: 'ping.ts', + parentPath: 'C:/commands', + middlewares: [], + category: null, + }, + metadata: options.metadata ?? {}, + data: { + command: { name }, + metadata: options.metadata ?? {}, + }, + }, + middlewares: [], + } as const; +} diff --git a/packages/ratelimit/spec/plugin.test.ts b/packages/ratelimit/spec/plugin.test.ts new file mode 100644 index 00000000..1908144e --- /dev/null +++ b/packages/ratelimit/spec/plugin.test.ts @@ -0,0 +1,347 @@ +// Plugin integration tests. +// +// Uses stubs to keep plugin tests fast and offline. + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { MessageFlags } from 'discord.js'; +import { RateLimitPlugin } from '../src/plugin'; +import { MemoryRateLimitStorage } from '../src/storage/memory'; +import { RATELIMIT_STORE_KEY } from '../src/constants'; +import { setRateLimitRuntime, setRateLimitStorage } from '../src/runtime'; +import { configureRatelimit } from '../src/configure'; +import { + createEnv, + createInteractionStub, + createPreparedCommand, + createRuntimeContext, +} from './helpers'; +import type { RateLimitStorage } from '../src/types'; + +afterEach(() => { + setRateLimitRuntime(null); + setRateLimitStorage(null as unknown as RateLimitStorage); + vi.useRealTimers(); +}); + +describe('RateLimitPlugin', () => { + beforeEach(() => { + configureRatelimit({}); + }); + + test('allows first request and stores result', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { maxRequests: 2, interval: 1000 }, + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const env = createEnv('ping'); + const interaction = createInteractionStub(); + const prepared = createPreparedCommand({ + name: 'ping', + metadata: { ratelimit: true }, + }); + const execute = vi.fn(async () => undefined); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + execute, + ); + + const stored = env.store.get(RATELIMIT_STORE_KEY); + expect(stored?.limited).toBe(false); + expect(interaction.reply).not.toHaveBeenCalled(); + expect(execute).not.toHaveBeenCalled(); + expect(runtime.capture).not.toHaveBeenCalled(); + }); + + test('replies when limit is exceeded', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { maxRequests: 1, interval: 1000 }, + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const env = createEnv('ping'); + const interaction = createInteractionStub(); + const prepared = createPreparedCommand({ + name: 'ping', + metadata: { ratelimit: true }, + }); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + const stored = env.store.get(RATELIMIT_STORE_KEY); + expect(stored?.limited).toBe(true); + expect(stored?.retryAfter).toBeGreaterThan(0); + expect(interaction.reply).toHaveBeenCalledTimes(1); + + const [payload] = interaction.reply.mock.calls[0]; + expect(payload.flags).toBe(MessageFlags.Ephemeral); + expect(runtime.capture).toHaveBeenCalled(); + }); + + test('emits ratelimited event when blocked', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { maxRequests: 1, interval: 1000 }, + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const env = createEnv('ping'); + const interaction = createInteractionStub(); + const prepared = createPreparedCommand({ + name: 'ping', + metadata: { ratelimit: true }, + }); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + expect(runtime.eventsTo).toHaveBeenCalledWith('ratelimits'); + expect(runtime.eventsEmit).toHaveBeenCalledTimes(1); + const [eventName, payload] = runtime.eventsEmit.mock.calls[0]; + expect(eventName).toBe('ratelimited'); + expect(payload.commandName).toBe('ping'); + expect(payload.queued).toBe(false); + expect(payload.aggregate.limited).toBe(true); + }); + + test('uses followUp when interaction already replied', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { maxRequests: 1, interval: 1000 }, + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const env = createEnv('ping'); + const interaction = createInteractionStub({ replied: true }); + const prepared = createPreparedCommand({ + name: 'ping', + metadata: { ratelimit: true }, + }); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + expect(interaction.followUp).toHaveBeenCalledTimes(1); + }); + + test('queues execution when enabled', async () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { maxRequests: 1, interval: 1000 }, + queue: { enabled: true, timeout: '5s' }, + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const env = createEnv('ping'); + const interaction = createInteractionStub(); + const prepared = createPreparedCommand({ + name: 'ping', + metadata: { ratelimit: true }, + }); + const execute = vi.fn(async () => undefined); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + execute, + ); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + execute, + ); + + expect(interaction.deferReply).toHaveBeenCalledTimes(1); + expect(execute).not.toHaveBeenCalled(); + expect(runtime.capture).toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1100); + + expect(execute).toHaveBeenCalledTimes(1); + }); + + test('applies role-specific limits', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { maxRequests: 2, interval: 1000 }, + roleLimits: { + 'role-1': { maxRequests: 1, interval: 1000 }, + }, + roleLimitStrategy: 'highest', + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const env = createEnv('ping'); + const interaction = createInteractionStub({ roleIds: ['role-1'] }); + const prepared = createPreparedCommand({ + name: 'ping', + metadata: { ratelimit: true }, + }); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + const stored = env.store.get(RATELIMIT_STORE_KEY); + expect(stored?.limited).toBe(true); + }); + + test('stores multi-window results', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { + algorithm: 'fixed-window', + scope: 'user', + windows: [ + { id: 'short', maxRequests: 2, interval: '1s' }, + { id: 'long', maxRequests: 5, interval: '1m' }, + ], + }, + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const env = createEnv('ping'); + const interaction = createInteractionStub(); + const prepared = createPreparedCommand({ + name: 'ping', + metadata: { ratelimit: true }, + }); + + await plugin.executeCommand( + runtime.ctx as any, + env as any, + interaction as any, + prepared as any, + vi.fn(async () => undefined), + ); + + const stored = env.store.get(RATELIMIT_STORE_KEY); + expect(stored?.results).toHaveLength(2); + expect(stored?.results?.map((r: any) => r.windowId)).toEqual([ + 'short', + 'long', + ]); + expect(stored?.remaining).toBe(1); + }); + + test('performHMR resets matching command keys', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ storage }); + + const commandPath = 'C:/commands/ping.ts'; + const prepared = createPreparedCommand({ + name: 'ping', + path: commandPath, + metadata: { ratelimit: true }, + }); + + const runtime = createRuntimeContext({ commands: [prepared.command] }); + await plugin.activate(runtime.ctx as any); + + const key = 'rl:user:user-1:ping'; + await storage.set(key, { count: 1 }, 1000); + await storage.set(`violation:${key}`, { count: 1 }, 1000); + await storage.set(`${key}:w:short`, { count: 1 }, 1000); + + const event = { + path: commandPath, + accept: vi.fn(), + preventDefault: vi.fn(), + }; + + await plugin.performHMR(runtime.ctx as any, event as any); + + expect(await storage.get(key)).toBeNull(); + expect(await storage.get(`violation:${key}`)).toBeNull(); + expect(await storage.get(`${key}:w:short`)).toBeNull(); + expect(event.accept).toHaveBeenCalled(); + expect(event.preventDefault).toHaveBeenCalled(); + }); +}); diff --git a/packages/ratelimit/spec/setup.ts b/packages/ratelimit/spec/setup.ts new file mode 100644 index 00000000..bbd97e64 --- /dev/null +++ b/packages/ratelimit/spec/setup.ts @@ -0,0 +1,10 @@ +// Vitest setup for ratelimit specs. +// +// Restores the Console constructor so logging helpers behave consistently. + +import { Console } from 'node:console'; + +const consoleAny = console as Console & { Console?: typeof Console }; +if (typeof consoleAny.Console !== 'function') { + consoleAny.Console = Console; +} diff --git a/packages/ratelimit/src/api.ts b/packages/ratelimit/src/api.ts new file mode 100644 index 00000000..53c9e925 --- /dev/null +++ b/packages/ratelimit/src/api.ts @@ -0,0 +1,298 @@ +// Public rate limit helpers. +// +// Used by handlers and admin tools to inspect, reset, and manage exemptions. + +import type { CommandKitEnvironment, Context } from 'commandkit'; +import { RATELIMIT_STORE_KEY } from './constants'; +import { getRateLimitRuntime, getRateLimitStorage } from './runtime'; +import type { + RateLimitExemptionGrantParams, + RateLimitExemptionInfo, + RateLimitExemptionListParams, + RateLimitExemptionRevokeParams, + RateLimitScope, + RateLimitStorage, + RateLimitStoreValue, +} from './types'; +import { + buildExemptionKey, + buildExemptionPrefix, + buildScopePrefix, + parseExemptionKey, +} from './utils/keys'; +import { resolveDuration } from './utils/time'; + +/** + * Parameters for resetting a single key or scope-derived key. + */ +export interface ResetRateLimitParams { + key?: string; + scope?: RateLimitScope; + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; + keyPrefix?: string; +} + +/** + * Parameters for batch resets by scope, prefix, or pattern. + */ +export interface ResetAllRateLimitsParams { + scope?: RateLimitScope; + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; + keyPrefix?: string; + pattern?: string; + prefix?: string; +} + +/** + * Read aggregated rate limit info stored on a CommandKit env or context. + */ +export function getRateLimitInfo( + envOrCtx: CommandKitEnvironment | Context | null | undefined, +): RateLimitStoreValue | null { + if (!envOrCtx) return null; + const store = 'store' in envOrCtx ? envOrCtx.store : null; + if (!store) return null; + return (store.get(RATELIMIT_STORE_KEY) as RateLimitStoreValue) ?? null; +} + +function getRequiredStorage(): RateLimitStorage { + return getRuntimeStorage().storage; +} + +function getRuntimeStorage(): { + runtime: ReturnType; + storage: RateLimitStorage; +} { + const runtime = getRateLimitRuntime(); + const storage = runtime?.storage ?? getRateLimitStorage(); + if (!storage) { + throw new Error('Rate limit storage not configured'); + } + return { runtime, storage }; +} + +function toWindowPrefix(prefix: string): string { + return prefix.endsWith(':') ? `${prefix}w:` : `${prefix}:w:`; +} + +/** + * Reset a single key and its violation/window variants to keep state consistent. + */ +export async function resetRateLimit( + params: ResetRateLimitParams, +): Promise { + const storage = getRequiredStorage(); + const hooks = getRateLimitRuntime()?.hooks; + + if (params.key) { + await storage.delete(params.key); + await storage.delete(`violation:${params.key}`); + await deleteWindowVariants(storage, params.key); + if (hooks?.onReset) { + await hooks.onReset(params.key); + } + return; + } + + if (!params.scope || !params.commandName) { + throw new Error( + 'scope and commandName are required when key is not provided', + ); + } + + const prefix = buildScopePrefix(params.scope, params.keyPrefix, { + userId: params.userId, + guildId: params.guildId, + channelId: params.channelId, + }); + + if (!prefix) { + throw new Error('Missing identifiers for scope'); + } + + const key = `${prefix}${params.commandName}`; + await storage.delete(key); + await storage.delete(`violation:${key}`); + await deleteWindowVariants(storage, key); + if (hooks?.onReset) { + await hooks.onReset(key); + } +} + +/** + * Reset multiple keys by scope, command name, prefix, or pattern for bulk cleanup. + */ +export async function resetAllRateLimits( + params: ResetAllRateLimitsParams = {}, +): Promise { + const storage = getRequiredStorage(); + + if (params.pattern) { + if (!storage.deleteByPattern) { + throw new Error('Storage does not support pattern deletes'); + } + await storage.deleteByPattern(params.pattern); + await storage.deleteByPattern(`violation:${params.pattern}`); + await storage.deleteByPattern(`${params.pattern}:w:*`); + await storage.deleteByPattern(`violation:${params.pattern}:w:*`); + return; + } + + if (params.prefix) { + if (!storage.deleteByPrefix) { + throw new Error('Storage does not support prefix deletes'); + } + const windowPrefix = toWindowPrefix(params.prefix); + await storage.deleteByPrefix(params.prefix); + await storage.deleteByPrefix(`violation:${params.prefix}`); + await storage.deleteByPrefix(windowPrefix); + await storage.deleteByPrefix(`violation:${windowPrefix}`); + return; + } + + if (params.commandName) { + if (!storage.deleteByPattern) { + throw new Error('Storage does not support pattern deletes'); + } + const prefix = params.keyPrefix ?? ''; + const pattern = `${prefix}*:${params.commandName}`; + await storage.deleteByPattern(pattern); + await storage.deleteByPattern(`violation:${pattern}`); + await storage.deleteByPattern(`${pattern}:w:*`); + await storage.deleteByPattern(`violation:${pattern}:w:*`); + return; + } + + if (!params.scope) { + throw new Error('scope is required when commandName is not provided'); + } + + const scopePrefix = buildScopePrefix(params.scope, params.keyPrefix, { + userId: params.userId, + guildId: params.guildId, + channelId: params.channelId, + }); + + if (!scopePrefix) { + throw new Error('Missing identifiers for scope'); + } + + if (!storage.deleteByPrefix) { + throw new Error('Storage does not support prefix deletes'); + } + + const windowPrefix = toWindowPrefix(scopePrefix); + await storage.deleteByPrefix(scopePrefix); + await storage.deleteByPrefix(`violation:${scopePrefix}`); + await storage.deleteByPrefix(windowPrefix); + await storage.deleteByPrefix(`violation:${windowPrefix}`); +} + +/** + * Grant a temporary exemption for a scope/id pair. + */ +export async function grantRateLimitExemption( + params: RateLimitExemptionGrantParams, +): Promise { + const { runtime, storage } = getRuntimeStorage(); + const keyPrefix = params.keyPrefix ?? runtime?.keyPrefix; + const ttlMs = resolveDuration(params.duration, 0); + + if (!ttlMs || ttlMs <= 0) { + throw new Error('duration must be a positive value'); + } + + const key = buildExemptionKey(params.scope, params.id, keyPrefix); + await storage.set(key, true, ttlMs); +} + +/** + * Revoke a temporary exemption for a scope/id pair. + */ +export async function revokeRateLimitExemption( + params: RateLimitExemptionRevokeParams, +): Promise { + const { runtime, storage } = getRuntimeStorage(); + const keyPrefix = params.keyPrefix ?? runtime?.keyPrefix; + const key = buildExemptionKey(params.scope, params.id, keyPrefix); + await storage.delete(key); +} + +/** + * List exemptions by scope and/or id for admin/reporting. + */ +export async function listRateLimitExemptions( + params: RateLimitExemptionListParams = {}, +): Promise { + const { runtime, storage } = getRuntimeStorage(); + const keyPrefix = params.keyPrefix ?? runtime?.keyPrefix; + + if (params.id && !params.scope) { + throw new Error('scope is required when id is provided'); + } + + if (params.scope && params.id) { + const key = buildExemptionKey(params.scope, params.id, keyPrefix); + const exists = await storage.get(key); + if (!exists) return []; + const expiresInMs = storage.ttl ? await storage.ttl(key) : null; + return [ + { + key, + scope: params.scope, + id: params.id, + expiresInMs, + }, + ]; + } + + if (!storage.keysByPrefix) { + throw new Error('Storage does not support listing exemptions'); + } + + const prefix = buildExemptionPrefix(keyPrefix, params.scope); + const keys = await storage.keysByPrefix(prefix); + const results: RateLimitExemptionInfo[] = []; + + for (const key of keys) { + const parsed = parseExemptionKey(key, keyPrefix); + if (!parsed) continue; + if (params.scope && parsed.scope !== params.scope) continue; + + const expiresInMs = storage.ttl ? await storage.ttl(key) : null; + results.push({ + key, + scope: parsed.scope, + id: parsed.id, + expiresInMs, + }); + + if (params.limit && results.length >= params.limit) { + break; + } + } + + return results; +} + +async function deleteWindowVariants( + storage: RateLimitStorage, + key: string, +): Promise { + const prefix = `${key}:w:`; + if (storage.deleteByPrefix) { + await storage.deleteByPrefix(prefix); + await storage.deleteByPrefix(`violation:${prefix}`); + return; + } + if (storage.deleteByPattern) { + await storage.deleteByPattern(`${key}:w:*`); + await storage.deleteByPattern(`violation:${key}:w:*`); + } +} diff --git a/packages/ratelimit/src/augmentation.ts b/packages/ratelimit/src/augmentation.ts new file mode 100644 index 00000000..a056c6c5 --- /dev/null +++ b/packages/ratelimit/src/augmentation.ts @@ -0,0 +1,11 @@ +// CommandKit metadata augmentation. +// +// Extends CommandKit metadata so commands can declare per-command limits. + +import type { RateLimitCommandConfig } from './types'; + +declare module 'commandkit' { + interface CommandMetadata { + ratelimit?: RateLimitCommandConfig | boolean; + } +} diff --git a/packages/ratelimit/src/configure.ts b/packages/ratelimit/src/configure.ts new file mode 100644 index 00000000..b5f1ae8a --- /dev/null +++ b/packages/ratelimit/src/configure.ts @@ -0,0 +1,82 @@ +// Runtime configuration for the rate limit plugin. +// +// Mirrors configureAI so runtime options can be set outside commandkit.config +// before the plugin evaluates commands. + +import { DEFAULT_LIMITER } from './utils/config'; +import { + getRateLimitRuntime, + setRateLimitRuntime, + setRateLimitStorage, +} from './runtime'; +import type { + RateLimitPluginOptions, + RateLimitRuntimeContext, + RateLimitStorage, + RateLimitStorageConfig, +} from './types'; + +const rateLimitConfig: RateLimitPluginOptions = {}; +let configured = false; + +function resolveStorage( + config: RateLimitStorageConfig, +): RateLimitStorage | null { + if (!config) return null; + if (typeof config === 'object' && 'driver' in config) { + return config.driver; + } + return config; +} + +function updateRuntime(config: RateLimitPluginOptions): void { + const runtime = getRateLimitRuntime(); + const storageOverride = config.storage + ? resolveStorage(config.storage) + : null; + + if (storageOverride) { + setRateLimitStorage(storageOverride); + } + + if (!runtime) { + return; + } + + const nextRuntime: RateLimitRuntimeContext = { + storage: storageOverride ?? runtime.storage, + keyPrefix: config.keyPrefix ?? runtime.keyPrefix, + defaultLimiter: + config.defaultLimiter ?? runtime.defaultLimiter ?? DEFAULT_LIMITER, + limiters: config.limiters ?? runtime.limiters, + hooks: config.hooks ?? runtime.hooks, + }; + + setRateLimitRuntime(nextRuntime); +} + +/** + * Returns true once configureRatelimit has been called. + */ +export function isRateLimitConfigured(): boolean { + return configured; +} + +/** + * Retrieves the current rate limit configuration. + */ +export function getRateLimitConfig(): RateLimitPluginOptions { + return rateLimitConfig; +} + +/** + * Configures the rate limit plugin runtime options. + * Call this once during startup (for example in src/ratelimit.ts). + */ +export function configureRatelimit( + config: RateLimitPluginOptions = {}, +): void { + configured = true; + Object.assign(rateLimitConfig, config); + updateRuntime(config); +} diff --git a/packages/ratelimit/src/constants.ts b/packages/ratelimit/src/constants.ts new file mode 100644 index 00000000..0ca2993c --- /dev/null +++ b/packages/ratelimit/src/constants.ts @@ -0,0 +1,13 @@ +// Rate limit constants shared across runtime and tests. +// +// Keeps key names consistent across storage, runtime, and docs. + +/** + * Store key used to stash aggregated results in CommandKit envs. + */ +export const RATELIMIT_STORE_KEY = 'ratelimit'; + +/** + * Default prefix for storage keys; can be overridden per config. + */ +export const DEFAULT_KEY_PREFIX = 'rl:'; diff --git a/packages/ratelimit/src/directive/use-ratelimit-directive.ts b/packages/ratelimit/src/directive/use-ratelimit-directive.ts new file mode 100644 index 00000000..911ad9b6 --- /dev/null +++ b/packages/ratelimit/src/directive/use-ratelimit-directive.ts @@ -0,0 +1,27 @@ +import { + CommonDirectiveTransformer, + type CommonDirectiveTransformerOptions, + type CompilerPluginRuntime, +} from 'commandkit'; + +/** + * Compiler plugin for the "use ratelimit" directive. + */ +export class UseRateLimitDirectivePlugin extends CommonDirectiveTransformer { + public readonly name = 'UseRateLimitDirectivePlugin'; + + public constructor(options?: Partial) { + super({ + enabled: true, + ...options, + directive: 'use ratelimit', + importPath: '@commandkit/ratelimit', + importName: '$ckitirl', + asyncOnly: true, + }); + } + + public async activate(ctx: CompilerPluginRuntime): Promise { + await super.activate(ctx); + } +} diff --git a/packages/ratelimit/src/directive/use-ratelimit.ts b/packages/ratelimit/src/directive/use-ratelimit.ts new file mode 100644 index 00000000..d28fa62f --- /dev/null +++ b/packages/ratelimit/src/directive/use-ratelimit.ts @@ -0,0 +1,152 @@ +// Runtime wrapper for the "use ratelimit" directive. +// +// Uses the runtime default limiter for arbitrary async functions. +// Throws RateLimitError when the call is limited. + +import { randomUUID } from 'node:crypto'; +import type { AsyncFunction, GenericFunction } from 'commandkit'; +import { RateLimitEngine } from '../engine/RateLimitEngine'; +import { RateLimitError } from '../errors'; +import type { + RateLimitLimiterConfig, + RateLimitResult, + RateLimitStorage, + RateLimitStoreValue, +} from '../types'; +import { + DEFAULT_LIMITER, + mergeLimiterConfigs, + resolveLimiterConfigs, +} from '../utils/config'; +import { getRateLimitRuntime } from '../runtime'; +import { DEFAULT_KEY_PREFIX } from '../constants'; + +const RATELIMIT_FN_SYMBOL = Symbol('commandkit.ratelimit.directive'); + +let cachedEngine: RateLimitEngine | null = null; +let cachedStorage: RateLimitStorage | null = null; + +function getEngine(storage: RateLimitStorage): RateLimitEngine { + // Cache per storage instance so violation tracking stays consistent. + if (!cachedEngine || cachedStorage !== storage) { + cachedEngine = new RateLimitEngine(storage); + cachedStorage = storage; + } + return cachedEngine; +} + +function withPrefix(prefix: string | undefined, key: string): string { + if (!prefix) return key; + return `${prefix}${key}`; +} + +function withWindowSuffix(key: string, windowId?: string): string { + if (!windowId) return key; + return `${key}:w:${windowId}`; +} + +function resolveLimiter( + runtimeDefault: RateLimitLimiterConfig, + limiter?: RateLimitLimiterConfig, +): RateLimitLimiterConfig { + if (!limiter) return runtimeDefault; + return mergeLimiterConfigs(runtimeDefault, limiter); +} + +/** + * Wrap an async function with the runtime default limiter. + * Throws RateLimitError when the call exceeds limits. + */ +function useRateLimit>(fn: F): F { + if (Object.prototype.hasOwnProperty.call(fn, RATELIMIT_FN_SYMBOL)) { + return fn; + } + + const fnId = randomUUID(); + + const wrapped = (async (...args: R) => { + const runtime = getRateLimitRuntime(); + if (!runtime) { + throw new Error( + 'RateLimit runtime is not initialized. Register the RateLimitPlugin first.', + ); + } + + const limiterConfig = resolveLimiter( + mergeLimiterConfigs(DEFAULT_LIMITER, runtime.defaultLimiter), + ); + + const key = `${DEFAULT_KEY_PREFIX}fn:${fnId}`; + const finalKey = withPrefix(runtime.keyPrefix, key); + + const engine = getEngine(runtime.storage); + const resolvedConfigs = resolveLimiterConfigs(limiterConfig, 'custom'); + const results: RateLimitResult[] = []; + for (const resolved of resolvedConfigs) { + const resolvedKey = withWindowSuffix(finalKey, resolved.windowId); + const { result } = await engine.consume(resolvedKey, resolved); + results.push(result); + } + + const aggregate = aggregateResults(results); + if (aggregate.limited) { + throw new RateLimitError(aggregate); + } + + return fn(...args); + }) as F; + + Object.defineProperty(wrapped, RATELIMIT_FN_SYMBOL, { + value: true, + configurable: false, + enumerable: false, + writable: false, + }); + + return wrapped; +} + +function aggregateResults(results: RateLimitResult[]): RateLimitStoreValue { + if (!results.length) { + return { + limited: false, + remaining: 0, + resetAt: 0, + retryAfter: 0, + results: [], + }; + } + + const limitedResults = results.filter((r) => r.limited); + const limited = limitedResults.length > 0; + const remaining = Math.min(...results.map((r) => r.remaining)); + const resetAt = Math.max(...results.map((r) => r.resetAt)); + const retryAfter = limited + ? Math.max(...limitedResults.map((r) => r.retryAfter)) + : 0; + + return { + limited, + remaining, + resetAt, + retryAfter, + results, + }; +} + +/** + * Wrapper symbol injected by the compiler plugin. + */ +export const $ckitirl: GenericFunction = (fn: GenericFunction) => { + return useRateLimit(fn as AsyncFunction); +}; + +if (!('$ckitirl' in globalThis)) { + // Expose the wrapper globally so directive transforms can call it. + Object.defineProperty(globalThis, '$ckitirl', { + value: $ckitirl, + configurable: false, + enumerable: false, + writable: false, + }); +} diff --git a/packages/ratelimit/src/engine/RateLimitEngine.ts b/packages/ratelimit/src/engine/RateLimitEngine.ts new file mode 100644 index 00000000..ae391221 --- /dev/null +++ b/packages/ratelimit/src/engine/RateLimitEngine.ts @@ -0,0 +1,144 @@ +// Engine coordinator. +// +// Selects algorithms and applies violation escalation before returning results. + +import type { + RateLimitAlgorithm, + RateLimitAlgorithmType, + RateLimitResult, + RateLimitStorage, + ResolvedLimiterConfig, +} from '../types'; +import { FixedWindowAlgorithm } from './algorithms/fixed-window'; +import { SlidingWindowLogAlgorithm } from './algorithms/sliding-window'; +import { TokenBucketAlgorithm } from './algorithms/token-bucket'; +import { LeakyBucketAlgorithm } from './algorithms/leaky-bucket'; +import { ViolationTracker } from './violations'; + +/** + * Consume output including optional violation count for callers. + */ +export interface RateLimitConsumeOutput { + result: RateLimitResult; + violationCount?: number; +} + +/** + * Coordinates algorithm selection and violation escalation per storage. + */ +export class RateLimitEngine { + private readonly violations: ViolationTracker; + + public constructor(private readonly storage: RateLimitStorage) { + this.violations = new ViolationTracker(storage); + } + + /** + * Create an algorithm instance for a resolved config. + */ + private createAlgorithm(config: ResolvedLimiterConfig): RateLimitAlgorithm { + switch (config.algorithm) { + case 'fixed-window': + return new FixedWindowAlgorithm(this.storage, { + maxRequests: config.maxRequests, + intervalMs: config.intervalMs, + scope: config.scope, + }); + case 'sliding-window': + return new SlidingWindowLogAlgorithm(this.storage, { + maxRequests: config.maxRequests, + intervalMs: config.intervalMs, + scope: config.scope, + }); + case 'token-bucket': + return new TokenBucketAlgorithm(this.storage, { + capacity: config.burst, + refillRate: config.refillRate, + scope: config.scope, + }); + case 'leaky-bucket': + return new LeakyBucketAlgorithm(this.storage, { + capacity: config.burst, + leakRate: config.leakRate, + scope: config.scope, + }); + default: + // Fall back to fixed-window so unknown algorithms still enforce a limit. + return new FixedWindowAlgorithm(this.storage, { + maxRequests: config.maxRequests, + intervalMs: config.intervalMs, + scope: config.scope, + }); + } + } + + /** + * Consume a single key and apply escalation rules when enabled. + */ + public async consume( + key: string, + config: ResolvedLimiterConfig, + ): Promise { + const now = Date.now(); + const shouldEscalate = + config.violations != null && config.violations.escalate !== false; + if (shouldEscalate) { + const active = await this.violations.checkCooldown(key); + if (active) { + // When an escalation cooldown is active, skip the algorithm to enforce the cooldown. + const limit = + config.algorithm === 'token-bucket' || + config.algorithm === 'leaky-bucket' + ? config.burst + : config.maxRequests; + const result = { + key, + scope: config.scope, + algorithm: config.algorithm, + limited: true, + remaining: 0, + resetAt: active.cooldownUntil, + retryAfter: Math.max(0, active.cooldownUntil - now), + limit, + windowId: config.windowId, + }; + return { + result, + violationCount: active.count, + }; + } + } + + const algorithm = this.createAlgorithm(config); + const result = await algorithm.consume(key); + if (config.windowId) { + result.windowId = config.windowId; + } + + if (result.limited && shouldEscalate) { + const state = await this.violations.recordViolation( + key, + result.retryAfter, + config.violations, + ); + + // If escalation extends the cooldown, update the result so retry info stays accurate. + if (state.cooldownUntil > result.resetAt) { + result.resetAt = state.cooldownUntil; + result.retryAfter = Math.max(0, state.cooldownUntil - now); + } + + return { result, violationCount: state.count }; + } + + return { result }; + } + + /** + * Reset a key and its associated violation state. + */ + public async reset(key: string): Promise { + await this.storage.delete(key); + await this.violations.reset(key); + } +} diff --git a/packages/ratelimit/src/engine/algorithms/fixed-window.ts b/packages/ratelimit/src/engine/algorithms/fixed-window.ts new file mode 100644 index 00000000..bd752faf --- /dev/null +++ b/packages/ratelimit/src/engine/algorithms/fixed-window.ts @@ -0,0 +1,210 @@ +// Fixed window rate limiting. +// +// Simple counters per window are fast and predictable, at the cost of allowing +// bursts within the window boundary. Prefer atomic storage for correctness. + +import type { + RateLimitAlgorithm, + RateLimitAlgorithmType, + RateLimitResult, + RateLimitStorage, +} from '../../types'; +import { withStorageKeyLock } from '../../utils/locking'; + +interface FixedWindowConfig { + maxRequests: number; + intervalMs: number; + scope: RateLimitResult['scope']; +} + +interface FixedWindowState { + count: number; + resetAt: number; + version?: number; +} + +/** + * Basic fixed-window counter for low-cost rate limits. + */ +export class FixedWindowAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'fixed-window'; + + public constructor( + private readonly storage: RateLimitStorage, + private readonly config: FixedWindowConfig, + ) {} + + /** + * Record one attempt and return the current window status for this key. + */ + public async consume(key: string): Promise { + const limit = this.config.maxRequests; + const interval = this.config.intervalMs; + + if (this.storage.consumeFixedWindow) { + const now = Date.now(); + const { count, ttlMs } = await this.storage.consumeFixedWindow( + key, + limit, + interval, + now, + ); + const resetAt = now + ttlMs; + const limited = count > limit; + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited, + remaining: Math.max(0, limit - count), + resetAt, + retryAfter: limited ? Math.max(0, resetAt - now) : 0, + limit, + }; + } + + if (this.storage.incr) { + const now = Date.now(); + const { count, ttlMs } = await this.storage.incr(key, interval); + const resetAt = now + ttlMs; + const limited = count > limit; + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited, + remaining: Math.max(0, limit - count), + resetAt, + retryAfter: limited ? Math.max(0, resetAt - now) : 0, + limit, + }; + } + + // Fallback is serialized per process to avoid same-instance races. + // Multi-process strictness still requires atomic storage operations. + return withStorageKeyLock(this.storage, key, async () => { + const maxRetries = 5; + for (let attempt = 0; attempt < maxRetries; attempt++) { + const attemptNow = Date.now(); + const existingRaw = await this.storage.get(key); + const existing = isFixedWindowState(existingRaw) ? existingRaw : null; + + if (!existing || existing.resetAt <= attemptNow) { + const resetAt = attemptNow + interval; + const state: FixedWindowState = { count: 1, resetAt, version: 1 }; + const currentRaw = await this.storage.get(key); + const current = isFixedWindowState(currentRaw) ? currentRaw : null; + if (current && current.resetAt > attemptNow) { + continue; + } + await this.storage.set(key, state, interval); + const verifyRaw = await this.storage.get(key); + const verify = isFixedWindowState(verifyRaw) ? verifyRaw : null; + if ((verify?.version ?? 0) !== 1) { + continue; + } + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: false, + remaining: Math.max(0, limit - 1), + resetAt, + retryAfter: 0, + limit, + }; + } + + if (existing.count >= limit) { + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: true, + remaining: 0, + resetAt: existing.resetAt, + retryAfter: Math.max(0, existing.resetAt - attemptNow), + limit, + }; + } + + let nextState: FixedWindowState = { + count: existing.count + 1, + resetAt: existing.resetAt, + version: (existing.version ?? 0) + 1, + }; + + const currentRaw = await this.storage.get(key); + const current = isFixedWindowState(currentRaw) ? currentRaw : null; + if ( + !current || + current.resetAt !== existing.resetAt || + current.count !== existing.count || + (current.version ?? 0) !== (existing.version ?? 0) + ) { + continue; + } + + let ttlMs = existing.resetAt - attemptNow; + if (ttlMs <= 0) { + nextState = { + count: 1, + resetAt: attemptNow + interval, + version: 1, + }; + ttlMs = interval; + } + + await this.storage.set(key, nextState, ttlMs); + const verifyRaw = await this.storage.get(key); + const verify = isFixedWindowState(verifyRaw) ? verifyRaw : null; + if ((verify?.version ?? 0) !== (nextState.version ?? 0)) { + continue; + } + + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: false, + remaining: Math.max(0, limit - nextState.count), + resetAt: nextState.resetAt, + retryAfter: 0, + limit, + }; + } + + const now = Date.now(); + const resetAt = now + interval; + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: true, + remaining: 0, + resetAt, + retryAfter: Math.max(0, resetAt - now), + limit, + }; + }); + } + + public async reset(key: string): Promise { + await this.storage.delete(key); + } +} + +function isFixedWindowState(value: unknown): value is FixedWindowState { + if (!value || typeof value !== 'object') return false; + const state = value as FixedWindowState; + const hasValidVersion = + state.version === undefined || + (typeof state.version === 'number' && Number.isFinite(state.version)); + return ( + typeof state.count === 'number' && + Number.isFinite(state.count) && + typeof state.resetAt === 'number' && + Number.isFinite(state.resetAt) && + hasValidVersion + ); +} diff --git a/packages/ratelimit/src/engine/algorithms/leaky-bucket.ts b/packages/ratelimit/src/engine/algorithms/leaky-bucket.ts new file mode 100644 index 00000000..1455755d --- /dev/null +++ b/packages/ratelimit/src/engine/algorithms/leaky-bucket.ts @@ -0,0 +1,124 @@ +// Leaky bucket rate limiting. +// +// Drains at a steady rate to smooth spikes in traffic. +// The stored level keeps limits consistent across commands. + +import type { + RateLimitAlgorithm, + RateLimitAlgorithmType, + RateLimitResult, + RateLimitStorage, +} from '../../types'; + +interface LeakyBucketConfig { + /** Maximum fill level before limiting. */ + capacity: number; + /** Tokens drained per second during leak. */ + leakRate: number; + /** Scope reported in rate-limit results. */ + scope: RateLimitResult['scope']; +} + +interface LeakyBucketState { + level: number; + lastLeak: number; +} + +/** + * Leaky bucket algorithm for smoothing output to a steady rate. + */ +export class LeakyBucketAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'leaky-bucket'; + + public constructor( + private readonly storage: RateLimitStorage, + private readonly config: LeakyBucketConfig, + ) {} + + /** + * Record one attempt and return the current bucket status for this key. + */ + public async consume(key: string): Promise { + const now = Date.now(); + const { capacity, leakRate } = this.config; + + if (leakRate <= 0) { + throw new Error('leakRate must be greater than 0'); + } + + const stored = await this.storage.get(key); + const state = isLeakyBucketState(stored) + ? stored + : ({ level: 0, lastLeak: now } satisfies LeakyBucketState); + + const elapsedSeconds = Math.max(0, (now - state.lastLeak) / 1000); + const leaked = Math.max(0, state.level - elapsedSeconds * leakRate); + + const nextState: LeakyBucketState = { + level: leaked, + lastLeak: now, + }; + + if (leaked + 1 > capacity) { + const overflow = leaked + 1 - capacity; + const retryAfter = Math.ceil((overflow / leakRate) * 1000); + const resetAt = now + retryAfter; + await this.storage.set( + key, + nextState, + estimateLeakyTtl(capacity, leakRate), + ); + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: true, + remaining: 0, + resetAt, + retryAfter, + limit: capacity, + }; + } + + nextState.level = leaked + 1; + await this.storage.set( + key, + nextState, + estimateLeakyTtl(capacity, leakRate), + ); + + const remaining = Math.floor(Math.max(0, capacity - nextState.level)); + const resetAt = now + Math.ceil((nextState.level / leakRate) * 1000); + + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: false, + remaining, + resetAt, + retryAfter: 0, + limit: capacity, + }; + } + + public async reset(key: string): Promise { + await this.storage.delete(key); + } +} + +function isLeakyBucketState(value: unknown): value is LeakyBucketState { + if (!value || typeof value !== 'object') return false; + const state = value as LeakyBucketState; + return ( + typeof state.level === 'number' && + Number.isFinite(state.level) && + typeof state.lastLeak === 'number' && + Number.isFinite(state.lastLeak) + ); +} + +function estimateLeakyTtl(capacity: number, leakRate: number): number { + if (leakRate <= 0) return 60_000; + return Math.ceil((capacity / leakRate) * 1000 * 2); +} diff --git a/packages/ratelimit/src/engine/algorithms/sliding-window.ts b/packages/ratelimit/src/engine/algorithms/sliding-window.ts new file mode 100644 index 00000000..cebe37ba --- /dev/null +++ b/packages/ratelimit/src/engine/algorithms/sliding-window.ts @@ -0,0 +1,136 @@ +// Sliding window log rate limiting. +// +// Tracks individual request timestamps for smoother limits and accurate retry +// timing. Requires sorted-set support or an atomic storage helper. + +import type { + RateLimitAlgorithm, + RateLimitAlgorithmType, + RateLimitResult, + RateLimitStorage, +} from '../../types'; +import { withStorageKeyLock } from '../../utils/locking'; + +interface SlidingWindowConfig { + maxRequests: number; + intervalMs: number; + scope: RateLimitResult['scope']; +} + +/** + * Sliding-window log algorithm for smoother limits. + */ +export class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'sliding-window'; + + public constructor( + private readonly storage: RateLimitStorage, + private readonly config: SlidingWindowConfig, + ) {} + + /** + * Record one attempt and return the current window status for this key. + */ + public async consume(key: string): Promise { + const limit = this.config.maxRequests; + const windowMs = this.config.intervalMs; + + if (this.storage.consumeSlidingWindowLog) { + const now = Date.now(); + // Include the timestamp so reset time can be derived without extra reads. + const member = `${now}-${Math.random().toString(36).slice(2, 8)}`; + const res = await this.storage.consumeSlidingWindowLog( + key, + limit, + windowMs, + now, + member, + ); + const limited = !res.allowed; + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited, + remaining: Math.max(0, limit - res.count), + resetAt: res.resetAt, + retryAfter: limited ? Math.max(0, res.resetAt - now) : 0, + limit, + }; + } + + if ( + !this.storage.zRemRangeByScore || + !this.storage.zCard || + !this.storage.zAdd + ) { + throw new Error('Sliding window requires sorted set support in storage'); + } + + return withStorageKeyLock(this.storage, key, async () => { + const now = Date.now(); + // Include the timestamp so reset time can be derived without extra reads. + const member = `${now}-${Math.random().toString(36).slice(2, 8)}`; + // Fallback is serialized per process; multi-process strictness needs atomic storage. + await this.storage.zRemRangeByScore!(key, 0, now - windowMs); + const count = await this.storage.zCard!(key); + + if (count >= limit) { + const oldestMembers = this.storage.zRangeByScore + ? await this.storage.zRangeByScore( + key, + Number.NEGATIVE_INFINITY, + Number.POSITIVE_INFINITY, + ) + : []; + const oldestMember = oldestMembers[0]; + const oldestTs = oldestMember + ? Number(oldestMember.split('-')[0]) + : now; + const resetAt = oldestTs + windowMs; + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: true, + remaining: 0, + resetAt, + retryAfter: Math.max(0, resetAt - now), + limit, + }; + } + + await this.storage.zAdd!(key, now, member); + if (this.storage.expire) { + await this.storage.expire(key, windowMs); + } + + const newCount = count + 1; + const oldestMembers = this.storage.zRangeByScore + ? await this.storage.zRangeByScore( + key, + Number.NEGATIVE_INFINITY, + Number.POSITIVE_INFINITY, + ) + : []; + const oldestMember = oldestMembers[0]; + const oldestTs = oldestMember ? Number(oldestMember.split('-')[0]) : now; + const resetAt = oldestTs + windowMs; + + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: false, + remaining: Math.max(0, limit - newCount), + resetAt, + retryAfter: 0, + limit, + }; + }); + } + + public async reset(key: string): Promise { + await this.storage.delete(key); + } +} diff --git a/packages/ratelimit/src/engine/algorithms/token-bucket.ts b/packages/ratelimit/src/engine/algorithms/token-bucket.ts new file mode 100644 index 00000000..b65594fe --- /dev/null +++ b/packages/ratelimit/src/engine/algorithms/token-bucket.ts @@ -0,0 +1,126 @@ +// Token bucket rate limiting. +// +// Allows short bursts while refilling steadily up to a cap. +// Bucket state is stored so limits stay consistent across commands. + +import type { + RateLimitAlgorithm, + RateLimitAlgorithmType, + RateLimitResult, + RateLimitStorage, +} from '../../types'; + +export interface TokenBucketConfig { + /** Maximum tokens available when the bucket is full. */ + capacity: number; + /** Tokens added per second during refill. */ + refillRate: number; + /** Scope reported in rate-limit results. */ + scope: RateLimitResult['scope']; +} + +interface TokenBucketState { + tokens: number; + lastRefill: number; +} + +/** + * Token bucket algorithm for bursty traffic with steady refill. + */ +export class TokenBucketAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'token-bucket'; + + public constructor( + private readonly storage: RateLimitStorage, + private readonly config: TokenBucketConfig, + ) {} + + /** + * Record one attempt and return the current bucket status for this key. + */ + public async consume(key: string): Promise { + const now = Date.now(); + const { capacity, refillRate } = this.config; + + if (refillRate <= 0) { + throw new Error('refillRate must be greater than 0'); + } + + const stored = await this.storage.get(key); + const state = isTokenBucketState(stored) + ? stored + : ({ tokens: capacity, lastRefill: now } satisfies TokenBucketState); + + const elapsedSeconds = Math.max(0, (now - state.lastRefill) / 1000); + const refilled = Math.min( + capacity, + state.tokens + elapsedSeconds * refillRate, + ); + const nextState: TokenBucketState = { + tokens: refilled, + lastRefill: now, + }; + + if (refilled < 1) { + const retryAfter = Math.ceil(((1 - refilled) / refillRate) * 1000); + const resetAt = now + retryAfter; + await this.storage.set( + key, + nextState, + estimateBucketTtl(capacity, refillRate), + ); + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: true, + remaining: 0, + resetAt, + retryAfter, + limit: capacity, + }; + } + + nextState.tokens = refilled - 1; + await this.storage.set( + key, + nextState, + estimateBucketTtl(capacity, refillRate), + ); + + const remaining = Math.floor(nextState.tokens); + const resetAt = + now + Math.ceil(((capacity - nextState.tokens) / refillRate) * 1000); + + return { + key, + scope: this.config.scope, + algorithm: this.type, + limited: false, + remaining, + resetAt, + retryAfter: 0, + limit: capacity, + }; + } + + public async reset(key: string): Promise { + await this.storage.delete(key); + } +} + +function isTokenBucketState(value: unknown): value is TokenBucketState { + if (!value || typeof value !== 'object') return false; + const state = value as TokenBucketState; + return ( + typeof state.tokens === 'number' && + Number.isFinite(state.tokens) && + typeof state.lastRefill === 'number' && + Number.isFinite(state.lastRefill) + ); +} + +function estimateBucketTtl(capacity: number, refillRate: number): number { + if (refillRate <= 0) return 60_000; + return Math.ceil((capacity / refillRate) * 1000 * 2); +} diff --git a/packages/ratelimit/src/engine/violations.ts b/packages/ratelimit/src/engine/violations.ts new file mode 100644 index 00000000..73986477 --- /dev/null +++ b/packages/ratelimit/src/engine/violations.ts @@ -0,0 +1,95 @@ +// Violation tracking. +// +// Persists repeat violations so cooldowns can escalate predictably. + +import type { RateLimitStorage, ViolationOptions } from '../types'; +import { resolveDuration } from '../utils/time'; + +interface ViolationState { + count: number; + cooldownUntil: number; + lastViolationAt: number; +} + +const DEFAULT_MAX_VIOLATIONS = 5; +const DEFAULT_ESCALATION_MULTIPLIER = 2; +const DEFAULT_RESET_AFTER_MS = 60 * 60 * 1000; + +/** + * Tracks repeated violations and computes escalating cooldowns. + */ +export class ViolationTracker { + public constructor(private readonly storage: RateLimitStorage) {} + + private key(key: string): string { + return `violation:${key}`; + } + + /** + * Read stored violation state for a key, if present. + */ + async getState(key: string): Promise { + const stored = await this.storage.get(this.key(key)); + return isViolationState(stored) ? stored : null; + } + + /** + * Check if a cooldown is currently active for this key. + */ + async checkCooldown(key: string): Promise { + const state = await this.getState(key); + if (!state) return null; + if (state.cooldownUntil > Date.now()) return state; + return null; + } + + /** + * Record a violation and return the updated state for callers. + */ + async recordViolation( + key: string, + baseRetryAfterMs: number, + options?: ViolationOptions, + ): Promise { + const now = Date.now(); + const prev = await this.getState(key); + const maxViolations = options?.maxViolations ?? DEFAULT_MAX_VIOLATIONS; + const multiplier = + options?.escalationMultiplier ?? DEFAULT_ESCALATION_MULTIPLIER; + const resetAfter = resolveDuration( + options?.resetAfter, + DEFAULT_RESET_AFTER_MS, + ); + + const count = Math.min((prev?.count ?? 0) + 1, maxViolations); + const base = Math.max(0, baseRetryAfterMs); + const cooldownMs = base * Math.pow(multiplier, Math.max(0, count - 1)); + const cooldownUntil = now + cooldownMs; + + const state: ViolationState = { + count, + cooldownUntil, + lastViolationAt: now, + }; + + await this.storage.set(this.key(key), state, resetAfter); + return state; + } + + async reset(key: string): Promise { + await this.storage.delete(this.key(key)); + } +} + +function isViolationState(value: unknown): value is ViolationState { + if (!value || typeof value !== 'object') return false; + const state = value as ViolationState; + return ( + typeof state.count === 'number' && + Number.isFinite(state.count) && + typeof state.cooldownUntil === 'number' && + Number.isFinite(state.cooldownUntil) && + typeof state.lastViolationAt === 'number' && + Number.isFinite(state.lastViolationAt) + ); +} diff --git a/packages/ratelimit/src/errors.ts b/packages/ratelimit/src/errors.ts new file mode 100644 index 00000000..eb66157f --- /dev/null +++ b/packages/ratelimit/src/errors.ts @@ -0,0 +1,18 @@ +// Rate limit error type. +// +// Lets callers distinguish rate-limit failures from other errors. + +import type { RateLimitStoreValue } from './types'; + +/** + * Error thrown by the directive wrapper when a function is rate-limited. + */ +export class RateLimitError extends Error { + public readonly result: RateLimitStoreValue; + + public constructor(result: RateLimitStoreValue, message?: string) { + super(message ?? 'Rate limit exceeded'); + this.name = 'RateLimitError'; + this.result = result; + } +} diff --git a/packages/ratelimit/src/index.ts b/packages/ratelimit/src/index.ts new file mode 100644 index 00000000..2b43f50f --- /dev/null +++ b/packages/ratelimit/src/index.ts @@ -0,0 +1,38 @@ +import './augmentation'; +import { RateLimitPlugin } from './plugin'; +import { UseRateLimitDirectivePlugin } from './directive/use-ratelimit-directive'; +import type { CommandKitPlugin } from 'commandkit'; +import { getRateLimitConfig } from './configure'; + +/** + * Create compiler + runtime plugins for rate limiting. + * Runtime options are provided via configureRatelimit(). + */ +export function ratelimit( + options?: Partial<{ + compiler: import('commandkit').CommonDirectiveTransformerOptions; + }>, +): CommandKitPlugin[] { + const compiler = new UseRateLimitDirectivePlugin(options?.compiler); + const runtime = new RateLimitPlugin(getRateLimitConfig()); + return [compiler, runtime]; +} + +export * from './types'; +export * from './constants'; +export * from './runtime'; +export * from './configure'; +export * from './errors'; +export * from './api'; +export * from './plugin'; +export * from './directive/use-ratelimit'; +export * from './directive/use-ratelimit-directive'; +export * from './engine/RateLimitEngine'; +export * from './engine/algorithms/fixed-window'; +export * from './engine/algorithms/sliding-window'; +export * from './engine/algorithms/token-bucket'; +export * from './engine/algorithms/leaky-bucket'; +export * from './engine/violations'; +export * from './storage/memory'; +export * from './storage/redis'; +export * from './storage/fallback'; diff --git a/packages/ratelimit/src/plugin.ts b/packages/ratelimit/src/plugin.ts new file mode 100644 index 00000000..6c3999a9 --- /dev/null +++ b/packages/ratelimit/src/plugin.ts @@ -0,0 +1,854 @@ +import { Logger, RuntimePlugin, defer } from 'commandkit'; +import type { + CommandKitEnvironment, + CommandKitPluginRuntime, + CommandKitHMREvent, + PreparedAppCommandExecution, +} from 'commandkit'; +import { createAsyncQueue, type AsyncQueue } from 'commandkit/async-queue'; +import { EmbedBuilder, MessageFlags } from 'discord.js'; +import type { Interaction, Message } from 'discord.js'; +import { RateLimitEngine } from './engine/RateLimitEngine'; +import type { + RateLimitCommandConfig, + RateLimitLimiterConfig, + RateLimitPluginOptions, + RateLimitResult, + RateLimitScope, + RateLimitStorage, + RateLimitStorageConfig, + RateLimitQueueOptions, + RateLimitRoleLimitStrategy, + RateLimitStoreValue, +} from './types'; +import { + DEFAULT_LIMITER, + mergeLimiterConfigs, + resolveLimiterConfigs, +} from './utils/config'; +import { + getRoleIds, + resolveExemptionKeys, + resolveScopeKeys, +} from './utils/keys'; +import type { ResolvedScopeKey } from './utils/keys'; +import { RATELIMIT_STORE_KEY } from './constants'; +import { MemoryRateLimitStorage } from './storage/memory'; +import { + getRateLimitStorage, + setRateLimitRuntime, + setRateLimitStorage, +} from './runtime'; +import { isRateLimitConfigured } from './configure'; +import { clampAtLeast, resolveDuration } from './utils/time'; + +const ANALYTICS_EVENTS = { + HIT: 'ratelimit_hit', + ALLOWED: 'ratelimit_allowed', + RESET: 'ratelimit_reset', + VIOLATION: 'ratelimit_violation', +} as const; + +type RateLimitEventPayload = { + key: string; + result: RateLimitResult; + source: Interaction | Message; + aggregate: RateLimitStoreValue; + commandName: string; + queued: boolean; +}; + +/** + * Runtime plugin that enforces rate limits for CommandKit commands so handlers stay lean. + */ +export class RateLimitPlugin extends RuntimePlugin { + public readonly name = 'RateLimitPlugin'; + private readonly engines = new WeakMap(); + private readonly memoryStorage = new MemoryRateLimitStorage(); + private readonly queues = new Map(); + private hasLoggedMissingStorage = false; + + public constructor(options: RateLimitPluginOptions) { + super(options); + this.preload.add('ratelimit.js'); + } + + /** + * Initialize runtime storage and defaults for this plugin instance. + */ + public async activate(ctx: CommandKitPluginRuntime): Promise { + if (!isRateLimitConfigured()) { + throw new Error( + 'RateLimit is not configured. Call configureRatelimit() during startup (for example in src/ratelimit.ts).', + ); + } + + const runtimeStorage = this.resolveDefaultStorage(); + + if (!runtimeStorage) { + this.logMissingStorage(); + setRateLimitRuntime(null); + return; + } + + if (!getRateLimitStorage()) { + setRateLimitStorage(runtimeStorage); + } + + setRateLimitRuntime({ + storage: runtimeStorage, + keyPrefix: this.options.keyPrefix, + defaultLimiter: this.options.defaultLimiter ?? DEFAULT_LIMITER, + limiters: this.options.limiters, + hooks: this.options.hooks, + }); + } + + /** + * Dispose queues and clear shared runtime state. + */ + public async deactivate(): Promise { + for (const queue of this.queues.values()) { + queue.abort(); + } + this.queues.clear(); + setRateLimitRuntime(null); + } + + /** + * Evaluate rate limits and optionally queue execution to avoid dropping commands. + */ + public async executeCommand( + ctx: CommandKitPluginRuntime, + env: CommandKitEnvironment, + source: Interaction | Message, + prepared: PreparedAppCommandExecution, + execute: () => Promise, + ): Promise { + const metadata = prepared.command.metadata as { + ratelimit?: RateLimitCommandConfig | boolean; + }; + + const rateLimitSetting = metadata?.ratelimit; + if (rateLimitSetting == null || rateLimitSetting === false) { + return false; + } + + if (!env.context) { + return false; + } + + if (await this.shouldBypass(source)) { + return false; + } + + const commandConfig = + typeof rateLimitSetting === 'object' ? rateLimitSetting : {}; + + const { limiter: limiterName, ...commandOverrides } = commandConfig; + const namedLimiter = limiterName + ? this.options.limiters?.[limiterName] + : undefined; + + const mergedLimiter = mergeLimiterConfigs( + DEFAULT_LIMITER, + this.options.defaultLimiter, + namedLimiter, + commandOverrides, + ); + + const roleLimits = mergeRoleLimits( + this.options.roleLimits, + this.options.defaultLimiter?.roleLimits, + namedLimiter?.roleLimits, + commandOverrides.roleLimits, + ); + const roleStrategy = + commandOverrides.roleLimitStrategy ?? + namedLimiter?.roleLimitStrategy ?? + this.options.defaultLimiter?.roleLimitStrategy ?? + this.options.roleLimitStrategy; + const roleOverride = resolveRoleLimit(roleLimits, roleStrategy, source); + + const effectiveLimiter = roleOverride + ? mergeLimiterConfigs(mergedLimiter, roleOverride) + : mergedLimiter; + + const queueConfig = resolveQueueOptions( + this.options.queue, + this.options.defaultLimiter?.queue, + namedLimiter?.queue, + commandOverrides.queue, + roleOverride?.queue, + ); + + const scopes = normalizeScopes(effectiveLimiter.scope); + const keyResolver = + effectiveLimiter.keyResolver ?? this.options.keyResolver; + const keyPrefix = effectiveLimiter.keyPrefix ?? this.options.keyPrefix; + const storage = + this.resolveStorage(effectiveLimiter.storage) ?? + this.resolveDefaultStorage(); + + if (!storage) { + this.logMissingStorage(); + env.store.set(RATELIMIT_STORE_KEY, createEmptyStoreValue()); + return false; + } + + const engine = this.getEngine(storage); + + const resolvedKeys = resolveScopeKeys({ + ctx: env.context, + source, + command: prepared.command, + scopes, + keyPrefix, + keyResolver, + }); + + if (!resolvedKeys.length) { + env.store.set(RATELIMIT_STORE_KEY, createEmptyStoreValue()); + return false; + } + + const results: RateLimitResult[] = []; + let violationCount: number | undefined; + + for (const resolved of resolvedKeys) { + const resolvedConfigs = resolveLimiterConfigs( + effectiveLimiter, + resolved.scope, + ); + + for (const resolvedConfig of resolvedConfigs) { + const resolvedKey = withWindowSuffix( + resolved.key, + resolvedConfig.windowId, + ); + + let output: Awaited>; + try { + output = await engine.consume(resolvedKey, resolvedConfig); + } catch (error) { + if (this.options.hooks?.onStorageError) { + await this.options.hooks.onStorageError(error, false); + } + Logger.error`[ratelimit] Storage error during consume: ${error}`; + env.store.set(RATELIMIT_STORE_KEY, createEmptyStoreValue()); + return false; + } + + const { result, violationCount: count } = output; + results.push(result); + if (typeof count === 'number') { + violationCount = + violationCount == null ? count : Math.max(violationCount, count); + } + + if (result.limited) { + defer(() => + ctx.commandkit.analytics.track({ + name: ANALYTICS_EVENTS.HIT, + id: prepared.command.command.name, + data: { + key: result.key, + scope: result.scope, + algorithm: result.algorithm, + resetAt: result.resetAt, + remaining: result.remaining, + }, + }), + ); + + if (violationCount != null) { + defer(() => + ctx.commandkit.analytics.track({ + name: ANALYTICS_EVENTS.VIOLATION, + id: prepared.command.command.name, + data: { + key: result.key, + count: violationCount, + }, + }), + ); + } + } else { + defer(() => + ctx.commandkit.analytics.track({ + name: ANALYTICS_EVENTS.ALLOWED, + id: prepared.command.command.name, + data: { + key: result.key, + scope: result.scope, + algorithm: result.algorithm, + remaining: result.remaining, + }, + }), + ); + } + } + } + + // Aggregate across all scopes/windows so callers see a single response. + const aggregate = aggregateResults(results); + env.store.set(RATELIMIT_STORE_KEY, aggregate); + + if (aggregate.limited) { + const firstLimited = results.find((r) => r.limited) ?? results[0]; + if (!firstLimited) { + return false; + } + + if ( + queueConfig.enabled && + (await this.enqueueExecution({ + queueKey: selectQueueKey(results), + queue: queueConfig, + initialDelayMs: aggregate.retryAfter, + source, + execute, + engine, + resolvedKeys, + limiter: effectiveLimiter, + })) + ) { + Logger.info( + `[ratelimit] Queued command /${prepared.command.command.name} for retry in ${Math.ceil(aggregate.retryAfter / 1000)}s`, + ); + ctx.capture(); + if (this.options.hooks?.onRateLimited) { + await this.options.hooks.onRateLimited({ + key: firstLimited.key, + result: firstLimited, + source, + }); + } + + if (violationCount != null && this.options.hooks?.onViolation) { + await this.options.hooks.onViolation( + firstLimited.key, + violationCount, + ); + } + + this.emitRateLimited(ctx, { + key: firstLimited.key, + result: firstLimited, + source, + aggregate, + commandName: prepared.command.command.name, + queued: true, + }); + + return false; + } + + Logger.warn( + `[ratelimit] User hit rate limit on /${prepared.command.command.name} - retry in ${Math.ceil(aggregate.retryAfter / 1000)}s`, + ); + + await this.respondRateLimited(env, source, aggregate); + + if (this.options.hooks?.onRateLimited) { + await this.options.hooks.onRateLimited({ + key: firstLimited.key, + result: firstLimited, + source, + }); + } + + if (violationCount != null && this.options.hooks?.onViolation) { + await this.options.hooks.onViolation(firstLimited.key, violationCount); + } + + ctx.capture(); + + this.emitRateLimited(ctx, { + key: firstLimited.key, + result: firstLimited, + source, + aggregate, + commandName: prepared.command.command.name, + queued: false, + }); + } else if (this.options.hooks?.onAllowed) { + const first = results[0]; + if (first) { + await this.options.hooks.onAllowed({ + key: first.key, + result: first, + source, + }); + } + } + + return false; + } + + /** + * Clear matching keys when a command is hot-reloaded to avoid stale state. + */ + public async performHMR( + ctx: CommandKitPluginRuntime, + event: CommandKitHMREvent, + ): Promise { + if (!event.path) return; + + const normalized = normalizePath(event.path); + const commands = ctx.commandkit.commandHandler.getCommandsArray(); + const matched = commands.filter((cmd) => + cmd.command.path ? normalizePath(cmd.command.path) === normalized : false, + ); + + if (!matched.length) return; + + const storage = this.resolveDefaultStorage(); + + if (!storage) { + this.logMissingStorage(); + return; + } + + for (const cmd of matched) { + await resetByCommand(storage, this.options.keyPrefix, cmd.command.name); + } + + event.accept(); + event.preventDefault(); + } + + private getEngine(storage: RateLimitStorage): RateLimitEngine { + const existing = this.engines.get(storage); + if (existing) return existing; + const engine = new RateLimitEngine(storage); + this.engines.set(storage, engine); + return engine; + } + + private resolveStorage( + config?: RateLimitStorageConfig, + ): RateLimitStorage | null { + if (!config) return null; + if ('driver' in config) return config.driver; + return config; + } + + private resolveDefaultStorage(): RateLimitStorage | null { + const resolved = + this.resolveStorage(this.options.storage) ?? getRateLimitStorage(); + + if (resolved) return resolved; + if ( + this.options.initializeDefaultStorage === false || + this.options.initializeDefaultDriver === false + ) { + return null; + } + return this.memoryStorage; + } + + private logMissingStorage(): void { + if (this.hasLoggedMissingStorage) return; + this.hasLoggedMissingStorage = true; + Logger.error( + '[ratelimit] No storage configured. Set storage via configureRatelimit({ storage }), setRateLimitStorage(), or enable initializeDefaultStorage.', + ); + } + + private emitRateLimited( + ctx: CommandKitPluginRuntime, + payload: RateLimitEventPayload, + ): void { + ctx.commandkit.events?.to('ratelimits').emit('ratelimited', payload); + } + + private async shouldBypass(source: Interaction | Message): Promise { + const bypass = this.options.bypass; + if (bypass) { + // Check permanent allowlists first to avoid storage lookups. + const userId = + source instanceof Message ? source.author.id : source.user?.id; + if (userId && bypass.userIds?.includes(userId)) return true; + + const guildId = source.guildId ?? null; + if (guildId && bypass.guildIds?.includes(guildId)) return true; + + const roleIds = getRoleIds(source); + if (roleIds.length && bypass.roleIds?.length) { + if (roleIds.some((roleId) => bypass.roleIds!.includes(roleId))) + return true; + } + } + + // Check temporary exemptions stored in the rate limit storage next. + if (await this.hasTemporaryBypass(source)) { + return true; + } + + // Run custom predicate last so it can override previous checks. + if (bypass?.check) { + return Boolean(await bypass.check(source)); + } + + return false; + } + + private async hasTemporaryBypass( + source: Interaction | Message, + ): Promise { + const storage = this.resolveDefaultStorage(); + if (!storage) return false; + + const keys = resolveExemptionKeys(source, this.options.keyPrefix); + if (!keys.length) return false; + + try { + for (const key of keys) { + if (await storage.get(key)) return true; + } + } catch (error) { + if (this.options.hooks?.onStorageError) { + await this.options.hooks.onStorageError(error, false); + } + Logger.error`[ratelimit] Storage error during exemption check: ${error}`; + } + + return false; + } + + private async respondRateLimited( + env: CommandKitEnvironment, + source: Interaction | Message, + info: RateLimitStoreValue, + ) { + const ctx = env.context; + if (this.options.onRateLimited && ctx) { + await this.options.onRateLimited(ctx, info); + return; + } + + const retrySeconds = Math.ceil(info.retryAfter / 1000); + const embed = new EmbedBuilder() + .setTitle(':hourglass_flowing_sand: You are on cooldown') + .setDescription( + `Try again (in ${retrySeconds}s).`, + ) + .setColor('Red'); + + if (source instanceof Message) { + if (source.channel?.isSendable()) { + try { + await source.reply({ embeds: [embed] }); + } catch (error) { + Logger.error`[ratelimit] Failed to reply with rate limit embed: ${error}`; + } + } + return; + } + + if (!source.isRepliable()) return; + + if (source.replied || source.deferred) { + try { + await source.followUp({ + embeds: [embed], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + Logger.error`[ratelimit] Failed to follow up with rate limit embed: ${error}`; + } + return; + } + + try { + await source.reply({ + embeds: [embed], + flags: MessageFlags.Ephemeral, + }); + } catch (error) { + Logger.error`[ratelimit] Failed to reply with rate limit embed: ${error}`; + } + } + + private async enqueueExecution(params: { + queueKey: string; + queue: NormalizedQueueOptions; + initialDelayMs: number; + source: Interaction | Message; + execute: () => Promise; + engine: RateLimitEngine; + resolvedKeys: ResolvedScopeKey[]; + limiter: RateLimitLimiterConfig; + }): Promise { + if (!params.queue.enabled) return false; + + const queue = this.getQueue(params.queueKey, params.queue); + const size = queue.getPending() + queue.getRunning(); + if (size >= params.queue.maxSize) { + // Queue full: fall back to immediate rate-limit handling to avoid unbounded growth. + return false; + } + + await this.deferInteractionIfNeeded(params.source, params.queue); + + const queuedAt = Date.now(); + const timeoutAt = queuedAt + params.queue.timeoutMs; + const initialDelay = Math.max(0, params.initialDelayMs); + + void queue + .add(async () => { + let delayMs = initialDelay; + while (true) { + if (delayMs > 0) { + await sleep(delayMs); + } + + if (Date.now() > timeoutAt) { + Logger.warn( + `[ratelimit] Queue timeout exceeded for key ${params.queueKey}`, + ); + return; + } + + const aggregate = await this.consumeForQueue( + params.engine, + params.limiter, + params.resolvedKeys, + ).catch(async (error) => { + if (this.options.hooks?.onStorageError) { + await this.options.hooks.onStorageError(error, false); + } + Logger.error`[ratelimit] Storage error during queued consume: ${error}`; + return null; + }); + + if (!aggregate) { + return; + } + + if (!aggregate.limited) { + await params.execute(); + return; + } + + delayMs = Math.max(aggregate.retryAfter, 250); + } + }) + .catch((error) => { + Logger.error`[ratelimit] Queue task failed: ${error}`; + }); + + return true; + } + + private getQueue(key: string, options: NormalizedQueueOptions): AsyncQueue { + const existing = this.queues.get(key); + if (existing) return existing; + const queue = createAsyncQueue({ concurrency: options.concurrency }); + this.queues.set(key, queue); + return queue; + } + + private async consumeForQueue( + engine: RateLimitEngine, + limiter: RateLimitLimiterConfig, + resolvedKeys: ResolvedScopeKey[], + ): Promise { + const results: RateLimitResult[] = []; + for (const resolved of resolvedKeys) { + const resolvedConfigs = resolveLimiterConfigs(limiter, resolved.scope); + for (const resolvedConfig of resolvedConfigs) { + const resolvedKey = withWindowSuffix( + resolved.key, + resolvedConfig.windowId, + ); + const output = await engine.consume(resolvedKey, resolvedConfig); + results.push(output.result); + } + } + + return aggregateResults(results); + } + + private async deferInteractionIfNeeded( + source: Interaction | Message, + queue: NormalizedQueueOptions, + ): Promise { + if (!queue.deferInteraction) return; + if (source instanceof Message) return; + if (!source.isRepliable()) return; + if (source.deferred || source.replied) return; + + try { + await source.deferReply({ + flags: queue.ephemeral ? MessageFlags.Ephemeral : undefined, + }); + } catch (error) { + Logger.debug( + `[ratelimit] Failed to defer interaction for queued command: ${error}`, + ); + } + } +} + +interface NormalizedQueueOptions { + enabled: boolean; + maxSize: number; + timeoutMs: number; + deferInteraction: boolean; + ephemeral: boolean; + concurrency: number; +} + +function normalizeScopes( + scope: RateLimitLimiterConfig['scope'] | undefined, +): RateLimitScope[] { + if (!scope) return ['user']; + if (Array.isArray(scope)) return Array.from(new Set(scope)); + return [scope]; +} + +function aggregateResults(results: RateLimitResult[]): RateLimitStoreValue { + if (!results.length) { + return createEmptyStoreValue(); + } + + const limitedResults = results.filter((r) => r.limited); + const limited = limitedResults.length > 0; + const remaining = Math.min(...results.map((r) => r.remaining)); + const resetAt = Math.max(...results.map((r) => r.resetAt)); + const retryAfter = limited + ? Math.max(...limitedResults.map((r) => r.retryAfter)) + : 0; + + return { + limited, + remaining, + resetAt, + retryAfter, + results, + }; +} + +function withWindowSuffix(key: string, windowId?: string): string { + if (!windowId) return key; + return `${key}:w:${windowId}`; +} + +function createEmptyStoreValue(): RateLimitStoreValue { + return { + limited: false, + remaining: 0, + resetAt: 0, + retryAfter: 0, + results: [], + }; +} + +function mergeRoleLimits( + ...limits: Array | undefined> +): Record | undefined { + const merged: Record = {}; + for (const limit of limits) { + if (!limit) continue; + Object.assign(merged, limit); + } + return Object.keys(merged).length ? merged : undefined; +} + +function resolveRoleLimit( + limits: Record | undefined, + strategy: RateLimitRoleLimitStrategy | undefined, + source: Interaction | Message, +): RateLimitLimiterConfig | null { + if (!limits) return null; + const roleIds = getRoleIds(source); + if (!roleIds.length) return null; + + const entries = Object.entries(limits).filter(([roleId]) => + roleIds.includes(roleId), + ); + if (!entries.length) return null; + + const resolvedStrategy = strategy ?? 'highest'; + if (resolvedStrategy === 'first') { + return entries[0]?.[1] ?? null; + } + + const scored = entries.map(([, limiter]) => ({ + limiter, + score: computeLimiterScore(limiter), + })); + + scored.sort((a, b) => { + if (resolvedStrategy === 'lowest') { + return a.score - b.score; + } + return b.score - a.score; + }); + + return scored[0]?.limiter ?? null; +} + +function computeLimiterScore(limiter: RateLimitLimiterConfig): number { + const resolvedConfigs = resolveLimiterConfigs(limiter, 'user'); + if (!resolvedConfigs.length) return 0; + const scores = resolvedConfigs.map( + (resolved) => resolved.maxRequests / resolved.intervalMs, + ); + return Math.min(...scores); +} + +function resolveQueueOptions( + ...options: Array +): NormalizedQueueOptions { + const merged = options.reduce( + (acc, opt) => ({ ...acc, ...(opt ?? {}) }), + {}, + ); + const hasConfig = options.some((opt) => opt != null); + const enabled = merged.enabled ?? hasConfig; + + return { + enabled, + maxSize: clampAtLeast(merged.maxSize ?? 3, 1), + timeoutMs: clampAtLeast(resolveDuration(merged.timeout, 30_000), 1), + deferInteraction: merged.deferInteraction !== false, + ephemeral: merged.ephemeral !== false, + concurrency: clampAtLeast(merged.concurrency ?? 1, 1), + }; +} + +function selectQueueKey(results: RateLimitResult[]): string { + let target: RateLimitResult | undefined; + for (const result of results) { + if (!result.limited) continue; + if (!target || result.retryAfter > target.retryAfter) { + target = result; + } + } + return (target ?? results[0])?.key ?? 'ratelimit:queue'; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function resetByCommand( + storage: RateLimitStorage, + keyPrefix: string | undefined, + commandName: string, +) { + if (!storage.deleteByPattern) return; + const prefix = keyPrefix ?? ''; + const pattern = `${prefix}*:${commandName}`; + await storage.deleteByPattern(pattern); + await storage.deleteByPattern(`violation:${pattern}`); + await storage.deleteByPattern(`${pattern}:w:*`); + await storage.deleteByPattern(`violation:${pattern}:w:*`); +} + +function normalizePath(path: string): string { + return path.replace(/\\/g, '/'); +} diff --git a/packages/ratelimit/src/providers/fallback.ts b/packages/ratelimit/src/providers/fallback.ts new file mode 100644 index 00000000..0ec7e7b4 --- /dev/null +++ b/packages/ratelimit/src/providers/fallback.ts @@ -0,0 +1,6 @@ +// Provider re-export for fallback storage. +// +// Exposes the wrapper and its options for consumers. + +export { FallbackRateLimitStorage } from '../storage/fallback'; +export type { FallbackRateLimitStorageOptions } from '../storage/fallback'; diff --git a/packages/ratelimit/src/providers/memory.ts b/packages/ratelimit/src/providers/memory.ts new file mode 100644 index 00000000..411524ed --- /dev/null +++ b/packages/ratelimit/src/providers/memory.ts @@ -0,0 +1,5 @@ +// Provider re-export for memory storage. +// +// Keeps public imports stable across plugin packages. + +export { MemoryRateLimitStorage } from '../storage/memory'; diff --git a/packages/ratelimit/src/providers/redis.ts b/packages/ratelimit/src/providers/redis.ts new file mode 100644 index 00000000..a3fe8bde --- /dev/null +++ b/packages/ratelimit/src/providers/redis.ts @@ -0,0 +1,6 @@ +// Provider re-export for Redis storage. +// +// Exposes the storage class and RedisOptions type for consumers. + +export { RedisRateLimitStorage } from '../storage/redis'; +export type { RedisOptions } from 'ioredis'; diff --git a/packages/ratelimit/src/runtime.ts b/packages/ratelimit/src/runtime.ts new file mode 100644 index 00000000..e2af1b71 --- /dev/null +++ b/packages/ratelimit/src/runtime.ts @@ -0,0 +1,52 @@ +// Runtime globals for rate limiting. +// +// Stores the active storage and plugin context for directives and helpers. + +import type { RateLimitRuntimeContext, RateLimitStorage } from './types'; + +let defaultStorage: RateLimitStorage | null = null; +let activeRuntime: RateLimitRuntimeContext | null = null; + +/** + * Set the default rate limit storage instance for the process. + */ +export function setRateLimitStorage(storage: RateLimitStorage): void { + defaultStorage = storage; +} + +/** + * Get the default rate limit storage instance for the process. + */ +export function getRateLimitStorage(): RateLimitStorage | null { + return defaultStorage; +} + +/** + * Alias for setRateLimitStorage to match other packages (tasks/queue). + */ +export function setDriver(storage: RateLimitStorage): void { + setRateLimitStorage(storage); +} + +/** + * Alias for getRateLimitStorage to match other packages (tasks/queue). + */ +export function getDriver(): RateLimitStorage | null { + return getRateLimitStorage(); +} + +/** + * Set the active runtime context used by directives and APIs. + */ +export function setRateLimitRuntime( + runtime: RateLimitRuntimeContext | null, +): void { + activeRuntime = runtime; +} + +/** + * Get the active runtime context for directives and APIs. + */ +export function getRateLimitRuntime(): RateLimitRuntimeContext | null { + return activeRuntime; +} diff --git a/packages/ratelimit/src/storage/fallback.ts b/packages/ratelimit/src/storage/fallback.ts new file mode 100644 index 00000000..64bdca57 --- /dev/null +++ b/packages/ratelimit/src/storage/fallback.ts @@ -0,0 +1,223 @@ +// Fallback storage wrapper. +// +// Routes storage calls to a secondary backend when the primary fails. + +import { Logger } from 'commandkit'; +import type { RateLimitStorage } from '../types'; + +/** + * Options that control fallback logging/cooldown behavior. + */ +export interface FallbackRateLimitStorageOptions { + /** Minimum time between fallback log entries (to avoid log spam). */ + cooldownMs?: number; +} + +/** + * Storage wrapper that falls back to a secondary implementation on failure. + */ +export class FallbackRateLimitStorage implements RateLimitStorage { + private lastErrorAt = 0; + + public constructor( + private readonly primary: RateLimitStorage, + private readonly secondary: RateLimitStorage, + private readonly options: FallbackRateLimitStorageOptions = {}, + ) {} + + private shouldLog(): boolean { + const now = Date.now(); + const cooldown = this.options.cooldownMs ?? 30_000; + if (now - this.lastErrorAt > cooldown) { + this.lastErrorAt = now; + return true; + } + return false; + } + + private async withFallback( + op: () => Promise, + fallback: () => Promise, + ): Promise { + try { + return await op(); + } catch (error) { + if (this.shouldLog()) { + Logger.error`[ratelimit] Storage error, falling back to secondary: ${error}`; + } + return fallback(); + } + } + + async get(key: string): Promise { + return this.withFallback( + () => this.primary.get(key), + () => this.secondary.get(key), + ); + } + + async set(key: string, value: T, ttlMs?: number): Promise { + return this.withFallback( + () => this.primary.set(key, value, ttlMs), + () => this.secondary.set(key, value, ttlMs), + ); + } + + async delete(key: string): Promise { + return this.withFallback( + () => this.primary.delete(key), + () => this.secondary.delete(key), + ); + } + + async incr(key: string, ttlMs: number) { + if (!this.primary.incr || !this.secondary.incr) { + throw new Error('incr not supported by both storages'); + } + return this.withFallback( + () => this.primary.incr!(key, ttlMs), + () => this.secondary.incr!(key, ttlMs), + ); + } + + async ttl(key: string) { + if (!this.primary.ttl || !this.secondary.ttl) { + throw new Error('ttl not supported by both storages'); + } + return this.withFallback( + () => this.primary.ttl!(key), + () => this.secondary.ttl!(key), + ); + } + + async expire(key: string, ttlMs: number) { + if (!this.primary.expire || !this.secondary.expire) { + throw new Error('expire not supported by both storages'); + } + return this.withFallback( + () => this.primary.expire!(key, ttlMs), + () => this.secondary.expire!(key, ttlMs), + ); + } + + async zAdd(key: string, score: number, member: string) { + if (!this.primary.zAdd || !this.secondary.zAdd) { + throw new Error('zAdd not supported by both storages'); + } + return this.withFallback( + () => this.primary.zAdd!(key, score, member), + () => this.secondary.zAdd!(key, score, member), + ); + } + + async zRemRangeByScore(key: string, min: number, max: number) { + if (!this.primary.zRemRangeByScore || !this.secondary.zRemRangeByScore) { + throw new Error('zRemRangeByScore not supported by both storages'); + } + return this.withFallback( + () => this.primary.zRemRangeByScore!(key, min, max), + () => this.secondary.zRemRangeByScore!(key, min, max), + ); + } + + async zCard(key: string) { + if (!this.primary.zCard || !this.secondary.zCard) { + throw new Error('zCard not supported by both storages'); + } + return this.withFallback( + () => this.primary.zCard!(key), + () => this.secondary.zCard!(key), + ); + } + + async zRangeByScore(key: string, min: number, max: number) { + if (!this.primary.zRangeByScore || !this.secondary.zRangeByScore) { + throw new Error('zRangeByScore not supported by both storages'); + } + return this.withFallback( + () => this.primary.zRangeByScore!(key, min, max), + () => this.secondary.zRangeByScore!(key, min, max), + ); + } + + async consumeFixedWindow( + key: string, + limit: number, + windowMs: number, + nowMs: number, + ) { + if ( + !this.primary.consumeFixedWindow || + !this.secondary.consumeFixedWindow + ) { + throw new Error('consumeFixedWindow not supported by both storages'); + } + return this.withFallback( + () => this.primary.consumeFixedWindow!(key, limit, windowMs, nowMs), + () => this.secondary.consumeFixedWindow!(key, limit, windowMs, nowMs), + ); + } + + async consumeSlidingWindowLog( + key: string, + limit: number, + windowMs: number, + nowMs: number, + member: string, + ) { + if ( + !this.primary.consumeSlidingWindowLog || + !this.secondary.consumeSlidingWindowLog + ) { + throw new Error('consumeSlidingWindowLog not supported by both storages'); + } + return this.withFallback( + () => + this.primary.consumeSlidingWindowLog!( + key, + limit, + windowMs, + nowMs, + member, + ), + () => + this.secondary.consumeSlidingWindowLog!( + key, + limit, + windowMs, + nowMs, + member, + ), + ); + } + + async deleteByPrefix(prefix: string) { + if (!this.primary.deleteByPrefix || !this.secondary.deleteByPrefix) { + throw new Error('deleteByPrefix not supported by both storages'); + } + return this.withFallback( + () => this.primary.deleteByPrefix!(prefix), + () => this.secondary.deleteByPrefix!(prefix), + ); + } + + async deleteByPattern(pattern: string) { + if (!this.primary.deleteByPattern || !this.secondary.deleteByPattern) { + throw new Error('deleteByPattern not supported by both storages'); + } + return this.withFallback( + () => this.primary.deleteByPattern!(pattern), + () => this.secondary.deleteByPattern!(pattern), + ); + } + + async keysByPrefix(prefix: string) { + if (!this.primary.keysByPrefix || !this.secondary.keysByPrefix) { + throw new Error('keysByPrefix not supported by both storages'); + } + return this.withFallback( + () => this.primary.keysByPrefix!(prefix), + () => this.secondary.keysByPrefix!(prefix), + ); + } +} diff --git a/packages/ratelimit/src/storage/memory.ts b/packages/ratelimit/src/storage/memory.ts new file mode 100644 index 00000000..48b344ec --- /dev/null +++ b/packages/ratelimit/src/storage/memory.ts @@ -0,0 +1,252 @@ +// In-memory storage. +// +// Used for tests and local development; implements TTL and sorted-set helpers. +// Not suitable for multi-process deployments. + +import type { + FixedWindowConsumeResult, + RateLimitStorage, + SlidingWindowConsumeResult, +} from '../types'; + +interface KvEntry { + value: unknown; + expiresAt: number | null; +} + +interface ZSetItem { + score: number; + member: string; +} + +interface ZSetEntry { + items: ZSetItem[]; + expiresAt: number | null; +} + +/** + * In-memory storage used for tests and local usage. + */ +export class MemoryRateLimitStorage implements RateLimitStorage { + private readonly kv = new Map(); + private readonly zsets = new Map(); + + private now(): number { + return Date.now(); + } + + private isExpired(expiresAt: number | null): boolean { + return expiresAt != null && expiresAt <= this.now(); + } + + /** + * Clear expired entries so reads reflect current state. + */ + private cleanupKey(key: string) { + const kvEntry = this.kv.get(key); + if (kvEntry && this.isExpired(kvEntry.expiresAt)) { + this.kv.delete(key); + } + + const zEntry = this.zsets.get(key); + if (zEntry && this.isExpired(zEntry.expiresAt)) { + this.zsets.delete(key); + } + } + + async get(key: string): Promise { + this.cleanupKey(key); + const entry = this.kv.get(key); + if (!entry) return null; + return entry.value as T; + } + + async set(key: string, value: T, ttlMs?: number): Promise { + const expiresAt = typeof ttlMs === 'number' ? this.now() + ttlMs : null; + this.kv.set(key, { value, expiresAt }); + } + + async delete(key: string): Promise { + this.kv.delete(key); + this.zsets.delete(key); + } + + async incr(key: string, ttlMs: number): Promise { + this.cleanupKey(key); + const entry = this.kv.get(key); + + if (!entry || typeof entry.value !== 'number') { + const expiresAt = this.now() + ttlMs; + this.kv.set(key, { value: 1, expiresAt }); + return { count: 1, ttlMs }; + } + + const count = entry.value + 1; + entry.value = count; + if (!entry.expiresAt) { + entry.expiresAt = this.now() + ttlMs; + } + + const remainingTtl = Math.max( + 0, + (entry.expiresAt ?? this.now()) - this.now(), + ); + return { count, ttlMs: remainingTtl }; + } + + async ttl(key: string): Promise { + this.cleanupKey(key); + const entry = this.kv.get(key) ?? this.zsets.get(key); + if (!entry) return null; + if (entry.expiresAt == null) return null; + return Math.max(0, entry.expiresAt - this.now()); + } + + async expire(key: string, ttlMs: number): Promise { + const expiresAt = this.now() + ttlMs; + const kvEntry = this.kv.get(key); + if (kvEntry) kvEntry.expiresAt = expiresAt; + const zEntry = this.zsets.get(key); + if (zEntry) zEntry.expiresAt = expiresAt; + } + + async zAdd(key: string, score: number, member: string): Promise { + this.cleanupKey(key); + const entry = this.zsets.get(key) ?? { items: [], expiresAt: null }; + const existingIndex = entry.items.findIndex( + (item) => item.member === member, + ); + if (existingIndex >= 0) { + entry.items[existingIndex] = { score, member }; + } else { + entry.items.push({ score, member }); + } + entry.items.sort((a, b) => a.score - b.score); + this.zsets.set(key, entry); + } + + async zRemRangeByScore(key: string, min: number, max: number): Promise { + this.cleanupKey(key); + const entry = this.zsets.get(key); + if (!entry) return; + entry.items = entry.items.filter( + (item) => item.score < min || item.score > max, + ); + } + + async zCard(key: string): Promise { + this.cleanupKey(key); + const entry = this.zsets.get(key); + return entry ? entry.items.length : 0; + } + + async zRangeByScore( + key: string, + min: number, + max: number, + ): Promise { + this.cleanupKey(key); + const entry = this.zsets.get(key); + if (!entry) return []; + return entry.items + .filter((item) => item.score >= min && item.score <= max) + .map((item) => item.member); + } + + async consumeFixedWindow( + key: string, + _limit: number, + windowMs: number, + _nowMs: number, + ): Promise { + return this.incr(key, windowMs); + } + + async consumeSlidingWindowLog( + key: string, + limit: number, + windowMs: number, + nowMs: number, + member: string, + ): Promise { + await this.zRemRangeByScore(key, 0, nowMs - windowMs); + const count = await this.zCard(key); + if (count >= limit) { + const oldest = await this.zRangeByScore( + key, + Number.NEGATIVE_INFINITY, + Number.POSITIVE_INFINITY, + ); + const oldestMember = oldest[0]; + const oldestTs = parseMemberTimestamp(oldestMember, nowMs); + return { allowed: false, count, resetAt: oldestTs + windowMs }; + } + + await this.zAdd(key, nowMs, member); + await this.expire(key, windowMs); + const newCount = count + 1; + const oldest = await this.zRangeByScore( + key, + Number.NEGATIVE_INFINITY, + Number.POSITIVE_INFINITY, + ); + const oldestMember = oldest[0]; + const oldestTs = parseMemberTimestamp(oldestMember, nowMs); + + return { allowed: true, count: newCount, resetAt: oldestTs + windowMs }; + } + + async deleteByPrefix(prefix: string): Promise { + for (const key of Array.from(this.kv.keys())) { + if (key.startsWith(prefix)) this.kv.delete(key); + } + for (const key of Array.from(this.zsets.keys())) { + if (key.startsWith(prefix)) this.zsets.delete(key); + } + } + + async deleteByPattern(pattern: string): Promise { + const regex = globToRegex(pattern); + for (const key of Array.from(this.kv.keys())) { + if (regex.test(key)) this.kv.delete(key); + } + for (const key of Array.from(this.zsets.keys())) { + if (regex.test(key)) this.zsets.delete(key); + } + } + + async keysByPrefix(prefix: string): Promise { + const keys = new Set(); + const kvKeys = Array.from(this.kv.keys()); + for (const key of kvKeys) { + this.cleanupKey(key); + if (this.kv.has(key) && key.startsWith(prefix)) { + keys.add(key); + } + } + const zsetKeys = Array.from(this.zsets.keys()); + for (const key of zsetKeys) { + this.cleanupKey(key); + if (this.zsets.has(key) && key.startsWith(prefix)) { + keys.add(key); + } + } + return Array.from(keys); + } +} + +function globToRegex(glob: string): RegExp { + const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, '\\$&'); + const regex = `^${escaped.replace(/\*/g, '.*')}$`; + return new RegExp(regex); +} + +function parseMemberTimestamp( + member: string | undefined, + fallback: number, +): number { + if (!member) return fallback; + const prefix = member.split('-')[0]; + const parsed = Number(prefix); + return Number.isFinite(parsed) ? parsed : fallback; +} diff --git a/packages/ratelimit/src/storage/redis.ts b/packages/ratelimit/src/storage/redis.ts new file mode 100644 index 00000000..20286eeb --- /dev/null +++ b/packages/ratelimit/src/storage/redis.ts @@ -0,0 +1,202 @@ +// Redis storage. +// +// Uses Lua scripts for atomic fixed/sliding window operations. + +import Redis, { type RedisOptions } from 'ioredis'; +import type { + FixedWindowConsumeResult, + RateLimitStorage, + SlidingWindowConsumeResult, +} from '../types'; + +const FIXED_WINDOW_SCRIPT = ` + local key = KEYS[1] + local window = tonumber(ARGV[1]) + local count = redis.call('INCR', key) + local ttl = redis.call('PTTL', key) + if ttl < 0 then + redis.call('PEXPIRE', key, window) + ttl = window + end + return {count, ttl} +`; + +const SLIDING_WINDOW_SCRIPT = ` + local key = KEYS[1] + local limit = tonumber(ARGV[1]) + local window = tonumber(ARGV[2]) + local now = tonumber(ARGV[3]) + local member = ARGV[4] + + redis.call('ZREMRANGEBYSCORE', key, 0, now - window) + local count = redis.call('ZCARD', key) + + if count >= limit then + local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') + local resetAt = now + window + if oldest[2] then + resetAt = tonumber(oldest[2]) + window + end + return {0, count, resetAt} + end + + redis.call('ZADD', key, now, member) + redis.call('PEXPIRE', key, window) + count = count + 1 + local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES') + local resetAt = now + window + if oldest[2] then + resetAt = tonumber(oldest[2]) + window + end + return {1, count, resetAt} +`; + +/** + * Redis-backed storage with Lua scripts for atomic window operations. + */ +export class RedisRateLimitStorage implements RateLimitStorage { + public readonly redis: Redis; + + public constructor(redis?: Redis | RedisOptions) { + this.redis = redis instanceof Redis ? redis : new Redis(redis ?? {}); + } + + async get(key: string): Promise { + const value = await this.redis.get(key); + if (value == null) return null; + return JSON.parse(value) as T; + } + + async set(key: string, value: T, ttlMs?: number): Promise { + const payload = JSON.stringify(value); + if (typeof ttlMs === 'number') { + await this.redis.set(key, payload, 'PX', ttlMs); + return; + } + await this.redis.set(key, payload); + } + + async delete(key: string): Promise { + await this.redis.del(key); + } + + async ttl(key: string): Promise { + const ttl = await this.redis.pttl(key); + if (ttl < 0) return null; + return ttl; + } + + async expire(key: string, ttlMs: number): Promise { + await this.redis.pexpire(key, ttlMs); + } + + async zAdd(key: string, score: number, member: string): Promise { + await this.redis.zadd(key, score.toString(), member); + } + + async zRemRangeByScore(key: string, min: number, max: number): Promise { + await this.redis.zremrangebyscore(key, min.toString(), max.toString()); + } + + async zCard(key: string): Promise { + return Number(await this.redis.zcard(key)); + } + + async zRangeByScore( + key: string, + min: number, + max: number, + ): Promise { + return this.redis.zrangebyscore(key, min.toString(), max.toString()); + } + + async consumeFixedWindow( + key: string, + _limit: number, + windowMs: number, + _nowMs: number, + ): Promise { + const result = (await this.redis.eval( + FIXED_WINDOW_SCRIPT, + 1, + key, + windowMs.toString(), + )) as [number, number]; + + return { + count: Number(result[0]), + ttlMs: Number(result[1]), + }; + } + + async consumeSlidingWindowLog( + key: string, + limit: number, + windowMs: number, + nowMs: number, + member: string, + ): Promise { + const result = (await this.redis.eval( + SLIDING_WINDOW_SCRIPT, + 1, + key, + limit.toString(), + windowMs.toString(), + nowMs.toString(), + member, + )) as [number, number, number]; + + return { + allowed: Number(result[0]) === 1, + count: Number(result[1]), + resetAt: Number(result[2]), + }; + } + + async deleteByPrefix(prefix: string): Promise { + await this.deleteByPattern(`${prefix}*`); + } + + /** + * Delete keys matching a glob pattern using SCAN to avoid blocking Redis. + */ + async deleteByPattern(pattern: string): Promise { + let cursor = '0'; + do { + const [nextCursor, keys] = (await this.redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + '100', + )) as [string, string[]]; + + if (keys.length) { + await this.redis.del(...keys); + } + cursor = nextCursor; + } while (cursor !== '0'); + } + + async keysByPrefix(prefix: string): Promise { + const pattern = `${prefix}*`; + const collected = new Set(); + let cursor = '0'; + do { + const [nextCursor, keys] = (await this.redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + '100', + )) as [string, string[]]; + + for (const key of keys) { + collected.add(key); + } + cursor = nextCursor; + } while (cursor !== '0'); + + return Array.from(collected); + } +} diff --git a/packages/ratelimit/src/types.ts b/packages/ratelimit/src/types.ts new file mode 100644 index 00000000..3dd64b2d --- /dev/null +++ b/packages/ratelimit/src/types.ts @@ -0,0 +1,369 @@ +// Rate limit type contracts. +// +// Shared config and result shapes for the plugin, engine, storage, and helpers. +// Keeping them in one place reduces drift between runtime behavior and docs. + +import type { Interaction, Message } from 'discord.js'; +import type { Context } from 'commandkit'; +import type { LoadedCommand } from 'commandkit'; + +/** + * Scopes used to build rate limit keys and apply per-scope limits. + */ +export const RATE_LIMIT_SCOPES = [ + 'user', + 'guild', + 'channel', + 'global', + 'user-guild', + 'custom', +] as const; + +/** + * Literal union of supported key scopes. + */ +export type RateLimitScope = (typeof RATE_LIMIT_SCOPES)[number]; + +/** + * Scopes eligible for temporary exemptions stored in rate limit storage. + */ +export const RATE_LIMIT_EXEMPTION_SCOPES = [ + 'user', + 'guild', + 'role', + 'channel', + 'category', +] as const; + +/** + * Literal union of exemption scopes. + */ +export type RateLimitExemptionScope = + (typeof RATE_LIMIT_EXEMPTION_SCOPES)[number]; + +/** + * Algorithm identifiers used to select the limiter implementation. + */ +export const RATE_LIMIT_ALGORITHMS = [ + 'fixed-window', + 'sliding-window', + 'token-bucket', + 'leaky-bucket', +] as const; + +/** + * Literal union of algorithm identifiers. + */ +export type RateLimitAlgorithmType = (typeof RATE_LIMIT_ALGORITHMS)[number]; + +/** + * Duration input accepted by configs: milliseconds or a duration string. + */ +export type DurationLike = number | string; + +/** + * Queue behavior for delayed retries after a limit is hit. + */ +export interface RateLimitQueueOptions { + enabled?: boolean; + maxSize?: number; + timeout?: DurationLike; + deferInteraction?: boolean; + ephemeral?: boolean; + concurrency?: number; +} + +/** + * Strategy for choosing among matching role-based overrides. + */ +export type RateLimitRoleLimitStrategy = 'highest' | 'lowest' | 'first'; + +/** + * Result for a single limiter/window evaluation used for aggregation. + */ +export interface RateLimitResult { + key: string; + scope: RateLimitScope; + algorithm: RateLimitAlgorithmType; + windowId?: string; + limited: boolean; + remaining: number; + resetAt: number; + retryAfter: number; + limit: number; +} + +/** + * Contract for rate limit algorithms used by the engine. + */ +export interface RateLimitAlgorithm { + readonly type: RateLimitAlgorithmType; + consume(key: string): Promise; + reset(key: string): Promise; +} + +/** + * Storage result for fixed-window atomic consumes. + */ +export interface FixedWindowConsumeResult { + count: number; + ttlMs: number; +} + +/** + * Storage result for sliding-window log consumes. + */ +export interface SlidingWindowConsumeResult { + allowed: boolean; + count: number; + resetAt: number; +} + +/** + * Storage contract for rate limit state, with optional optimization hooks. + */ +export interface RateLimitStorage { + get(key: string): Promise; + set(key: string, value: T, ttlMs?: number): Promise; + delete(key: string): Promise; + incr?(key: string, ttlMs: number): Promise; + ttl?(key: string): Promise; + expire?(key: string, ttlMs: number): Promise; + zAdd?(key: string, score: number, member: string): Promise; + zRemRangeByScore?(key: string, min: number, max: number): Promise; + zCard?(key: string): Promise; + zRangeByScore?(key: string, min: number, max: number): Promise; + consumeFixedWindow?( + key: string, + limit: number, + windowMs: number, + nowMs: number, + ): Promise; + consumeSlidingWindowLog?( + key: string, + limit: number, + windowMs: number, + nowMs: number, + member: string, + ): Promise; + deleteByPrefix?(prefix: string): Promise; + deleteByPattern?(pattern: string): Promise; + keysByPrefix?(prefix: string): Promise; +} + +/** + * Storage configuration: direct instance or `{ driver }` wrapper for parity. + */ +export type RateLimitStorageConfig = + | RateLimitStorage + | { + driver: RateLimitStorage; + }; + +/** + * Escalation settings for repeated violations. + */ +export interface ViolationOptions { + escalate?: boolean; + maxViolations?: number; + escalationMultiplier?: number; + resetAfter?: DurationLike; +} + +/** + * Per-window overrides when a limiter defines multiple windows. + */ +export interface RateLimitWindowConfig { + id?: string; + maxRequests?: number; + interval?: DurationLike; + algorithm?: RateLimitAlgorithmType; + burst?: number; + refillRate?: number; + leakRate?: number; + violations?: ViolationOptions; +} + +/** + * Custom key builder for the `custom` scope. + */ +export type RateLimitKeyResolver = ( + ctx: Context, + command: LoadedCommand, + source: Interaction | Message, +) => string; + +/** + * Core limiter configuration used by plugin and directives. + */ +export interface RateLimitLimiterConfig { + maxRequests?: number; + interval?: DurationLike; + scope?: RateLimitScope | RateLimitScope[]; + algorithm?: RateLimitAlgorithmType; + burst?: number; + refillRate?: number; + leakRate?: number; + keyResolver?: RateLimitKeyResolver; + keyPrefix?: string; + storage?: RateLimitStorageConfig; + violations?: ViolationOptions; + queue?: RateLimitQueueOptions; + windows?: RateLimitWindowConfig[]; + roleLimits?: Record; + roleLimitStrategy?: RateLimitRoleLimitStrategy; +} + +/** + * Per-command override stored in CommandKit metadata. + */ +export interface RateLimitCommandConfig extends RateLimitLimiterConfig { + limiter?: string; +} + +/** + * Permanent allowlist rules for rate limiting. + */ +export interface RateLimitBypassOptions { + userIds?: string[]; + roleIds?: string[]; + guildIds?: string[]; + check?: (source: Interaction | Message) => boolean | Promise; +} + +/** + * Parameters for granting a temporary exemption. + */ +export interface RateLimitExemptionGrantParams { + scope: RateLimitExemptionScope; + id: string; + duration: DurationLike; + keyPrefix?: string; +} + +/** + * Parameters for revoking a temporary exemption. + */ +export interface RateLimitExemptionRevokeParams { + scope: RateLimitExemptionScope; + id: string; + keyPrefix?: string; +} + +/** + * Filters for listing temporary exemptions. + */ +export interface RateLimitExemptionListParams { + scope?: RateLimitExemptionScope; + id?: string; + keyPrefix?: string; + limit?: number; +} + +/** + * Listed exemption entry with key and expiry info. + */ +export interface RateLimitExemptionInfo { + key: string; + scope: RateLimitExemptionScope; + id: string; + expiresInMs: number | null; +} + +/** + * Hook payload for rate limit lifecycle callbacks. + */ +export interface RateLimitHookContext { + key: string; + result: RateLimitResult; + source: Interaction | Message; +} + +/** + * Optional lifecycle hooks used by the plugin to surface rate limit events. + */ +export interface RateLimitHooks { + onRateLimited?: (info: RateLimitHookContext) => void | Promise; + onAllowed?: (info: RateLimitHookContext) => void | Promise; + onReset?: (key: string) => void | Promise; + onViolation?: (key: string, count: number) => void | Promise; + onStorageError?: ( + error: unknown, + fallbackUsed: boolean, + ) => void | Promise; +} + +/** + * Override for responding when a command is rate-limited. + */ +export type RateLimitResponseHandler = ( + ctx: Context, + info: RateLimitStoreValue, +) => Promise | void; + +/** + * Runtime plugin options consumed by RateLimitPlugin. + * Configure these via configureRatelimit(). + */ +export interface RateLimitPluginOptions { + defaultLimiter?: RateLimitLimiterConfig; + limiters?: Record; + storage?: RateLimitStorageConfig; + keyPrefix?: string; + keyResolver?: RateLimitKeyResolver; + bypass?: RateLimitBypassOptions; + hooks?: RateLimitHooks; + onRateLimited?: RateLimitResponseHandler; + queue?: RateLimitQueueOptions; + roleLimits?: Record; + roleLimitStrategy?: RateLimitRoleLimitStrategy; + /** + * Whether to initialize the default in-memory storage if no storage is configured. + * + * @default true + */ + initializeDefaultStorage?: boolean; + /** + * Alias for initializeDefaultStorage, aligned with other packages. + * + * @default true + */ + initializeDefaultDriver?: boolean; +} + +/** + * Aggregate results stored on the environment store for downstream handlers. + */ +export interface RateLimitStoreValue { + limited: boolean; + remaining: number; + resetAt: number; + retryAfter: number; + results: RateLimitResult[]; +} + +/** + * Limiter configuration after defaults are applied. + */ +export interface ResolvedLimiterConfig { + maxRequests: number; + intervalMs: number; + algorithm: RateLimitAlgorithmType; + scope: RateLimitScope; + burst: number; + refillRate: number; + leakRate: number; + violations?: ViolationOptions; + windowId?: string; +} + +/** + * Active runtime context shared with APIs and directives. + */ +export interface RateLimitRuntimeContext { + storage: RateLimitStorage; + keyPrefix?: string; + defaultLimiter: RateLimitLimiterConfig; + limiters?: Record; + hooks?: RateLimitHooks; +} diff --git a/packages/ratelimit/src/utils/config.ts b/packages/ratelimit/src/utils/config.ts new file mode 100644 index 00000000..09d06e0e --- /dev/null +++ b/packages/ratelimit/src/utils/config.ts @@ -0,0 +1,114 @@ +// Limiter config resolution. +// +// Applies defaults and merges overrides into concrete limiter settings +// used by the engine and plugin. + +import type { + RateLimitAlgorithmType, + RateLimitLimiterConfig, + RateLimitScope, + RateLimitWindowConfig, + ResolvedLimiterConfig, +} from '../types'; +import { clampAtLeast, resolveDuration } from './time'; + +const DEFAULT_MAX_REQUESTS = 10; +const DEFAULT_INTERVAL_MS = 60_000; +const DEFAULT_ALGORITHM: RateLimitAlgorithmType = 'fixed-window'; +const DEFAULT_SCOPE: RateLimitScope = 'user'; + +/** + * Default limiter used when no explicit configuration is provided. + */ +export const DEFAULT_LIMITER: RateLimitLimiterConfig = { + maxRequests: DEFAULT_MAX_REQUESTS, + interval: DEFAULT_INTERVAL_MS, + algorithm: DEFAULT_ALGORITHM, + scope: DEFAULT_SCOPE, +}; + +/** + * Merge limiter configs; later values override earlier ones for layering. + */ +export function mergeLimiterConfigs( + ...configs: Array +): RateLimitLimiterConfig { + return configs.reduce( + (acc, cfg) => ({ ...acc, ...(cfg ?? {}) }), + {}, + ); +} + +/** + * Resolve a limiter config for a single scope with defaults applied. + */ +export function resolveLimiterConfig( + config: RateLimitLimiterConfig, + scope: RateLimitScope, +): ResolvedLimiterConfig { + const maxRequests = + typeof config.maxRequests === 'number' && config.maxRequests > 0 + ? config.maxRequests + : DEFAULT_MAX_REQUESTS; + + const intervalMs = clampAtLeast( + resolveDuration(config.interval, DEFAULT_INTERVAL_MS), + 1, + ); + + const algorithm = config.algorithm ?? DEFAULT_ALGORITHM; + const intervalSeconds = intervalMs / 1000; + const burst = + typeof config.burst === 'number' && config.burst > 0 + ? config.burst + : maxRequests; + + const refillRate = + typeof config.refillRate === 'number' && config.refillRate > 0 + ? config.refillRate + : maxRequests / intervalSeconds; + + const leakRate = + typeof config.leakRate === 'number' && config.leakRate > 0 + ? config.leakRate + : maxRequests / intervalSeconds; + + return { + maxRequests, + intervalMs, + algorithm, + scope, + burst, + refillRate, + leakRate, + violations: config.violations, + }; +} + +function resolveWindowId(window: RateLimitWindowConfig, index: number): string { + if (window.id && window.id.trim()) return window.id; + // Stable fallback IDs keep window identity deterministic for resets. + return `w${index + 1}`; +} + +/** + * Resolve limiter configs for a scope across all configured windows. + */ +export function resolveLimiterConfigs( + config: RateLimitLimiterConfig, + scope: RateLimitScope, +): ResolvedLimiterConfig[] { + const windows = config.windows; + if (!windows || windows.length === 0) { + return [resolveLimiterConfig(config, scope)]; + } + + const { windows: _windows, ...base } = config; + + return windows.map((window, index) => { + const windowId = resolveWindowId(window, index); + const merged: RateLimitLimiterConfig = { ...base, ...window }; + const resolved = resolveLimiterConfig(merged, scope); + return windowId ? { ...resolved, windowId } : resolved; + }); +} diff --git a/packages/ratelimit/src/utils/keys.ts b/packages/ratelimit/src/utils/keys.ts new file mode 100644 index 00000000..d8654d16 --- /dev/null +++ b/packages/ratelimit/src/utils/keys.ts @@ -0,0 +1,312 @@ +// Key construction helpers. +// +// Builds consistent storage keys for scopes and exemptions across +// message and interaction sources so limits remain comparable. + +import { Message } from 'discord.js'; +import type { Interaction } from 'discord.js'; +import type { Context } from 'commandkit'; +import type { LoadedCommand } from 'commandkit'; +import type { + RateLimitExemptionScope, + RateLimitKeyResolver, + RateLimitScope, +} from '../types'; +import { RATE_LIMIT_EXEMPTION_SCOPES } from '../types'; +import { DEFAULT_KEY_PREFIX } from '../constants'; + +/** + * Inputs for resolving a scope-based key from a command/source. + */ +export interface ResolveScopeKeyParams { + ctx: Context; + source: Interaction | Message; + command: LoadedCommand; + scope: RateLimitScope; + keyPrefix?: string; + keyResolver?: RateLimitKeyResolver; +} + +/** + * Resolved key paired with its scope for aggregation. + */ +export interface ResolvedScopeKey { + scope: RateLimitScope; + key: string; +} + +function applyPrefix(prefix: string | undefined, key: string): string { + if (!prefix) return key; + return `${prefix}${key}`; +} + +function getUserId(source: Interaction | Message): string | null { + if (source instanceof Message) return source.author.id; + return source.user?.id ?? null; +} + +function getGuildId(source: Interaction | Message): string | null { + if (source instanceof Message) return source.guildId ?? null; + return source.guildId ?? null; +} + +function getChannelId(source: Interaction | Message): string | null { + if (source instanceof Message) return source.channelId ?? null; + return source.channelId ?? null; +} + +function getParentId(channel: unknown): string | null { + if (!channel || typeof channel !== 'object') return null; + if (!('parentId' in channel)) return null; + const parentId = (channel as { parentId?: string | null }).parentId; + return parentId ?? null; +} + +function getCategoryId(source: Interaction | Message): string | null { + if (source instanceof Message) { + return getParentId(source.channel); + } + return getParentId(source.channel); +} + +/** + * Extract role IDs from a message/interaction for role-based limits. + */ +export function getRoleIds(source: Interaction | Message): string[] { + const roles = source.member?.roles; + if (!roles) return []; + if (Array.isArray(roles)) return roles; + if ('cache' in roles) { + return roles.cache.map((role) => role.id); + } + return []; +} + +/** + * Build a storage key for a temporary exemption entry. + */ +export function buildExemptionKey( + scope: RateLimitExemptionScope, + id: string, + keyPrefix?: string, +): string { + const prefix = keyPrefix ?? ''; + return applyPrefix(prefix, `${DEFAULT_KEY_PREFIX}exempt:${scope}:${id}`); +} + +/** + * Build a prefix for scanning exemption keys in storage. + */ +export function buildExemptionPrefix( + keyPrefix?: string, + scope?: RateLimitExemptionScope, +): string { + const prefix = keyPrefix ?? ''; + const base = `${DEFAULT_KEY_PREFIX}exempt:`; + if (!scope) return applyPrefix(prefix, base); + return applyPrefix(prefix, `${base}${scope}:`); +} + +/** + * Parse an exemption key into scope and ID for listing. + */ +export function parseExemptionKey( + key: string, + keyPrefix?: string, +): { scope: RateLimitExemptionScope; id: string } | null { + const prefix = keyPrefix ?? ''; + const base = `${prefix}${DEFAULT_KEY_PREFIX}exempt:`; + if (!key.startsWith(base)) return null; + const rest = key.slice(base.length); + const [scope, ...idParts] = rest.split(':'); + if (!scope || idParts.length === 0) return null; + if (!RATE_LIMIT_EXEMPTION_SCOPES.includes(scope as RateLimitExemptionScope)) { + return null; + } + return { scope: scope as RateLimitExemptionScope, id: idParts.join(':') }; +} + +/** + * Resolve all exemption keys that could apply to a source. + */ +export function resolveExemptionKeys( + source: Interaction | Message, + keyPrefix?: string, +): string[] { + const keys: string[] = []; + + const userId = getUserId(source); + if (userId) { + keys.push(buildExemptionKey('user', userId, keyPrefix)); + } + + const guildId = getGuildId(source); + if (guildId) { + keys.push(buildExemptionKey('guild', guildId, keyPrefix)); + } + + const channelId = getChannelId(source); + if (channelId) { + keys.push(buildExemptionKey('channel', channelId, keyPrefix)); + } + + const categoryId = getCategoryId(source); + if (categoryId) { + keys.push(buildExemptionKey('category', categoryId, keyPrefix)); + } + + const roleIds = getRoleIds(source); + for (const roleId of roleIds) { + keys.push(buildExemptionKey('role', roleId, keyPrefix)); + } + + return keys; +} + +/** + * Resolve the storage key for a single scope. + */ +export function resolveScopeKey({ + ctx, + source, + command, + scope, + keyPrefix, + keyResolver, +}: ResolveScopeKeyParams): ResolvedScopeKey | null { + const prefix = keyPrefix ?? ''; + const commandName = ctx.commandName || command.command.name; + + switch (scope) { + case 'user': { + const userId = getUserId(source); + if (!userId) return null; + return { + scope, + key: applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}user:${userId}:${commandName}`, + ), + }; + } + case 'guild': { + const guildId = getGuildId(source); + if (!guildId) return null; + return { + scope, + key: applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}guild:${guildId}:${commandName}`, + ), + }; + } + case 'channel': { + const channelId = getChannelId(source); + if (!channelId) return null; + return { + scope, + key: applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}channel:${channelId}:${commandName}`, + ), + }; + } + case 'global': { + return { + scope, + key: applyPrefix(prefix, `${DEFAULT_KEY_PREFIX}global:${commandName}`), + }; + } + case 'user-guild': { + const userId = getUserId(source); + const guildId = getGuildId(source); + if (!userId || !guildId) return null; + return { + scope, + key: applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}user:${userId}:guild:${guildId}:${commandName}`, + ), + }; + } + case 'custom': { + if (!keyResolver) return null; + const customKey = keyResolver(ctx, command, source); + if (!customKey) return null; + return { + scope, + key: applyPrefix(prefix, customKey), + }; + } + default: + return null; + } +} + +/** + * Resolve keys for multiple scopes, dropping unresolvable ones. + */ +export function resolveScopeKeys( + params: Omit & { + scopes: RateLimitScope[]; + }, +): ResolvedScopeKey[] { + const results: ResolvedScopeKey[] = []; + for (const scope of params.scopes) { + const resolved = resolveScopeKey({ ...params, scope }); + if (resolved) results.push(resolved); + } + return results; +} + +/** + * Build a prefix for resets by scope/identifier. + */ +export function buildScopePrefix( + scope: RateLimitScope, + keyPrefix: string | undefined, + identifiers: { + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; + }, +): string | null { + const prefix = keyPrefix ?? ''; + switch (scope) { + case 'user': + return identifiers.userId + ? applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}user:${identifiers.userId}:`, + ) + : null; + case 'guild': + return identifiers.guildId + ? applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}guild:${identifiers.guildId}:`, + ) + : null; + case 'channel': + return identifiers.channelId + ? applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}channel:${identifiers.channelId}:`, + ) + : null; + case 'global': + return applyPrefix(prefix, `${DEFAULT_KEY_PREFIX}global:`); + case 'user-guild': + return identifiers.userId && identifiers.guildId + ? applyPrefix( + prefix, + `${DEFAULT_KEY_PREFIX}user:${identifiers.userId}:guild:${identifiers.guildId}:`, + ) + : null; + case 'custom': + return null; + default: + return null; + } +} diff --git a/packages/ratelimit/src/utils/locking.ts b/packages/ratelimit/src/utils/locking.ts new file mode 100644 index 00000000..eb4a8683 --- /dev/null +++ b/packages/ratelimit/src/utils/locking.ts @@ -0,0 +1,42 @@ +import type { RateLimitStorage } from '../types'; + +type LockedFn = () => Promise; + +class KeyedMutex { + private readonly queues = new Map>(); + + public async run(key: string, fn: LockedFn): Promise { + const previous = this.queues.get(key) ?? Promise.resolve(); + let release: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const tail = previous.then(() => current); + this.queues.set(key, tail); + + await previous; + try { + return await fn(); + } finally { + release!(); + if (this.queues.get(key) === tail) { + this.queues.delete(key); + } + } + } +} + +const mutexByStorage = new WeakMap(); + +export async function withStorageKeyLock( + storage: RateLimitStorage, + key: string, + fn: LockedFn, +): Promise { + let mutex = mutexByStorage.get(storage); + if (!mutex) { + mutex = new KeyedMutex(); + mutexByStorage.set(storage, mutex); + } + return mutex.run(key, fn); +} diff --git a/packages/ratelimit/src/utils/time.ts b/packages/ratelimit/src/utils/time.ts new file mode 100644 index 00000000..385fb1af --- /dev/null +++ b/packages/ratelimit/src/utils/time.ts @@ -0,0 +1,55 @@ +// Time helpers for rate limits. +// +// Converts user-friendly durations into milliseconds and clamps values +// so storage and algorithms always receive safe inputs. + +import ms, { type StringValue } from 'ms'; +import type { DurationLike } from '../types'; + +const WEEK_MS = 7 * 24 * 60 * 60 * 1000; +const MONTH_MS = 30 * 24 * 60 * 60 * 1000; + +/** + * Resolve a duration input into milliseconds with a fallback. + */ +export function resolveDuration( + value: DurationLike | undefined, + fallback: number, +): number { + if (value == null) return fallback; + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'string') { + // Allow week/month units so config can use human-friendly windows. + const custom = parseExtendedDuration(value); + if (custom != null) return custom; + const parsed = ms(value as StringValue); + if (typeof parsed === 'number' && Number.isFinite(parsed)) return parsed; + } + return fallback; +} + +function parseExtendedDuration(value: string): number | null { + const trimmed = value.trim(); + if (!trimmed) return null; + + const match = trimmed.match( + /^(\d+(?:\.\d+)?)\s*(w|week|weeks|mo|month|months)$/i, + ); + if (!match) return null; + + const amount = Number(match[1]); + if (!Number.isFinite(amount) || amount <= 0) return null; + + const unit = match[2].toLowerCase(); + const multiplier = + unit === 'w' || unit === 'week' || unit === 'weeks' ? WEEK_MS : MONTH_MS; + + return Math.round(amount * multiplier); +} + +/** + * Clamp a number to a minimum value to avoid zero/negative windows. + */ +export function clampAtLeast(value: number, min: number): number { + return value < min ? min : value; +} diff --git a/packages/ratelimit/tsconfig.json b/packages/ratelimit/tsconfig.json new file mode 100644 index 00000000..b75302e4 --- /dev/null +++ b/packages/ratelimit/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "tsconfig/base.json", + "compilerOptions": { + "outDir": "dist", + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "declaration": true, + "inlineSourceMap": true, + "target": "ES2020", + "module": "CommonJS", + "noEmit": false + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/ratelimit/vitest.config.ts b/packages/ratelimit/vitest.config.ts new file mode 100644 index 00000000..24b1da30 --- /dev/null +++ b/packages/ratelimit/vitest.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'vitest/config'; +import { join } from 'path'; + +export default defineConfig({ + test: { + include: ['./spec/**/*.{test,spec}.?(c|m)[jt]s?(x)'], + watch: false, + setupFiles: ['./spec/setup.ts'], + }, + resolve: { + alias: { + '@commandkit/ratelimit': join(import.meta.dirname, 'src', 'index.ts'), + }, + }, +}); From 9c70a868f4137b00e18a75764e04d3d5eec31431 Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:51:40 +0100 Subject: [PATCH 02/10] docs(ratelimit): add plugin docs and api reference --- .../classes/fallback-rate-limit-storage.mdx | 128 ++++ .../classes/fixed-window-algorithm.mdx | 56 ++ .../api-reference/ratelimit/classes/index.mdx | 16 + .../classes/leaky-bucket-algorithm.mdx | 56 ++ .../classes/memory-rate-limit-storage.mdx | 122 ++++ .../ratelimit/classes/rate-limit-engine.mdx | 47 ++ .../ratelimit/classes/rate-limit-error.mdx | 44 ++ .../ratelimit/classes/rate-limit-plugin.mdx | 68 ++ .../classes/redis-rate-limit-storage.mdx | 128 ++++ .../classes/sliding-window-log-algorithm.mdx | 56 ++ .../classes/token-bucket-algorithm.mdx | 56 ++ .../use-rate-limit-directive-plugin.mdx | 50 ++ .../ratelimit/classes/violation-tracker.mdx | 59 ++ .../functions/build-exemption-key.mdx | 36 + .../functions/build-exemption-prefix.mdx | 32 + .../functions/build-scope-prefix.mdx | 41 ++ .../ratelimit/functions/clamp-at-least.mdx | 32 + .../functions/configure-ratelimit.mdx | 29 + .../ratelimit/functions/get-driver.mdx | 22 + .../functions/get-rate-limit-config.mdx | 22 + .../functions/get-rate-limit-info.mdx | 28 + .../functions/get-rate-limit-runtime.mdx | 22 + .../functions/get-rate-limit-storage.mdx | 22 + .../ratelimit/functions/get-role-ids.mdx | 28 + .../functions/grant-rate-limit-exemption.mdx | 28 + .../ratelimit/functions/index.mdx | 16 + .../functions/is-rate-limit-configured.mdx | 22 + .../functions/list-rate-limit-exemptions.mdx | 28 + .../functions/merge-limiter-configs.mdx | 28 + .../functions/parse-exemption-key.mdx | 32 + .../ratelimit/functions/ratelimit.mdx | 31 + .../functions/reset-all-rate-limits.mdx | 28 + .../ratelimit/functions/reset-rate-limit.mdx | 28 + .../ratelimit/functions/resolve-duration.mdx | 32 + .../functions/resolve-exemption-keys.mdx | 32 + .../functions/resolve-limiter-config.mdx | 32 + .../functions/resolve-limiter-configs.mdx | 32 + .../ratelimit/functions/resolve-scope-key.mdx | 42 ++ .../functions/resolve-scope-keys.mdx | 30 + .../functions/revoke-rate-limit-exemption.mdx | 28 + .../ratelimit/functions/set-driver.mdx | 28 + .../functions/set-rate-limit-runtime.mdx | 28 + .../functions/set-rate-limit-storage.mdx | 28 + .../functions/with-storage-key-lock.mdx | 36 + .../docs/api-reference/ratelimit/index.mdx | 16 + .../fallback-rate-limit-storage-options.mdx | 35 + .../fixed-window-consume-result.mdx | 41 ++ .../ratelimit/interfaces/index.mdx | 16 + .../interfaces/rate-limit-algorithm.mdx | 47 ++ .../interfaces/rate-limit-bypass-options.mdx | 53 ++ .../interfaces/rate-limit-command-config.mdx | 38 ++ .../interfaces/rate-limit-consume-output.mdx | 41 ++ .../rate-limit-exemption-grant-params.mdx | 53 ++ .../interfaces/rate-limit-exemption-info.mdx | 53 ++ .../rate-limit-exemption-list-params.mdx | 53 ++ .../rate-limit-exemption-revoke-params.mdx | 47 ++ .../interfaces/rate-limit-hook-context.mdx | 47 ++ .../ratelimit/interfaces/rate-limit-hooks.mdx | 62 ++ .../interfaces/rate-limit-limiter-config.mdx | 119 ++++ .../interfaces/rate-limit-plugin-options.mdx | 108 +++ .../interfaces/rate-limit-queue-options.mdx | 65 ++ .../interfaces/rate-limit-result.mdx | 83 +++ .../interfaces/rate-limit-runtime-context.mdx | 59 ++ .../interfaces/rate-limit-storage.mdx | 130 ++++ .../interfaces/rate-limit-store-value.mdx | 59 ++ .../interfaces/rate-limit-window-config.mdx | 77 +++ .../reset-all-rate-limits-params.mdx | 77 +++ .../interfaces/reset-rate-limit-params.mdx | 71 ++ .../interfaces/resolve-scope-key-params.mdx | 65 ++ .../interfaces/resolved-limiter-config.mdx | 83 +++ .../interfaces/resolved-scope-key.mdx | 41 ++ .../sliding-window-consume-result.mdx | 47 ++ .../interfaces/token-bucket-config.mdx | 47 ++ .../interfaces/violation-options.mdx | 53 ++ .../ratelimit/types/duration-like.mdx | 22 + .../api-reference/ratelimit/types/index.mdx | 16 + .../types/rate-limit-algorithm-type.mdx | 22 + .../types/rate-limit-exemption-scope.mdx | 22 + .../types/rate-limit-key-resolver.mdx | 26 + .../types/rate-limit-response-handler.mdx | 25 + .../types/rate-limit-role-limit-strategy.mdx | 22 + .../ratelimit/types/rate-limit-scope.mdx | 22 + .../types/rate-limit-storage-config.mdx | 25 + .../ratelimit/variables/-ckitirl.mdx | 19 + .../variables/default_key_prefix.mdx | 19 + .../ratelimit/variables/default_limiter.mdx | 19 + .../ratelimit/variables/index.mdx | 16 + .../variables/rate_limit_algorithms.mdx | 19 + .../variables/rate_limit_exemption_scopes.mdx | 19 + .../ratelimit/variables/rate_limit_scopes.mdx | 19 + .../variables/ratelimit_store_key.mdx | 19 + .../07-commandkit-ratelimit.mdx | 643 ++++++++++++++++++ scripts/docs/generate-typescript-docs.ts | 5 + 93 files changed, 4574 insertions(+) create mode 100644 apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/index.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/index.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/index.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/index.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/duration-like.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/index.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/index.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx create mode 100644 apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx create mode 100644 apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx diff --git a/apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx new file mode 100644 index 00000000..5486a4cc --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx @@ -0,0 +1,128 @@ +--- +title: "FallbackRateLimitStorage" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## FallbackRateLimitStorage + + + +Storage wrapper that falls back to a secondary implementation on failure. + +```ts title="Signature" +class FallbackRateLimitStorage implements RateLimitStorage { + constructor(primary: RateLimitStorage, secondary: RateLimitStorage, options: FallbackRateLimitStorageOptions = {}) + get(key: string) => Promise; + set(key: string, value: T, ttlMs?: number) => Promise; + delete(key: string) => Promise; + incr(key: string, ttlMs: number) => ; + ttl(key: string) => ; + expire(key: string, ttlMs: number) => ; + zAdd(key: string, score: number, member: string) => ; + zRemRangeByScore(key: string, min: number, max: number) => ; + zCard(key: string) => ; + zRangeByScore(key: string, min: number, max: number) => ; + consumeFixedWindow(key: string, limit: number, windowMs: number, nowMs: number) => ; + consumeSlidingWindowLog(key: string, limit: number, windowMs: number, nowMs: number, member: string) => ; + deleteByPrefix(prefix: string) => ; + deleteByPattern(pattern: string) => ; + keysByPrefix(prefix: string) => ; +} +``` +* Implements: RateLimitStorage + + + +
+ +### constructor + +RateLimitStorage, secondary: RateLimitStorage, options: FallbackRateLimitStorageOptions = {}) => FallbackRateLimitStorage`} /> + + +### get + + Promise<T | null>`} /> + + +### set + + Promise<void>`} /> + + +### delete + + Promise<void>`} /> + + +### incr + + `} /> + + +### ttl + + `} /> + + +### expire + + `} /> + + +### zAdd + + `} /> + + +### zRemRangeByScore + + `} /> + + +### zCard + + `} /> + + +### zRangeByScore + + `} /> + + +### consumeFixedWindow + + `} /> + + +### consumeSlidingWindowLog + + `} /> + + +### deleteByPrefix + + `} /> + + +### deleteByPattern + + `} /> + + +### keysByPrefix + + `} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx new file mode 100644 index 00000000..72e39183 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx @@ -0,0 +1,56 @@ +--- +title: "FixedWindowAlgorithm" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## FixedWindowAlgorithm + + + +Basic fixed-window counter for low-cost rate limits. + +```ts title="Signature" +class FixedWindowAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'fixed-window'; + constructor(storage: RateLimitStorage, config: FixedWindowConfig) + consume(key: string) => Promise; + reset(key: string) => Promise; +} +``` +* Implements: RateLimitAlgorithm + + + +
+ +### type + +RateLimitAlgorithmType`} /> + + +### constructor + +RateLimitStorage, config: FixedWindowConfig) => FixedWindowAlgorithm`} /> + + +### consume + + Promise<RateLimitResult>`} /> + +Record one attempt and return the current window status for this key. +### reset + + Promise<void>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/index.mdx b/apps/website/docs/api-reference/ratelimit/classes/index.mdx new file mode 100644 index 00000000..79258416 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/index.mdx @@ -0,0 +1,16 @@ +--- +title: "Classes" +isDefaultIndex: true +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +import DocCardList from '@theme/DocCardList'; + + \ No newline at end of file diff --git a/apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx new file mode 100644 index 00000000..74522368 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx @@ -0,0 +1,56 @@ +--- +title: "LeakyBucketAlgorithm" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## LeakyBucketAlgorithm + + + +Leaky bucket algorithm for smoothing output to a steady rate. + +```ts title="Signature" +class LeakyBucketAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'leaky-bucket'; + constructor(storage: RateLimitStorage, config: LeakyBucketConfig) + consume(key: string) => Promise; + reset(key: string) => Promise; +} +``` +* Implements: RateLimitAlgorithm + + + +
+ +### type + +RateLimitAlgorithmType`} /> + + +### constructor + +RateLimitStorage, config: LeakyBucketConfig) => LeakyBucketAlgorithm`} /> + + +### consume + + Promise<RateLimitResult>`} /> + +Record one attempt and return the current bucket status for this key. +### reset + + Promise<void>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx new file mode 100644 index 00000000..c36d51c4 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx @@ -0,0 +1,122 @@ +--- +title: "MemoryRateLimitStorage" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## MemoryRateLimitStorage + + + +In-memory storage used for tests and local usage. + +```ts title="Signature" +class MemoryRateLimitStorage implements RateLimitStorage { + get(key: string) => Promise; + set(key: string, value: T, ttlMs?: number) => Promise; + delete(key: string) => Promise; + incr(key: string, ttlMs: number) => Promise; + ttl(key: string) => Promise; + expire(key: string, ttlMs: number) => Promise; + zAdd(key: string, score: number, member: string) => Promise; + zRemRangeByScore(key: string, min: number, max: number) => Promise; + zCard(key: string) => Promise; + zRangeByScore(key: string, min: number, max: number) => Promise; + consumeFixedWindow(key: string, _limit: number, windowMs: number, _nowMs: number) => Promise; + consumeSlidingWindowLog(key: string, limit: number, windowMs: number, nowMs: number, member: string) => Promise; + deleteByPrefix(prefix: string) => Promise; + deleteByPattern(pattern: string) => Promise; + keysByPrefix(prefix: string) => Promise; +} +``` +* Implements: RateLimitStorage + + + +
+ +### get + + Promise<T | null>`} /> + + +### set + + Promise<void>`} /> + + +### delete + + Promise<void>`} /> + + +### incr + + Promise<FixedWindowConsumeResult>`} /> + + +### ttl + + Promise<number | null>`} /> + + +### expire + + Promise<void>`} /> + + +### zAdd + + Promise<void>`} /> + + +### zRemRangeByScore + + Promise<void>`} /> + + +### zCard + + Promise<number>`} /> + + +### zRangeByScore + + Promise<string[]>`} /> + + +### consumeFixedWindow + + Promise<FixedWindowConsumeResult>`} /> + + +### consumeSlidingWindowLog + + Promise<SlidingWindowConsumeResult>`} /> + + +### deleteByPrefix + + Promise<void>`} /> + + +### deleteByPattern + + Promise<void>`} /> + + +### keysByPrefix + + Promise<string[]>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx new file mode 100644 index 00000000..1865a037 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx @@ -0,0 +1,47 @@ +--- +title: "RateLimitEngine" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitEngine + + + +Coordinates algorithm selection and violation escalation per storage. + +```ts title="Signature" +class RateLimitEngine { + constructor(storage: RateLimitStorage) + consume(key: string, config: ResolvedLimiterConfig) => Promise; + reset(key: string) => Promise; +} +``` + +
+ +### constructor + +RateLimitStorage) => RateLimitEngine`} /> + + +### consume + +ResolvedLimiterConfig) => Promise<RateLimitConsumeOutput>`} /> + +Consume a single key and apply escalation rules when enabled. +### reset + + Promise<void>`} /> + +Reset a key and its associated violation state. + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx new file mode 100644 index 00000000..d6bf4d1f --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx @@ -0,0 +1,44 @@ +--- +title: "RateLimitError" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitError + + + +Error thrown by the directive wrapper when a function is rate-limited. + +```ts title="Signature" +class RateLimitError extends Error { + public readonly result: RateLimitStoreValue; + constructor(result: RateLimitStoreValue, message?: string) +} +``` +* Extends: Error + + + +
+ +### result + +RateLimitStoreValue`} /> + + +### constructor + +RateLimitStoreValue, message?: string) => RateLimitError`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx new file mode 100644 index 00000000..dcd83d0b --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx @@ -0,0 +1,68 @@ +--- +title: "RateLimitPlugin" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitPlugin + + + +Runtime plugin that enforces rate limits for CommandKit commands so handlers stay lean. + +```ts title="Signature" +class RateLimitPlugin extends RuntimePlugin { + public readonly name = 'RateLimitPlugin'; + constructor(options: RateLimitPluginOptions) + activate(ctx: CommandKitPluginRuntime) => Promise; + deactivate() => Promise; + executeCommand(ctx: CommandKitPluginRuntime, env: CommandKitEnvironment, source: Interaction | Message, prepared: PreparedAppCommandExecution, execute: () => Promise) => Promise; + performHMR(ctx: CommandKitPluginRuntime, event: CommandKitHMREvent) => Promise; +} +``` +* Extends: RuntimePlugin<RateLimitPluginOptions> + + + +
+ +### name + + + + +### constructor + +RateLimitPluginOptions) => RateLimitPlugin`} /> + + +### activate + +CommandKitPluginRuntime) => Promise<void>`} /> + +Initialize runtime storage and defaults for this plugin instance. +### deactivate + + Promise<void>`} /> + +Dispose queues and clear shared runtime state. +### executeCommand + +CommandKitPluginRuntime, env: CommandKitEnvironment, source: Interaction | Message, prepared: PreparedAppCommandExecution, execute: () => Promise<any>) => Promise<boolean>`} /> + +Evaluate rate limits and optionally queue execution to avoid dropping commands. +### performHMR + +CommandKitPluginRuntime, event: CommandKitHMREvent) => Promise<void>`} /> + +Clear matching keys when a command is hot-reloaded to avoid stale state. + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx new file mode 100644 index 00000000..9ddb8695 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx @@ -0,0 +1,128 @@ +--- +title: "RedisRateLimitStorage" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RedisRateLimitStorage + + + +Redis-backed storage with Lua scripts for atomic window operations. + +```ts title="Signature" +class RedisRateLimitStorage implements RateLimitStorage { + public readonly redis: Redis; + constructor(redis?: Redis | RedisOptions) + get(key: string) => Promise; + set(key: string, value: T, ttlMs?: number) => Promise; + delete(key: string) => Promise; + ttl(key: string) => Promise; + expire(key: string, ttlMs: number) => Promise; + zAdd(key: string, score: number, member: string) => Promise; + zRemRangeByScore(key: string, min: number, max: number) => Promise; + zCard(key: string) => Promise; + zRangeByScore(key: string, min: number, max: number) => Promise; + consumeFixedWindow(key: string, _limit: number, windowMs: number, _nowMs: number) => Promise; + consumeSlidingWindowLog(key: string, limit: number, windowMs: number, nowMs: number, member: string) => Promise; + deleteByPrefix(prefix: string) => Promise; + deleteByPattern(pattern: string) => Promise; + keysByPrefix(prefix: string) => Promise; +} +``` +* Implements: RateLimitStorage + + + +
+ +### redis + + + + +### constructor + + RedisRateLimitStorage`} /> + + +### get + + Promise<T | null>`} /> + + +### set + + Promise<void>`} /> + + +### delete + + Promise<void>`} /> + + +### ttl + + Promise<number | null>`} /> + + +### expire + + Promise<void>`} /> + + +### zAdd + + Promise<void>`} /> + + +### zRemRangeByScore + + Promise<void>`} /> + + +### zCard + + Promise<number>`} /> + + +### zRangeByScore + + Promise<string[]>`} /> + + +### consumeFixedWindow + + Promise<FixedWindowConsumeResult>`} /> + + +### consumeSlidingWindowLog + + Promise<SlidingWindowConsumeResult>`} /> + + +### deleteByPrefix + + Promise<void>`} /> + + +### deleteByPattern + + Promise<void>`} /> + +Delete keys matching a glob pattern using SCAN to avoid blocking Redis. +### keysByPrefix + + Promise<string[]>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx new file mode 100644 index 00000000..70c5f48e --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx @@ -0,0 +1,56 @@ +--- +title: "SlidingWindowLogAlgorithm" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## SlidingWindowLogAlgorithm + + + +Sliding-window log algorithm for smoother limits. + +```ts title="Signature" +class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'sliding-window'; + constructor(storage: RateLimitStorage, config: SlidingWindowConfig) + consume(key: string) => Promise; + reset(key: string) => Promise; +} +``` +* Implements: RateLimitAlgorithm + + + +
+ +### type + +RateLimitAlgorithmType`} /> + + +### constructor + +RateLimitStorage, config: SlidingWindowConfig) => SlidingWindowLogAlgorithm`} /> + + +### consume + + Promise<RateLimitResult>`} /> + +Record one attempt and return the current window status for this key. +### reset + + Promise<void>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx new file mode 100644 index 00000000..0717af41 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx @@ -0,0 +1,56 @@ +--- +title: "TokenBucketAlgorithm" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## TokenBucketAlgorithm + + + +Token bucket algorithm for bursty traffic with steady refill. + +```ts title="Signature" +class TokenBucketAlgorithm implements RateLimitAlgorithm { + public readonly type: RateLimitAlgorithmType = 'token-bucket'; + constructor(storage: RateLimitStorage, config: TokenBucketConfig) + consume(key: string) => Promise; + reset(key: string) => Promise; +} +``` +* Implements: RateLimitAlgorithm + + + +
+ +### type + +RateLimitAlgorithmType`} /> + + +### constructor + +RateLimitStorage, config: TokenBucketConfig) => TokenBucketAlgorithm`} /> + + +### consume + + Promise<RateLimitResult>`} /> + +Record one attempt and return the current bucket status for this key. +### reset + + Promise<void>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx b/apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx new file mode 100644 index 00000000..298feeb5 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx @@ -0,0 +1,50 @@ +--- +title: "UseRateLimitDirectivePlugin" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## UseRateLimitDirectivePlugin + + + +Compiler plugin for the "use ratelimit" directive. + +```ts title="Signature" +class UseRateLimitDirectivePlugin extends CommonDirectiveTransformer { + public readonly name = 'UseRateLimitDirectivePlugin'; + constructor(options?: Partial) + activate(ctx: CompilerPluginRuntime) => Promise; +} +``` +* Extends: CommonDirectiveTransformer + + + +
+ +### name + + + + +### constructor + +CommonDirectiveTransformerOptions>) => UseRateLimitDirectivePlugin`} /> + + +### activate + +CompilerPluginRuntime) => Promise<void>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx b/apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx new file mode 100644 index 00000000..c78b6f7b --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx @@ -0,0 +1,59 @@ +--- +title: "ViolationTracker" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ViolationTracker + + + +Tracks repeated violations and computes escalating cooldowns. + +```ts title="Signature" +class ViolationTracker { + constructor(storage: RateLimitStorage) + getState(key: string) => Promise; + checkCooldown(key: string) => Promise; + recordViolation(key: string, baseRetryAfterMs: number, options?: ViolationOptions) => Promise; + reset(key: string) => Promise; +} +``` + +
+ +### constructor + +RateLimitStorage) => ViolationTracker`} /> + + +### getState + + Promise<ViolationState | null>`} /> + +Read stored violation state for a key, if present. +### checkCooldown + + Promise<ViolationState | null>`} /> + +Check if a cooldown is currently active for this key. +### recordViolation + +ViolationOptions) => Promise<ViolationState>`} /> + +Record a violation and return the updated state for callers. +### reset + + Promise<void>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx new file mode 100644 index 00000000..19ecd40d --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx @@ -0,0 +1,36 @@ +--- +title: "BuildExemptionKey" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## buildExemptionKey + + + +Build a storage key for a temporary exemption entry. + +```ts title="Signature" +function buildExemptionKey(scope: RateLimitExemptionScope, id: string, keyPrefix?: string): string +``` +Parameters + +### scope + +RateLimitExemptionScope`} /> + +### id + + + +### keyPrefix + + + diff --git a/apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx new file mode 100644 index 00000000..a9bef7df --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx @@ -0,0 +1,32 @@ +--- +title: "BuildExemptionPrefix" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## buildExemptionPrefix + + + +Build a prefix for scanning exemption keys in storage. + +```ts title="Signature" +function buildExemptionPrefix(keyPrefix?: string, scope?: RateLimitExemptionScope): string +``` +Parameters + +### keyPrefix + + + +### scope + +RateLimitExemptionScope`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx b/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx new file mode 100644 index 00000000..db946be2 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx @@ -0,0 +1,41 @@ +--- +title: "BuildScopePrefix" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## buildScopePrefix + + + +Build a prefix for resets by scope/identifier. + +```ts title="Signature" +function buildScopePrefix(scope: RateLimitScope, keyPrefix: string | undefined, identifiers: { + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; + }): string | null +``` +Parameters + +### scope + +RateLimitScope`} /> + +### keyPrefix + + + +### identifiers + + + diff --git a/apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx b/apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx new file mode 100644 index 00000000..e44f9f08 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx @@ -0,0 +1,32 @@ +--- +title: "ClampAtLeast" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## clampAtLeast + + + +Clamp a number to a minimum value to avoid zero/negative windows. + +```ts title="Signature" +function clampAtLeast(value: number, min: number): number +``` +Parameters + +### value + + + +### min + + + diff --git a/apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx b/apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx new file mode 100644 index 00000000..7d9aba23 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx @@ -0,0 +1,29 @@ +--- +title: "ConfigureRatelimit" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## configureRatelimit + + + +Configures the rate limit plugin runtime options. +Call this once during startup (for example in src/ratelimit.ts). + +```ts title="Signature" +function configureRatelimit(config: RateLimitPluginOptions = {}): void +``` +Parameters + +### config + +RateLimitPluginOptions`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx new file mode 100644 index 00000000..896b02b2 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx @@ -0,0 +1,22 @@ +--- +title: "GetDriver" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getDriver + + + +Alias for getRateLimitStorage to match other packages (tasks/queue). + +```ts title="Signature" +function getDriver(): RateLimitStorage | null +``` diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx new file mode 100644 index 00000000..f2c9acb5 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx @@ -0,0 +1,22 @@ +--- +title: "GetRateLimitConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getRateLimitConfig + + + +Retrieves the current rate limit configuration. + +```ts title="Signature" +function getRateLimitConfig(): RateLimitPluginOptions +``` diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx new file mode 100644 index 00000000..b020a9d4 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx @@ -0,0 +1,28 @@ +--- +title: "GetRateLimitInfo" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getRateLimitInfo + + + +Read aggregated rate limit info stored on a CommandKit env or context. + +```ts title="Signature" +function getRateLimitInfo(envOrCtx: CommandKitEnvironment | Context | null | undefined): RateLimitStoreValue | null +``` +Parameters + +### envOrCtx + +CommandKitEnvironment | Context | null | undefined`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx new file mode 100644 index 00000000..0592d223 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx @@ -0,0 +1,22 @@ +--- +title: "GetRateLimitRuntime" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getRateLimitRuntime + + + +Get the active runtime context for directives and APIs. + +```ts title="Signature" +function getRateLimitRuntime(): RateLimitRuntimeContext | null +``` diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx new file mode 100644 index 00000000..fe123fc4 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx @@ -0,0 +1,22 @@ +--- +title: "GetRateLimitStorage" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getRateLimitStorage + + + +Get the default rate limit storage instance for the process. + +```ts title="Signature" +function getRateLimitStorage(): RateLimitStorage | null +``` diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx new file mode 100644 index 00000000..951d6f5e --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx @@ -0,0 +1,28 @@ +--- +title: "GetRoleIds" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## getRoleIds + + + +Extract role IDs from a message/interaction for role-based limits. + +```ts title="Signature" +function getRoleIds(source: Interaction | Message): string[] +``` +Parameters + +### source + + + diff --git a/apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx b/apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx new file mode 100644 index 00000000..60658ccc --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx @@ -0,0 +1,28 @@ +--- +title: "GrantRateLimitExemption" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## grantRateLimitExemption + + + +Grant a temporary exemption for a scope/id pair. + +```ts title="Signature" +function grantRateLimitExemption(params: RateLimitExemptionGrantParams): Promise +``` +Parameters + +### params + +RateLimitExemptionGrantParams`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/index.mdx b/apps/website/docs/api-reference/ratelimit/functions/index.mdx new file mode 100644 index 00000000..f31e98c7 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/index.mdx @@ -0,0 +1,16 @@ +--- +title: "Functions" +isDefaultIndex: true +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +import DocCardList from '@theme/DocCardList'; + + \ No newline at end of file diff --git a/apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx b/apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx new file mode 100644 index 00000000..39412628 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx @@ -0,0 +1,22 @@ +--- +title: "IsRateLimitConfigured" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## isRateLimitConfigured + + + +Returns true once configureRatelimit has been called. + +```ts title="Signature" +function isRateLimitConfigured(): boolean +``` diff --git a/apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx b/apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx new file mode 100644 index 00000000..c775a0a1 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx @@ -0,0 +1,28 @@ +--- +title: "ListRateLimitExemptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## listRateLimitExemptions + + + +List exemptions by scope and/or id for admin/reporting. + +```ts title="Signature" +function listRateLimitExemptions(params: RateLimitExemptionListParams = {}): Promise +``` +Parameters + +### params + +RateLimitExemptionListParams`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx b/apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx new file mode 100644 index 00000000..458dfe9d --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx @@ -0,0 +1,28 @@ +--- +title: "MergeLimiterConfigs" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## mergeLimiterConfigs + + + +Merge limiter configs; later values override earlier ones for layering. + +```ts title="Signature" +function mergeLimiterConfigs(configs: Array): RateLimitLimiterConfig +``` +Parameters + +### configs + +RateLimitLimiterConfig | undefined>`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx b/apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx new file mode 100644 index 00000000..361caae9 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx @@ -0,0 +1,32 @@ +--- +title: "ParseExemptionKey" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## parseExemptionKey + + + +Parse an exemption key into scope and ID for listing. + +```ts title="Signature" +function parseExemptionKey(key: string, keyPrefix?: string): { scope: RateLimitExemptionScope; id: string } | null +``` +Parameters + +### key + + + +### keyPrefix + + + diff --git a/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx b/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx new file mode 100644 index 00000000..357b2476 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx @@ -0,0 +1,31 @@ +--- +title: "Ratelimit" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ratelimit + + + +Create compiler + runtime plugins for rate limiting. +Runtime options are provided via configureRatelimit(). + +```ts title="Signature" +function ratelimit(options?: Partial<{ + compiler: import('commandkit').CommonDirectiveTransformerOptions; + }>): CommandKitPlugin[] +``` +Parameters + +### options + +commandkit').CommonDirectiveTransformerOptions; }>`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx b/apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx new file mode 100644 index 00000000..7340ca5b --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx @@ -0,0 +1,28 @@ +--- +title: "ResetAllRateLimits" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resetAllRateLimits + + + +Reset multiple keys by scope, command name, prefix, or pattern for bulk cleanup. + +```ts title="Signature" +function resetAllRateLimits(params: ResetAllRateLimitsParams = {}): Promise +``` +Parameters + +### params + +ResetAllRateLimitsParams`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx b/apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx new file mode 100644 index 00000000..f7dab7ec --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx @@ -0,0 +1,28 @@ +--- +title: "ResetRateLimit" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resetRateLimit + + + +Reset a single key and its violation/window variants to keep state consistent. + +```ts title="Signature" +function resetRateLimit(params: ResetRateLimitParams): Promise +``` +Parameters + +### params + +ResetRateLimitParams`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx new file mode 100644 index 00000000..dc1fe816 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx @@ -0,0 +1,32 @@ +--- +title: "ResolveDuration" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resolveDuration + + + +Resolve a duration input into milliseconds with a fallback. + +```ts title="Signature" +function resolveDuration(value: DurationLike | undefined, fallback: number): number +``` +Parameters + +### value + +DurationLike | undefined`} /> + +### fallback + + + diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx new file mode 100644 index 00000000..77c861ed --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx @@ -0,0 +1,32 @@ +--- +title: "ResolveExemptionKeys" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resolveExemptionKeys + + + +Resolve all exemption keys that could apply to a source. + +```ts title="Signature" +function resolveExemptionKeys(source: Interaction | Message, keyPrefix?: string): string[] +``` +Parameters + +### source + + + +### keyPrefix + + + diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx new file mode 100644 index 00000000..8128e200 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx @@ -0,0 +1,32 @@ +--- +title: "ResolveLimiterConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resolveLimiterConfig + + + +Resolve a limiter config for a single scope with defaults applied. + +```ts title="Signature" +function resolveLimiterConfig(config: RateLimitLimiterConfig, scope: RateLimitScope): ResolvedLimiterConfig +``` +Parameters + +### config + +RateLimitLimiterConfig`} /> + +### scope + +RateLimitScope`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx new file mode 100644 index 00000000..9c1708d5 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx @@ -0,0 +1,32 @@ +--- +title: "ResolveLimiterConfigs" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resolveLimiterConfigs + + + +Resolve limiter configs for a scope across all configured windows. + +```ts title="Signature" +function resolveLimiterConfigs(config: RateLimitLimiterConfig, scope: RateLimitScope): ResolvedLimiterConfig[] +``` +Parameters + +### config + +RateLimitLimiterConfig`} /> + +### scope + +RateLimitScope`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx new file mode 100644 index 00000000..6eec9ca6 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx @@ -0,0 +1,42 @@ +--- +title: "ResolveScopeKey" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resolveScopeKey + + + +Resolve the storage key for a single scope. + +```ts title="Signature" +function resolveScopeKey({ + ctx, + source, + command, + scope, + keyPrefix, + keyResolver, +}: ResolveScopeKeyParams): ResolvedScopeKey | null +``` +Parameters + +### \{ + ctx, + source, + command, + scope, + keyPrefix, + keyResolver, +} + +ResolveScopeKeyParams`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx new file mode 100644 index 00000000..79d4d6ae --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx @@ -0,0 +1,30 @@ +--- +title: "ResolveScopeKeys" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## resolveScopeKeys + + + +Resolve keys for multiple scopes, dropping unresolvable ones. + +```ts title="Signature" +function resolveScopeKeys(params: Omit & { + scopes: RateLimitScope[]; + }): ResolvedScopeKey[] +``` +Parameters + +### params + +ResolveScopeKeyParams, 'scope'> & { scopes: RateLimitScope[]; }`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx b/apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx new file mode 100644 index 00000000..1e51c8e5 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx @@ -0,0 +1,28 @@ +--- +title: "RevokeRateLimitExemption" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## revokeRateLimitExemption + + + +Revoke a temporary exemption for a scope/id pair. + +```ts title="Signature" +function revokeRateLimitExemption(params: RateLimitExemptionRevokeParams): Promise +``` +Parameters + +### params + +RateLimitExemptionRevokeParams`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx b/apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx new file mode 100644 index 00000000..b93068e3 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx @@ -0,0 +1,28 @@ +--- +title: "SetDriver" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## setDriver + + + +Alias for setRateLimitStorage to match other packages (tasks/queue). + +```ts title="Signature" +function setDriver(storage: RateLimitStorage): void +``` +Parameters + +### storage + +RateLimitStorage`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx new file mode 100644 index 00000000..8f55ff6c --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx @@ -0,0 +1,28 @@ +--- +title: "SetRateLimitRuntime" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## setRateLimitRuntime + + + +Set the active runtime context used by directives and APIs. + +```ts title="Signature" +function setRateLimitRuntime(runtime: RateLimitRuntimeContext | null): void +``` +Parameters + +### runtime + +RateLimitRuntimeContext | null`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx new file mode 100644 index 00000000..2821f960 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx @@ -0,0 +1,28 @@ +--- +title: "SetRateLimitStorage" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## setRateLimitStorage + + + +Set the default rate limit storage instance for the process. + +```ts title="Signature" +function setRateLimitStorage(storage: RateLimitStorage): void +``` +Parameters + +### storage + +RateLimitStorage`} /> + diff --git a/apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx b/apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx new file mode 100644 index 00000000..5dcf6105 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx @@ -0,0 +1,36 @@ +--- +title: "WithStorageKeyLock" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## withStorageKeyLock + + + + + +```ts title="Signature" +function withStorageKeyLock(storage: RateLimitStorage, key: string, fn: LockedFn): Promise +``` +Parameters + +### storage + +RateLimitStorage`} /> + +### key + + + +### fn + + + diff --git a/apps/website/docs/api-reference/ratelimit/index.mdx b/apps/website/docs/api-reference/ratelimit/index.mdx new file mode 100644 index 00000000..e38ac855 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/index.mdx @@ -0,0 +1,16 @@ +--- +title: "Ratelimit" +isDefaultIndex: true +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +import DocCardList from '@theme/DocCardList'; + + \ No newline at end of file diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx new file mode 100644 index 00000000..cfcfa96a --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx @@ -0,0 +1,35 @@ +--- +title: "FallbackRateLimitStorageOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## FallbackRateLimitStorageOptions + + + +Options that control fallback logging/cooldown behavior. + +```ts title="Signature" +interface FallbackRateLimitStorageOptions { + cooldownMs?: number; +} +``` + +
+ +### cooldownMs + + + +Minimum time between fallback log entries (to avoid log spam). + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx new file mode 100644 index 00000000..7236ce72 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx @@ -0,0 +1,41 @@ +--- +title: "FixedWindowConsumeResult" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## FixedWindowConsumeResult + + + +Storage result for fixed-window atomic consumes. + +```ts title="Signature" +interface FixedWindowConsumeResult { + count: number; + ttlMs: number; +} +``` + +
+ +### count + + + + +### ttlMs + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/index.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/index.mdx new file mode 100644 index 00000000..c13efc1b --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/index.mdx @@ -0,0 +1,16 @@ +--- +title: "Interfaces" +isDefaultIndex: true +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +import DocCardList from '@theme/DocCardList'; + + \ No newline at end of file diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx new file mode 100644 index 00000000..11a85ac1 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx @@ -0,0 +1,47 @@ +--- +title: "RateLimitAlgorithm" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitAlgorithm + + + +Contract for rate limit algorithms used by the engine. + +```ts title="Signature" +interface RateLimitAlgorithm { + readonly type: RateLimitAlgorithmType; + consume(key: string): Promise; + reset(key: string): Promise; +} +``` + +
+ +### type + +RateLimitAlgorithmType`} /> + + +### consume + + Promise<RateLimitResult>`} /> + + +### reset + + Promise<void>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx new file mode 100644 index 00000000..adc8189b --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx @@ -0,0 +1,53 @@ +--- +title: "RateLimitBypassOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitBypassOptions + + + +Permanent allowlist rules for rate limiting. + +```ts title="Signature" +interface RateLimitBypassOptions { + userIds?: string[]; + roleIds?: string[]; + guildIds?: string[]; + check?: (source: Interaction | Message) => boolean | Promise; +} +``` + +
+ +### userIds + + + + +### roleIds + + + + +### guildIds + + + + +### check + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx new file mode 100644 index 00000000..55b14746 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx @@ -0,0 +1,38 @@ +--- +title: "RateLimitCommandConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitCommandConfig + + + +Per-command override stored in CommandKit metadata. + +```ts title="Signature" +interface RateLimitCommandConfig extends RateLimitLimiterConfig { + limiter?: string; +} +``` +* Extends: RateLimitLimiterConfig + + + +
+ +### limiter + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx new file mode 100644 index 00000000..83467e44 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx @@ -0,0 +1,41 @@ +--- +title: "RateLimitConsumeOutput" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitConsumeOutput + + + +Consume output including optional violation count for callers. + +```ts title="Signature" +interface RateLimitConsumeOutput { + result: RateLimitResult; + violationCount?: number; +} +``` + +
+ +### result + +RateLimitResult`} /> + + +### violationCount + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx new file mode 100644 index 00000000..9db7ffab --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx @@ -0,0 +1,53 @@ +--- +title: "RateLimitExemptionGrantParams" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitExemptionGrantParams + + + +Parameters for granting a temporary exemption. + +```ts title="Signature" +interface RateLimitExemptionGrantParams { + scope: RateLimitExemptionScope; + id: string; + duration: DurationLike; + keyPrefix?: string; +} +``` + +
+ +### scope + +RateLimitExemptionScope`} /> + + +### id + + + + +### duration + +DurationLike`} /> + + +### keyPrefix + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx new file mode 100644 index 00000000..3969be52 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx @@ -0,0 +1,53 @@ +--- +title: "RateLimitExemptionInfo" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitExemptionInfo + + + +Listed exemption entry with key and expiry info. + +```ts title="Signature" +interface RateLimitExemptionInfo { + key: string; + scope: RateLimitExemptionScope; + id: string; + expiresInMs: number | null; +} +``` + +
+ +### key + + + + +### scope + +RateLimitExemptionScope`} /> + + +### id + + + + +### expiresInMs + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx new file mode 100644 index 00000000..0b3bd708 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx @@ -0,0 +1,53 @@ +--- +title: "RateLimitExemptionListParams" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitExemptionListParams + + + +Filters for listing temporary exemptions. + +```ts title="Signature" +interface RateLimitExemptionListParams { + scope?: RateLimitExemptionScope; + id?: string; + keyPrefix?: string; + limit?: number; +} +``` + +
+ +### scope + +RateLimitExemptionScope`} /> + + +### id + + + + +### keyPrefix + + + + +### limit + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx new file mode 100644 index 00000000..0b1fbc4e --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx @@ -0,0 +1,47 @@ +--- +title: "RateLimitExemptionRevokeParams" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitExemptionRevokeParams + + + +Parameters for revoking a temporary exemption. + +```ts title="Signature" +interface RateLimitExemptionRevokeParams { + scope: RateLimitExemptionScope; + id: string; + keyPrefix?: string; +} +``` + +
+ +### scope + +RateLimitExemptionScope`} /> + + +### id + + + + +### keyPrefix + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx new file mode 100644 index 00000000..154114d6 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx @@ -0,0 +1,47 @@ +--- +title: "RateLimitHookContext" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitHookContext + + + +Hook payload for rate limit lifecycle callbacks. + +```ts title="Signature" +interface RateLimitHookContext { + key: string; + result: RateLimitResult; + source: Interaction | Message; +} +``` + +
+ +### key + + + + +### result + +RateLimitResult`} /> + + +### source + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx new file mode 100644 index 00000000..22fd6084 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx @@ -0,0 +1,62 @@ +--- +title: "RateLimitHooks" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitHooks + + + +Optional lifecycle hooks used by the plugin to surface rate limit events. + +```ts title="Signature" +interface RateLimitHooks { + onRateLimited?: (info: RateLimitHookContext) => void | Promise; + onAllowed?: (info: RateLimitHookContext) => void | Promise; + onReset?: (key: string) => void | Promise; + onViolation?: (key: string, count: number) => void | Promise; + onStorageError?: ( + error: unknown, + fallbackUsed: boolean, + ) => void | Promise; +} +``` + +
+ +### onRateLimited + +RateLimitHookContext) => void | Promise<void>`} /> + + +### onAllowed + +RateLimitHookContext) => void | Promise<void>`} /> + + +### onReset + + + + +### onViolation + + + + +### onStorageError + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx new file mode 100644 index 00000000..47e07801 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx @@ -0,0 +1,119 @@ +--- +title: "RateLimitLimiterConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitLimiterConfig + + + +Core limiter configuration used by plugin and directives. + +```ts title="Signature" +interface RateLimitLimiterConfig { + maxRequests?: number; + interval?: DurationLike; + scope?: RateLimitScope | RateLimitScope[]; + algorithm?: RateLimitAlgorithmType; + burst?: number; + refillRate?: number; + leakRate?: number; + keyResolver?: RateLimitKeyResolver; + keyPrefix?: string; + storage?: RateLimitStorageConfig; + violations?: ViolationOptions; + queue?: RateLimitQueueOptions; + windows?: RateLimitWindowConfig[]; + roleLimits?: Record; + roleLimitStrategy?: RateLimitRoleLimitStrategy; +} +``` + +
+ +### maxRequests + + + + +### interval + +DurationLike`} /> + + +### scope + +RateLimitScope | RateLimitScope[]`} /> + + +### algorithm + +RateLimitAlgorithmType`} /> + + +### burst + + + + +### refillRate + + + + +### leakRate + + + + +### keyResolver + +RateLimitKeyResolver`} /> + + +### keyPrefix + + + + +### storage + +RateLimitStorageConfig`} /> + + +### violations + +ViolationOptions`} /> + + +### queue + +RateLimitQueueOptions`} /> + + +### windows + +RateLimitWindowConfig[]`} /> + + +### roleLimits + +RateLimitLimiterConfig>`} /> + + +### roleLimitStrategy + +RateLimitRoleLimitStrategy`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx new file mode 100644 index 00000000..ec999f4a --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx @@ -0,0 +1,108 @@ +--- +title: "RateLimitPluginOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitPluginOptions + + + +Runtime plugin options consumed by RateLimitPlugin. +Configure these via configureRatelimit(). + +```ts title="Signature" +interface RateLimitPluginOptions { + defaultLimiter?: RateLimitLimiterConfig; + limiters?: Record; + storage?: RateLimitStorageConfig; + keyPrefix?: string; + keyResolver?: RateLimitKeyResolver; + bypass?: RateLimitBypassOptions; + hooks?: RateLimitHooks; + onRateLimited?: RateLimitResponseHandler; + queue?: RateLimitQueueOptions; + roleLimits?: Record; + roleLimitStrategy?: RateLimitRoleLimitStrategy; + initializeDefaultStorage?: boolean; + initializeDefaultDriver?: boolean; +} +``` + +
+ +### defaultLimiter + +RateLimitLimiterConfig`} /> + + +### limiters + +RateLimitLimiterConfig>`} /> + + +### storage + +RateLimitStorageConfig`} /> + + +### keyPrefix + + + + +### keyResolver + +RateLimitKeyResolver`} /> + + +### bypass + +RateLimitBypassOptions`} /> + + +### hooks + +RateLimitHooks`} /> + + +### onRateLimited + +RateLimitResponseHandler`} /> + + +### queue + +RateLimitQueueOptions`} /> + + +### roleLimits + +RateLimitLimiterConfig>`} /> + + +### roleLimitStrategy + +RateLimitRoleLimitStrategy`} /> + + +### initializeDefaultStorage + + + +Whether to initialize the default in-memory storage if no storage is configured. +### initializeDefaultDriver + + + +Alias for initializeDefaultStorage, aligned with other packages. + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx new file mode 100644 index 00000000..4ebcba59 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx @@ -0,0 +1,65 @@ +--- +title: "RateLimitQueueOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitQueueOptions + + + +Queue behavior for delayed retries after a limit is hit. + +```ts title="Signature" +interface RateLimitQueueOptions { + enabled?: boolean; + maxSize?: number; + timeout?: DurationLike; + deferInteraction?: boolean; + ephemeral?: boolean; + concurrency?: number; +} +``` + +
+ +### enabled + + + + +### maxSize + + + + +### timeout + +DurationLike`} /> + + +### deferInteraction + + + + +### ephemeral + + + + +### concurrency + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx new file mode 100644 index 00000000..7415cfc8 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx @@ -0,0 +1,83 @@ +--- +title: "RateLimitResult" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitResult + + + +Result for a single limiter/window evaluation used for aggregation. + +```ts title="Signature" +interface RateLimitResult { + key: string; + scope: RateLimitScope; + algorithm: RateLimitAlgorithmType; + windowId?: string; + limited: boolean; + remaining: number; + resetAt: number; + retryAfter: number; + limit: number; +} +``` + +
+ +### key + + + + +### scope + +RateLimitScope`} /> + + +### algorithm + +RateLimitAlgorithmType`} /> + + +### windowId + + + + +### limited + + + + +### remaining + + + + +### resetAt + + + + +### retryAfter + + + + +### limit + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx new file mode 100644 index 00000000..aedf67c8 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx @@ -0,0 +1,59 @@ +--- +title: "RateLimitRuntimeContext" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitRuntimeContext + + + +Active runtime context shared with APIs and directives. + +```ts title="Signature" +interface RateLimitRuntimeContext { + storage: RateLimitStorage; + keyPrefix?: string; + defaultLimiter: RateLimitLimiterConfig; + limiters?: Record; + hooks?: RateLimitHooks; +} +``` + +
+ +### storage + +RateLimitStorage`} /> + + +### keyPrefix + + + + +### defaultLimiter + +RateLimitLimiterConfig`} /> + + +### limiters + +RateLimitLimiterConfig>`} /> + + +### hooks + +RateLimitHooks`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx new file mode 100644 index 00000000..08752fb5 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx @@ -0,0 +1,130 @@ +--- +title: "RateLimitStorage" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitStorage + + + +Storage contract for rate limit state, with optional optimization hooks. + +```ts title="Signature" +interface RateLimitStorage { + get(key: string): Promise; + set(key: string, value: T, ttlMs?: number): Promise; + delete(key: string): Promise; + incr?(key: string, ttlMs: number): Promise; + ttl?(key: string): Promise; + expire?(key: string, ttlMs: number): Promise; + zAdd?(key: string, score: number, member: string): Promise; + zRemRangeByScore?(key: string, min: number, max: number): Promise; + zCard?(key: string): Promise; + zRangeByScore?(key: string, min: number, max: number): Promise; + consumeFixedWindow?( + key: string, + limit: number, + windowMs: number, + nowMs: number, + ): Promise; + consumeSlidingWindowLog?( + key: string, + limit: number, + windowMs: number, + nowMs: number, + member: string, + ): Promise; + deleteByPrefix?(prefix: string): Promise; + deleteByPattern?(pattern: string): Promise; + keysByPrefix?(prefix: string): Promise; +} +``` + +
+ +### get + + Promise<T | null>`} /> + + +### set + + Promise<void>`} /> + + +### delete + + Promise<void>`} /> + + +### incr + + Promise<FixedWindowConsumeResult>`} /> + + +### ttl + + Promise<number | null>`} /> + + +### expire + + Promise<void>`} /> + + +### zAdd + + Promise<void>`} /> + + +### zRemRangeByScore + + Promise<void>`} /> + + +### zCard + + Promise<number>`} /> + + +### zRangeByScore + + Promise<string[]>`} /> + + +### consumeFixedWindow + + Promise<FixedWindowConsumeResult>`} /> + + +### consumeSlidingWindowLog + + Promise<SlidingWindowConsumeResult>`} /> + + +### deleteByPrefix + + Promise<void>`} /> + + +### deleteByPattern + + Promise<void>`} /> + + +### keysByPrefix + + Promise<string[]>`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx new file mode 100644 index 00000000..39a1613a --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx @@ -0,0 +1,59 @@ +--- +title: "RateLimitStoreValue" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitStoreValue + + + +Aggregate results stored on the environment store for downstream handlers. + +```ts title="Signature" +interface RateLimitStoreValue { + limited: boolean; + remaining: number; + resetAt: number; + retryAfter: number; + results: RateLimitResult[]; +} +``` + +
+ +### limited + + + + +### remaining + + + + +### resetAt + + + + +### retryAfter + + + + +### results + +RateLimitResult[]`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx new file mode 100644 index 00000000..1ec1129c --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx @@ -0,0 +1,77 @@ +--- +title: "RateLimitWindowConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitWindowConfig + + + +Per-window overrides when a limiter defines multiple windows. + +```ts title="Signature" +interface RateLimitWindowConfig { + id?: string; + maxRequests?: number; + interval?: DurationLike; + algorithm?: RateLimitAlgorithmType; + burst?: number; + refillRate?: number; + leakRate?: number; + violations?: ViolationOptions; +} +``` + +
+ +### id + + + + +### maxRequests + + + + +### interval + +DurationLike`} /> + + +### algorithm + +RateLimitAlgorithmType`} /> + + +### burst + + + + +### refillRate + + + + +### leakRate + + + + +### violations + +ViolationOptions`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx new file mode 100644 index 00000000..f229984c --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx @@ -0,0 +1,77 @@ +--- +title: "ResetAllRateLimitsParams" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ResetAllRateLimitsParams + + + +Parameters for batch resets by scope, prefix, or pattern. + +```ts title="Signature" +interface ResetAllRateLimitsParams { + scope?: RateLimitScope; + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; + keyPrefix?: string; + pattern?: string; + prefix?: string; +} +``` + +
+ +### scope + +RateLimitScope`} /> + + +### userId + + + + +### guildId + + + + +### channelId + + + + +### commandName + + + + +### keyPrefix + + + + +### pattern + + + + +### prefix + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx new file mode 100644 index 00000000..c0669cc3 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx @@ -0,0 +1,71 @@ +--- +title: "ResetRateLimitParams" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ResetRateLimitParams + + + +Parameters for resetting a single key or scope-derived key. + +```ts title="Signature" +interface ResetRateLimitParams { + key?: string; + scope?: RateLimitScope; + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; + keyPrefix?: string; +} +``` + +
+ +### key + + + + +### scope + +RateLimitScope`} /> + + +### userId + + + + +### guildId + + + + +### channelId + + + + +### commandName + + + + +### keyPrefix + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx new file mode 100644 index 00000000..82f60fc8 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx @@ -0,0 +1,65 @@ +--- +title: "ResolveScopeKeyParams" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ResolveScopeKeyParams + + + +Inputs for resolving a scope-based key from a command/source. + +```ts title="Signature" +interface ResolveScopeKeyParams { + ctx: Context; + source: Interaction | Message; + command: LoadedCommand; + scope: RateLimitScope; + keyPrefix?: string; + keyResolver?: RateLimitKeyResolver; +} +``` + +
+ +### ctx + +Context`} /> + + +### source + + + + +### command + +LoadedCommand`} /> + + +### scope + +RateLimitScope`} /> + + +### keyPrefix + + + + +### keyResolver + +RateLimitKeyResolver`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx new file mode 100644 index 00000000..be91a3a6 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx @@ -0,0 +1,83 @@ +--- +title: "ResolvedLimiterConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ResolvedLimiterConfig + + + +Limiter configuration after defaults are applied. + +```ts title="Signature" +interface ResolvedLimiterConfig { + maxRequests: number; + intervalMs: number; + algorithm: RateLimitAlgorithmType; + scope: RateLimitScope; + burst: number; + refillRate: number; + leakRate: number; + violations?: ViolationOptions; + windowId?: string; +} +``` + +
+ +### maxRequests + + + + +### intervalMs + + + + +### algorithm + +RateLimitAlgorithmType`} /> + + +### scope + +RateLimitScope`} /> + + +### burst + + + + +### refillRate + + + + +### leakRate + + + + +### violations + +ViolationOptions`} /> + + +### windowId + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx new file mode 100644 index 00000000..7d0f85e1 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx @@ -0,0 +1,41 @@ +--- +title: "ResolvedScopeKey" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ResolvedScopeKey + + + +Resolved key paired with its scope for aggregation. + +```ts title="Signature" +interface ResolvedScopeKey { + scope: RateLimitScope; + key: string; +} +``` + +
+ +### scope + +RateLimitScope`} /> + + +### key + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx new file mode 100644 index 00000000..f901a667 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx @@ -0,0 +1,47 @@ +--- +title: "SlidingWindowConsumeResult" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## SlidingWindowConsumeResult + + + +Storage result for sliding-window log consumes. + +```ts title="Signature" +interface SlidingWindowConsumeResult { + allowed: boolean; + count: number; + resetAt: number; +} +``` + +
+ +### allowed + + + + +### count + + + + +### resetAt + + + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx new file mode 100644 index 00000000..04dbcf00 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx @@ -0,0 +1,47 @@ +--- +title: "TokenBucketConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## TokenBucketConfig + + + + + +```ts title="Signature" +interface TokenBucketConfig { + capacity: number; + refillRate: number; + scope: RateLimitResult['scope']; +} +``` + +
+ +### capacity + + + +Maximum tokens available when the bucket is full. +### refillRate + + + +Tokens added per second during refill. +### scope + +RateLimitResult['scope']`} /> + +Scope reported in rate-limit results. + + +
diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx new file mode 100644 index 00000000..4cb1a4da --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx @@ -0,0 +1,53 @@ +--- +title: "ViolationOptions" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## ViolationOptions + + + +Escalation settings for repeated violations. + +```ts title="Signature" +interface ViolationOptions { + escalate?: boolean; + maxViolations?: number; + escalationMultiplier?: number; + resetAfter?: DurationLike; +} +``` + +
+ +### escalate + + + + +### maxViolations + + + + +### escalationMultiplier + + + + +### resetAfter + +DurationLike`} /> + + + + +
diff --git a/apps/website/docs/api-reference/ratelimit/types/duration-like.mdx b/apps/website/docs/api-reference/ratelimit/types/duration-like.mdx new file mode 100644 index 00000000..aa305c88 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/duration-like.mdx @@ -0,0 +1,22 @@ +--- +title: "DurationLike" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## DurationLike + + + +Duration input accepted by configs: milliseconds or a duration string. + +```ts title="Signature" +type DurationLike = number | string +``` diff --git a/apps/website/docs/api-reference/ratelimit/types/index.mdx b/apps/website/docs/api-reference/ratelimit/types/index.mdx new file mode 100644 index 00000000..224a2db8 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/index.mdx @@ -0,0 +1,16 @@ +--- +title: "Type Aliases" +isDefaultIndex: true +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +import DocCardList from '@theme/DocCardList'; + + \ No newline at end of file diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx new file mode 100644 index 00000000..f3352494 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx @@ -0,0 +1,22 @@ +--- +title: "RateLimitAlgorithmType" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitAlgorithmType + + + +Literal union of algorithm identifiers. + +```ts title="Signature" +type RateLimitAlgorithmType = (typeof RATE_LIMIT_ALGORITHMS)[number] +``` diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx new file mode 100644 index 00000000..5d14f193 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx @@ -0,0 +1,22 @@ +--- +title: "RateLimitExemptionScope" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitExemptionScope + + + +Literal union of exemption scopes. + +```ts title="Signature" +type RateLimitExemptionScope = (typeof RATE_LIMIT_EXEMPTION_SCOPES)[number] +``` diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx new file mode 100644 index 00000000..ee5a30cd --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx @@ -0,0 +1,26 @@ +--- +title: "RateLimitKeyResolver" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitKeyResolver + + + +Custom key builder for the `custom` scope. + +```ts title="Signature" +type RateLimitKeyResolver = ( + ctx: Context, + command: LoadedCommand, + source: Interaction | Message, +) => string +``` diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx new file mode 100644 index 00000000..d64423da --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx @@ -0,0 +1,25 @@ +--- +title: "RateLimitResponseHandler" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitResponseHandler + + + +Override for responding when a command is rate-limited. + +```ts title="Signature" +type RateLimitResponseHandler = ( + ctx: Context, + info: RateLimitStoreValue, +) => Promise | void +``` diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx new file mode 100644 index 00000000..464bf1d9 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx @@ -0,0 +1,22 @@ +--- +title: "RateLimitRoleLimitStrategy" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitRoleLimitStrategy + + + +Strategy for choosing among matching role-based overrides. + +```ts title="Signature" +type RateLimitRoleLimitStrategy = 'highest' | 'lowest' | 'first' +``` diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx new file mode 100644 index 00000000..3c49bac3 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx @@ -0,0 +1,22 @@ +--- +title: "RateLimitScope" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitScope + + + +Literal union of supported key scopes. + +```ts title="Signature" +type RateLimitScope = (typeof RATE_LIMIT_SCOPES)[number] +``` diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx new file mode 100644 index 00000000..6d1c8921 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx @@ -0,0 +1,25 @@ +--- +title: "RateLimitStorageConfig" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RateLimitStorageConfig + + + +Storage configuration: direct instance or `{ driver }` wrapper for parity. + +```ts title="Signature" +type RateLimitStorageConfig = | RateLimitStorage + | { + driver: RateLimitStorage; + } +``` diff --git a/apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx b/apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx new file mode 100644 index 00000000..0e84cb1b --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx @@ -0,0 +1,19 @@ +--- +title: "$ckitirl" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## $ckitirl + + + +Wrapper symbol injected by the compiler plugin. + diff --git a/apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx b/apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx new file mode 100644 index 00000000..b7c45278 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx @@ -0,0 +1,19 @@ +--- +title: "DEFAULT_KEY_PREFIX" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## DEFAULT_KEY_PREFIX + + + +Default prefix for storage keys; can be overridden per config. + diff --git a/apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx b/apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx new file mode 100644 index 00000000..a45e9b8a --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx @@ -0,0 +1,19 @@ +--- +title: "DEFAULT_LIMITER" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## DEFAULT_LIMITER + + + +Default limiter used when no explicit configuration is provided. + diff --git a/apps/website/docs/api-reference/ratelimit/variables/index.mdx b/apps/website/docs/api-reference/ratelimit/variables/index.mdx new file mode 100644 index 00000000..44c32427 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/index.mdx @@ -0,0 +1,16 @@ +--- +title: "Variables" +isDefaultIndex: true +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +import DocCardList from '@theme/DocCardList'; + + \ No newline at end of file diff --git a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx new file mode 100644 index 00000000..1c75247a --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx @@ -0,0 +1,19 @@ +--- +title: "RATE_LIMIT_ALGORITHMS" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RATE_LIMIT_ALGORITHMS + + + +Algorithm identifiers used to select the limiter implementation. + diff --git a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx new file mode 100644 index 00000000..180b7eb9 --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx @@ -0,0 +1,19 @@ +--- +title: "RATE_LIMIT_EXEMPTION_SCOPES" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RATE_LIMIT_EXEMPTION_SCOPES + + + +Scopes eligible for temporary exemptions stored in rate limit storage. + diff --git a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx new file mode 100644 index 00000000..80cc649b --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx @@ -0,0 +1,19 @@ +--- +title: "RATE_LIMIT_SCOPES" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RATE_LIMIT_SCOPES + + + +Scopes used to build rate limit keys and apply per-scope limits. + diff --git a/apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx b/apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx new file mode 100644 index 00000000..627b04cd --- /dev/null +++ b/apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx @@ -0,0 +1,19 @@ +--- +title: "RATELIMIT_STORE_KEY" +isDefaultIndex: false +generated: true +--- + +import MemberInfo from '@site/src/components/MemberInfo'; +import GenerationInfo from '@site/src/components/GenerationInfo'; +import MemberDescription from '@site/src/components/MemberDescription'; + + + + +## RATELIMIT_STORE_KEY + + + +Store key used to stash aggregated results in CommandKit envs. + diff --git a/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx b/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx new file mode 100644 index 00000000..f2503f01 --- /dev/null +++ b/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx @@ -0,0 +1,643 @@ +--- +title: '@commandkit/ratelimit' +--- + +`@commandkit/ratelimit` is the official CommandKit plugin for advanced +rate limiting, with multi-window policies, role overrides, queueing, +exemptions, and multiple algorithms. + +## Installation + +```bash npm2yarn +npm install @commandkit/ratelimit +``` + +## Setup + +```ts title="src/ratelimit.ts" +import { configureRatelimit } from '@commandkit/ratelimit'; + +configureRatelimit({ + defaultLimiter: { + maxRequests: 5, + interval: '1m', + scope: 'user', + algorithm: 'fixed-window', + }, +}); +``` + +```ts title="commandkit.config.ts" +import { defineConfig } from 'commandkit'; +import { ratelimit } from '@commandkit/ratelimit'; + +export default defineConfig({ + plugins: [ratelimit()], +}); +``` + +The runtime plugin auto-loads `ratelimit.ts`/`ratelimit.js` on startup before +commands execute. + +Use `getRateLimitConfig()` to read the active configuration and +`isRateLimitConfigured()` to guard flows that depend on runtime setup. + +## Per-command configuration + +```ts title="src/app/commands/ping.ts" +export const metadata = { + ratelimit: { + maxRequests: 3, + interval: '10s', + scope: 'user', + algorithm: 'sliding-window', + }, +}; +``` + +## `ratelimit()` options + +The factory returns compiler + runtime plugins and accepts: + +- `compiler`: Options for the `"use ratelimit"` directive transformer. + +Runtime options are configured via `configureRatelimit()`. + +## Scopes and key format + +Scopes: + +- `user` +- `guild` +- `channel` +- `global` +- `user-guild` +- `custom` + +Key format per scope: + +- `user` -> `rl:user:{userId}:{commandName}` +- `guild` -> `rl:guild:{guildId}:{commandName}` +- `channel` -> `rl:channel:{channelId}:{commandName}` +- `global` -> `rl:global:{commandName}` +- `user-guild` -> `rl:user:{userId}:guild:{guildId}:{commandName}` +- `custom` -> `keyResolver(ctx, command, source)` + +If `keyPrefix` is provided, it is prepended before `rl:`. + +Multi-window limits add a suffix: `:w:{windowId}`. + +For `custom` scope you must provide `keyResolver`: + +```ts +const keyResolver = (ctx, command, source) => { + return `custom:${ctx.commandName}:${source.user?.id ?? 'unknown'}`; +}; +``` + +If `keyResolver` returns a falsy value, the limiter is skipped. + +Exemption keys use `rl:exempt:{scope}:{id}` with an optional `keyPrefix`. + +## Plugin options + +- `defaultLimiter`: Default limiter for all commands. +- `limiters`: Named limiter presets for command metadata `limiter`. +- `storage`: Storage driver or `{ driver }` wrapper. +- `keyPrefix`: Optional string prepended to keys. +- `keyResolver`: Resolver for `custom` scope. +- `bypass`: Bypass rules for users, roles, guilds, or a custom check. +- `hooks`: Lifecycle hooks for allowed, limited, reset, violation, storage error. +- `onRateLimited`: Custom response handler for rate-limited commands. +- `queue`: Queue settings for retrying instead of rejecting. +- `roleLimits`: Role-specific limiter overrides. +- `roleLimitStrategy`: `highest`, `lowest`, or `first`. +- `initializeDefaultStorage`: Enable default memory storage when no storage set. +- `initializeDefaultDriver`: Alias for `initializeDefaultStorage`. + +## Limiter options + +- `maxRequests`: Requests per interval. +- `interval`: Duration in ms or string. +- `scope`: Single scope or list of scopes. +- `algorithm`: `fixed-window`, `sliding-window`, `token-bucket`, `leaky-bucket`. +- `burst`: Capacity for token/leaky buckets. +- `refillRate`: Tokens/sec for token bucket. +- `leakRate`: Tokens/sec for leaky bucket. +- `keyResolver`: Custom scope key resolver. +- `keyPrefix`: Limiter-specific prefix. +- `storage`: Limiter-specific storage override. +- `violations`: Escalation policy. +- `queue`: Limiter-specific queue override. +- `windows`: Multi-window configuration. +- `roleLimits`: Limiter-specific role overrides. +- `roleLimitStrategy`: Limiter-specific role strategy. + +## Resolution order + +- Built-in defaults. +- `defaultLimiter`. +- Named limiter (when `metadata.ratelimit.limiter` is set). +- Command metadata overrides. +- Role limit overrides. + +## Algorithms + +- `fixed-window` uses counters per interval. +- `sliding-window` uses sorted sets and trims by time window. +- `token-bucket` refills tokens continuously. +- `leaky-bucket` leaks tokens continuously. +- Sliding-window sorted-set fallback is non-atomic under concurrency; implement `consumeSlidingWindowLog` for strict enforcement. +- `refillRate` must be greater than 0 for token buckets. +- `leakRate` must be greater than 0 for leaky buckets. + +Storage requirements: + +- `fixed-window` uses `consumeFixedWindow` or `incr`, with a `get/set` fallback. +- `sliding-window` requires sorted-set ops or `consumeSlidingWindowLog`. +- `token-bucket` and `leaky-bucket` use `get/set`. + +## Storage drivers + +`storage` accepts either a `RateLimitStorage` instance or `{ driver }`. + +### Memory storage + +```ts +import { + MemoryRateLimitStorage, + setRateLimitStorage, +} from '@commandkit/ratelimit'; + +setRateLimitStorage(new MemoryRateLimitStorage()); +``` + +### Redis storage + +```ts +import { setRateLimitStorage } from '@commandkit/ratelimit'; +import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; + +setRateLimitStorage( + new RedisRateLimitStorage({ host: 'localhost', port: 6379 }), +); +``` + +`@commandkit/ratelimit/redis` also re-exports `RedisOptions` from `ioredis`. + +### Fallback storage + +```ts +import { FallbackRateLimitStorage } from '@commandkit/ratelimit/fallback'; +import { MemoryRateLimitStorage } from '@commandkit/ratelimit/memory'; +import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; +import { setRateLimitStorage } from '@commandkit/ratelimit'; + +const primary = new RedisRateLimitStorage({ host: 'localhost', port: 6379 }); +const secondary = new MemoryRateLimitStorage(); + +setRateLimitStorage(new FallbackRateLimitStorage(primary, secondary)); +``` + +Fallback storage options: + +- `cooldownMs`: Log cooldown between primary errors (default 30s). + +Disable default memory storage: + +```ts +configureRatelimit({ + initializeDefaultStorage: false, + // or: initializeDefaultDriver: false +}); +``` + +## Storage interface and requirements + +`storage` accepts either a `RateLimitStorage` instance or `{ driver }`. + +Required methods: + +- `get`, `set`, `delete`. + +Optional methods used by features: + +- `incr` and `consumeFixedWindow` for fixed-window efficiency. +- `zAdd`, `zRemRangeByScore`, `zCard`, `zRangeByScore`, `consumeSlidingWindowLog` for sliding window. +- `ttl`, `expire` for expiry visibility. +- `deleteByPrefix`, `deleteByPattern`, `keysByPrefix` for resets and exemption listing. + +## Queue mode + +```ts +configureRatelimit({ + queue: { + enabled: true, + maxSize: 3, + timeout: '30s', + deferInteraction: true, + ephemeral: true, + concurrency: 1, + }, +}); +``` + +Queue options: + +- `enabled` +- `maxSize` +- `timeout` +- `deferInteraction` +- `ephemeral` +- `concurrency` + +If any queue config is provided and `enabled` is unset, it defaults to `true`. + +Queue defaults: + +- `maxSize`: 3 +- `timeout`: 30s +- `deferInteraction`: true +- `ephemeral`: true +- `concurrency`: 1 + +`deferInteraction` only applies to interactions (messages are ignored). + +`maxSize`, `timeout`, and `concurrency` are clamped to a minimum of 1. + +Queue resolution order is (later overrides earlier): + +- `queue` +- `defaultLimiter.queue` +- `named limiter queue` +- `command metadata queue` +- `role limit queue` + +## Role limits + +```ts +configureRatelimit({ + roleLimits: { + ROLE_ID_1: { maxRequests: 30, interval: '1m' }, + ROLE_ID_2: { maxRequests: 5, interval: '1m' }, + }, + roleLimitStrategy: 'highest', +}); +``` + +If no strategy is provided, `roleLimitStrategy` defaults to `highest`. + +Role scoring uses `maxRequests / intervalMs` (minimum across windows). + +## Multi-window limits + +```ts +configureRatelimit({ + defaultLimiter: { + scope: 'user', + algorithm: 'sliding-window', + windows: [ + { id: 'short', maxRequests: 10, interval: '1m' }, + { id: 'long', maxRequests: 1000, interval: '1d' }, + ], + }, +}); +``` + +## Violations and escalation + +```ts +configureRatelimit({ + defaultLimiter: { + maxRequests: 1, + interval: '10s', + violations: { + maxViolations: 5, + escalationMultiplier: 2, + resetAfter: '1h', + }, + }, +}); +``` + +Violation defaults and flags: + +- `escalate`: Defaults to true when `violations` is set. Set `false` to disable escalation. +- `maxViolations`: Default 5. +- `escalationMultiplier`: Default 2. +- `resetAfter`: Default 1h. + +## Hooks + +```ts +configureRatelimit({ + hooks: { + onAllowed: ({ key, result }) => { + console.log('allowed', key, result.remaining); + }, + onRateLimited: ({ key, result }) => { + console.log('limited', key, result.retryAfter); + }, + onViolation: (key, count) => { + console.log('violation', key, count); + }, + onReset: (key) => { + console.log('reset', key); + }, + onStorageError: (error, fallbackUsed) => { + console.error('storage error', error, fallbackUsed); + }, + }, +}); +``` + +## Analytics events + +- `ratelimit_allowed` +- `ratelimit_hit` +- `ratelimit_violation` + +## Events + +```ts +commandkit.events + .to('ratelimits') + .on('ratelimited', ({ key, result, source, aggregate, commandName, queued }) => { + console.log('ratelimited', key, commandName, queued, aggregate.retryAfter); + }); +``` + +## Bypass rules + +```ts +configureRatelimit({ + bypass: { + userIds: ['USER_ID'], + guildIds: ['GUILD_ID'], + roleIds: ['ROLE_ID'], + check: (source) => source.channelId === 'ALLOWLIST_CHANNEL', + }, +}); +``` + +## Custom rate-limited response + +```ts +configureRatelimit({ + onRateLimited: async (ctx, info) => { + await ctx.reply(`Cooldown: ${Math.ceil(info.retryAfter / 1000)}s`); + }, +}); +``` + +## Exemptions and resets + +```ts +import { + grantRateLimitExemption, + revokeRateLimitExemption, + listRateLimitExemptions, + resetRateLimit, + resetAllRateLimits, +} from '@commandkit/ratelimit'; + +await grantRateLimitExemption({ + scope: 'user', + id: 'USER_ID', + duration: '1h', +}); + +await revokeRateLimitExemption({ + scope: 'user', + id: 'USER_ID', +}); + +const exemptions = await listRateLimitExemptions({ scope: 'user' }); +``` + +All exemption helpers accept an optional `keyPrefix`. + +```ts +await resetRateLimit({ key: 'rl:user:USER_ID:ping' }); +await resetAllRateLimits({ commandName: 'ping' }); +``` + +Reset parameter notes: + +- `resetRateLimit` accepts either `key` or (`scope` + `commandName` + required IDs). +- `resetAllRateLimits` accepts `pattern`, `prefix`, `commandName`, or `scope` + IDs. +- `keyPrefix` can be passed to both reset helpers. + +Listing notes: + +- `listRateLimitExemptions({ scope, id })` checks a single key directly. +- `listRateLimitExemptions({ scope })` scans by prefix if supported. +- `limit` caps the number of results. +- `expiresInMs` is `null` if `ttl` is not supported. + +Supported exemption scopes: + +- `user` +- `guild` +- `role` +- `channel` +- `category` + +## Runtime helpers + +```ts +import { + getRateLimitInfo, + getRateLimitConfig, + isRateLimitConfigured, + setRateLimitStorage, + getRateLimitStorage, + setDriver, + getDriver, + setRateLimitRuntime, + getRateLimitRuntime, +} from '@commandkit/ratelimit'; + +export const chatInput = async (ctx) => { + const info = getRateLimitInfo(ctx); + if (info?.limited) { + console.log(info.retryAfter); + } +}; +``` + +Advanced runtime access is available via `setRateLimitRuntime` and +`getRateLimitRuntime`. + +## Result shape + +`RateLimitStoreValue` includes: + +- `limited`, `remaining`, `resetAt`, `retryAfter`. +- `results`: array of `RateLimitResult` entries. + +Each `RateLimitResult` includes: + +- `key`, `scope`, `algorithm`, optional `windowId`. +- `limited`, `remaining`, `resetAt`, `retryAfter`, `limit`. + +## Directive: `use ratelimit` + +```ts +import { RateLimitError } from '@commandkit/ratelimit'; + +const heavy = async () => { + 'use ratelimit'; + return 'ok'; +}; + +try { + await heavy(); +} catch (error) { + if (error instanceof RateLimitError) { + console.log(error.result.retryAfter); + } +} +``` + +The directive applies only to async functions. + +## RateLimitEngine reset + +`RateLimitEngine.reset(key)` removes both the main key and +`violation:{key}`. + +## HMR reset behavior + +When a command file is hot-reloaded, the runtime plugin clears that command's +rate-limit keys using `deleteByPattern` (including `violation:` and `:w:` variants). +If the storage does not support pattern deletes, nothing is cleared. + +## Behavior details and edge cases + +- `ratelimit()` returns `[UseRateLimitDirectivePlugin, RateLimitPlugin]` in that order. +- If required IDs are missing for a scope (for example no guild in DMs), that scope is skipped. +- `interval` is clamped to at least 1ms when resolving limiter config. +- `RateLimitResult.limit` is `burst` for token/leaky buckets and `maxRequests` for fixed/sliding windows. +- Default rate-limit response uses an embed titled `:hourglass_flowing_sand: You are on cooldown` with a relative timestamp. Interactions reply ephemerally (or follow up if already replied/deferred). Non-repliable interactions are skipped. Messages reply only if the channel is sendable. +- Queue behavior: queue size is pending + running; if `maxSize` is reached, the command is not queued and falls back to immediate rate-limit handling. Queued tasks stop after `timeout` and log a warning. After the initial delay, retries wait at least 250ms between checks. When queued, `ctx.capture()` and `onRateLimited`/`onViolation` hooks still run. +- Bypass order is user/guild/role lists, then temporary exemptions, then `bypass.check`. +- `roleLimitStrategy: 'first'` respects object insertion order. Role limits merge in this order: plugin `roleLimits` -> `defaultLimiter.roleLimits` -> named limiter `roleLimits` -> command overrides. +- `resetRateLimit` triggers `hooks.onReset` for the key; `resetAllRateLimits` does not. +- `onStorageError` is invoked with `fallbackUsed = false` from runtime plugin calls. +- `grantRateLimitExemption` uses the runtime `keyPrefix` by default unless `keyPrefix` is provided. +- `RateLimitError` defaults to message `Rate limit exceeded`. +- If no storage is configured and default storage is disabled, the plugin logs once and stores an empty `RateLimitStoreValue` without limiting. +- `FallbackRateLimitStorage` throws if either storage does not support an optional operation. +- `MemoryRateLimitStorage.deleteByPattern` supports `*` wildcards (simple glob). + +## Duration units + +String durations support `ms`, `s`, `m`, `h`, `d` via `ms`, plus: + +- `w`, `week`, `weeks` +- `mo`, `month`, `months` + +## Constants + +- `RATELIMIT_STORE_KEY`: `ratelimit` store key for aggregated results. +- `DEFAULT_KEY_PREFIX`: `rl:` prefix used in generated keys. + +## Type reference (exported) + +- `RateLimitScope` and `RATE_LIMIT_SCOPES`: Scope values used in keys. +- `RateLimitExemptionScope` and `RATE_LIMIT_EXEMPTION_SCOPES`: Exemption scopes. +- `RateLimitAlgorithmType` and `RATE_LIMIT_ALGORITHMS`: Algorithm identifiers. +- `DurationLike`: Number in ms or duration string. +- `RateLimitQueueOptions`: Queue settings for retries. +- `RateLimitRoleLimitStrategy`: `highest`, `lowest`, or `first`. +- `RateLimitResult`: Result for a single limiter/window. +- `RateLimitAlgorithm`: Interface for algorithm implementations. +- `FixedWindowConsumeResult` and `SlidingWindowConsumeResult`: Storage consume return types. +- `RateLimitStorage` and `RateLimitStorageConfig`: Storage interface and wrapper. +- `ViolationOptions`: Escalation controls. +- `RateLimitWindowConfig`: Per-window limiter config. +- `RateLimitKeyResolver`: Custom scope key resolver signature. +- `RateLimitLimiterConfig`: Base limiter configuration. +- `RateLimitCommandConfig`: Limiter config plus `limiter` name. +- `RateLimitBypassOptions`: Bypass lists and optional `check`. +- `RateLimitExemptionGrantParams`, `RateLimitExemptionRevokeParams`, `RateLimitExemptionListParams`: Exemption helper params. +- `RateLimitExemptionInfo`: Exemption listing entry shape. +- `RateLimitHookContext` and `RateLimitHooks`: Hook payloads and callbacks. +- `RateLimitResponseHandler`: `onRateLimited` handler signature. +- `RateLimitPluginOptions`: Runtime plugin options. +- `RateLimitStoreValue`: Aggregated results stored in `env.store`. +- `ResolvedLimiterConfig`: Resolved limiter config with defaults and `intervalMs`. +- `RateLimitRuntimeContext`: Active runtime state. + +## Exports + +- `ratelimit` plugin factory (compiler + runtime). +- `RateLimitPlugin` and `UseRateLimitDirectivePlugin`. +- `RateLimitEngine`, algorithm classes, and `ViolationTracker`. +- Storage implementations and helpers. +- Runtime helpers and API helpers. +- `RateLimitError`. + +## Defaults + +- `maxRequests`: 10 +- `interval`: 60s +- `algorithm`: `fixed-window` +- `scope`: `user` +- `initializeDefaultStorage`: true + +## Subpath exports + +- `@commandkit/ratelimit/redis` +- `@commandkit/ratelimit/memory` +- `@commandkit/ratelimit/fallback` + +## Manual testing + +- Configure `maxRequests: 1` and `interval: '5s'`. +- Trigger the command twice and confirm a cooldown response. +- Enable queue mode and verify the second call is deferred and executes later. +- Grant an exemption and verify the user bypasses limits. +- Reset the command and verify the cooldown clears immediately. + +## Complete source map (packages/ratelimit) + +
+All source files and what they provide + +- `packages/ratelimit/src/index.ts`: Package entrypoint and public re-exports. +- `packages/ratelimit/src/augmentation.ts`: `CommandMetadata` augmentation for `metadata.ratelimit`. +- `packages/ratelimit/src/configure.ts`: `configureRatelimit`, `getRateLimitConfig`, `isRateLimitConfigured`, runtime updates. +- `packages/ratelimit/src/runtime.ts`: Runtime storage accessors plus `setDriver`/`getDriver`. +- `packages/ratelimit/src/plugin.ts`: Runtime plugin logic (resolution, queueing, hooks, responses, analytics/events, HMR resets). +- `packages/ratelimit/src/directive/use-ratelimit-directive.ts`: Compiler plugin for `"use ratelimit"`. +- `packages/ratelimit/src/directive/use-ratelimit.ts`: Runtime directive wrapper using `RateLimitEngine`. +- `packages/ratelimit/src/api.ts`: Public helpers for info, resets, and exemptions. +- `packages/ratelimit/src/types.ts`: All exported config/result/storage types. +- `packages/ratelimit/src/constants.ts`: `RATELIMIT_STORE_KEY` and `DEFAULT_KEY_PREFIX`. +- `packages/ratelimit/src/errors.ts`: `RateLimitError`. +- `packages/ratelimit/src/engine/RateLimitEngine.ts`: Algorithm selection and violation escalation. +- `packages/ratelimit/src/engine/violations.ts`: `ViolationTracker` and escalation state. +- `packages/ratelimit/src/engine/algorithms/fixed-window.ts`: Fixed-window algorithm. +- `packages/ratelimit/src/engine/algorithms/sliding-window.ts`: Sliding-window log algorithm. +- `packages/ratelimit/src/engine/algorithms/token-bucket.ts`: Token-bucket algorithm. +- `packages/ratelimit/src/engine/algorithms/leaky-bucket.ts`: Leaky-bucket algorithm. +- `packages/ratelimit/src/storage/memory.ts`: In-memory storage with TTL and sorted-set helpers. +- `packages/ratelimit/src/storage/redis.ts`: Redis storage with Lua scripts for atomic windows. +- `packages/ratelimit/src/storage/fallback.ts`: Fallback storage wrapper with cooldown logging. +- `packages/ratelimit/src/providers/memory.ts`: Subpath export for memory storage. +- `packages/ratelimit/src/providers/redis.ts`: Subpath export for Redis storage. +- `packages/ratelimit/src/providers/fallback.ts`: Subpath export for fallback storage. +- `packages/ratelimit/src/utils/config.ts`: Defaults, normalization, multi-window resolution, role-limit merging. +- `packages/ratelimit/src/utils/keys.ts`: Key building and parsing for scopes/exemptions. +- `packages/ratelimit/src/utils/time.ts`: Duration parsing and clamp helpers. +- `packages/ratelimit/src/utils/locking.ts`: Per-storage keyed mutex for fallback algorithm serialization. +- `packages/ratelimit/spec/setup.ts`: Shared test setup for vitest. +- `packages/ratelimit/spec/helpers.ts`: Test helpers and stubs. +- `packages/ratelimit/spec/algorithms.test.ts`: Algorithm integration tests. +- `packages/ratelimit/spec/engine.test.ts`: Engine + violation behavior tests. +- `packages/ratelimit/spec/api.test.ts`: API helper tests. +- `packages/ratelimit/spec/plugin.test.ts`: Runtime plugin behavior tests. + +
diff --git a/scripts/docs/generate-typescript-docs.ts b/scripts/docs/generate-typescript-docs.ts index 5f342b38..17ceca26 100644 --- a/scripts/docs/generate-typescript-docs.ts +++ b/scripts/docs/generate-typescript-docs.ts @@ -62,6 +62,11 @@ const sections: DocsSectionConfig[] = [ outputPath: '', category: 'tasks', }, + { + sourceDirs: ['packages/ratelimit/src/'], + outputPath: '', + category: 'ratelimit', + }, ]; generateTypescriptDocs(sections); From 87ece894945215e803cc9419db01278a07779643 Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:52:26 +0100 Subject: [PATCH 03/10] chore(test-bot): add ratelimit demos --- apps/test-bot/commandkit.config.ts | 2 + apps/test-bot/package.json | 1 + .../app/commands/(general)/(animal)/cat.ts | 7 + .../app/commands/(general)/ratelimit-admin.ts | 155 ++++++++++++++++++ .../app/commands/(general)/ratelimit-basic.ts | 47 ++++++ .../commands/(general)/ratelimit-directive.ts | 35 ++++ .../app/commands/(general)/ratelimit-queue.ts | 32 ++++ apps/test-bot/src/app/commands/translate.ts | 19 ++- .../events/(ratelimits)/ratelimited/logger.ts | 25 +++ apps/test-bot/src/ratelimit.ts | 58 +++++++ 10 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 apps/test-bot/src/app/commands/(general)/ratelimit-admin.ts create mode 100644 apps/test-bot/src/app/commands/(general)/ratelimit-basic.ts create mode 100644 apps/test-bot/src/app/commands/(general)/ratelimit-directive.ts create mode 100644 apps/test-bot/src/app/commands/(general)/ratelimit-queue.ts create mode 100644 apps/test-bot/src/app/events/(ratelimits)/ratelimited/logger.ts create mode 100644 apps/test-bot/src/ratelimit.ts diff --git a/apps/test-bot/commandkit.config.ts b/apps/test-bot/commandkit.config.ts index 91eac5dc..d9dc8189 100644 --- a/apps/test-bot/commandkit.config.ts +++ b/apps/test-bot/commandkit.config.ts @@ -6,6 +6,7 @@ import { ai } from '@commandkit/ai'; import { tasks, setDriver } from '@commandkit/tasks'; import { SQLiteDriver } from '@commandkit/tasks/sqlite'; import { workflow } from '@commandkit/workflow'; +import { ratelimit } from '@commandkit/ratelimit'; noBuildOnly(() => { setDriver(new SQLiteDriver()); @@ -21,6 +22,7 @@ export default defineConfig({ i18n(), devtools(), cache(), + ratelimit(), ai(), tasks({ initializeDefaultDriver: false, diff --git a/apps/test-bot/package.json b/apps/test-bot/package.json index afcbf6bc..96103de7 100644 --- a/apps/test-bot/package.json +++ b/apps/test-bot/package.json @@ -22,6 +22,7 @@ "@commandkit/devtools": "workspace:*", "@commandkit/i18n": "workspace:*", "@commandkit/legacy": "workspace:*", + "@commandkit/ratelimit": "workspace:*", "@commandkit/tasks": "workspace:*", "@commandkit/workflow": "workspace:*", "commandkit": "workspace:*", diff --git a/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts b/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts index c378aea5..fab7f0f9 100644 --- a/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts +++ b/apps/test-bot/src/app/commands/(general)/(animal)/cat.ts @@ -1,6 +1,7 @@ import type { ChatInputCommand, CommandData, + CommandMetadata, MessageCommand, MessageContextMenuCommand, } from 'commandkit'; @@ -24,6 +25,12 @@ export const command: CommandData = { ], }; +export const metadata: CommandMetadata = { + nameAliases: { + message: 'Cat Message', + }, +}; + export const messageContextMenu: MessageContextMenuCommand = async (ctx) => { const content = ctx.interaction.targetMessage.content || 'No content found'; diff --git a/apps/test-bot/src/app/commands/(general)/ratelimit-admin.ts b/apps/test-bot/src/app/commands/(general)/ratelimit-admin.ts new file mode 100644 index 00000000..6a6fc431 --- /dev/null +++ b/apps/test-bot/src/app/commands/(general)/ratelimit-admin.ts @@ -0,0 +1,155 @@ +// Admin/demo command for managing rate limit exemptions and resets. +// +// Keeps the workflows in one place for test-bot demos. + +import type { ChatInputCommand, CommandData } from 'commandkit'; +import { ApplicationCommandOptionType, PermissionFlagsBits } from 'discord.js'; +import { + grantRateLimitExemption, + listRateLimitExemptions, + resetAllRateLimits, + resetRateLimit, + revokeRateLimitExemption, +} from '@commandkit/ratelimit'; + +const actions = ['grant', 'revoke', 'list', 'reset', 'resetAll'] as const; +type Action = (typeof actions)[number]; + +const actionChoices = actions.map((action) => ({ + name: action, + value: action, +})); + +const isAction = (value: string): value is Action => + actions.includes(value as Action); + +const demoCommandName = 'ratelimit-basic'; + +export const command: CommandData = { + name: 'ratelimit-admin', + description: 'Manage rate limit exemptions and resets for demos.', + options: [ + { + name: 'action', + description: 'Action to perform.', + type: ApplicationCommandOptionType.String, + required: true, + choices: actionChoices, + }, + { + name: 'duration', + description: 'Exemption duration (ex: 1m, 10m, 1h).', + type: ApplicationCommandOptionType.String, + required: false, + }, + ], +}; + +export const chatInput: ChatInputCommand = async (ctx) => { + const hasAdminPermission = ctx.interaction.memberPermissions?.has( + PermissionFlagsBits.Administrator, + ); + if (!hasAdminPermission) { + await ctx.interaction.reply({ + content: 'You are not authorized to use this command.', + ephemeral: true, + }); + return; + } + + const actionValue = ctx.options.getString('action', true); + if (!isAction(actionValue)) { + await ctx.interaction.reply({ + content: `Unknown action: ${actionValue}`, + ephemeral: true, + }); + return; + } + + const action = actionValue; + const duration = ctx.options.getString('duration') ?? '1m'; + const userId = ctx.interaction.user.id; + + try { + switch (action) { + case 'grant': { + await grantRateLimitExemption({ + scope: 'user', + id: userId, + duration, + }); + + await ctx.interaction.reply({ + content: `Granted user exemption for ${duration}.`, + ephemeral: true, + }); + return; + } + case 'revoke': { + await revokeRateLimitExemption({ + scope: 'user', + id: userId, + }); + + await ctx.interaction.reply({ + content: 'Revoked user exemption.', + ephemeral: true, + }); + return; + } + case 'list': { + const exemptions = await listRateLimitExemptions({ + scope: 'user', + id: userId, + }); + + const lines = [`Exemptions: ${exemptions.length}`]; + for (const exemption of exemptions) { + const expiresIn = + exemption.expiresInMs === null + ? 'unknown' + : `${Math.ceil(exemption.expiresInMs / 1000)}s`; + lines.push(`expiresIn: ${expiresIn}`); + } + + await ctx.interaction.reply({ + content: lines.join('\n'), + ephemeral: true, + }); + return; + } + case 'reset': { + await resetRateLimit({ + scope: 'user', + userId, + commandName: demoCommandName, + }); + + await ctx.interaction.reply({ + content: `Reset rate limit for ${demoCommandName}.`, + ephemeral: true, + }); + return; + } + case 'resetAll': { + await resetAllRateLimits({ commandName: demoCommandName }); + + await ctx.interaction.reply({ + content: `Reset all rate limits for ${demoCommandName}.`, + ephemeral: true, + }); + return; + } + default: { + const _exhaustive: never = action; + throw new Error(`Unsupported action: ${_exhaustive}`); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + await ctx.interaction.reply({ + content: `Ratelimit admin error: ${message}`, + ephemeral: true, + }); + } +}; diff --git a/apps/test-bot/src/app/commands/(general)/ratelimit-basic.ts b/apps/test-bot/src/app/commands/(general)/ratelimit-basic.ts new file mode 100644 index 00000000..9b8f0b40 --- /dev/null +++ b/apps/test-bot/src/app/commands/(general)/ratelimit-basic.ts @@ -0,0 +1,47 @@ +// Demo command for reading aggregated rate limit info. +// +// Reports remaining/reset values captured in the env store. + +import type { ChatInputCommand, CommandData, CommandMetadata } from 'commandkit'; +import { getRateLimitInfo } from '@commandkit/ratelimit'; + +export const command: CommandData = { + name: 'ratelimit-basic', + description: 'Hit a strict limiter and show remaining/reset info.', +}; + +export const metadata: CommandMetadata = { + ratelimit: { + limiter: 'strict', + }, +}; + +export const chatInput: ChatInputCommand = async (ctx) => { + const info = getRateLimitInfo(ctx); + + if (!info) { + await ctx.interaction.reply({ + content: + 'No rate limit info was found. Ensure the ratelimit() plugin is enabled.', + }); + return; + } + + const now = Date.now(); + const resetAt = info.resetAt + ? new Date(info.resetAt).toISOString() + : 'n/a'; + const resetInMs = info.resetAt ? Math.max(0, info.resetAt - now) : 0; + + const lines = [ + `limited: ${info.limited}`, + `remaining: ${info.remaining}`, + `retryAfterMs: ${info.retryAfter}`, + `resetAt: ${resetAt}`, + `resetInMs: ${resetInMs}`, + ]; + + await ctx.interaction.reply({ + content: lines.join('\n'), + }); +}; diff --git a/apps/test-bot/src/app/commands/(general)/ratelimit-directive.ts b/apps/test-bot/src/app/commands/(general)/ratelimit-directive.ts new file mode 100644 index 00000000..eaf767c4 --- /dev/null +++ b/apps/test-bot/src/app/commands/(general)/ratelimit-directive.ts @@ -0,0 +1,35 @@ +// Demo for the "use ratelimit" directive. +// +// Catches RateLimitError and replies with retry info. + +import type { ChatInputCommand, CommandData } from 'commandkit'; +import { RateLimitError } from '@commandkit/ratelimit'; + +export const command: CommandData = { + name: 'ratelimit-directive', + description: 'Demo the use ratelimit directive on a helper function.', +}; + +const doWork = async () => { + 'use ratelimit'; + return `work-${Date.now()}`; +}; + +export const chatInput: ChatInputCommand = async (ctx) => { + try { + const value = await doWork(); + await ctx.interaction.reply({ + content: `Directive call succeeded: ${value}`, + }); + } catch (error) { + if (error instanceof RateLimitError) { + const retrySeconds = Math.ceil(error.result.retryAfter / 1000); + await ctx.interaction.reply({ + content: `Rate limited. Retry after ${retrySeconds}s.`, + }); + return; + } + + throw error; + } +}; diff --git a/apps/test-bot/src/app/commands/(general)/ratelimit-queue.ts b/apps/test-bot/src/app/commands/(general)/ratelimit-queue.ts new file mode 100644 index 00000000..bc1d8dee --- /dev/null +++ b/apps/test-bot/src/app/commands/(general)/ratelimit-queue.ts @@ -0,0 +1,32 @@ +// Demo command for queued rate limiting. +// +// Shows queue delay by comparing interaction creation vs handling time. + +import type { ChatInputCommand, CommandData, CommandMetadata } from 'commandkit'; + +export const command: CommandData = { + name: 'ratelimit-queue', + description: 'Demo queued rate limiting with timestamps.', +}; + +export const metadata: CommandMetadata = { + ratelimit: { + limiter: 'queued', + }, +}; + +export const chatInput: ChatInputCommand = async (ctx) => { + const createdAtMs = ctx.interaction.createdTimestamp; + const handledAtMs = Date.now(); + const delayMs = Math.max(0, handledAtMs - createdAtMs); + + const lines = [ + `createdAt: ${new Date(createdAtMs).toISOString()}`, + `handledAt: ${new Date(handledAtMs).toISOString()}`, + `delayMs: ${delayMs}`, + ]; + + await ctx.interaction.reply({ + content: lines.join('\n'), + }); +}; diff --git a/apps/test-bot/src/app/commands/translate.ts b/apps/test-bot/src/app/commands/translate.ts index 60999975..f97c1803 100644 --- a/apps/test-bot/src/app/commands/translate.ts +++ b/apps/test-bot/src/app/commands/translate.ts @@ -1,18 +1,23 @@ import { ChatInputCommand, + CommandData, + CommandMetadata, MessageCommand, MessageContextMenuCommand, UserContextMenuCommand, } from 'commandkit'; -import { ApplicationCommandType, ContextMenuCommandBuilder } from 'discord.js'; -export const command = new ContextMenuCommandBuilder() - .setName('translate') - .setType(ApplicationCommandType.User); +export const command: CommandData = { + name: 'translate', + description: 'translate command', +}; -// export const command: CommandData = { -// name: 'translate', -// }; +export const metadata: CommandMetadata = { + nameAliases: { + user: 'Translate User', + message: 'Translate Message', + }, +}; export const userContextMenu: UserContextMenuCommand = async ({ interaction, diff --git a/apps/test-bot/src/app/events/(ratelimits)/ratelimited/logger.ts b/apps/test-bot/src/app/events/(ratelimits)/ratelimited/logger.ts new file mode 100644 index 00000000..1c83eac5 --- /dev/null +++ b/apps/test-bot/src/app/events/(ratelimits)/ratelimited/logger.ts @@ -0,0 +1,25 @@ +// Ratelimit event logger for the test bot. +// +// Logs aggregated retry info when commands are blocked. + +import { Logger } from 'commandkit'; +import type { RateLimitResult, RateLimitStoreValue } from '@commandkit/ratelimit'; +import type { Interaction, Message } from 'discord.js'; + +type RateLimitedEventPayload = { + key: string; + result: RateLimitResult; + source: Interaction | Message; + aggregate: RateLimitStoreValue; + commandName: string; + queued: boolean; +}; + +const handler = (payload: RateLimitedEventPayload) => { + const { key, aggregate, commandName, queued } = payload; + Logger.warn( + `[ratelimit] ratelimited ${key} command=${commandName} queued=${queued} retryAfter=${aggregate.retryAfter}ms`, + ); +}; + +export default handler; diff --git a/apps/test-bot/src/ratelimit.ts b/apps/test-bot/src/ratelimit.ts new file mode 100644 index 00000000..5860e9dd --- /dev/null +++ b/apps/test-bot/src/ratelimit.ts @@ -0,0 +1,58 @@ +// Demo ratelimit configuration for the test bot. +// +// Exercises defaults, a strict limiter, and queued retries with hooks logging. + +import { configureRatelimit } from '@commandkit/ratelimit'; +import { Logger } from 'commandkit'; + +const formatError = (error: unknown): string => { + if (error instanceof Error) return error.message; + return String(error); +}; + +configureRatelimit({ + defaultLimiter: { + maxRequests: 10, + interval: '30s', + scope: 'user', + }, + limiters: { + strict: { + maxRequests: 2, + interval: '1m', + scope: 'user', + }, + queued: { + maxRequests: 1, + interval: '5s', + scope: 'user', + queue: { + enabled: true, + maxSize: 3, + timeout: '20s', + deferInteraction: true, + ephemeral: true, + concurrency: 1, + }, + }, + }, + hooks: { + onAllowed: ({ key, result }) => { + Logger.info(`[ratelimit] allowed ${key} remaining=${result.remaining}`); + }, + onRateLimited: ({ key, result }) => { + Logger.warn(`[ratelimit] limited ${key} retryAfter=${result.retryAfter}ms`); + }, + onViolation: (key, count) => { + Logger.warn(`[ratelimit] violation ${key} count=${count}`); + }, + onReset: (key) => { + Logger.info(`[ratelimit] reset ${key}`); + }, + onStorageError: (error, fallbackUsed) => { + Logger.error( + `[ratelimit] storage error fallback=${fallbackUsed} error=${formatError(error)}`, + ); + }, + }, +}); From d1909a9f8d502932c8ae13e410c7a947d0235364 Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Fri, 13 Mar 2026 23:53:02 +0100 Subject: [PATCH 04/10] chore(deps): update pnpm lockfile --- pnpm-lock.yaml | 380 +++++-------------------------------------------- 1 file changed, 37 insertions(+), 343 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e6c90c99..df86a677 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -120,6 +120,9 @@ importers: '@commandkit/legacy': specifier: workspace:* version: link:../../packages/legacy + '@commandkit/ratelimit': + specifier: workspace:* + version: link:../../packages/ratelimit '@commandkit/tasks': specifier: workspace:* version: link:../../packages/tasks @@ -649,6 +652,34 @@ importers: specifier: catalog:build version: 5.9.3 + packages/ratelimit: + dependencies: + ioredis: + specifier: ^5.10.0 + version: 5.10.0 + ms: + specifier: ^2.1.3 + version: 2.1.3 + devDependencies: + '@types/ms': + specifier: ^2.1.0 + version: 2.1.0 + commandkit: + specifier: workspace:* + version: link:../commandkit + discord.js: + specifier: catalog:discordjs + version: 14.25.1 + tsconfig: + specifier: workspace:* + version: link:../tsconfig + typescript: + specifier: catalog:build + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2) + packages/redis: dependencies: ioredis: @@ -2121,312 +2152,156 @@ packages: '@epic-web/invariant@1.0.0': resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@esbuild/aix-ppc64@0.25.11': - resolution: {integrity: sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.25.11': - resolution: {integrity: sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.27.3': resolution: {integrity: sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.25.11': - resolution: {integrity: sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.27.3': resolution: {integrity: sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.25.11': - resolution: {integrity: sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.27.3': resolution: {integrity: sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.25.11': - resolution: {integrity: sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.27.3': resolution: {integrity: sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.25.11': - resolution: {integrity: sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.27.3': resolution: {integrity: sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.25.11': - resolution: {integrity: sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.27.3': resolution: {integrity: sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.25.11': - resolution: {integrity: sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.27.3': resolution: {integrity: sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.25.11': - resolution: {integrity: sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.27.3': resolution: {integrity: sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.25.11': - resolution: {integrity: sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.27.3': resolution: {integrity: sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.25.11': - resolution: {integrity: sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.27.3': resolution: {integrity: sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.25.11': - resolution: {integrity: sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.27.3': resolution: {integrity: sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.25.11': - resolution: {integrity: sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.27.3': resolution: {integrity: sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.25.11': - resolution: {integrity: sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.27.3': resolution: {integrity: sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.25.11': - resolution: {integrity: sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.27.3': resolution: {integrity: sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.25.11': - resolution: {integrity: sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.27.3': resolution: {integrity: sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.25.11': - resolution: {integrity: sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.27.3': resolution: {integrity: sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.25.11': - resolution: {integrity: sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.27.3': resolution: {integrity: sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.25.11': - resolution: {integrity: sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.27.3': resolution: {integrity: sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.25.11': - resolution: {integrity: sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.27.3': resolution: {integrity: sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.25.11': - resolution: {integrity: sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.27.3': resolution: {integrity: sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.25.11': - resolution: {integrity: sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.27.3': resolution: {integrity: sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.25.11': - resolution: {integrity: sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.27.3': resolution: {integrity: sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.25.11': - resolution: {integrity: sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.27.3': resolution: {integrity: sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.25.11': - resolution: {integrity: sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.27.3': resolution: {integrity: sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.25.11': - resolution: {integrity: sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.27.3': resolution: {integrity: sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==} engines: {node: '>=18'} @@ -6735,11 +6610,6 @@ packages: esast-util-from-js@2.0.1: resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} - esbuild@0.25.11: - resolution: {integrity: sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} engines: {node: '>=18'} @@ -8158,9 +8028,6 @@ packages: magic-bytes.js@1.12.1: resolution: {integrity: sha512-ThQLOhN86ZkJ7qemtVRGYM+gRgR8GEXNli9H/PMvpnZsE44Xfh3wx9kGJaldg314v85m+bFW6WBMaVHJc/c3zA==} - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -10100,9 +9967,6 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} - std-env@3.9.0: - resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} - stdin-discarder@0.2.2: resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} engines: {node: '>=18'} @@ -10771,46 +10635,6 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} - vite@7.1.11: - resolution: {integrity: sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -13551,159 +13375,81 @@ snapshots: '@epic-web/invariant@1.0.0': {} - '@esbuild/aix-ppc64@0.25.11': - optional: true - '@esbuild/aix-ppc64@0.27.3': optional: true - '@esbuild/android-arm64@0.25.11': - optional: true - '@esbuild/android-arm64@0.27.3': optional: true - '@esbuild/android-arm@0.25.11': - optional: true - '@esbuild/android-arm@0.27.3': optional: true - '@esbuild/android-x64@0.25.11': - optional: true - '@esbuild/android-x64@0.27.3': optional: true - '@esbuild/darwin-arm64@0.25.11': - optional: true - '@esbuild/darwin-arm64@0.27.3': optional: true - '@esbuild/darwin-x64@0.25.11': - optional: true - '@esbuild/darwin-x64@0.27.3': optional: true - '@esbuild/freebsd-arm64@0.25.11': - optional: true - '@esbuild/freebsd-arm64@0.27.3': optional: true - '@esbuild/freebsd-x64@0.25.11': - optional: true - '@esbuild/freebsd-x64@0.27.3': optional: true - '@esbuild/linux-arm64@0.25.11': - optional: true - '@esbuild/linux-arm64@0.27.3': optional: true - '@esbuild/linux-arm@0.25.11': - optional: true - '@esbuild/linux-arm@0.27.3': optional: true - '@esbuild/linux-ia32@0.25.11': - optional: true - '@esbuild/linux-ia32@0.27.3': optional: true - '@esbuild/linux-loong64@0.25.11': - optional: true - '@esbuild/linux-loong64@0.27.3': optional: true - '@esbuild/linux-mips64el@0.25.11': - optional: true - '@esbuild/linux-mips64el@0.27.3': optional: true - '@esbuild/linux-ppc64@0.25.11': - optional: true - '@esbuild/linux-ppc64@0.27.3': optional: true - '@esbuild/linux-riscv64@0.25.11': - optional: true - '@esbuild/linux-riscv64@0.27.3': optional: true - '@esbuild/linux-s390x@0.25.11': - optional: true - '@esbuild/linux-s390x@0.27.3': optional: true - '@esbuild/linux-x64@0.25.11': - optional: true - '@esbuild/linux-x64@0.27.3': optional: true - '@esbuild/netbsd-arm64@0.25.11': - optional: true - '@esbuild/netbsd-arm64@0.27.3': optional: true - '@esbuild/netbsd-x64@0.25.11': - optional: true - '@esbuild/netbsd-x64@0.27.3': optional: true - '@esbuild/openbsd-arm64@0.25.11': - optional: true - '@esbuild/openbsd-arm64@0.27.3': optional: true - '@esbuild/openbsd-x64@0.25.11': - optional: true - '@esbuild/openbsd-x64@0.27.3': optional: true - '@esbuild/openharmony-arm64@0.25.11': - optional: true - '@esbuild/openharmony-arm64@0.27.3': optional: true - '@esbuild/sunos-x64@0.25.11': - optional: true - '@esbuild/sunos-x64@0.27.3': optional: true - '@esbuild/win32-arm64@0.25.11': - optional: true - '@esbuild/win32-arm64@0.27.3': optional: true - '@esbuild/win32-ia32@0.25.11': - optional: true - '@esbuild/win32-ia32@0.27.3': optional: true - '@esbuild/win32-x64@0.25.11': - optional: true - '@esbuild/win32-x64@0.27.3': optional: true @@ -15668,7 +15414,7 @@ snapshots: enhanced-resolve: 5.18.3 jiti: 2.6.1 lightningcss: 1.30.1 - magic-string: 0.30.19 + magic-string: 0.30.21 source-map-js: 1.2.1 tailwindcss: 4.1.14 @@ -16468,13 +16214,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.18(vite@7.1.11(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.1.11(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -18488,35 +18234,6 @@ snapshots: esast-util-from-estree: 2.0.0 vfile-message: 4.0.2 - esbuild@0.25.11: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.11 - '@esbuild/android-arm': 0.25.11 - '@esbuild/android-arm64': 0.25.11 - '@esbuild/android-x64': 0.25.11 - '@esbuild/darwin-arm64': 0.25.11 - '@esbuild/darwin-x64': 0.25.11 - '@esbuild/freebsd-arm64': 0.25.11 - '@esbuild/freebsd-x64': 0.25.11 - '@esbuild/linux-arm': 0.25.11 - '@esbuild/linux-arm64': 0.25.11 - '@esbuild/linux-ia32': 0.25.11 - '@esbuild/linux-loong64': 0.25.11 - '@esbuild/linux-mips64el': 0.25.11 - '@esbuild/linux-ppc64': 0.25.11 - '@esbuild/linux-riscv64': 0.25.11 - '@esbuild/linux-s390x': 0.25.11 - '@esbuild/linux-x64': 0.25.11 - '@esbuild/netbsd-arm64': 0.25.11 - '@esbuild/netbsd-x64': 0.25.11 - '@esbuild/openbsd-arm64': 0.25.11 - '@esbuild/openbsd-x64': 0.25.11 - '@esbuild/openharmony-arm64': 0.25.11 - '@esbuild/sunos-x64': 0.25.11 - '@esbuild/win32-arm64': 0.25.11 - '@esbuild/win32-ia32': 0.25.11 - '@esbuild/win32-x64': 0.25.11 - esbuild@0.27.3: optionalDependencies: '@esbuild/aix-ppc64': 0.27.3 @@ -20067,10 +19784,6 @@ snapshots: magic-bytes.js@1.12.1: {} - magic-string@0.30.19: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -22526,8 +22239,6 @@ snapshots: std-env@3.10.0: {} - std-env@3.9.0: {} - stdin-discarder@0.2.2: {} stdin-discarder@0.3.1: {} @@ -23240,23 +22951,6 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.1.11(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2): - dependencies: - esbuild: 0.25.11 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.59.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 25.3.2 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.31.1 - terser: 5.39.2 - tsx: 4.21.0 - yaml: 2.8.2 - vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 @@ -23277,7 +22971,7 @@ snapshots: vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.1.11(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -23294,7 +22988,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.1.11(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.2)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.39.2)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -23471,7 +23165,7 @@ snapshots: figures: 3.2.0 markdown-table: 2.0.0 pretty-time: 1.1.0 - std-env: 3.9.0 + std-env: 3.10.0 webpack: 5.105.3 wrap-ansi: 7.0.0 From cf99c084f1006908a13a4caf5a9aa6b8a380a6f3 Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Sat, 14 Mar 2026 14:57:26 +0100 Subject: [PATCH 05/10] docs(ratelimit): improve TSDoc coverage --- packages/ratelimit/spec/algorithms.test.ts | 18 +- packages/ratelimit/spec/api.test.ts | 8 +- packages/ratelimit/spec/engine.test.ts | 8 +- packages/ratelimit/spec/helpers.ts | 26 ++- packages/ratelimit/spec/plugin.test.ts | 8 +- packages/ratelimit/spec/setup.ts | 8 +- packages/ratelimit/src/api.ts | 56 ++++- packages/ratelimit/src/augmentation.ts | 8 +- packages/ratelimit/src/configure.ts | 30 ++- packages/ratelimit/src/constants.ts | 12 +- .../src/directive/use-ratelimit-directive.ts | 15 +- .../ratelimit/src/directive/use-ratelimit.ts | 61 +++++- .../ratelimit/src/engine/RateLimitEngine.ts | 41 +++- .../src/engine/algorithms/fixed-window.ts | 39 +++- .../src/engine/algorithms/leaky-bucket.ts | 41 +++- .../src/engine/algorithms/sliding-window.ts | 40 +++- .../src/engine/algorithms/token-bucket.ts | 41 +++- packages/ratelimit/src/engine/violations.ts | 42 +++- packages/ratelimit/src/errors.ts | 16 +- packages/ratelimit/src/index.ts | 6 +- packages/ratelimit/src/plugin.ts | 194 +++++++++++++++++- packages/ratelimit/src/providers/fallback.ts | 8 +- packages/ratelimit/src/providers/memory.ts | 8 +- packages/ratelimit/src/providers/redis.ts | 8 +- packages/ratelimit/src/runtime.ts | 23 ++- packages/ratelimit/src/storage/fallback.ts | 150 +++++++++++++- packages/ratelimit/src/storage/memory.ts | 121 ++++++++++- packages/ratelimit/src/storage/redis.ts | 107 +++++++++- packages/ratelimit/src/types.ts | 10 +- packages/ratelimit/src/utils/config.ts | 32 ++- packages/ratelimit/src/utils/keys.ts | 78 ++++++- packages/ratelimit/src/utils/locking.ts | 26 ++- packages/ratelimit/src/utils/time.ts | 28 ++- packages/ratelimit/vitest.config.ts | 2 +- 34 files changed, 1191 insertions(+), 128 deletions(-) diff --git a/packages/ratelimit/spec/algorithms.test.ts b/packages/ratelimit/spec/algorithms.test.ts index 25fa3615..efe34920 100644 --- a/packages/ratelimit/spec/algorithms.test.ts +++ b/packages/ratelimit/spec/algorithms.test.ts @@ -1,6 +1,8 @@ -// Algorithm integration tests. -// -// Fake timers keep limiter math deterministic and avoid flakiness. +/** + * Algorithm integration tests. + * + * Fake timers keep limiter math deterministic and avoid flakiness. + */ import { afterEach, describe, expect, test, vi } from 'vitest'; import { MemoryRateLimitStorage } from '../src/storage/memory'; @@ -13,6 +15,11 @@ import type { RateLimitStorage } from '../src/types'; const scope = 'user' as const; const delay = (ms = 0) => new Promise((resolve) => setTimeout(resolve, ms)); +/** + * Test storage that delays sorted-set calls to simulate contention. + * + * @implements RateLimitStorage + */ class DelayedSlidingWindowStorage implements RateLimitStorage { private readonly kv = new Map(); private readonly zset = new MemoryRateLimitStorage(); @@ -60,6 +67,11 @@ class DelayedSlidingWindowStorage implements RateLimitStorage { } } +/** + * Test storage that delays key/value calls for fixed-window tests. + * + * @implements RateLimitStorage + */ class DelayedFixedWindowStorage implements RateLimitStorage { private readonly kv = new Map(); diff --git a/packages/ratelimit/spec/api.test.ts b/packages/ratelimit/spec/api.test.ts index f741d73d..07a21198 100644 --- a/packages/ratelimit/spec/api.test.ts +++ b/packages/ratelimit/spec/api.test.ts @@ -1,6 +1,8 @@ -// API helper tests. -// -// Uses in-memory storage to keep exemption/reset tests isolated. +/** + * API helper tests. + * + * Uses in-memory storage to keep exemption/reset tests isolated. + */ import { afterEach, describe, expect, test } from 'vitest'; import { MemoryRateLimitStorage } from '../src/storage/memory'; diff --git a/packages/ratelimit/spec/engine.test.ts b/packages/ratelimit/spec/engine.test.ts index bf1a7a16..e7717615 100644 --- a/packages/ratelimit/spec/engine.test.ts +++ b/packages/ratelimit/spec/engine.test.ts @@ -1,6 +1,8 @@ -// Engine escalation tests. -// -// Fake timers keep violation cooldowns deterministic. +/** + * Engine escalation tests. + * + * Fake timers keep violation cooldowns deterministic. + */ import { afterEach, describe, expect, test, vi } from 'vitest'; import { RateLimitEngine } from '../src/engine/RateLimitEngine'; diff --git a/packages/ratelimit/spec/helpers.ts b/packages/ratelimit/spec/helpers.ts index 555fd0a8..cefad775 100644 --- a/packages/ratelimit/spec/helpers.ts +++ b/packages/ratelimit/spec/helpers.ts @@ -1,7 +1,9 @@ -// Test helpers for ratelimit specs. -// -// Provides lightweight stubs for Discord and CommandKit so tests stay focused -// on rate limit behavior without a live client. +/** + * Test helpers for ratelimit specs. + * + * Provides lightweight stubs for Discord and CommandKit so tests stay focused + * on rate limit behavior without a live client. + */ import { Collection, Message } from 'discord.js'; import { vi } from 'vitest'; @@ -19,7 +21,11 @@ export interface InteractionStubOptions { /** * Build an Interaction-like stub with only the fields the plugin reads. + * * Keeps tests fast without a live Discord client. + * + * @param options - Overrides for interaction fields used in tests. + * @returns Interaction stub matching the minimal plugin contract. */ export function createInteractionStub(options: InteractionStubOptions = {}) { const interaction = { @@ -61,6 +67,9 @@ export interface MessageStubOptions { /** * Build a Message-like stub with minimal fields used by rate limit logic. + * + * @param options - Overrides for message fields used in tests. + * @returns Message stub matching the minimal plugin contract. */ export function createMessageStub(options: MessageStubOptions = {}) { const message = Object.create(Message.prototype) as Message & { @@ -87,6 +96,9 @@ export function createMessageStub(options: MessageStubOptions = {}) { /** * Create a minimal CommandKit env with a store for plugin results. + * + * @param commandName - Command name to seed into the context. + * @returns Minimal CommandKit environment for plugin tests. */ export function createEnv(commandName = 'ping') { return { @@ -97,6 +109,9 @@ export function createEnv(commandName = 'ping') { /** * Create a runtime context with stubbed analytics and capture hooks. + * + * @param overrides - Optional overrides for command arrays. + * @returns Runtime context and stubbed helpers. */ export function createRuntimeContext( overrides: { @@ -129,6 +144,9 @@ export function createRuntimeContext( /** * Build a prepared command shape for plugin tests. + * + * @param options - Command metadata overrides. + * @returns Prepared command payload for plugin tests. */ export function createPreparedCommand(options: { name?: string; diff --git a/packages/ratelimit/spec/plugin.test.ts b/packages/ratelimit/spec/plugin.test.ts index 1908144e..d7e01545 100644 --- a/packages/ratelimit/spec/plugin.test.ts +++ b/packages/ratelimit/spec/plugin.test.ts @@ -1,6 +1,8 @@ -// Plugin integration tests. -// -// Uses stubs to keep plugin tests fast and offline. +/** + * Plugin integration tests. + * + * Uses stubs to keep plugin tests fast and offline. + */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { MessageFlags } from 'discord.js'; diff --git a/packages/ratelimit/spec/setup.ts b/packages/ratelimit/spec/setup.ts index bbd97e64..d360f0bc 100644 --- a/packages/ratelimit/spec/setup.ts +++ b/packages/ratelimit/spec/setup.ts @@ -1,6 +1,8 @@ -// Vitest setup for ratelimit specs. -// -// Restores the Console constructor so logging helpers behave consistently. +/** + * Vitest setup for ratelimit specs. + * + * Restores the Console constructor so logging helpers behave consistently. + */ import { Console } from 'node:console'; diff --git a/packages/ratelimit/src/api.ts b/packages/ratelimit/src/api.ts index 53c9e925..7110a4b2 100644 --- a/packages/ratelimit/src/api.ts +++ b/packages/ratelimit/src/api.ts @@ -1,6 +1,8 @@ -// Public rate limit helpers. -// -// Used by handlers and admin tools to inspect, reset, and manage exemptions. +/** + * Public rate limit helpers. + * + * Used by handlers and admin tools to inspect, reset, and manage exemptions. + */ import type { CommandKitEnvironment, Context } from 'commandkit'; import { RATELIMIT_STORE_KEY } from './constants'; @@ -51,6 +53,9 @@ export interface ResetAllRateLimitsParams { /** * Read aggregated rate limit info stored on a CommandKit env or context. + * + * @param envOrCtx - CommandKit environment or context holding the rate-limit store. + * @returns Aggregated rate-limit info or null when no store is present. */ export function getRateLimitInfo( envOrCtx: CommandKitEnvironment | Context | null | undefined, @@ -61,10 +66,22 @@ export function getRateLimitInfo( return (store.get(RATELIMIT_STORE_KEY) as RateLimitStoreValue) ?? null; } +/** + * Resolve the active storage or throw when none is configured. + * + * @returns Configured rate-limit storage. + * @throws Error when storage is not configured. + */ function getRequiredStorage(): RateLimitStorage { return getRuntimeStorage().storage; } +/** + * Resolve runtime context plus the effective storage to use. + * + * @returns Runtime context (if any) and the resolved storage. + * @throws Error when storage is not configured. + */ function getRuntimeStorage(): { runtime: ReturnType; storage: RateLimitStorage; @@ -77,12 +94,22 @@ function getRuntimeStorage(): { return { runtime, storage }; } +/** + * Normalize a prefix to include the window suffix marker. + * + * @param prefix - Base key prefix. + * @returns Prefix guaranteed to end with `w:`. + */ function toWindowPrefix(prefix: string): string { return prefix.endsWith(':') ? `${prefix}w:` : `${prefix}:w:`; } /** * Reset a single key and its violation/window variants to keep state consistent. + * + * @param params - Reset parameters for a single key or scope-derived key. + * @returns Resolves when deletes and reset hooks (if any) complete. + * @throws Error when required scope identifiers are missing. */ export async function resetRateLimit( params: ResetRateLimitParams, @@ -127,6 +154,11 @@ export async function resetRateLimit( /** * Reset multiple keys by scope, command name, prefix, or pattern for bulk cleanup. + * + * @param params - Batch reset parameters, defaulting to an empty config. + * @returns Resolves when all matching keys are deleted. + * @throws Error when the storage backend lacks required delete helpers. + * @throws Error when scope identifiers are missing for scope-based resets. */ export async function resetAllRateLimits( params: ResetAllRateLimitsParams = {}, @@ -196,6 +228,10 @@ export async function resetAllRateLimits( /** * Grant a temporary exemption for a scope/id pair. + * + * @param params - Exemption scope, id, and duration. + * @returns Resolves when the exemption key is written. + * @throws Error when duration is missing or non-positive. */ export async function grantRateLimitExemption( params: RateLimitExemptionGrantParams, @@ -214,6 +250,9 @@ export async function grantRateLimitExemption( /** * Revoke a temporary exemption for a scope/id pair. + * + * @param params - Exemption scope and id to revoke. + * @returns Resolves when the exemption key is removed. */ export async function revokeRateLimitExemption( params: RateLimitExemptionRevokeParams, @@ -226,6 +265,10 @@ export async function revokeRateLimitExemption( /** * List exemptions by scope and/or id for admin/reporting. + * + * @param params - Optional scope/id filters and limits. + * @returns Exemption info entries that match the requested filters. + * @throws Error when scope is required but missing or listing is unsupported. */ export async function listRateLimitExemptions( params: RateLimitExemptionListParams = {}, @@ -281,6 +324,13 @@ export async function listRateLimitExemptions( return results; } +/** + * Delete windowed variants for a base key using available storage helpers. + * + * @param storage - Storage driver to delete from. + * @param key - Base key to delete window variants for. + * @returns Resolves after window variants are removed. + */ async function deleteWindowVariants( storage: RateLimitStorage, key: string, diff --git a/packages/ratelimit/src/augmentation.ts b/packages/ratelimit/src/augmentation.ts index a056c6c5..94cdf913 100644 --- a/packages/ratelimit/src/augmentation.ts +++ b/packages/ratelimit/src/augmentation.ts @@ -1,6 +1,8 @@ -// CommandKit metadata augmentation. -// -// Extends CommandKit metadata so commands can declare per-command limits. +/** + * CommandKit metadata augmentation. + * + * Extends CommandKit metadata so commands can declare per-command limits. + */ import type { RateLimitCommandConfig } from './types'; diff --git a/packages/ratelimit/src/configure.ts b/packages/ratelimit/src/configure.ts index b5f1ae8a..09149ee1 100644 --- a/packages/ratelimit/src/configure.ts +++ b/packages/ratelimit/src/configure.ts @@ -1,7 +1,9 @@ -// Runtime configuration for the rate limit plugin. -// -// Mirrors configureAI so runtime options can be set outside commandkit.config -// before the plugin evaluates commands. +/** + * Runtime configuration for the rate limit plugin. + * + * Mirrors configureAI so runtime options can be set outside commandkit.config + * before the plugin evaluates commands. + */ import { DEFAULT_LIMITER } from './utils/config'; import { @@ -19,6 +21,12 @@ import type { const rateLimitConfig: RateLimitPluginOptions = {}; let configured = false; +/** + * Normalize a storage config into a storage driver instance. + * + * @param config - Storage config or driver. + * @returns Storage driver instance or null when not configured. + */ function resolveStorage( config: RateLimitStorageConfig, ): RateLimitStorage | null { @@ -29,6 +37,12 @@ function resolveStorage( return config; } +/** + * Apply updated config to the active runtime context. + * + * @param config - Runtime configuration updates. + * @returns Nothing; mutates the active runtime context when present. + */ function updateRuntime(config: RateLimitPluginOptions): void { const runtime = getRateLimitRuntime(); const storageOverride = config.storage @@ -57,6 +71,8 @@ function updateRuntime(config: RateLimitPluginOptions): void { /** * Returns true once configureRatelimit has been called. + * + * @returns True when runtime configuration has been initialized. */ export function isRateLimitConfigured(): boolean { return configured; @@ -64,6 +80,8 @@ export function isRateLimitConfigured(): boolean { /** * Retrieves the current rate limit configuration. + * + * @returns The current in-memory rate limit config object. */ export function getRateLimitConfig(): RateLimitPluginOptions { return rateLimitConfig; @@ -71,7 +89,11 @@ export function getRateLimitConfig(): RateLimitPluginOptions { /** * Configures the rate limit plugin runtime options. + * * Call this once during startup (for example in src/ratelimit.ts). + * + * @param config - Runtime options to merge into the active configuration. + * @returns Nothing; updates runtime state in place. */ export function configureRatelimit( config: RateLimitPluginOptions = {}, diff --git a/packages/ratelimit/src/constants.ts b/packages/ratelimit/src/constants.ts index 0ca2993c..daa97c55 100644 --- a/packages/ratelimit/src/constants.ts +++ b/packages/ratelimit/src/constants.ts @@ -1,13 +1,19 @@ -// Rate limit constants shared across runtime and tests. -// -// Keeps key names consistent across storage, runtime, and docs. +/** + * Rate limit constants shared across runtime and tests. + * + * Keeps key names consistent across storage, runtime, and docs. + */ /** * Store key used to stash aggregated results in CommandKit envs. + * + * @default 'ratelimit' */ export const RATELIMIT_STORE_KEY = 'ratelimit'; /** * Default prefix for storage keys; can be overridden per config. + * + * @default 'rl:' */ export const DEFAULT_KEY_PREFIX = 'rl:'; diff --git a/packages/ratelimit/src/directive/use-ratelimit-directive.ts b/packages/ratelimit/src/directive/use-ratelimit-directive.ts index 911ad9b6..e2111f21 100644 --- a/packages/ratelimit/src/directive/use-ratelimit-directive.ts +++ b/packages/ratelimit/src/directive/use-ratelimit-directive.ts @@ -1,4 +1,4 @@ -import { +import { CommonDirectiveTransformer, type CommonDirectiveTransformerOptions, type CompilerPluginRuntime, @@ -6,10 +6,17 @@ import { /** * Compiler plugin for the "use ratelimit" directive. + * + * @extends CommonDirectiveTransformer */ export class UseRateLimitDirectivePlugin extends CommonDirectiveTransformer { public readonly name = 'UseRateLimitDirectivePlugin'; + /** + * Create the directive compiler plugin with optional overrides. + * + * @param options - Common directive transformer overrides. + */ public constructor(options?: Partial) { super({ enabled: true, @@ -21,6 +28,12 @@ export class UseRateLimitDirectivePlugin extends CommonDirectiveTransformer { }); } + /** + * Activate the compiler plugin in the current build runtime. + * + * @param ctx - Compiler plugin runtime. + * @returns Resolves after activation completes. + */ public async activate(ctx: CompilerPluginRuntime): Promise { await super.activate(ctx); } diff --git a/packages/ratelimit/src/directive/use-ratelimit.ts b/packages/ratelimit/src/directive/use-ratelimit.ts index d28fa62f..15e75f1b 100644 --- a/packages/ratelimit/src/directive/use-ratelimit.ts +++ b/packages/ratelimit/src/directive/use-ratelimit.ts @@ -1,7 +1,9 @@ -// Runtime wrapper for the "use ratelimit" directive. -// -// Uses the runtime default limiter for arbitrary async functions. -// Throws RateLimitError when the call is limited. +/** + * Runtime wrapper for the "use ratelimit" directive. + * + * Uses the runtime default limiter for arbitrary async functions. + * Throws RateLimitError when the call is limited. + */ import { randomUUID } from 'node:crypto'; import type { AsyncFunction, GenericFunction } from 'commandkit'; @@ -26,8 +28,16 @@ const RATELIMIT_FN_SYMBOL = Symbol('commandkit.ratelimit.directive'); let cachedEngine: RateLimitEngine | null = null; let cachedStorage: RateLimitStorage | null = null; +/** + * Resolve the cached engine instance for a storage backend. + * + * @param storage - Storage backend to associate with the engine. + * @returns Cached engine instance for the storage. + */ function getEngine(storage: RateLimitStorage): RateLimitEngine { - // Cache per storage instance so violation tracking stays consistent. + /** + * Cache per storage instance so violation tracking stays consistent. + */ if (!cachedEngine || cachedStorage !== storage) { cachedEngine = new RateLimitEngine(storage); cachedStorage = storage; @@ -35,16 +45,37 @@ function getEngine(storage: RateLimitStorage): RateLimitEngine { return cachedEngine; } +/** + * Apply an optional prefix to a storage key. + * + * @param prefix - Optional prefix to prepend. + * @param key - Base key to prefix. + * @returns Prefixed key. + */ function withPrefix(prefix: string | undefined, key: string): string { if (!prefix) return key; return `${prefix}${key}`; } +/** + * Append a window suffix to a key when a window id is present. + * + * @param key - Base storage key. + * @param windowId - Optional window identifier. + * @returns Key with window suffix when provided. + */ function withWindowSuffix(key: string, windowId?: string): string { if (!windowId) return key; return `${key}:w:${windowId}`; } +/** + * Merge a runtime default limiter with an override when provided. + * + * @param runtimeDefault - Runtime default limiter configuration. + * @param limiter - Optional override limiter. + * @returns Resolved limiter configuration. + */ function resolveLimiter( runtimeDefault: RateLimitLimiterConfig, limiter?: RateLimitLimiterConfig, @@ -55,7 +86,14 @@ function resolveLimiter( /** * Wrap an async function with the runtime default limiter. + * * Throws RateLimitError when the call exceeds limits. + * + * @template R - Argument tuple type for the wrapped async function. + * @template F - Async function type being wrapped. + * @param fn - Async function to wrap with rate limiting. + * @returns Wrapped async function that enforces the default limiter. + * @throws RateLimitError when the call exceeds limits. */ function useRateLimit>(fn: F): F { if (Object.prototype.hasOwnProperty.call(fn, RATELIMIT_FN_SYMBOL)) { @@ -106,6 +144,12 @@ function useRateLimit>(fn: F): F { return wrapped; } +/** + * Aggregate multiple rate-limit results into a single summary object. + * + * @param results - Individual limiter/window results. + * @returns Aggregated rate-limit store value. + */ function aggregateResults(results: RateLimitResult[]): RateLimitStoreValue { if (!results.length) { return { @@ -136,13 +180,18 @@ function aggregateResults(results: RateLimitResult[]): RateLimitStoreValue { /** * Wrapper symbol injected by the compiler plugin. + * + * @param fn - Generic function to wrap with runtime rate limiting. + * @returns Wrapped function that enforces the runtime default limiter. */ export const $ckitirl: GenericFunction = (fn: GenericFunction) => { return useRateLimit(fn as AsyncFunction); }; if (!('$ckitirl' in globalThis)) { - // Expose the wrapper globally so directive transforms can call it. + /** + * Expose the wrapper globally so directive transforms can call it. + */ Object.defineProperty(globalThis, '$ckitirl', { value: $ckitirl, configurable: false, diff --git a/packages/ratelimit/src/engine/RateLimitEngine.ts b/packages/ratelimit/src/engine/RateLimitEngine.ts index ae391221..49b35512 100644 --- a/packages/ratelimit/src/engine/RateLimitEngine.ts +++ b/packages/ratelimit/src/engine/RateLimitEngine.ts @@ -1,6 +1,8 @@ -// Engine coordinator. -// -// Selects algorithms and applies violation escalation before returning results. +/** + * Engine coordinator. + * + * Selects algorithms and applies violation escalation before returning results. + */ import type { RateLimitAlgorithm, @@ -29,13 +31,21 @@ export interface RateLimitConsumeOutput { export class RateLimitEngine { private readonly violations: ViolationTracker; + /** + * Create a rate limit engine bound to a storage backend. + * + * @param storage - Storage backend for rate-limit state. + */ public constructor(private readonly storage: RateLimitStorage) { this.violations = new ViolationTracker(storage); } - /** - * Create an algorithm instance for a resolved config. - */ +/** + * Create an algorithm instance for a resolved config. + * + * @param config - Resolved limiter configuration. + * @returns Algorithm instance for the resolved config. + */ private createAlgorithm(config: ResolvedLimiterConfig): RateLimitAlgorithm { switch (config.algorithm) { case 'fixed-window': @@ -63,7 +73,9 @@ export class RateLimitEngine { scope: config.scope, }); default: - // Fall back to fixed-window so unknown algorithms still enforce a limit. + /** + * Fall back to fixed-window so unknown algorithms still enforce a limit. + */ return new FixedWindowAlgorithm(this.storage, { maxRequests: config.maxRequests, intervalMs: config.intervalMs, @@ -74,6 +86,10 @@ export class RateLimitEngine { /** * Consume a single key and apply escalation rules when enabled. + * + * @param key - Storage key for the limiter. + * @param config - Resolved limiter configuration. + * @returns Result plus optional violation count. */ public async consume( key: string, @@ -85,7 +101,9 @@ export class RateLimitEngine { if (shouldEscalate) { const active = await this.violations.checkCooldown(key); if (active) { - // When an escalation cooldown is active, skip the algorithm to enforce the cooldown. + /** + * When an escalation cooldown is active, skip the algorithm to enforce the cooldown. + */ const limit = config.algorithm === 'token-bucket' || config.algorithm === 'leaky-bucket' @@ -122,7 +140,9 @@ export class RateLimitEngine { config.violations, ); - // If escalation extends the cooldown, update the result so retry info stays accurate. + /** + * If escalation extends the cooldown, update the result so retry info stays accurate. + */ if (state.cooldownUntil > result.resetAt) { result.resetAt = state.cooldownUntil; result.retryAfter = Math.max(0, state.cooldownUntil - now); @@ -136,6 +156,9 @@ export class RateLimitEngine { /** * Reset a key and its associated violation state. + * + * @param key - Storage key to reset. + * @returns Resolves after the key and violations are cleared. */ public async reset(key: string): Promise { await this.storage.delete(key); diff --git a/packages/ratelimit/src/engine/algorithms/fixed-window.ts b/packages/ratelimit/src/engine/algorithms/fixed-window.ts index bd752faf..5b1cb6da 100644 --- a/packages/ratelimit/src/engine/algorithms/fixed-window.ts +++ b/packages/ratelimit/src/engine/algorithms/fixed-window.ts @@ -1,7 +1,9 @@ -// Fixed window rate limiting. -// -// Simple counters per window are fast and predictable, at the cost of allowing -// bursts within the window boundary. Prefer atomic storage for correctness. +/** + * Fixed window rate limiting. + * + * Simple counters per window are fast and predictable, at the cost of allowing + * bursts within the window boundary. Prefer atomic storage for correctness. + */ import type { RateLimitAlgorithm, @@ -25,10 +27,18 @@ interface FixedWindowState { /** * Basic fixed-window counter for low-cost rate limits. + * + * @implements RateLimitAlgorithm */ export class FixedWindowAlgorithm implements RateLimitAlgorithm { public readonly type: RateLimitAlgorithmType = 'fixed-window'; + /** + * Create a fixed-window algorithm bound to a storage backend. + * + * @param storage - Storage backend for rate-limit state. + * @param config - Fixed-window configuration. + */ public constructor( private readonly storage: RateLimitStorage, private readonly config: FixedWindowConfig, @@ -36,6 +46,9 @@ export class FixedWindowAlgorithm implements RateLimitAlgorithm { /** * Record one attempt and return the current window status for this key. + * + * @param key - Storage key for the limiter. + * @returns Rate limit result for the current window. */ public async consume(key: string): Promise { const limit = this.config.maxRequests; @@ -80,8 +93,10 @@ export class FixedWindowAlgorithm implements RateLimitAlgorithm { }; } - // Fallback is serialized per process to avoid same-instance races. - // Multi-process strictness still requires atomic storage operations. + /** + * Fallback is serialized per process to avoid same-instance races. + * Multi-process strictness still requires atomic storage operations. + */ return withStorageKeyLock(this.storage, key, async () => { const maxRetries = 5; for (let attempt = 0; attempt < maxRetries; attempt++) { @@ -189,11 +204,23 @@ export class FixedWindowAlgorithm implements RateLimitAlgorithm { }); } + /** + * Reset the stored key state for this limiter. + * + * @param key - Storage key to reset. + * @returns Resolves after the key is deleted. + */ public async reset(key: string): Promise { await this.storage.delete(key); } } +/** + * Type guard for fixed-window state entries loaded from storage. + * + * @param value - Stored value to validate. + * @returns True when the value matches the FixedWindowState shape. + */ function isFixedWindowState(value: unknown): value is FixedWindowState { if (!value || typeof value !== 'object') return false; const state = value as FixedWindowState; diff --git a/packages/ratelimit/src/engine/algorithms/leaky-bucket.ts b/packages/ratelimit/src/engine/algorithms/leaky-bucket.ts index 1455755d..ac5d72f8 100644 --- a/packages/ratelimit/src/engine/algorithms/leaky-bucket.ts +++ b/packages/ratelimit/src/engine/algorithms/leaky-bucket.ts @@ -1,7 +1,9 @@ -// Leaky bucket rate limiting. -// -// Drains at a steady rate to smooth spikes in traffic. -// The stored level keeps limits consistent across commands. +/** + * Leaky bucket rate limiting. + * + * Drains at a steady rate to smooth spikes in traffic. + * The stored level keeps limits consistent across commands. + */ import type { RateLimitAlgorithm, @@ -26,10 +28,18 @@ interface LeakyBucketState { /** * Leaky bucket algorithm for smoothing output to a steady rate. + * + * @implements RateLimitAlgorithm */ export class LeakyBucketAlgorithm implements RateLimitAlgorithm { public readonly type: RateLimitAlgorithmType = 'leaky-bucket'; + /** + * Create a leaky-bucket algorithm bound to a storage backend. + * + * @param storage - Storage backend for rate-limit state. + * @param config - Leaky-bucket configuration. + */ public constructor( private readonly storage: RateLimitStorage, private readonly config: LeakyBucketConfig, @@ -37,6 +47,10 @@ export class LeakyBucketAlgorithm implements RateLimitAlgorithm { /** * Record one attempt and return the current bucket status for this key. + * + * @param key - Storage key for the limiter. + * @returns Rate limit result for the current bucket. + * @throws Error when leakRate is non-positive. */ public async consume(key: string): Promise { const now = Date.now(); @@ -102,11 +116,23 @@ export class LeakyBucketAlgorithm implements RateLimitAlgorithm { }; } + /** + * Reset the stored key state for this limiter. + * + * @param key - Storage key to reset. + * @returns Resolves after the key is deleted. + */ public async reset(key: string): Promise { await this.storage.delete(key); } } +/** + * Type guard for leaky-bucket state entries loaded from storage. + * + * @param value - Stored value to validate. + * @returns True when the value matches the LeakyBucketState shape. + */ function isLeakyBucketState(value: unknown): value is LeakyBucketState { if (!value || typeof value !== 'object') return false; const state = value as LeakyBucketState; @@ -118,6 +144,13 @@ function isLeakyBucketState(value: unknown): value is LeakyBucketState { ); } +/** + * Estimate a TTL window large enough to cover full bucket drainage. + * + * @param capacity - Bucket capacity. + * @param leakRate - Tokens drained per second. + * @returns TTL in milliseconds. + */ function estimateLeakyTtl(capacity: number, leakRate: number): number { if (leakRate <= 0) return 60_000; return Math.ceil((capacity / leakRate) * 1000 * 2); diff --git a/packages/ratelimit/src/engine/algorithms/sliding-window.ts b/packages/ratelimit/src/engine/algorithms/sliding-window.ts index cebe37ba..7fda0ff9 100644 --- a/packages/ratelimit/src/engine/algorithms/sliding-window.ts +++ b/packages/ratelimit/src/engine/algorithms/sliding-window.ts @@ -1,7 +1,9 @@ -// Sliding window log rate limiting. -// -// Tracks individual request timestamps for smoother limits and accurate retry -// timing. Requires sorted-set support or an atomic storage helper. +/** + * Sliding window log rate limiting. + * + * Tracks individual request timestamps for smoother limits and accurate retry + * timing. Requires sorted-set support or an atomic storage helper. + */ import type { RateLimitAlgorithm, @@ -19,10 +21,18 @@ interface SlidingWindowConfig { /** * Sliding-window log algorithm for smoother limits. + * + * @implements RateLimitAlgorithm */ export class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { public readonly type: RateLimitAlgorithmType = 'sliding-window'; + /** + * Create a sliding-window algorithm bound to a storage backend. + * + * @param storage - Storage backend for rate-limit state. + * @param config - Sliding-window configuration. + */ public constructor( private readonly storage: RateLimitStorage, private readonly config: SlidingWindowConfig, @@ -30,6 +40,10 @@ export class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { /** * Record one attempt and return the current window status for this key. + * + * @param key - Storage key for the limiter. + * @returns Rate limit result for the current window. + * @throws Error when the storage backend lacks sorted-set support. */ public async consume(key: string): Promise { const limit = this.config.maxRequests; @@ -37,7 +51,9 @@ export class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { if (this.storage.consumeSlidingWindowLog) { const now = Date.now(); - // Include the timestamp so reset time can be derived without extra reads. + /** + * Include the timestamp so reset time can be derived without extra reads. + */ const member = `${now}-${Math.random().toString(36).slice(2, 8)}`; const res = await this.storage.consumeSlidingWindowLog( key, @@ -69,9 +85,13 @@ export class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { return withStorageKeyLock(this.storage, key, async () => { const now = Date.now(); - // Include the timestamp so reset time can be derived without extra reads. + /** + * Include the timestamp so reset time can be derived without extra reads. + */ const member = `${now}-${Math.random().toString(36).slice(2, 8)}`; - // Fallback is serialized per process; multi-process strictness needs atomic storage. + /** + * Fallback is serialized per process; multi-process strictness needs atomic storage. + */ await this.storage.zRemRangeByScore!(key, 0, now - windowMs); const count = await this.storage.zCard!(key); @@ -130,6 +150,12 @@ export class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { }); } + /** + * Reset the stored key state for this limiter. + * + * @param key - Storage key to reset. + * @returns Resolves after the key is deleted. + */ public async reset(key: string): Promise { await this.storage.delete(key); } diff --git a/packages/ratelimit/src/engine/algorithms/token-bucket.ts b/packages/ratelimit/src/engine/algorithms/token-bucket.ts index b65594fe..e6feadb6 100644 --- a/packages/ratelimit/src/engine/algorithms/token-bucket.ts +++ b/packages/ratelimit/src/engine/algorithms/token-bucket.ts @@ -1,7 +1,9 @@ -// Token bucket rate limiting. -// -// Allows short bursts while refilling steadily up to a cap. -// Bucket state is stored so limits stay consistent across commands. +/** + * Token bucket rate limiting. + * + * Allows short bursts while refilling steadily up to a cap. + * Bucket state is stored so limits stay consistent across commands. + */ import type { RateLimitAlgorithm, @@ -26,10 +28,18 @@ interface TokenBucketState { /** * Token bucket algorithm for bursty traffic with steady refill. + * + * @implements RateLimitAlgorithm */ export class TokenBucketAlgorithm implements RateLimitAlgorithm { public readonly type: RateLimitAlgorithmType = 'token-bucket'; + /** + * Create a token-bucket algorithm bound to a storage backend. + * + * @param storage - Storage backend for rate-limit state. + * @param config - Token-bucket configuration. + */ public constructor( private readonly storage: RateLimitStorage, private readonly config: TokenBucketConfig, @@ -37,6 +47,10 @@ export class TokenBucketAlgorithm implements RateLimitAlgorithm { /** * Record one attempt and return the current bucket status for this key. + * + * @param key - Storage key for the limiter. + * @returns Rate limit result for the current bucket. + * @throws Error when refillRate is non-positive. */ public async consume(key: string): Promise { const now = Date.now(); @@ -104,11 +118,23 @@ export class TokenBucketAlgorithm implements RateLimitAlgorithm { }; } + /** + * Reset the stored key state for this limiter. + * + * @param key - Storage key to reset. + * @returns Resolves after the key is deleted. + */ public async reset(key: string): Promise { await this.storage.delete(key); } } +/** + * Type guard for token-bucket state entries loaded from storage. + * + * @param value - Stored value to validate. + * @returns True when the value matches the TokenBucketState shape. + */ function isTokenBucketState(value: unknown): value is TokenBucketState { if (!value || typeof value !== 'object') return false; const state = value as TokenBucketState; @@ -120,6 +146,13 @@ function isTokenBucketState(value: unknown): value is TokenBucketState { ); } +/** + * Estimate a TTL window large enough to cover full bucket refills. + * + * @param capacity - Bucket capacity. + * @param refillRate - Tokens refilled per second. + * @returns TTL in milliseconds. + */ function estimateBucketTtl(capacity: number, refillRate: number): number { if (refillRate <= 0) return 60_000; return Math.ceil((capacity / refillRate) * 1000 * 2); diff --git a/packages/ratelimit/src/engine/violations.ts b/packages/ratelimit/src/engine/violations.ts index 73986477..3d635251 100644 --- a/packages/ratelimit/src/engine/violations.ts +++ b/packages/ratelimit/src/engine/violations.ts @@ -1,6 +1,8 @@ -// Violation tracking. -// -// Persists repeat violations so cooldowns can escalate predictably. +/** + * Violation tracking. + * + * Persists repeat violations so cooldowns can escalate predictably. + */ import type { RateLimitStorage, ViolationOptions } from '../types'; import { resolveDuration } from '../utils/time'; @@ -19,15 +21,23 @@ const DEFAULT_RESET_AFTER_MS = 60 * 60 * 1000; * Tracks repeated violations and computes escalating cooldowns. */ export class ViolationTracker { + /** + * Create a violation tracker bound to a storage backend. + * + * @param storage - Storage backend for violation state. + */ public constructor(private readonly storage: RateLimitStorage) {} private key(key: string): string { return `violation:${key}`; } - /** - * Read stored violation state for a key, if present. - */ +/** + * Read stored violation state for a key, if present. + * + * @param key - Storage key for the limiter. + * @returns Stored violation state or null when none is present. + */ async getState(key: string): Promise { const stored = await this.storage.get(this.key(key)); return isViolationState(stored) ? stored : null; @@ -35,6 +45,9 @@ export class ViolationTracker { /** * Check if a cooldown is currently active for this key. + * + * @param key - Storage key for the limiter. + * @returns Violation state when cooldown is active, otherwise null. */ async checkCooldown(key: string): Promise { const state = await this.getState(key); @@ -45,6 +58,11 @@ export class ViolationTracker { /** * Record a violation and return the updated state for callers. + * + * @param key - Storage key for the limiter. + * @param baseRetryAfterMs - Base retry delay in milliseconds. + * @param options - Optional escalation settings. + * @returns Updated violation state. */ async recordViolation( key: string, @@ -76,11 +94,23 @@ export class ViolationTracker { return state; } + /** + * Clear stored violation state for a key. + * + * @param key - Storage key to reset. + * @returns Resolves after the violation entry is deleted. + */ async reset(key: string): Promise { await this.storage.delete(this.key(key)); } } +/** + * Type guard for violation state entries loaded from storage. + * + * @param value - Stored value to validate. + * @returns True when the value matches the ViolationState shape. + */ function isViolationState(value: unknown): value is ViolationState { if (!value || typeof value !== 'object') return false; const state = value as ViolationState; diff --git a/packages/ratelimit/src/errors.ts b/packages/ratelimit/src/errors.ts index eb66157f..aed4b805 100644 --- a/packages/ratelimit/src/errors.ts +++ b/packages/ratelimit/src/errors.ts @@ -1,15 +1,25 @@ -// Rate limit error type. -// -// Lets callers distinguish rate-limit failures from other errors. +/** + * Rate limit error type. + * + * Lets callers distinguish rate-limit failures from other errors. + */ import type { RateLimitStoreValue } from './types'; /** * Error thrown by the directive wrapper when a function is rate-limited. + * + * @extends Error */ export class RateLimitError extends Error { public readonly result: RateLimitStoreValue; + /** + * Create a rate-limit error with the stored result payload. + * + * @param result - Aggregated rate-limit result. + * @param message - Optional error message override. + */ public constructor(result: RateLimitStoreValue, message?: string) { super(message ?? 'Rate limit exceeded'); this.name = 'RateLimitError'; diff --git a/packages/ratelimit/src/index.ts b/packages/ratelimit/src/index.ts index 2b43f50f..e014d00e 100644 --- a/packages/ratelimit/src/index.ts +++ b/packages/ratelimit/src/index.ts @@ -1,4 +1,4 @@ -import './augmentation'; +import './augmentation'; import { RateLimitPlugin } from './plugin'; import { UseRateLimitDirectivePlugin } from './directive/use-ratelimit-directive'; import type { CommandKitPlugin } from 'commandkit'; @@ -6,7 +6,11 @@ import { getRateLimitConfig } from './configure'; /** * Create compiler + runtime plugins for rate limiting. + * * Runtime options are provided via configureRatelimit(). + * + * @param options - Optional compiler plugin configuration. + * @returns Ordered array of compiler and runtime plugins. */ export function ratelimit( options?: Partial<{ diff --git a/packages/ratelimit/src/plugin.ts b/packages/ratelimit/src/plugin.ts index 6c3999a9..cd76dec0 100644 --- a/packages/ratelimit/src/plugin.ts +++ b/packages/ratelimit/src/plugin.ts @@ -1,4 +1,4 @@ -import { Logger, RuntimePlugin, defer } from 'commandkit'; +import { Logger, RuntimePlugin, defer } from 'commandkit'; import type { CommandKitEnvironment, CommandKitPluginRuntime, @@ -60,6 +60,8 @@ type RateLimitEventPayload = { /** * Runtime plugin that enforces rate limits for CommandKit commands so handlers stay lean. + * + * @extends RuntimePlugin */ export class RateLimitPlugin extends RuntimePlugin { public readonly name = 'RateLimitPlugin'; @@ -75,6 +77,10 @@ export class RateLimitPlugin extends RuntimePlugin { /** * Initialize runtime storage and defaults for this plugin instance. + * + * @param ctx - CommandKit runtime for the active application. + * @returns Resolves when runtime storage has been initialized. + * @throws Error when the plugin has not been configured. */ public async activate(ctx: CommandKitPluginRuntime): Promise { if (!isRateLimitConfigured()) { @@ -106,6 +112,8 @@ export class RateLimitPlugin extends RuntimePlugin { /** * Dispose queues and clear shared runtime state. + * + * @returns Resolves after queues are aborted and runtime state is cleared. */ public async deactivate(): Promise { for (const queue of this.queues.values()) { @@ -117,6 +125,13 @@ export class RateLimitPlugin extends RuntimePlugin { /** * Evaluate rate limits and optionally queue execution to avoid dropping commands. + * + * @param ctx - CommandKit runtime for the active application. + * @param env - Command execution environment. + * @param source - Interaction or message triggering the command. + * @param prepared - Prepared command execution data. + * @param execute - Callback that executes the command handler. + * @returns True when execution is deferred or handled, otherwise false to continue. */ public async executeCommand( ctx: CommandKitPluginRuntime, @@ -290,7 +305,9 @@ export class RateLimitPlugin extends RuntimePlugin { } } - // Aggregate across all scopes/windows so callers see a single response. + /** + * Aggregate across all scopes/windows so callers see a single response. + */ const aggregate = aggregateResults(results); env.store.set(RATELIMIT_STORE_KEY, aggregate); @@ -388,6 +405,10 @@ export class RateLimitPlugin extends RuntimePlugin { /** * Clear matching keys when a command is hot-reloaded to avoid stale state. + * + * @param ctx - CommandKit runtime for the active application. + * @param event - HMR event describing the changed file. + * @returns Resolves after matching keys are cleared and the event is handled. */ public async performHMR( ctx: CommandKitPluginRuntime, @@ -418,6 +439,12 @@ export class RateLimitPlugin extends RuntimePlugin { event.preventDefault(); } + /** + * Resolve a cached engine instance for a storage backend. + * + * @param storage - Storage backend to associate with the engine. + * @returns Cached engine instance for the storage. + */ private getEngine(storage: RateLimitStorage): RateLimitEngine { const existing = this.engines.get(storage); if (existing) return existing; @@ -426,6 +453,12 @@ export class RateLimitPlugin extends RuntimePlugin { return engine; } + /** + * Normalize a storage config into a storage driver instance. + * + * @param config - Storage config or driver. + * @returns Storage driver instance or null when not configured. + */ private resolveStorage( config?: RateLimitStorageConfig, ): RateLimitStorage | null { @@ -434,6 +467,11 @@ export class RateLimitPlugin extends RuntimePlugin { return config; } + /** + * Resolve the default storage, falling back to memory when enabled. + * + * @returns Resolved storage instance or null when disabled. + */ private resolveDefaultStorage(): RateLimitStorage | null { const resolved = this.resolveStorage(this.options.storage) ?? getRateLimitStorage(); @@ -448,6 +486,11 @@ export class RateLimitPlugin extends RuntimePlugin { return this.memoryStorage; } + /** + * Log a one-time error when storage is missing. + * + * @returns Nothing; logs at most once per process. + */ private logMissingStorage(): void { if (this.hasLoggedMissingStorage) return; this.hasLoggedMissingStorage = true; @@ -456,6 +499,13 @@ export class RateLimitPlugin extends RuntimePlugin { ); } + /** + * Emit a ratelimited event through CommandKit's event bus. + * + * @param ctx - CommandKit runtime for the active application. + * @param payload - Rate-limit event payload to emit. + * @returns Nothing; emits the event when available. + */ private emitRateLimited( ctx: CommandKitPluginRuntime, payload: RateLimitEventPayload, @@ -463,10 +513,18 @@ export class RateLimitPlugin extends RuntimePlugin { ctx.commandkit.events?.to('ratelimits').emit('ratelimited', payload); } + /** + * Determine whether a source should bypass rate limits. + * + * @param source - Interaction or message to evaluate. + * @returns True when the source should bypass rate limiting. + */ private async shouldBypass(source: Interaction | Message): Promise { const bypass = this.options.bypass; if (bypass) { - // Check permanent allowlists first to avoid storage lookups. + /** + * Check permanent allowlists first to avoid storage lookups. + */ const userId = source instanceof Message ? source.author.id : source.user?.id; if (userId && bypass.userIds?.includes(userId)) return true; @@ -481,12 +539,16 @@ export class RateLimitPlugin extends RuntimePlugin { } } - // Check temporary exemptions stored in the rate limit storage next. + /** + * Check temporary exemptions stored in the rate limit storage next. + */ if (await this.hasTemporaryBypass(source)) { return true; } - // Run custom predicate last so it can override previous checks. + /** + * Run custom predicate last so it can override previous checks. + */ if (bypass?.check) { return Boolean(await bypass.check(source)); } @@ -494,6 +556,12 @@ export class RateLimitPlugin extends RuntimePlugin { return false; } + /** + * Check for temporary exemptions in storage for the source. + * + * @param source - Interaction or message to evaluate. + * @returns True when a temporary exemption is found. + */ private async hasTemporaryBypass( source: Interaction | Message, ): Promise { @@ -517,6 +585,14 @@ export class RateLimitPlugin extends RuntimePlugin { return false; } + /** + * Send the default rate-limited response when no custom handler is set. + * + * @param env - Command execution environment. + * @param source - Interaction or message that was limited. + * @param info - Aggregated rate-limit info for the response. + * @returns Resolves after the response is sent. + */ private async respondRateLimited( env: CommandKitEnvironment, source: Interaction | Message, @@ -571,6 +647,12 @@ export class RateLimitPlugin extends RuntimePlugin { } } + /** + * Enqueue a command execution for later retry under queue rules. + * + * @param params - Queue execution parameters. + * @returns True when the execution was queued. + */ private async enqueueExecution(params: { queueKey: string; queue: NormalizedQueueOptions; @@ -586,7 +668,9 @@ export class RateLimitPlugin extends RuntimePlugin { const queue = this.getQueue(params.queueKey, params.queue); const size = queue.getPending() + queue.getRunning(); if (size >= params.queue.maxSize) { - // Queue full: fall back to immediate rate-limit handling to avoid unbounded growth. + /** + * Queue full: fall back to immediate rate-limit handling to avoid unbounded growth. + */ return false; } @@ -642,6 +726,13 @@ export class RateLimitPlugin extends RuntimePlugin { return true; } + /** + * Get or create an async queue for the given key. + * + * @param key - Queue identifier. + * @param options - Normalized queue settings. + * @returns Async queue instance. + */ private getQueue(key: string, options: NormalizedQueueOptions): AsyncQueue { const existing = this.queues.get(key); if (existing) return existing; @@ -650,6 +741,14 @@ export class RateLimitPlugin extends RuntimePlugin { return queue; } + /** + * Consume limits for queued execution to decide whether to run now. + * + * @param engine - Rate limit engine. + * @param limiter - Resolved limiter configuration. + * @param resolvedKeys - Scope keys to consume. + * @returns Aggregated rate-limit info for the queue check. + */ private async consumeForQueue( engine: RateLimitEngine, limiter: RateLimitLimiterConfig, @@ -671,6 +770,13 @@ export class RateLimitPlugin extends RuntimePlugin { return aggregateResults(results); } + /** + * Defer interaction replies when queueing and the source is repliable. + * + * @param source - Interaction or message that may be deferred. + * @param queue - Normalized queue settings. + * @returns Resolves after attempting to defer the interaction. + */ private async deferInteractionIfNeeded( source: Interaction | Message, queue: NormalizedQueueOptions, @@ -701,6 +807,12 @@ interface NormalizedQueueOptions { concurrency: number; } +/** + * Normalize scope input into a de-duplicated scope array. + * + * @param scope - Scope config value. + * @returns Array of scopes to enforce. + */ function normalizeScopes( scope: RateLimitLimiterConfig['scope'] | undefined, ): RateLimitScope[] { @@ -709,6 +821,12 @@ function normalizeScopes( return [scope]; } +/** + * Aggregate multiple rate-limit results into a single summary object. + * + * @param results - Individual limiter/window results. + * @returns Aggregated rate-limit store value. + */ function aggregateResults(results: RateLimitResult[]): RateLimitStoreValue { if (!results.length) { return createEmptyStoreValue(); @@ -731,11 +849,23 @@ function aggregateResults(results: RateLimitResult[]): RateLimitStoreValue { }; } +/** + * Append a window suffix to a key when a window id is present. + * + * @param key - Base storage key. + * @param windowId - Optional window identifier. + * @returns Key with window suffix when provided. + */ function withWindowSuffix(key: string, windowId?: string): string { if (!windowId) return key; return `${key}:w:${windowId}`; } +/** + * Create an empty aggregate result for cases with no limiter results. + * + * @returns Empty rate-limit store value. + */ function createEmptyStoreValue(): RateLimitStoreValue { return { limited: false, @@ -746,6 +876,12 @@ function createEmptyStoreValue(): RateLimitStoreValue { }; } +/** + * Merge multiple role limit maps, with later maps overriding earlier ones. + * + * @param limits - Role limit maps ordered from lowest to highest priority. + * @returns Merged role limits or undefined when empty. + */ function mergeRoleLimits( ...limits: Array | undefined> ): Record | undefined { @@ -757,6 +893,14 @@ function mergeRoleLimits( return Object.keys(merged).length ? merged : undefined; } +/** + * Resolve a role-specific limiter for a source using a strategy. + * + * @param limits - Role limit map keyed by role id. + * @param strategy - Role limit strategy to apply. + * @param source - Interaction or message to resolve roles from. + * @returns Resolved role limiter or null when none match. + */ function resolveRoleLimit( limits: Record | undefined, strategy: RateLimitRoleLimitStrategy | undefined, @@ -791,6 +935,12 @@ function resolveRoleLimit( return scored[0]?.limiter ?? null; } +/** + * Compute a comparable score for a limiter for role-strategy sorting. + * + * @param limiter - Limiter configuration to score. + * @returns Minimum request rate across windows. + */ function computeLimiterScore(limiter: RateLimitLimiterConfig): number { const resolvedConfigs = resolveLimiterConfigs(limiter, 'user'); if (!resolvedConfigs.length) return 0; @@ -800,6 +950,12 @@ function computeLimiterScore(limiter: RateLimitLimiterConfig): number { return Math.min(...scores); } +/** + * Merge and normalize queue options across config layers. + * + * @param options - Queue option layers ordered from lowest to highest priority. + * @returns Normalized queue options. + */ function resolveQueueOptions( ...options: Array ): NormalizedQueueOptions { @@ -820,6 +976,12 @@ function resolveQueueOptions( }; } +/** + * Select the queue key from the result with the longest retry delay. + * + * @param results - Rate limit results for the command. + * @returns Queue key to use for serialization. + */ function selectQueueKey(results: RateLimitResult[]): string { let target: RateLimitResult | undefined; for (const result of results) { @@ -831,10 +993,24 @@ function selectQueueKey(results: RateLimitResult[]): string { return (target ?? results[0])?.key ?? 'ratelimit:queue'; } +/** + * Delay execution for a given duration. + * + * @param ms - Delay duration in milliseconds. + * @returns Promise that resolves after the delay. + */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +/** + * Reset all rate-limit keys for a specific command name. + * + * @param storage - Storage backend to delete from. + * @param keyPrefix - Optional prefix to prepend to the key. + * @param commandName - Command name to reset. + * @returns Resolves after matching keys are deleted. + */ async function resetByCommand( storage: RateLimitStorage, keyPrefix: string | undefined, @@ -849,6 +1025,12 @@ async function resetByCommand( await storage.deleteByPattern(`violation:${pattern}:w:*`); } +/** + * Normalize path separators to forward slashes for comparisons. + * + * @param path - Path to normalize. + * @returns Normalized path string. + */ function normalizePath(path: string): string { return path.replace(/\\/g, '/'); } diff --git a/packages/ratelimit/src/providers/fallback.ts b/packages/ratelimit/src/providers/fallback.ts index 0ec7e7b4..5111db35 100644 --- a/packages/ratelimit/src/providers/fallback.ts +++ b/packages/ratelimit/src/providers/fallback.ts @@ -1,6 +1,8 @@ -// Provider re-export for fallback storage. -// -// Exposes the wrapper and its options for consumers. +/** + * Provider re-export for fallback storage. + * + * Exposes the wrapper and its options for consumers. + */ export { FallbackRateLimitStorage } from '../storage/fallback'; export type { FallbackRateLimitStorageOptions } from '../storage/fallback'; diff --git a/packages/ratelimit/src/providers/memory.ts b/packages/ratelimit/src/providers/memory.ts index 411524ed..4c9656e0 100644 --- a/packages/ratelimit/src/providers/memory.ts +++ b/packages/ratelimit/src/providers/memory.ts @@ -1,5 +1,7 @@ -// Provider re-export for memory storage. -// -// Keeps public imports stable across plugin packages. +/** + * Provider re-export for memory storage. + * + * Keeps public imports stable across plugin packages. + */ export { MemoryRateLimitStorage } from '../storage/memory'; diff --git a/packages/ratelimit/src/providers/redis.ts b/packages/ratelimit/src/providers/redis.ts index a3fe8bde..44cbb314 100644 --- a/packages/ratelimit/src/providers/redis.ts +++ b/packages/ratelimit/src/providers/redis.ts @@ -1,6 +1,8 @@ -// Provider re-export for Redis storage. -// -// Exposes the storage class and RedisOptions type for consumers. +/** + * Provider re-export for Redis storage. + * + * Exposes the storage class and RedisOptions type for consumers. + */ export { RedisRateLimitStorage } from '../storage/redis'; export type { RedisOptions } from 'ioredis'; diff --git a/packages/ratelimit/src/runtime.ts b/packages/ratelimit/src/runtime.ts index e2af1b71..b5c3e1fe 100644 --- a/packages/ratelimit/src/runtime.ts +++ b/packages/ratelimit/src/runtime.ts @@ -1,6 +1,8 @@ -// Runtime globals for rate limiting. -// -// Stores the active storage and plugin context for directives and helpers. +/** + * Runtime globals for rate limiting. + * + * Stores the active storage and plugin context for directives and helpers. + */ import type { RateLimitRuntimeContext, RateLimitStorage } from './types'; @@ -9,6 +11,9 @@ let activeRuntime: RateLimitRuntimeContext | null = null; /** * Set the default rate limit storage instance for the process. + * + * @param storage - Storage driver to use for rate-limit state. + * @returns Nothing; updates the process-wide default storage. */ export function setRateLimitStorage(storage: RateLimitStorage): void { defaultStorage = storage; @@ -16,6 +21,8 @@ export function setRateLimitStorage(storage: RateLimitStorage): void { /** * Get the default rate limit storage instance for the process. + * + * @returns Default storage instance or null if unset. */ export function getRateLimitStorage(): RateLimitStorage | null { return defaultStorage; @@ -23,6 +30,9 @@ export function getRateLimitStorage(): RateLimitStorage | null { /** * Alias for setRateLimitStorage to match other packages (tasks/queue). + * + * @param storage - Storage driver to use for rate-limit state. + * @returns Nothing; updates the process-wide default storage. */ export function setDriver(storage: RateLimitStorage): void { setRateLimitStorage(storage); @@ -30,6 +40,8 @@ export function setDriver(storage: RateLimitStorage): void { /** * Alias for getRateLimitStorage to match other packages (tasks/queue). + * + * @returns Default storage instance or null if unset. */ export function getDriver(): RateLimitStorage | null { return getRateLimitStorage(); @@ -37,6 +49,9 @@ export function getDriver(): RateLimitStorage | null { /** * Set the active runtime context used by directives and APIs. + * + * @param runtime - Active runtime context or null to clear. + * @returns Nothing; updates the active runtime context. */ export function setRateLimitRuntime( runtime: RateLimitRuntimeContext | null, @@ -46,6 +61,8 @@ export function setRateLimitRuntime( /** * Get the active runtime context for directives and APIs. + * + * @returns Active runtime context or null if not initialized. */ export function getRateLimitRuntime(): RateLimitRuntimeContext | null { return activeRuntime; diff --git a/packages/ratelimit/src/storage/fallback.ts b/packages/ratelimit/src/storage/fallback.ts index 64bdca57..f4a89472 100644 --- a/packages/ratelimit/src/storage/fallback.ts +++ b/packages/ratelimit/src/storage/fallback.ts @@ -1,6 +1,8 @@ -// Fallback storage wrapper. -// -// Routes storage calls to a secondary backend when the primary fails. +/** + * Fallback storage wrapper. + * + * Routes storage calls to a secondary backend when the primary fails. + */ import { Logger } from 'commandkit'; import type { RateLimitStorage } from '../types'; @@ -9,22 +11,40 @@ import type { RateLimitStorage } from '../types'; * Options that control fallback logging/cooldown behavior. */ export interface FallbackRateLimitStorageOptions { - /** Minimum time between fallback log entries (to avoid log spam). */ + /** + * Minimum time between fallback log entries (to avoid log spam). + * + * @default 30000 + */ cooldownMs?: number; } /** * Storage wrapper that falls back to a secondary implementation on failure. + * + * @implements RateLimitStorage */ export class FallbackRateLimitStorage implements RateLimitStorage { private lastErrorAt = 0; + /** + * Create a fallback wrapper with primary/secondary storages. + * + * @param primary - Primary storage backend. + * @param secondary - Secondary storage backend used on failure. + * @param options - Fallback logging and cooldown options. + */ public constructor( private readonly primary: RateLimitStorage, private readonly secondary: RateLimitStorage, private readonly options: FallbackRateLimitStorageOptions = {}, ) {} + /** + * Check whether a fallback error should be logged. + * + * @returns True when the log cooldown has elapsed. + */ private shouldLog(): boolean { const now = Date.now(); const cooldown = this.options.cooldownMs ?? 30_000; @@ -35,6 +55,13 @@ export class FallbackRateLimitStorage implements RateLimitStorage { return false; } + /** + * Execute a storage operation with a fallback on failure. + * + * @param op - Primary operation. + * @param fallback - Secondary operation when primary fails. + * @returns Result from the primary or fallback operation. + */ private async withFallback( op: () => Promise, fallback: () => Promise, @@ -49,6 +76,12 @@ export class FallbackRateLimitStorage implements RateLimitStorage { } } + /** + * Read a value using primary storage with fallback. + * + * @param key - Storage key to read. + * @returns Stored value or null when absent. + */ async get(key: string): Promise { return this.withFallback( () => this.primary.get(key), @@ -56,6 +89,14 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Store a value using primary storage with fallback. + * + * @param key - Storage key to write. + * @param value - Value to store. + * @param ttlMs - Optional TTL in milliseconds. + * @returns Resolves when the value is stored. + */ async set(key: string, value: T, ttlMs?: number): Promise { return this.withFallback( () => this.primary.set(key, value, ttlMs), @@ -63,6 +104,12 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Delete a key using primary storage with fallback. + * + * @param key - Storage key to delete. + * @returns Resolves when the key is removed. + */ async delete(key: string): Promise { return this.withFallback( () => this.primary.delete(key), @@ -70,6 +117,14 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Increment a fixed-window counter using primary storage with fallback. + * + * @param key - Storage key to increment. + * @param ttlMs - TTL window in milliseconds. + * @returns Fixed-window consume result. + * @throws Error when either storage lacks incr support. + */ async incr(key: string, ttlMs: number) { if (!this.primary.incr || !this.secondary.incr) { throw new Error('incr not supported by both storages'); @@ -80,6 +135,13 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Read TTL using primary storage with fallback. + * + * @param key - Storage key to inspect. + * @returns Remaining TTL in ms or null when no TTL is set. + * @throws Error when either storage lacks ttl support. + */ async ttl(key: string) { if (!this.primary.ttl || !this.secondary.ttl) { throw new Error('ttl not supported by both storages'); @@ -90,6 +152,14 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Update TTL using primary storage with fallback. + * + * @param key - Storage key to update. + * @param ttlMs - TTL in milliseconds. + * @returns Resolves after the TTL is updated. + * @throws Error when either storage lacks expire support. + */ async expire(key: string, ttlMs: number) { if (!this.primary.expire || !this.secondary.expire) { throw new Error('expire not supported by both storages'); @@ -100,6 +170,14 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Add a member to a sorted set using primary storage with fallback. + * + * @param key - Sorted-set key. + * @param score - Score to associate with the member. + * @param member - Member identifier. + * @returns Resolves when the member is added. + */ async zAdd(key: string, score: number, member: string) { if (!this.primary.zAdd || !this.secondary.zAdd) { throw new Error('zAdd not supported by both storages'); @@ -110,6 +188,14 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Remove sorted-set members in a score range with fallback. + * + * @param key - Sorted-set key. + * @param min - Minimum score (inclusive). + * @param max - Maximum score (inclusive). + * @returns Resolves when the range is removed. + */ async zRemRangeByScore(key: string, min: number, max: number) { if (!this.primary.zRemRangeByScore || !this.secondary.zRemRangeByScore) { throw new Error('zRemRangeByScore not supported by both storages'); @@ -120,6 +206,12 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Count sorted-set members with fallback. + * + * @param key - Sorted-set key. + * @returns Number of members in the set. + */ async zCard(key: string) { if (!this.primary.zCard || !this.secondary.zCard) { throw new Error('zCard not supported by both storages'); @@ -130,6 +222,14 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Read sorted-set members in a score range with fallback. + * + * @param key - Sorted-set key. + * @param min - Minimum score (inclusive). + * @param max - Maximum score (inclusive). + * @returns Ordered members in the score range. + */ async zRangeByScore(key: string, min: number, max: number) { if (!this.primary.zRangeByScore || !this.secondary.zRangeByScore) { throw new Error('zRangeByScore not supported by both storages'); @@ -140,6 +240,16 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Atomically consume a fixed-window counter with fallback. + * + * @param key - Storage key to consume. + * @param limit - Request limit for the window. + * @param windowMs - Window size in milliseconds. + * @param nowMs - Current timestamp in milliseconds. + * @returns Fixed-window consume result. + * @throws Error when either storage lacks consumeFixedWindow support. + */ async consumeFixedWindow( key: string, limit: number, @@ -158,6 +268,17 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Atomically consume a sliding-window log with fallback. + * + * @param key - Storage key to consume. + * @param limit - Request limit for the window. + * @param windowMs - Window size in milliseconds. + * @param nowMs - Current timestamp in milliseconds. + * @param member - Member identifier for this request. + * @returns Sliding-window consume result. + * @throws Error when either storage lacks consumeSlidingWindowLog support. + */ async consumeSlidingWindowLog( key: string, limit: number, @@ -191,6 +312,13 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Delete keys with a prefix using primary storage with fallback. + * + * @param prefix - Prefix to match. + * @returns Resolves after matching keys are deleted. + * @throws Error when either storage lacks deleteByPrefix support. + */ async deleteByPrefix(prefix: string) { if (!this.primary.deleteByPrefix || !this.secondary.deleteByPrefix) { throw new Error('deleteByPrefix not supported by both storages'); @@ -201,6 +329,13 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * Delete keys matching a pattern using primary storage with fallback. + * + * @param pattern - Glob pattern to match. + * @returns Resolves after matching keys are deleted. + * @throws Error when either storage lacks deleteByPattern support. + */ async deleteByPattern(pattern: string) { if (!this.primary.deleteByPattern || !this.secondary.deleteByPattern) { throw new Error('deleteByPattern not supported by both storages'); @@ -211,6 +346,13 @@ export class FallbackRateLimitStorage implements RateLimitStorage { ); } + /** + * List keys matching a prefix using primary storage with fallback. + * + * @param prefix - Prefix to match. + * @returns Matching keys. + * @throws Error when either storage lacks keysByPrefix support. + */ async keysByPrefix(prefix: string) { if (!this.primary.keysByPrefix || !this.secondary.keysByPrefix) { throw new Error('keysByPrefix not supported by both storages'); diff --git a/packages/ratelimit/src/storage/memory.ts b/packages/ratelimit/src/storage/memory.ts index 48b344ec..0cea2ea8 100644 --- a/packages/ratelimit/src/storage/memory.ts +++ b/packages/ratelimit/src/storage/memory.ts @@ -1,7 +1,9 @@ -// In-memory storage. -// -// Used for tests and local development; implements TTL and sorted-set helpers. -// Not suitable for multi-process deployments. +/** + * In-memory storage. + * + * Used for tests and local development; implements TTL and sorted-set helpers. + * Not suitable for multi-process deployments. + */ import type { FixedWindowConsumeResult, @@ -26,6 +28,8 @@ interface ZSetEntry { /** * In-memory storage used for tests and local usage. + * + * @implements RateLimitStorage */ export class MemoryRateLimitStorage implements RateLimitStorage { private readonly kv = new Map(); @@ -41,6 +45,8 @@ export class MemoryRateLimitStorage implements RateLimitStorage { /** * Clear expired entries so reads reflect current state. + * + * @param key - Storage key to clean. */ private cleanupKey(key: string) { const kvEntry = this.kv.get(key); @@ -54,6 +60,12 @@ export class MemoryRateLimitStorage implements RateLimitStorage { } } + /** + * Read a value from the in-memory key/value store. + * + * @param key - Storage key to read. + * @returns Stored value or null when absent/expired. + */ async get(key: string): Promise { this.cleanupKey(key); const entry = this.kv.get(key); @@ -61,16 +73,37 @@ export class MemoryRateLimitStorage implements RateLimitStorage { return entry.value as T; } + /** + * Store a value in memory with optional TTL. + * + * @param key - Storage key to write. + * @param value - Value to store. + * @param ttlMs - Optional TTL in milliseconds. + * @returns Resolves when the value is stored. + */ async set(key: string, value: T, ttlMs?: number): Promise { const expiresAt = typeof ttlMs === 'number' ? this.now() + ttlMs : null; this.kv.set(key, { value, expiresAt }); } + /** + * Delete a key from the in-memory store. + * + * @param key - Storage key to delete. + * @returns Resolves when the key is removed. + */ async delete(key: string): Promise { this.kv.delete(key); this.zsets.delete(key); } + /** + * Increment a fixed-window counter with TTL handling. + * + * @param key - Storage key to increment. + * @param ttlMs - TTL window in milliseconds. + * @returns Updated counter value and remaining TTL. + */ async incr(key: string, ttlMs: number): Promise { this.cleanupKey(key); const entry = this.kv.get(key); @@ -94,6 +127,12 @@ export class MemoryRateLimitStorage implements RateLimitStorage { return { count, ttlMs: remainingTtl }; } + /** + * Read the TTL for a key when present. + * + * @param key - Storage key to inspect. + * @returns Remaining TTL in ms or null when no TTL is set. + */ async ttl(key: string): Promise { this.cleanupKey(key); const entry = this.kv.get(key) ?? this.zsets.get(key); @@ -102,6 +141,13 @@ export class MemoryRateLimitStorage implements RateLimitStorage { return Math.max(0, entry.expiresAt - this.now()); } + /** + * Update the TTL for an existing key. + * + * @param key - Storage key to update. + * @param ttlMs - TTL in milliseconds. + * @returns Resolves after the TTL is updated. + */ async expire(key: string, ttlMs: number): Promise { const expiresAt = this.now() + ttlMs; const kvEntry = this.kv.get(key); @@ -110,6 +156,14 @@ export class MemoryRateLimitStorage implements RateLimitStorage { if (zEntry) zEntry.expiresAt = expiresAt; } + /** + * Add a member to a sorted set with the given score. + * + * @param key - Sorted-set key. + * @param score - Score to associate with the member. + * @param member - Member identifier. + * @returns Resolves when the member is added. + */ async zAdd(key: string, score: number, member: string): Promise { this.cleanupKey(key); const entry = this.zsets.get(key) ?? { items: [], expiresAt: null }; @@ -125,6 +179,14 @@ export class MemoryRateLimitStorage implements RateLimitStorage { this.zsets.set(key, entry); } + /** + * Remove sorted-set members with scores in the given range. + * + * @param key - Sorted-set key. + * @param min - Minimum score (inclusive). + * @param max - Maximum score (inclusive). + * @returns Resolves when the range is removed. + */ async zRemRangeByScore(key: string, min: number, max: number): Promise { this.cleanupKey(key); const entry = this.zsets.get(key); @@ -134,12 +196,26 @@ export class MemoryRateLimitStorage implements RateLimitStorage { ); } + /** + * Count members in a sorted set. + * + * @param key - Sorted-set key. + * @returns Number of members in the set. + */ async zCard(key: string): Promise { this.cleanupKey(key); const entry = this.zsets.get(key); return entry ? entry.items.length : 0; } + /** + * Read sorted-set members in a score range. + * + * @param key - Sorted-set key. + * @param min - Minimum score (inclusive). + * @param max - Maximum score (inclusive). + * @returns Ordered members in the score range. + */ async zRangeByScore( key: string, min: number, @@ -153,6 +229,15 @@ export class MemoryRateLimitStorage implements RateLimitStorage { .map((item) => item.member); } + /** + * Atomically consume a fixed-window counter for the key. + * + * @param key - Storage key to consume. + * @param limit - Request limit for the window. + * @param windowMs - Window size in milliseconds. + * @param nowMs - Current timestamp in milliseconds. + * @returns Fixed-window consume result. + */ async consumeFixedWindow( key: string, _limit: number, @@ -162,6 +247,16 @@ export class MemoryRateLimitStorage implements RateLimitStorage { return this.incr(key, windowMs); } + /** + * Atomically consume a sliding-window log for the key. + * + * @param key - Storage key to consume. + * @param limit - Request limit for the window. + * @param windowMs - Window size in milliseconds. + * @param nowMs - Current timestamp in milliseconds. + * @param member - Member identifier for this request. + * @returns Sliding-window consume result. + */ async consumeSlidingWindowLog( key: string, limit: number, @@ -196,6 +291,12 @@ export class MemoryRateLimitStorage implements RateLimitStorage { return { allowed: true, count: newCount, resetAt: oldestTs + windowMs }; } + /** + * Delete keys with the given prefix. + * + * @param prefix - Prefix to match. + * @returns Resolves after matching keys are deleted. + */ async deleteByPrefix(prefix: string): Promise { for (const key of Array.from(this.kv.keys())) { if (key.startsWith(prefix)) this.kv.delete(key); @@ -205,6 +306,12 @@ export class MemoryRateLimitStorage implements RateLimitStorage { } } + /** + * Delete keys matching a glob pattern. + * + * @param pattern - Glob pattern to match. + * @returns Resolves after matching keys are deleted. + */ async deleteByPattern(pattern: string): Promise { const regex = globToRegex(pattern); for (const key of Array.from(this.kv.keys())) { @@ -215,6 +322,12 @@ export class MemoryRateLimitStorage implements RateLimitStorage { } } + /** + * List keys that match a prefix. + * + * @param prefix - Prefix to match. + * @returns Matching keys. + */ async keysByPrefix(prefix: string): Promise { const keys = new Set(); const kvKeys = Array.from(this.kv.keys()); diff --git a/packages/ratelimit/src/storage/redis.ts b/packages/ratelimit/src/storage/redis.ts index 20286eeb..3c6058ba 100644 --- a/packages/ratelimit/src/storage/redis.ts +++ b/packages/ratelimit/src/storage/redis.ts @@ -1,6 +1,8 @@ -// Redis storage. -// -// Uses Lua scripts for atomic fixed/sliding window operations. +/** + * Redis storage. + * + * Uses Lua scripts for atomic fixed/sliding window operations. + */ import Redis, { type RedisOptions } from 'ioredis'; import type { @@ -53,6 +55,8 @@ const SLIDING_WINDOW_SCRIPT = ` /** * Redis-backed storage with Lua scripts for atomic window operations. + * + * @implements RateLimitStorage */ export class RedisRateLimitStorage implements RateLimitStorage { public readonly redis: Redis; @@ -61,12 +65,26 @@ export class RedisRateLimitStorage implements RateLimitStorage { this.redis = redis instanceof Redis ? redis : new Redis(redis ?? {}); } + /** + * Read a value from Redis and JSON-decode it. + * + * @param key - Storage key to read. + * @returns Parsed value or null when absent. + */ async get(key: string): Promise { const value = await this.redis.get(key); if (value == null) return null; return JSON.parse(value) as T; } + /** + * Store a value in Redis with optional TTL. + * + * @param key - Storage key to write. + * @param value - Value to serialize and store. + * @param ttlMs - Optional TTL in milliseconds. + * @returns Resolves when the value is stored. + */ async set(key: string, value: T, ttlMs?: number): Promise { const payload = JSON.stringify(value); if (typeof ttlMs === 'number') { @@ -76,32 +94,81 @@ export class RedisRateLimitStorage implements RateLimitStorage { await this.redis.set(key, payload); } + /** + * Delete a key from Redis. + * + * @param key - Storage key to delete. + * @returns Resolves when the key is removed. + */ async delete(key: string): Promise { await this.redis.del(key); } + /** + * Read the TTL for a key when present. + * + * @param key - Storage key to inspect. + * @returns Remaining TTL in ms or null when no TTL is set. + */ async ttl(key: string): Promise { const ttl = await this.redis.pttl(key); if (ttl < 0) return null; return ttl; } + /** + * Update the TTL for an existing key. + * + * @param key - Storage key to update. + * @param ttlMs - TTL in milliseconds. + * @returns Resolves after the TTL is updated. + */ async expire(key: string, ttlMs: number): Promise { await this.redis.pexpire(key, ttlMs); } + /** + * Add a member to a sorted set with the given score. + * + * @param key - Sorted-set key. + * @param score - Score to associate with the member. + * @param member - Member identifier. + * @returns Resolves when the member is added. + */ async zAdd(key: string, score: number, member: string): Promise { await this.redis.zadd(key, score.toString(), member); } + /** + * Remove sorted-set members with scores in the given range. + * + * @param key - Sorted-set key. + * @param min - Minimum score (inclusive). + * @param max - Maximum score (inclusive). + * @returns Resolves when the range is removed. + */ async zRemRangeByScore(key: string, min: number, max: number): Promise { await this.redis.zremrangebyscore(key, min.toString(), max.toString()); } + /** + * Count members in a sorted set. + * + * @param key - Sorted-set key. + * @returns Number of members in the set. + */ async zCard(key: string): Promise { return Number(await this.redis.zcard(key)); } + /** + * Read sorted-set members in a score range. + * + * @param key - Sorted-set key. + * @param min - Minimum score (inclusive). + * @param max - Maximum score (inclusive). + * @returns Ordered members in the score range. + */ async zRangeByScore( key: string, min: number, @@ -110,6 +177,15 @@ export class RedisRateLimitStorage implements RateLimitStorage { return this.redis.zrangebyscore(key, min.toString(), max.toString()); } + /** + * Atomically consume a fixed-window counter via Lua. + * + * @param key - Storage key to consume. + * @param _limit - Limit (unused by the script). + * @param windowMs - Window size in milliseconds. + * @param _nowMs - Current time (unused by the script). + * @returns Fixed-window consume result. + */ async consumeFixedWindow( key: string, _limit: number, @@ -129,6 +205,16 @@ export class RedisRateLimitStorage implements RateLimitStorage { }; } + /** + * Atomically consume a sliding-window log via Lua. + * + * @param key - Storage key to consume. + * @param limit - Request limit for the window. + * @param windowMs - Window size in milliseconds. + * @param nowMs - Current timestamp in milliseconds. + * @param member - Member identifier for this request. + * @returns Sliding-window consume result. + */ async consumeSlidingWindowLog( key: string, limit: number, @@ -153,12 +239,21 @@ export class RedisRateLimitStorage implements RateLimitStorage { }; } + /** + * Delete keys with the given prefix. + * + * @param prefix - Prefix to match. + * @returns Resolves after matching keys are deleted. + */ async deleteByPrefix(prefix: string): Promise { await this.deleteByPattern(`${prefix}*`); } /** * Delete keys matching a glob pattern using SCAN to avoid blocking Redis. + * + * @param pattern - Glob pattern to match keys against. + * @returns Resolves after matching keys are deleted. */ async deleteByPattern(pattern: string): Promise { let cursor = '0'; @@ -178,6 +273,12 @@ export class RedisRateLimitStorage implements RateLimitStorage { } while (cursor !== '0'); } + /** + * List keys that match a prefix using SCAN. + * + * @param prefix - Prefix to match. + * @returns Matching keys. + */ async keysByPrefix(prefix: string): Promise { const pattern = `${prefix}*`; const collected = new Set(); diff --git a/packages/ratelimit/src/types.ts b/packages/ratelimit/src/types.ts index 3dd64b2d..cf42971f 100644 --- a/packages/ratelimit/src/types.ts +++ b/packages/ratelimit/src/types.ts @@ -1,7 +1,9 @@ -// Rate limit type contracts. -// -// Shared config and result shapes for the plugin, engine, storage, and helpers. -// Keeping them in one place reduces drift between runtime behavior and docs. +/** + * Rate limit type contracts. + * + * Shared config and result shapes for the plugin, engine, storage, and helpers. + * Keeping them in one place reduces drift between runtime behavior and docs. + */ import type { Interaction, Message } from 'discord.js'; import type { Context } from 'commandkit'; diff --git a/packages/ratelimit/src/utils/config.ts b/packages/ratelimit/src/utils/config.ts index 09d06e0e..6d1a0c25 100644 --- a/packages/ratelimit/src/utils/config.ts +++ b/packages/ratelimit/src/utils/config.ts @@ -1,7 +1,9 @@ -// Limiter config resolution. -// -// Applies defaults and merges overrides into concrete limiter settings -// used by the engine and plugin. +/** + * Limiter config resolution. + * + * Applies defaults and merges overrides into concrete limiter settings + * used by the engine and plugin. + */ import type { RateLimitAlgorithmType, @@ -29,6 +31,9 @@ export const DEFAULT_LIMITER: RateLimitLimiterConfig = { /** * Merge limiter configs; later values override earlier ones for layering. + * + * @param configs - Limiter configs ordered from lowest to highest priority. + * @returns Merged limiter config with later overrides applied. */ export function mergeLimiterConfigs( ...configs: Array @@ -41,6 +46,10 @@ export function mergeLimiterConfigs( /** * Resolve a limiter config for a single scope with defaults applied. + * + * @param config - Base limiter configuration. + * @param scope - Scope to resolve for the limiter. + * @returns Resolved limiter config with defaults and derived values. */ export function resolveLimiterConfig( config: RateLimitLimiterConfig, @@ -85,14 +94,27 @@ export function resolveLimiterConfig( }; } +/** + * Resolve a stable window id when one is missing. + * + * @param window - Window config entry. + * @param index - Index of the window in the config list. + * @returns Window id string. + */ function resolveWindowId(window: RateLimitWindowConfig, index: number): string { if (window.id && window.id.trim()) return window.id; - // Stable fallback IDs keep window identity deterministic for resets. + /** + * Stable fallback IDs keep window identity deterministic for resets. + */ return `w${index + 1}`; } /** * Resolve limiter configs for a scope across all configured windows. + * + * @param config - Base limiter configuration that may include windows. + * @param scope - Scope to resolve for the limiter. + * @returns Resolved limiter configs for each window (or a single config). */ export function resolveLimiterConfigs( config: RateLimitLimiterConfig, diff --git a/packages/ratelimit/src/utils/keys.ts b/packages/ratelimit/src/utils/keys.ts index d8654d16..ef1a649f 100644 --- a/packages/ratelimit/src/utils/keys.ts +++ b/packages/ratelimit/src/utils/keys.ts @@ -1,7 +1,9 @@ -// Key construction helpers. -// -// Builds consistent storage keys for scopes and exemptions across -// message and interaction sources so limits remain comparable. +/** + * Key construction helpers. + * + * Builds consistent storage keys for scopes and exemptions across + * message and interaction sources so limits remain comparable. + */ import { Message } from 'discord.js'; import type { Interaction } from 'discord.js'; @@ -35,26 +37,57 @@ export interface ResolvedScopeKey { key: string; } +/** + * Apply an optional prefix to a storage key. + * + * @param prefix - Optional prefix to prepend. + * @param key - Base key to prefix. + * @returns Prefixed key. + */ function applyPrefix(prefix: string | undefined, key: string): string { if (!prefix) return key; return `${prefix}${key}`; } +/** + * Resolve a user id from a message or interaction. + * + * @param source - Interaction or message source. + * @returns User id or null when unavailable. + */ function getUserId(source: Interaction | Message): string | null { if (source instanceof Message) return source.author.id; return source.user?.id ?? null; } +/** + * Resolve a guild id from a message or interaction. + * + * @param source - Interaction or message source. + * @returns Guild id or null when unavailable. + */ function getGuildId(source: Interaction | Message): string | null { if (source instanceof Message) return source.guildId ?? null; return source.guildId ?? null; } +/** + * Resolve a channel id from a message or interaction. + * + * @param source - Interaction or message source. + * @returns Channel id or null when unavailable. + */ function getChannelId(source: Interaction | Message): string | null { if (source instanceof Message) return source.channelId ?? null; return source.channelId ?? null; } +/** + * Resolve a parent category id from a channel object. + * + * @param channel - Channel object to inspect. + * @returns Parent id or null when unavailable. + */ function getParentId(channel: unknown): string | null { if (!channel || typeof channel !== 'object') return null; if (!('parentId' in channel)) return null; @@ -62,6 +95,12 @@ function getParentId(channel: unknown): string | null { return parentId ?? null; } +/** + * Resolve a category id from a message or interaction. + * + * @param source - Interaction or message source. + * @returns Category id or null when unavailable. + */ function getCategoryId(source: Interaction | Message): string | null { if (source instanceof Message) { return getParentId(source.channel); @@ -71,6 +110,9 @@ function getCategoryId(source: Interaction | Message): string | null { /** * Extract role IDs from a message/interaction for role-based limits. + * + * @param source - Interaction or message to read role data from. + * @returns Array of role IDs for the source, or an empty array. */ export function getRoleIds(source: Interaction | Message): string[] { const roles = source.member?.roles; @@ -84,6 +126,11 @@ export function getRoleIds(source: Interaction | Message): string[] { /** * Build a storage key for a temporary exemption entry. + * + * @param scope - Exemption scope to encode. + * @param id - Scope identifier (user, guild, role, etc.). + * @param keyPrefix - Optional prefix to prepend to the key. + * @returns Fully-qualified exemption storage key. */ export function buildExemptionKey( scope: RateLimitExemptionScope, @@ -96,6 +143,10 @@ export function buildExemptionKey( /** * Build a prefix for scanning exemption keys in storage. + * + * @param keyPrefix - Optional prefix to prepend to the key. + * @param scope - Optional exemption scope to narrow the prefix. + * @returns Prefix suitable for storage scans. */ export function buildExemptionPrefix( keyPrefix?: string, @@ -109,6 +160,10 @@ export function buildExemptionPrefix( /** * Parse an exemption key into scope and ID for listing. + * + * @param key - Exemption key to parse. + * @param keyPrefix - Optional prefix to strip before parsing. + * @returns Parsed scope/id pair or null when the key is invalid. */ export function parseExemptionKey( key: string, @@ -128,6 +183,10 @@ export function parseExemptionKey( /** * Resolve all exemption keys that could apply to a source. + * + * @param source - Interaction or message to resolve keys for. + * @param keyPrefix - Optional prefix to prepend to keys. + * @returns Exemption keys that should be checked for the source. */ export function resolveExemptionKeys( source: Interaction | Message, @@ -165,6 +224,9 @@ export function resolveExemptionKeys( /** * Resolve the storage key for a single scope. + * + * @param params - Inputs required to resolve the scope key. + * @returns Resolved scope key or null when required identifiers are missing. */ export function resolveScopeKey({ ctx, @@ -245,6 +307,9 @@ export function resolveScopeKey({ /** * Resolve keys for multiple scopes, dropping unresolvable ones. + * + * @param params - Inputs required to resolve all scope keys. + * @returns Array of resolved scope keys. */ export function resolveScopeKeys( params: Omit & { @@ -261,6 +326,11 @@ export function resolveScopeKeys( /** * Build a prefix for resets by scope/identifier. + * + * @param scope - Scope to build the prefix for. + * @param keyPrefix - Optional prefix to prepend to the key. + * @param identifiers - Identifiers required for the scope. + * @returns Prefix string or null when identifiers are missing. */ export function buildScopePrefix( scope: RateLimitScope, diff --git a/packages/ratelimit/src/utils/locking.ts b/packages/ratelimit/src/utils/locking.ts index eb4a8683..03c4b164 100644 --- a/packages/ratelimit/src/utils/locking.ts +++ b/packages/ratelimit/src/utils/locking.ts @@ -1,10 +1,26 @@ +/** + * Storage-scoped locking helpers. + * + * Serializes fallback storage operations per key to reduce same-process races. + */ + import type { RateLimitStorage } from '../types'; type LockedFn = () => Promise; +/** + * Queue-based mutex keyed by string identifiers. + */ class KeyedMutex { private readonly queues = new Map>(); + /** + * Run a function exclusively for the given key. + * + * @param key - Key to serialize on. + * @param fn - Async function to run under the lock. + * @returns Result of the locked function. + */ public async run(key: string, fn: LockedFn): Promise { const previous = this.queues.get(key) ?? Promise.resolve(); let release: () => void; @@ -28,6 +44,14 @@ class KeyedMutex { const mutexByStorage = new WeakMap(); +/** + * Serialize work for a storage key to avoid same-process conflicts. + * + * @param storage - Storage instance that owns the key. + * @param key - Storage key to lock on. + * @param fn - Async function to run under the lock. + * @returns Result of the locked function. + */ export async function withStorageKeyLock( storage: RateLimitStorage, key: string, @@ -39,4 +63,4 @@ export async function withStorageKeyLock( mutexByStorage.set(storage, mutex); } return mutex.run(key, fn); -} +} \ No newline at end of file diff --git a/packages/ratelimit/src/utils/time.ts b/packages/ratelimit/src/utils/time.ts index 385fb1af..bbfe44f9 100644 --- a/packages/ratelimit/src/utils/time.ts +++ b/packages/ratelimit/src/utils/time.ts @@ -1,7 +1,9 @@ -// Time helpers for rate limits. -// -// Converts user-friendly durations into milliseconds and clamps values -// so storage and algorithms always receive safe inputs. +/** + * Time helpers for rate limits. + * + * Converts user-friendly durations into milliseconds and clamps values + * so storage and algorithms always receive safe inputs. + */ import ms, { type StringValue } from 'ms'; import type { DurationLike } from '../types'; @@ -11,6 +13,10 @@ const MONTH_MS = 30 * 24 * 60 * 60 * 1000; /** * Resolve a duration input into milliseconds with a fallback. + * + * @param value - Duration input as ms or string. + * @param fallback - Fallback value used when parsing fails. + * @returns Parsed duration in milliseconds. */ export function resolveDuration( value: DurationLike | undefined, @@ -19,7 +25,9 @@ export function resolveDuration( if (value == null) return fallback; if (typeof value === 'number' && Number.isFinite(value)) return value; if (typeof value === 'string') { - // Allow week/month units so config can use human-friendly windows. + /** + * Allow week/month units so config can use human-friendly windows. + */ const custom = parseExtendedDuration(value); if (custom != null) return custom; const parsed = ms(value as StringValue); @@ -28,6 +36,12 @@ export function resolveDuration( return fallback; } +/** + * Parse week/month duration strings that ms does not support. + * + * @param value - Raw duration string. + * @returns Parsed duration in ms or null when invalid. + */ function parseExtendedDuration(value: string): number | null { const trimmed = value.trim(); if (!trimmed) return null; @@ -49,6 +63,10 @@ function parseExtendedDuration(value: string): number | null { /** * Clamp a number to a minimum value to avoid zero/negative windows. + * + * @param value - Value to clamp. + * @param min - Minimum allowed value. + * @returns The clamped value. */ export function clampAtLeast(value: number, min: number): number { return value < min ? min : value; diff --git a/packages/ratelimit/vitest.config.ts b/packages/ratelimit/vitest.config.ts index 24b1da30..50431dd2 100644 --- a/packages/ratelimit/vitest.config.ts +++ b/packages/ratelimit/vitest.config.ts @@ -1,4 +1,4 @@ -import { defineConfig } from 'vitest/config'; +import { defineConfig } from 'vitest/config'; import { join } from 'path'; export default defineConfig({ From 3ab7ebaf43770f8a5016b49aa750742ac864876f Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:00:47 +0100 Subject: [PATCH 06/10] feat(ratelimit): add directive tests --- packages/ratelimit/package.json | 1 + packages/ratelimit/spec/directive.test.ts | 85 +++++++++++++++++++++++ packages/ratelimit/src/storage/redis.ts | 4 +- packages/ratelimit/vitest.config.ts | 9 +++ 4 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 packages/ratelimit/spec/directive.test.ts diff --git a/packages/ratelimit/package.json b/packages/ratelimit/package.json index f1473058..a23b508a 100644 --- a/packages/ratelimit/package.json +++ b/packages/ratelimit/package.json @@ -57,6 +57,7 @@ "@types/ms": "^2.1.0", "commandkit": "workspace:*", "discord.js": "catalog:discordjs", + "directive-to-hof": "^0.0.3", "tsconfig": "workspace:*", "typescript": "catalog:build", "vitest": "^4.0.18" diff --git a/packages/ratelimit/spec/directive.test.ts b/packages/ratelimit/spec/directive.test.ts new file mode 100644 index 00000000..0e54f7ce --- /dev/null +++ b/packages/ratelimit/spec/directive.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; +import { RateLimitPlugin } from '../src/plugin'; +import { MemoryRateLimitStorage } from '../src/storage/memory'; +import { RateLimitError } from '../src/errors'; +import { configureRatelimit } from '../src/configure'; +import { createRuntimeContext } from './helpers'; +import { setRateLimitRuntime, setRateLimitStorage } from '../src/runtime'; +import type { RateLimitStorage } from '../src/types'; + +describe('RateLimit directive', () => { + beforeEach(() => { + configureRatelimit({}); + }); + + afterEach(() => { + setRateLimitRuntime(null); + setRateLimitStorage(null as unknown as RateLimitStorage); + }); + + test('enforces limits via runtime plugin', async () => { + const storage = new MemoryRateLimitStorage(); + const plugin = new RateLimitPlugin({ + storage, + defaultLimiter: { maxRequests: 1, interval: 1000 }, + }); + + const runtime = createRuntimeContext(); + await plugin.activate(runtime.ctx as any); + + const arrow = async () => { + 'use ratelimit'; + return 'ok'; + }; + + async function declared() { + 'use ratelimit'; + return 'ok'; + } + + const expressed = async function () { + 'use ratelimit'; + return 'ok'; + }; + + const obj = { + async method() { + 'use ratelimit'; + return 'ok'; + }, + }; + + const cases = [arrow, declared, expressed, obj.method]; + + for (const fn of cases) { + await fn(); + let thrown: unknown; + try { + await fn(); + } catch (error) { + thrown = error; + } + + expect(thrown).toBeInstanceOf(RateLimitError); + if (!(thrown instanceof RateLimitError)) { + throw thrown; + } + expect(thrown.result.limited).toBe(true); + expect(thrown.result.retryAfter).toBeGreaterThan(0); + } + }); + + test('throws when runtime is not initialized', async () => { + setRateLimitRuntime(null); + setRateLimitStorage(null as unknown as RateLimitStorage); + + const fn = async () => { + 'use ratelimit'; + return 'ok'; + }; + + await expect(fn()).rejects.toThrow( + 'RateLimit runtime is not initialized. Register the RateLimitPlugin first.', + ); + }); +}); diff --git a/packages/ratelimit/src/storage/redis.ts b/packages/ratelimit/src/storage/redis.ts index 3c6058ba..9fab4348 100644 --- a/packages/ratelimit/src/storage/redis.ts +++ b/packages/ratelimit/src/storage/redis.ts @@ -11,7 +11,7 @@ import type { SlidingWindowConsumeResult, } from '../types'; -const FIXED_WINDOW_SCRIPT = ` +const FIXED_WINDOW_SCRIPT = /* lua */ ` local key = KEYS[1] local window = tonumber(ARGV[1]) local count = redis.call('INCR', key) @@ -23,7 +23,7 @@ const FIXED_WINDOW_SCRIPT = ` return {count, ttl} `; -const SLIDING_WINDOW_SCRIPT = ` +const SLIDING_WINDOW_SCRIPT = /* lua */ ` local key = KEYS[1] local limit = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) diff --git a/packages/ratelimit/vitest.config.ts b/packages/ratelimit/vitest.config.ts index 50431dd2..55050a47 100644 --- a/packages/ratelimit/vitest.config.ts +++ b/packages/ratelimit/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import { vite as rateLimitDirectivePlugin } from 'directive-to-hof'; import { join } from 'path'; export default defineConfig({ @@ -7,6 +8,14 @@ export default defineConfig({ watch: false, setupFiles: ['./spec/setup.ts'], }, + plugins: [ + rateLimitDirectivePlugin({ + directive: 'use ratelimit', + importPath: '@commandkit/ratelimit', + importName: '$ckitirl', + asyncOnly: true, + }), + ], resolve: { alias: { '@commandkit/ratelimit': join(import.meta.dirname, 'src', 'index.ts'), From 8d71bd94db95331f111573fedd2f6077a4ad8f11 Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:04:17 +0100 Subject: [PATCH 07/10] ci: publish ratelimit package --- .github/workflows/publish-dev.yaml | 2 ++ .github/workflows/publish-latest.yaml | 2 ++ .github/workflows/publish-rc.yaml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/publish-dev.yaml b/.github/workflows/publish-dev.yaml index 802a276a..2e4905c8 100644 --- a/.github/workflows/publish-dev.yaml +++ b/.github/workflows/publish-dev.yaml @@ -64,6 +64,7 @@ jobs: "@commandkit/devtools:packages/devtools" "@commandkit/cache:packages/cache" "@commandkit/analytics:packages/analytics" + "@commandkit/ratelimit:packages/ratelimit" "@commandkit/ai:packages/ai" "@commandkit/queue:packages/queue" "@commandkit/tasks:packages/tasks" @@ -88,6 +89,7 @@ jobs: "@commandkit/devtools" "@commandkit/cache" "@commandkit/analytics" + "@commandkit/ratelimit" "@commandkit/ai" "@commandkit/queue" "@commandkit/tasks" diff --git a/.github/workflows/publish-latest.yaml b/.github/workflows/publish-latest.yaml index 717897bb..e57c7b8e 100644 --- a/.github/workflows/publish-latest.yaml +++ b/.github/workflows/publish-latest.yaml @@ -73,6 +73,7 @@ jobs: "@commandkit/devtools:packages/devtools" "@commandkit/cache:packages/cache" "@commandkit/analytics:packages/analytics" + "@commandkit/ratelimit:packages/ratelimit" "@commandkit/ai:packages/ai" "@commandkit/queue:packages/queue" "@commandkit/tasks:packages/tasks" @@ -125,6 +126,7 @@ jobs: "@commandkit/devtools:packages/devtools" "@commandkit/cache:packages/cache" "@commandkit/analytics:packages/analytics" + "@commandkit/ratelimit:packages/ratelimit" "@commandkit/ai:packages/ai" "@commandkit/queue:packages/queue" "@commandkit/tasks:packages/tasks" diff --git a/.github/workflows/publish-rc.yaml b/.github/workflows/publish-rc.yaml index 53deb808..78ca1ea2 100644 --- a/.github/workflows/publish-rc.yaml +++ b/.github/workflows/publish-rc.yaml @@ -74,6 +74,7 @@ jobs: "@commandkit/devtools:packages/devtools" "@commandkit/cache:packages/cache" "@commandkit/analytics:packages/analytics" + "@commandkit/ratelimit:packages/ratelimit" "@commandkit/ai:packages/ai" "@commandkit/queue:packages/queue" "@commandkit/tasks:packages/tasks" @@ -114,6 +115,7 @@ jobs: "@commandkit/devtools" "@commandkit/cache" "@commandkit/analytics" + "@commandkit/ratelimit" "@commandkit/ai" "@commandkit/queue" "@commandkit/tasks" From b1d525118878469026be11174ccb303be9dda82f Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Sat, 14 Mar 2026 19:07:26 +0100 Subject: [PATCH 08/10] docs: update ratelimit docs --- .../classes/fallback-rate-limit-storage.mdx | 34 +- .../classes/fixed-window-algorithm.mdx | 6 +- .../classes/leaky-bucket-algorithm.mdx | 6 +- .../classes/memory-rate-limit-storage.mdx | 32 +- .../ratelimit/classes/rate-limit-engine.mdx | 4 +- .../ratelimit/classes/rate-limit-error.mdx | 4 +- .../ratelimit/classes/rate-limit-plugin.mdx | 2 +- .../classes/redis-rate-limit-storage.mdx | 28 +- .../classes/sliding-window-log-algorithm.mdx | 6 +- .../classes/token-bucket-algorithm.mdx | 6 +- .../use-rate-limit-directive-plugin.mdx | 6 +- .../ratelimit/classes/violation-tracker.mdx | 6 +- .../functions/build-exemption-key.mdx | 2 +- .../functions/build-exemption-prefix.mdx | 2 +- .../functions/build-scope-prefix.mdx | 14 +- .../ratelimit/functions/clamp-at-least.mdx | 2 +- .../functions/configure-ratelimit.mdx | 3 +- .../ratelimit/functions/get-driver.mdx | 2 +- .../functions/get-rate-limit-config.mdx | 2 +- .../functions/get-rate-limit-info.mdx | 2 +- .../functions/get-rate-limit-runtime.mdx | 2 +- .../functions/get-rate-limit-storage.mdx | 2 +- .../ratelimit/functions/get-role-ids.mdx | 2 +- .../functions/grant-rate-limit-exemption.mdx | 2 +- .../functions/is-rate-limit-configured.mdx | 2 +- .../functions/list-rate-limit-exemptions.mdx | 2 +- .../functions/merge-limiter-configs.mdx | 2 +- .../functions/parse-exemption-key.mdx | 2 +- .../ratelimit/functions/ratelimit.mdx | 5 +- .../functions/reset-all-rate-limits.mdx | 2 +- .../ratelimit/functions/reset-rate-limit.mdx | 2 +- .../ratelimit/functions/resolve-duration.mdx | 2 +- .../functions/resolve-exemption-keys.mdx | 2 +- .../functions/resolve-limiter-config.mdx | 2 +- .../functions/resolve-limiter-configs.mdx | 2 +- .../ratelimit/functions/resolve-scope-key.mdx | 2 +- .../functions/resolve-scope-keys.mdx | 8 +- .../functions/revoke-rate-limit-exemption.mdx | 2 +- .../ratelimit/functions/set-driver.mdx | 2 +- .../functions/set-rate-limit-runtime.mdx | 2 +- .../functions/set-rate-limit-storage.mdx | 2 +- .../functions/with-storage-key-lock.mdx | 4 +- .../fallback-rate-limit-storage-options.mdx | 4 +- .../fixed-window-consume-result.mdx | 2 +- .../interfaces/rate-limit-algorithm.mdx | 2 +- .../interfaces/rate-limit-bypass-options.mdx | 2 +- .../interfaces/rate-limit-command-config.mdx | 2 +- .../interfaces/rate-limit-consume-output.mdx | 2 +- .../rate-limit-exemption-grant-params.mdx | 2 +- .../interfaces/rate-limit-exemption-info.mdx | 2 +- .../rate-limit-exemption-list-params.mdx | 2 +- .../rate-limit-exemption-revoke-params.mdx | 2 +- .../interfaces/rate-limit-hook-context.mdx | 2 +- .../ratelimit/interfaces/rate-limit-hooks.mdx | 10 +- .../interfaces/rate-limit-limiter-config.mdx | 2 +- .../interfaces/rate-limit-plugin-options.mdx | 2 +- .../interfaces/rate-limit-queue-options.mdx | 2 +- .../interfaces/rate-limit-result.mdx | 2 +- .../interfaces/rate-limit-runtime-context.mdx | 2 +- .../interfaces/rate-limit-storage.mdx | 2 +- .../interfaces/rate-limit-store-value.mdx | 2 +- .../interfaces/rate-limit-window-config.mdx | 2 +- .../reset-all-rate-limits-params.mdx | 2 +- .../interfaces/reset-rate-limit-params.mdx | 2 +- .../interfaces/resolve-scope-key-params.mdx | 2 +- .../interfaces/resolved-limiter-config.mdx | 2 +- .../interfaces/resolved-scope-key.mdx | 2 +- .../sliding-window-consume-result.mdx | 2 +- .../interfaces/token-bucket-config.mdx | 2 +- .../interfaces/violation-options.mdx | 2 +- .../ratelimit/types/duration-like.mdx | 2 +- .../types/rate-limit-algorithm-type.mdx | 2 +- .../types/rate-limit-exemption-scope.mdx | 2 +- .../types/rate-limit-key-resolver.mdx | 2 +- .../types/rate-limit-response-handler.mdx | 2 +- .../types/rate-limit-role-limit-strategy.mdx | 2 +- .../ratelimit/types/rate-limit-scope.mdx | 2 +- .../types/rate-limit-storage-config.mdx | 2 +- .../ratelimit/variables/-ckitirl.mdx | 2 +- .../variables/default_key_prefix.mdx | 2 +- .../ratelimit/variables/default_limiter.mdx | 2 +- .../variables/rate_limit_algorithms.mdx | 2 +- .../variables/rate_limit_exemption_scopes.mdx | 2 +- .../ratelimit/variables/rate_limit_scopes.mdx | 2 +- .../variables/ratelimit_store_key.mdx | 2 +- .../07-commandkit-ratelimit.mdx | 1045 +++++++++------- packages/ratelimit/README.md | 1069 +++++++++-------- 87 files changed, 1341 insertions(+), 1093 deletions(-) diff --git a/apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx index 5486a4cc..ebf0b497 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/fallback-rate-limit-storage.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## FallbackRateLimitStorage - + Storage wrapper that falls back to a secondary implementation on failure. @@ -47,82 +47,82 @@ class FallbackRateLimitStorage implements RateLimitStorage { RateLimitStorage, secondary: RateLimitStorage, options: FallbackRateLimitStorageOptions = {}) => FallbackRateLimitStorage`} /> - +Create a fallback wrapper with primary/secondary storages. ### get Promise<T | null>`} /> - +Read a value using primary storage with fallback. ### set Promise<void>`} /> - +Store a value using primary storage with fallback. ### delete Promise<void>`} /> - +Delete a key using primary storage with fallback. ### incr `} /> - +Increment a fixed-window counter using primary storage with fallback. ### ttl `} /> - +Read TTL using primary storage with fallback. ### expire `} /> - +Update TTL using primary storage with fallback. ### zAdd `} /> - +Add a member to a sorted set using primary storage with fallback. ### zRemRangeByScore `} /> - +Remove sorted-set members in a score range with fallback. ### zCard `} /> - +Count sorted-set members with fallback. ### zRangeByScore `} /> - +Read sorted-set members in a score range with fallback. ### consumeFixedWindow `} /> - +Atomically consume a fixed-window counter with fallback. ### consumeSlidingWindowLog `} /> - +Atomically consume a sliding-window log with fallback. ### deleteByPrefix `} /> - +Delete keys with a prefix using primary storage with fallback. ### deleteByPattern `} /> - +Delete keys matching a pattern using primary storage with fallback. ### keysByPrefix `} /> - +List keys matching a prefix using primary storage with fallback. diff --git a/apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx index 72e39183..8525b6b0 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/fixed-window-algorithm.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## FixedWindowAlgorithm - + Basic fixed-window counter for low-cost rate limits. @@ -40,7 +40,7 @@ class FixedWindowAlgorithm implements RateLimitAlgorithm { RateLimitStorage, config: FixedWindowConfig) => FixedWindowAlgorithm`} /> - +Create a fixed-window algorithm bound to a storage backend. ### consume Promise<RateLimitResult>`} /> @@ -50,7 +50,7 @@ Record one attempt and return the current window status for this key. Promise<void>`} /> - +Reset the stored key state for this limiter. diff --git a/apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx index 74522368..eeb42082 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/leaky-bucket-algorithm.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## LeakyBucketAlgorithm - + Leaky bucket algorithm for smoothing output to a steady rate. @@ -40,7 +40,7 @@ class LeakyBucketAlgorithm implements RateLimitAlgorithm { RateLimitStorage, config: LeakyBucketConfig) => LeakyBucketAlgorithm`} /> - +Create a leaky-bucket algorithm bound to a storage backend. ### consume Promise<RateLimitResult>`} /> @@ -50,7 +50,7 @@ Record one attempt and return the current bucket status for this key. Promise<void>`} /> - +Reset the stored key state for this limiter. diff --git a/apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx index c36d51c4..a6568286 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/memory-rate-limit-storage.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## MemoryRateLimitStorage - + In-memory storage used for tests and local usage. @@ -46,77 +46,77 @@ class MemoryRateLimitStorage implements RateLimitStorage { Promise<T | null>`} /> - +Read a value from the in-memory key/value store. ### set Promise<void>`} /> - +Store a value in memory with optional TTL. ### delete Promise<void>`} /> - +Delete a key from the in-memory store. ### incr Promise<FixedWindowConsumeResult>`} /> - +Increment a fixed-window counter with TTL handling. ### ttl Promise<number | null>`} /> - +Read the TTL for a key when present. ### expire Promise<void>`} /> - +Update the TTL for an existing key. ### zAdd Promise<void>`} /> - +Add a member to a sorted set with the given score. ### zRemRangeByScore Promise<void>`} /> - +Remove sorted-set members with scores in the given range. ### zCard Promise<number>`} /> - +Count members in a sorted set. ### zRangeByScore Promise<string[]>`} /> - +Read sorted-set members in a score range. ### consumeFixedWindow Promise<FixedWindowConsumeResult>`} /> - +Atomically consume a fixed-window counter for the key. ### consumeSlidingWindowLog Promise<SlidingWindowConsumeResult>`} /> - +Atomically consume a sliding-window log for the key. ### deleteByPrefix Promise<void>`} /> - +Delete keys with the given prefix. ### deleteByPattern Promise<void>`} /> - +Delete keys matching a glob pattern. ### keysByPrefix Promise<string[]>`} /> - +List keys that match a prefix. diff --git a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx index 1865a037..f9bc63da 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-engine.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitEngine - + Coordinates algorithm selection and violation escalation per storage. @@ -31,7 +31,7 @@ class RateLimitEngine { RateLimitStorage) => RateLimitEngine`} /> - +Create a rate limit engine bound to a storage backend. ### consume ResolvedLimiterConfig) => Promise<RateLimitConsumeOutput>`} /> diff --git a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx index d6bf4d1f..64a6b84d 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-error.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitError - + Error thrown by the directive wrapper when a function is rate-limited. @@ -38,7 +38,7 @@ class RateLimitError extends Error { RateLimitStoreValue, message?: string) => RateLimitError`} /> - +Create a rate-limit error with the stored result payload. diff --git a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx index dcd83d0b..43d4ac43 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/rate-limit-plugin.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitPlugin - + Runtime plugin that enforces rate limits for CommandKit commands so handlers stay lean. diff --git a/apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx index 9ddb8695..6c8dcb97 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/redis-rate-limit-storage.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RedisRateLimitStorage - + Redis-backed storage with Lua scripts for atomic window operations. @@ -57,62 +57,62 @@ class RedisRateLimitStorage implements RateLimitStorage { Promise<T | null>`} /> - +Read a value from Redis and JSON-decode it. ### set Promise<void>`} /> - +Store a value in Redis with optional TTL. ### delete Promise<void>`} /> - +Delete a key from Redis. ### ttl Promise<number | null>`} /> - +Read the TTL for a key when present. ### expire Promise<void>`} /> - +Update the TTL for an existing key. ### zAdd Promise<void>`} /> - +Add a member to a sorted set with the given score. ### zRemRangeByScore Promise<void>`} /> - +Remove sorted-set members with scores in the given range. ### zCard Promise<number>`} /> - +Count members in a sorted set. ### zRangeByScore Promise<string[]>`} /> - +Read sorted-set members in a score range. ### consumeFixedWindow Promise<FixedWindowConsumeResult>`} /> - +Atomically consume a fixed-window counter via Lua. ### consumeSlidingWindowLog Promise<SlidingWindowConsumeResult>`} /> - +Atomically consume a sliding-window log via Lua. ### deleteByPrefix Promise<void>`} /> - +Delete keys with the given prefix. ### deleteByPattern Promise<void>`} /> @@ -122,7 +122,7 @@ Delete keys matching a glob pattern using SCAN to avoid blocking Redis. Promise<string[]>`} /> - +List keys that match a prefix using SCAN. diff --git a/apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx index 70c5f48e..405f6bb9 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/sliding-window-log-algorithm.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## SlidingWindowLogAlgorithm - + Sliding-window log algorithm for smoother limits. @@ -40,7 +40,7 @@ class SlidingWindowLogAlgorithm implements RateLimitAlgorithm { RateLimitStorage, config: SlidingWindowConfig) => SlidingWindowLogAlgorithm`} /> - +Create a sliding-window algorithm bound to a storage backend. ### consume Promise<RateLimitResult>`} /> @@ -50,7 +50,7 @@ Record one attempt and return the current window status for this key. Promise<void>`} /> - +Reset the stored key state for this limiter. diff --git a/apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx index 0717af41..261ce535 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/token-bucket-algorithm.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## TokenBucketAlgorithm - + Token bucket algorithm for bursty traffic with steady refill. @@ -40,7 +40,7 @@ class TokenBucketAlgorithm implements RateLimitAlgorithm { RateLimitStorage, config: TokenBucketConfig) => TokenBucketAlgorithm`} /> - +Create a token-bucket algorithm bound to a storage backend. ### consume Promise<RateLimitResult>`} /> @@ -50,7 +50,7 @@ Record one attempt and return the current bucket status for this key. Promise<void>`} /> - +Reset the stored key state for this limiter. diff --git a/apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx b/apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx index 298feeb5..223f30b1 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/use-rate-limit-directive-plugin.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## UseRateLimitDirectivePlugin - + Compiler plugin for the "use ratelimit" directive. @@ -39,12 +39,12 @@ class UseRateLimitDirectivePlugin extends CommonDirectiveTransformer { CommonDirectiveTransformerOptions>) => UseRateLimitDirectivePlugin`} /> - +Create the directive compiler plugin with optional overrides. ### activate CompilerPluginRuntime) => Promise<void>`} /> - +Activate the compiler plugin in the current build runtime. diff --git a/apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx b/apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx index c78b6f7b..a3111f8a 100644 --- a/apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx +++ b/apps/website/docs/api-reference/ratelimit/classes/violation-tracker.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ViolationTracker - + Tracks repeated violations and computes escalating cooldowns. @@ -33,7 +33,7 @@ class ViolationTracker { RateLimitStorage) => ViolationTracker`} /> - +Create a violation tracker bound to a storage backend. ### getState Promise<ViolationState | null>`} /> @@ -53,7 +53,7 @@ Record a violation and return the updated state for callers. Promise<void>`} /> - +Clear stored violation state for a key. diff --git a/apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx index 19ecd40d..e3437638 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-key.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## buildExemptionKey - + Build a storage key for a temporary exemption entry. diff --git a/apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx index a9bef7df..7d48f487 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/build-exemption-prefix.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## buildExemptionPrefix - + Build a prefix for scanning exemption keys in storage. diff --git a/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx b/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx index db946be2..2894ed8a 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/build-scope-prefix.mdx @@ -13,16 +13,16 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## buildScopePrefix - + Build a prefix for resets by scope/identifier. ```ts title="Signature" -function buildScopePrefix(scope: RateLimitScope, keyPrefix: string | undefined, identifiers: { - userId?: string; - guildId?: string; - channelId?: string; - commandName?: string; +function buildScopePrefix(scope: RateLimitScope, keyPrefix: string | undefined, identifiers: { + userId?: string; + guildId?: string; + channelId?: string; + commandName?: string; }): string | null ``` Parameters @@ -37,5 +37,5 @@ Parameters ### identifiers - + diff --git a/apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx b/apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx index e44f9f08..a276901a 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/clamp-at-least.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## clampAtLeast - + Clamp a number to a minimum value to avoid zero/negative windows. diff --git a/apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx b/apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx index 7d9aba23..6497117a 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/configure-ratelimit.mdx @@ -13,9 +13,10 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## configureRatelimit - + Configures the rate limit plugin runtime options. + Call this once during startup (for example in src/ratelimit.ts). ```ts title="Signature" diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx index 896b02b2..ba4fe629 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/get-driver.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## getDriver - + Alias for getRateLimitStorage to match other packages (tasks/queue). diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx index f2c9acb5..5395f120 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## getRateLimitConfig - + Retrieves the current rate limit configuration. diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx index b020a9d4..48758aa5 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-info.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## getRateLimitInfo - + Read aggregated rate limit info stored on a CommandKit env or context. diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx index 0592d223..822f63ab 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-runtime.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## getRateLimitRuntime - + Get the active runtime context for directives and APIs. diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx index fe123fc4..7964ab1a 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/get-rate-limit-storage.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## getRateLimitStorage - + Get the default rate limit storage instance for the process. diff --git a/apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx b/apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx index 951d6f5e..018a76df 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/get-role-ids.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## getRoleIds - + Extract role IDs from a message/interaction for role-based limits. diff --git a/apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx b/apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx index 60658ccc..0bd67801 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/grant-rate-limit-exemption.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## grantRateLimitExemption - + Grant a temporary exemption for a scope/id pair. diff --git a/apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx b/apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx index 39412628..a75aa04d 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/is-rate-limit-configured.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## isRateLimitConfigured - + Returns true once configureRatelimit has been called. diff --git a/apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx b/apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx index c775a0a1..e8e51d9a 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/list-rate-limit-exemptions.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## listRateLimitExemptions - + List exemptions by scope and/or id for admin/reporting. diff --git a/apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx b/apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx index 458dfe9d..60ebb15f 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/merge-limiter-configs.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## mergeLimiterConfigs - + Merge limiter configs; later values override earlier ones for layering. diff --git a/apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx b/apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx index 361caae9..c46b3328 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/parse-exemption-key.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## parseExemptionKey - + Parse an exemption key into scope and ID for listing. diff --git a/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx b/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx index 357b2476..7b518949 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/ratelimit.mdx @@ -13,9 +13,10 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ratelimit - + + +Create compiler + runtime plugins for rate limiting. -Create compiler + runtime plugins for rate limiting. Runtime options are provided via configureRatelimit(). ```ts title="Signature" diff --git a/apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx b/apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx index 7340ca5b..2fc730ce 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/reset-all-rate-limits.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resetAllRateLimits - + Reset multiple keys by scope, command name, prefix, or pattern for bulk cleanup. diff --git a/apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx b/apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx index f7dab7ec..709286a9 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/reset-rate-limit.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resetRateLimit - + Reset a single key and its violation/window variants to keep state consistent. diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx index dc1fe816..52852b4e 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-duration.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resolveDuration - + Resolve a duration input into milliseconds with a fallback. diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx index 77c861ed..c6074fcd 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-exemption-keys.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resolveExemptionKeys - + Resolve all exemption keys that could apply to a source. diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx index 8128e200..9803f1b3 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resolveLimiterConfig - + Resolve a limiter config for a single scope with defaults applied. diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx index 9c1708d5..383f2d6e 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-limiter-configs.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resolveLimiterConfigs - + Resolve limiter configs for a scope across all configured windows. diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx index 6eec9ca6..c9b38b47 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-key.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resolveScopeKey - + Resolve the storage key for a single scope. diff --git a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx index 79d4d6ae..496aad2b 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/resolve-scope-keys.mdx @@ -13,18 +13,18 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## resolveScopeKeys - + Resolve keys for multiple scopes, dropping unresolvable ones. ```ts title="Signature" -function resolveScopeKeys(params: Omit & { - scopes: RateLimitScope[]; +function resolveScopeKeys(params: Omit & { + scopes: RateLimitScope[]; }): ResolvedScopeKey[] ``` Parameters ### params -ResolveScopeKeyParams, 'scope'> & { scopes: RateLimitScope[]; }`} /> +ResolveScopeKeyParams, 'scope'> & { scopes: RateLimitScope[]; }`} /> diff --git a/apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx b/apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx index 1e51c8e5..eb9e8e9d 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/revoke-rate-limit-exemption.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## revokeRateLimitExemption - + Revoke a temporary exemption for a scope/id pair. diff --git a/apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx b/apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx index b93068e3..3ebd4935 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/set-driver.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## setDriver - + Alias for setRateLimitStorage to match other packages (tasks/queue). diff --git a/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx index 8f55ff6c..829f2da7 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-runtime.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## setRateLimitRuntime - + Set the active runtime context used by directives and APIs. diff --git a/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx index 2821f960..190a8e35 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/set-rate-limit-storage.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## setRateLimitStorage - + Set the default rate limit storage instance for the process. diff --git a/apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx b/apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx index 5dcf6105..fb8f12b5 100644 --- a/apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx +++ b/apps/website/docs/api-reference/ratelimit/functions/with-storage-key-lock.mdx @@ -13,9 +13,9 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## withStorageKeyLock - - + +Serialize work for a storage key to avoid same-process conflicts. ```ts title="Signature" function withStorageKeyLock(storage: RateLimitStorage, key: string, fn: LockedFn): Promise diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx index cfcfa96a..901ef27c 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/fallback-rate-limit-storage-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## FallbackRateLimitStorageOptions - + Options that control fallback logging/cooldown behavior. @@ -27,7 +27,7 @@ interface FallbackRateLimitStorageOptions { ### cooldownMs - + Minimum time between fallback log entries (to avoid log spam). diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx index 7236ce72..19b8f91c 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/fixed-window-consume-result.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## FixedWindowConsumeResult - + Storage result for fixed-window atomic consumes. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx index 11a85ac1..8da1e5f0 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-algorithm.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitAlgorithm - + Contract for rate limit algorithms used by the engine. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx index adc8189b..3b23484e 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-bypass-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitBypassOptions - + Permanent allowlist rules for rate limiting. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx index 55b14746..b056b443 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-command-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitCommandConfig - + Per-command override stored in CommandKit metadata. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx index 83467e44..f1394f23 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-consume-output.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitConsumeOutput - + Consume output including optional violation count for callers. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx index 9db7ffab..e8ec84a0 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-grant-params.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitExemptionGrantParams - + Parameters for granting a temporary exemption. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx index 3969be52..4b822def 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-info.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitExemptionInfo - + Listed exemption entry with key and expiry info. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx index 0b3bd708..b2dd102d 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-list-params.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitExemptionListParams - + Filters for listing temporary exemptions. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx index 0b1fbc4e..df6f9111 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-exemption-revoke-params.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitExemptionRevokeParams - + Parameters for revoking a temporary exemption. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx index 154114d6..daf42050 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hook-context.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitHookContext - + Hook payload for rate limit lifecycle callbacks. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx index 22fd6084..73347345 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-hooks.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitHooks - + Optional lifecycle hooks used by the plugin to surface rate limit events. @@ -23,9 +23,9 @@ interface RateLimitHooks { onAllowed?: (info: RateLimitHookContext) => void | Promise; onReset?: (key: string) => void | Promise; onViolation?: (key: string, count: number) => void | Promise; - onStorageError?: ( - error: unknown, - fallbackUsed: boolean, + onStorageError?: ( + error: unknown, + fallbackUsed: boolean, ) => void | Promise; } ``` @@ -54,7 +54,7 @@ interface RateLimitHooks { ### onStorageError - + diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx index 47e07801..f504e4ba 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-limiter-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitLimiterConfig - + Core limiter configuration used by plugin and directives. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx index ec999f4a..88faf829 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-plugin-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitPluginOptions - + Runtime plugin options consumed by RateLimitPlugin. Configure these via configureRatelimit(). diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx index 4ebcba59..ba1af1ec 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-queue-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitQueueOptions - + Queue behavior for delayed retries after a limit is hit. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx index 7415cfc8..01035877 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-result.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitResult - + Result for a single limiter/window evaluation used for aggregation. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx index aedf67c8..e38373fe 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-runtime-context.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitRuntimeContext - + Active runtime context shared with APIs and directives. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx index 08752fb5..bc8c2bc5 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-storage.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitStorage - + Storage contract for rate limit state, with optional optimization hooks. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx index 39a1613a..35b31b00 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-store-value.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitStoreValue - + Aggregate results stored on the environment store for downstream handlers. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx index 1ec1129c..13f2228e 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/rate-limit-window-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitWindowConfig - + Per-window overrides when a limiter defines multiple windows. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx index f229984c..4f8043a5 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/reset-all-rate-limits-params.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResetAllRateLimitsParams - + Parameters for batch resets by scope, prefix, or pattern. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx index c0669cc3..de8af909 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/reset-rate-limit-params.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResetRateLimitParams - + Parameters for resetting a single key or scope-derived key. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx index 82f60fc8..874f7b89 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/resolve-scope-key-params.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResolveScopeKeyParams - + Inputs for resolving a scope-based key from a command/source. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx index be91a3a6..e9886c49 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-limiter-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResolvedLimiterConfig - + Limiter configuration after defaults are applied. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx index 7d0f85e1..3a8ef55e 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/resolved-scope-key.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ResolvedScopeKey - + Resolved key paired with its scope for aggregation. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx index f901a667..3b43136d 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/sliding-window-consume-result.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## SlidingWindowConsumeResult - + Storage result for sliding-window log consumes. diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx index 04dbcf00..ec3dca28 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/token-bucket-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## TokenBucketConfig - + diff --git a/apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx b/apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx index 4cb1a4da..b5db461e 100644 --- a/apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx +++ b/apps/website/docs/api-reference/ratelimit/interfaces/violation-options.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## ViolationOptions - + Escalation settings for repeated violations. diff --git a/apps/website/docs/api-reference/ratelimit/types/duration-like.mdx b/apps/website/docs/api-reference/ratelimit/types/duration-like.mdx index aa305c88..42047870 100644 --- a/apps/website/docs/api-reference/ratelimit/types/duration-like.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/duration-like.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## DurationLike - + Duration input accepted by configs: milliseconds or a duration string. diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx index f3352494..27a29129 100644 --- a/apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-algorithm-type.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitAlgorithmType - + Literal union of algorithm identifiers. diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx index 5d14f193..806dbddf 100644 --- a/apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-exemption-scope.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitExemptionScope - + Literal union of exemption scopes. diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx index ee5a30cd..2308fd4c 100644 --- a/apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-key-resolver.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitKeyResolver - + Custom key builder for the `custom` scope. diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx index d64423da..d8418d11 100644 --- a/apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-response-handler.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitResponseHandler - + Override for responding when a command is rate-limited. diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx index 464bf1d9..5bd03bdc 100644 --- a/apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-role-limit-strategy.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitRoleLimitStrategy - + Strategy for choosing among matching role-based overrides. diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx index 3c49bac3..e64bb379 100644 --- a/apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-scope.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitScope - + Literal union of supported key scopes. diff --git a/apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx b/apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx index 6d1c8921..46f3ffe0 100644 --- a/apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx +++ b/apps/website/docs/api-reference/ratelimit/types/rate-limit-storage-config.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RateLimitStorageConfig - + Storage configuration: direct instance or `{ driver }` wrapper for parity. diff --git a/apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx b/apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx index 0e84cb1b..e914ae97 100644 --- a/apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx +++ b/apps/website/docs/api-reference/ratelimit/variables/-ckitirl.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## $ckitirl - + Wrapper symbol injected by the compiler plugin. diff --git a/apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx b/apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx index b7c45278..0ea96f6a 100644 --- a/apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx +++ b/apps/website/docs/api-reference/ratelimit/variables/default_key_prefix.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## DEFAULT_KEY_PREFIX - + Default prefix for storage keys; can be overridden per config. diff --git a/apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx b/apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx index a45e9b8a..f4bc1194 100644 --- a/apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx +++ b/apps/website/docs/api-reference/ratelimit/variables/default_limiter.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## DEFAULT_LIMITER - + Default limiter used when no explicit configuration is provided. diff --git a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx index 1c75247a..f38c27c3 100644 --- a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx +++ b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_algorithms.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RATE_LIMIT_ALGORITHMS - + Algorithm identifiers used to select the limiter implementation. diff --git a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx index 180b7eb9..0a85b4bc 100644 --- a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx +++ b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_exemption_scopes.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RATE_LIMIT_EXEMPTION_SCOPES - + Scopes eligible for temporary exemptions stored in rate limit storage. diff --git a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx index 80cc649b..7c1f28d7 100644 --- a/apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx +++ b/apps/website/docs/api-reference/ratelimit/variables/rate_limit_scopes.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RATE_LIMIT_SCOPES - + Scopes used to build rate limit keys and apply per-scope limits. diff --git a/apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx b/apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx index 627b04cd..c9d713e9 100644 --- a/apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx +++ b/apps/website/docs/api-reference/ratelimit/variables/ratelimit_store_key.mdx @@ -13,7 +13,7 @@ import MemberDescription from '@site/src/components/MemberDescription'; ## RATELIMIT_STORE_KEY - + Store key used to stash aggregated results in CommandKit envs. diff --git a/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx b/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx index f2503f01..a9b3ce5e 100644 --- a/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx +++ b/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx @@ -1,19 +1,28 @@ --- title: '@commandkit/ratelimit' +description: Official CommandKit rate limiting plugin with detailed runtime, storage, and behavior reference. --- -`@commandkit/ratelimit` is the official CommandKit plugin for advanced -rate limiting, with multi-window policies, role overrides, queueing, -exemptions, and multiple algorithms. +`@commandkit/ratelimit` is the official CommandKit plugin for advanced rate limiting. It provides multi-window policies, role overrides, queueing, exemptions, and multiple algorithms while keeping command handlers lean. + +The `ratelimit()` factory returns two plugins in order: the compiler plugin for the "use ratelimit" directive and the runtime plugin that enforces limits. Runtime options must be configured before the runtime plugin activates. ## Installation +Install the ratelimit plugin to get started: + ```bash npm2yarn npm install @commandkit/ratelimit ``` ## Setup +Add the ratelimit plugin to your CommandKit configuration and define a runtime config file. + +### Quick start + +Create an auto-loaded runtime config file (for example `src/ratelimit.ts`) and configure the default limiter: + ```ts title="src/ratelimit.ts" import { configureRatelimit } from '@commandkit/ratelimit'; @@ -27,6 +36,8 @@ configureRatelimit({ }); ``` +Register the plugin in your config: + ```ts title="commandkit.config.ts" import { defineConfig } from 'commandkit'; import { ratelimit } from '@commandkit/ratelimit'; @@ -36,260 +47,358 @@ export default defineConfig({ }); ``` -The runtime plugin auto-loads `ratelimit.ts`/`ratelimit.js` on startup before -commands execute. +The runtime plugin auto-loads `ratelimit.ts` or `ratelimit.js` on startup before commands execute. -Use `getRateLimitConfig()` to read the active configuration and -`isRateLimitConfigured()` to guard flows that depend on runtime setup. +## Runtime configuration lifecycle -## Per-command configuration +### Runtime lifecycle diagram -```ts title="src/app/commands/ping.ts" -export const metadata = { - ratelimit: { - maxRequests: 3, - interval: '10s', - scope: 'user', - algorithm: 'sliding-window', - }, -}; +```mermaid +graph TD + A[App startup] --> B[Auto-load ratelimit.ts/js] + B --> C[configureRatelimit()] + C --> D[Runtime plugin activate()] + D --> E[Resolve storage] + E --> F[Resolve limiter config] + F --> G[Consume algorithm] + G --> H[Aggregate result] + H --> I[Default response / hooks / events] ``` -## `ratelimit()` options +### `configureRatelimit` is required -The factory returns compiler + runtime plugins and accepts: +`RateLimitPlugin.activate()` throws if `configureRatelimit()` was not called. This is enforced to avoid silently running without your intended defaults. -- `compiler`: Options for the `"use ratelimit"` directive transformer. +:::warning Runtime configuration required -Runtime options are configured via `configureRatelimit()`. +Make sure `configureRatelimit()` runs at startup (for example in `ratelimit.ts` or `ratelimit.js`) before the runtime plugin activates. If it does not, the plugin will throw on startup. -## Scopes and key format +::: -Scopes: +### How configuration is stored -- `user` -- `guild` -- `channel` -- `global` -- `user-guild` -- `custom` +`configureRatelimit()` merges your config into an in-memory object and sets the configured flag. `getRateLimitConfig()` returns the current object, and `isRateLimitConfigured()` returns whether initialization has happened. If a runtime context is already active, `configureRatelimit()` updates it immediately. -Key format per scope: +### Runtime storage selection -- `user` -> `rl:user:{userId}:{commandName}` -- `guild` -> `rl:guild:{guildId}:{commandName}` -- `channel` -> `rl:channel:{channelId}:{commandName}` -- `global` -> `rl:global:{commandName}` -- `user-guild` -> `rl:user:{userId}:guild:{guildId}:{commandName}` -- `custom` -> `keyResolver(ctx, command, source)` +Storage is resolved in this order: -If `keyPrefix` is provided, it is prepended before `rl:`. +| Order | Source | Notes | +| --- | --- | --- | +| 1 | Limiter `storage` override | `RateLimitLimiterConfig.storage` for the command being executed. | +| 2 | Plugin `storage` option | `RateLimitPluginOptions.storage`. | +| 3 | Process default | Set via `setRateLimitStorage()` or `setDriver()`. | +| 4 | Default memory storage | Used unless `initializeDefaultStorage` or `initializeDefaultDriver` is `false`. | -Multi-window limits add a suffix: `:w:{windowId}`. +If no storage is resolved and defaults are disabled, the plugin logs once and stores an empty result without limiting. -For `custom` scope you must provide `keyResolver`: +### Runtime helpers -```ts -const keyResolver = (ctx, command, source) => { - return `custom:${ctx.commandName}:${source.user?.id ?? 'unknown'}`; -}; -``` +These helpers are process-wide: -If `keyResolver` returns a falsy value, the limiter is skipped. - -Exemption keys use `rl:exempt:{scope}:{id}` with an optional `keyPrefix`. - -## Plugin options - -- `defaultLimiter`: Default limiter for all commands. -- `limiters`: Named limiter presets for command metadata `limiter`. -- `storage`: Storage driver or `{ driver }` wrapper. -- `keyPrefix`: Optional string prepended to keys. -- `keyResolver`: Resolver for `custom` scope. -- `bypass`: Bypass rules for users, roles, guilds, or a custom check. -- `hooks`: Lifecycle hooks for allowed, limited, reset, violation, storage error. -- `onRateLimited`: Custom response handler for rate-limited commands. -- `queue`: Queue settings for retrying instead of rejecting. -- `roleLimits`: Role-specific limiter overrides. -- `roleLimitStrategy`: `highest`, `lowest`, or `first`. -- `initializeDefaultStorage`: Enable default memory storage when no storage set. -- `initializeDefaultDriver`: Alias for `initializeDefaultStorage`. - -## Limiter options - -- `maxRequests`: Requests per interval. -- `interval`: Duration in ms or string. -- `scope`: Single scope or list of scopes. -- `algorithm`: `fixed-window`, `sliding-window`, `token-bucket`, `leaky-bucket`. -- `burst`: Capacity for token/leaky buckets. -- `refillRate`: Tokens/sec for token bucket. -- `leakRate`: Tokens/sec for leaky bucket. -- `keyResolver`: Custom scope key resolver. -- `keyPrefix`: Limiter-specific prefix. -- `storage`: Limiter-specific storage override. -- `violations`: Escalation policy. -- `queue`: Limiter-specific queue override. -- `windows`: Multi-window configuration. -- `roleLimits`: Limiter-specific role overrides. -- `roleLimitStrategy`: Limiter-specific role strategy. - -## Resolution order - -- Built-in defaults. -- `defaultLimiter`. -- Named limiter (when `metadata.ratelimit.limiter` is set). -- Command metadata overrides. -- Role limit overrides. +| Helper | Purpose | +| --- | --- | +| `configureRatelimit` | Set runtime options and update active runtime state. | +| `getRateLimitConfig` | Read the merged in-memory runtime config. | +| `isRateLimitConfigured` | Check whether `configureRatelimit()` was called. | +| `setRateLimitStorage` | Set the default storage for the process. | +| `getRateLimitStorage` | Get the process default storage (or `null`). | +| `setDriver` / `getDriver` | Aliases for `setRateLimitStorage` / `getRateLimitStorage`. | +| `setRateLimitRuntime` | Set the active runtime context for APIs and directives. | +| `getRateLimitRuntime` | Get the active runtime context (or `null`). | -## Algorithms +## Basic usage -- `fixed-window` uses counters per interval. -- `sliding-window` uses sorted sets and trims by time window. -- `token-bucket` refills tokens continuously. -- `leaky-bucket` leaks tokens continuously. -- Sliding-window sorted-set fallback is non-atomic under concurrency; implement `consumeSlidingWindowLog` for strict enforcement. -- `refillRate` must be greater than 0 for token buckets. -- `leakRate` must be greater than 0 for leaky buckets. +Use command metadata or the `use ratelimit` directive to enable rate limiting. +This section focuses on command metadata; see the directive section for +function-level usage. -Storage requirements: +### Command metadata and enablement -- `fixed-window` uses `consumeFixedWindow` or `incr`, with a `get/set` fallback. -- `sliding-window` requires sorted-set ops or `consumeSlidingWindowLog`. -- `token-bucket` and `leaky-bucket` use `get/set`. +Enable rate limiting by setting `metadata.ratelimit`: -## Storage drivers +```ts title="src/app/commands/ping.ts" +export const metadata = { + ratelimit: { + maxRequests: 3, + interval: '10s', + scope: 'user', + algorithm: 'sliding-window', + }, +}; +``` -`storage` accepts either a `RateLimitStorage` instance or `{ driver }`. +`metadata.ratelimit` can be one of: -### Memory storage +| Value | Meaning | +| --- | --- | +| `false` or `undefined` | Plugin does nothing for this command. | +| `true` | Enable rate limiting using resolved defaults. | +| `RateLimitCommandConfig` | Enable rate limiting with command-level overrides. | -```ts -import { - MemoryRateLimitStorage, - setRateLimitStorage, -} from '@commandkit/ratelimit'; +If `env.context` is missing in the execution environment, the plugin skips rate limiting. -setRateLimitStorage(new MemoryRateLimitStorage()); -``` +### Named limiter example -### Redis storage +```ts title="commandkit.config.ts" +configureRatelimit({ + limiters: { + heavy: { maxRequests: 1, interval: '10s', algorithm: 'fixed-window' }, + }, +}); +``` -```ts -import { setRateLimitStorage } from '@commandkit/ratelimit'; -import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; +```ts title="src/app/commands/report.ts" +export const metadata = { + ratelimit: { + limiter: 'heavy', + scope: 'user', + }, +}; +``` -setRateLimitStorage( - new RedisRateLimitStorage({ host: 'localhost', port: 6379 }), -); +## Configuration reference + +### RateLimitPluginOptions + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `defaultLimiter` | `RateLimitLimiterConfig` | `DEFAULT_LIMITER` when unset | Base limiter for all commands and directives. | +| `limiters` | `Record` | `undefined` | Named limiter presets. | +| `storage` | `RateLimitStorageConfig` | `undefined` | Resolved before default storage. | +| `keyPrefix` | `string` | `undefined` | Prepended before `rl:`. | +| `keyResolver` | `RateLimitKeyResolver` | `undefined` | Used for `custom` scope when the limiter does not override it. | +| `bypass` | `RateLimitBypassOptions` | `undefined` | Permanent allowlists and optional check. | +| `hooks` | `RateLimitHooks` | `undefined` | Lifecycle callbacks. | +| `onRateLimited` | `RateLimitResponseHandler` | `undefined` | Overrides default reply. | +| `queue` | `RateLimitQueueOptions` | `undefined` | If any queue config exists, `enabled` defaults to `true`. | +| `roleLimits` | `Record` | `undefined` | Base role limits. | +| `roleLimitStrategy` | `RateLimitRoleLimitStrategy` | `highest` when resolving | Used when multiple roles match. | +| `initializeDefaultStorage` | `boolean` | `true` | Disable to prevent memory fallback. | +| `initializeDefaultDriver` | `boolean` | `true` | Alias for `initializeDefaultStorage`. | + +### RateLimitLimiterConfig + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `maxRequests` | `number` | `10` when missing or `<= 0` | Used by fixed and sliding windows. | +| `interval` | `DurationLike` | `60s` when missing or invalid | Parsed and clamped to `>= 1ms`. | +| `scope` | `RateLimitScope` or `RateLimitScope[]` | `user` | Arrays are deduplicated. | +| `algorithm` | `RateLimitAlgorithmType` | `fixed-window` | Unknown values fall back to fixed-window. | +| `burst` | `number` | `maxRequests` when missing or `<= 0` | Capacity for token or leaky buckets. | +| `refillRate` | `number` | `maxRequests / intervalSeconds` | Must be `> 0` for token bucket. | +| `leakRate` | `number` | `maxRequests / intervalSeconds` | Must be `> 0` for leaky bucket. | +| `keyResolver` | `RateLimitKeyResolver` | `undefined` | Used only for `custom` scope. | +| `keyPrefix` | `string` | `undefined` | Overrides plugin prefix for this limiter. | +| `storage` | `RateLimitStorageConfig` | `undefined` | Overrides storage for this limiter. | +| `violations` | `ViolationOptions` | `undefined` | Enables escalation unless `escalate` is `false`. | +| `queue` | `RateLimitQueueOptions` | `undefined` | Overrides queue settings at this layer. | +| `windows` | `RateLimitWindowConfig[]` | `undefined` | Enables multi-window behavior. | +| `roleLimits` | `Record` | `undefined` | Role overrides at this layer. | +| `roleLimitStrategy` | `RateLimitRoleLimitStrategy` | `highest` when resolving | Used when role limits match. | + +### RateLimitWindowConfig + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `id` | `string` | `w1`, `w2`, ... | Auto-generated if empty or missing. | +| `maxRequests` | `number` | Inherits from base limiter | Applies only to this window. | +| `interval` | `DurationLike` | Inherits from base limiter | Parsed like the base limiter. | +| `algorithm` | `RateLimitAlgorithmType` | Inherits from base limiter | Usually keep consistent across windows. | +| `burst` | `number` | Inherits from base limiter | Used for token or leaky buckets. | +| `refillRate` | `number` | Inherits from base limiter | Must be `> 0` for token bucket. | +| `leakRate` | `number` | Inherits from base limiter | Must be `> 0` for leaky bucket. | +| `violations` | `ViolationOptions` | Inherits from base limiter | Overrides escalation for this window. | + +### RateLimitQueueOptions + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `true` when any queue config exists | Otherwise `false`. | +| `maxSize` | `number` | `3` and clamped to `>= 1` | Queue size is pending plus running. | +| `timeout` | `DurationLike` | `30s` and clamped to `>= 1ms` | Applies per queued task. | +| `deferInteraction` | `boolean` | `true` unless explicitly `false` | Only used for interactions. | +| `ephemeral` | `boolean` | `true` unless explicitly `false` | Applies to deferred replies. | +| `concurrency` | `number` | `1` and clamped to `>= 1` | Controls per-key queue concurrency. | + +### ViolationOptions + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `escalate` | `boolean` | `true` when `violations` is set | Set `false` to disable escalation. | +| `maxViolations` | `number` | `5` | Maximum escalation steps. | +| `escalationMultiplier` | `number` | `2` | Multiplies cooldown per violation. | +| `resetAfter` | `DurationLike` | `1h` | TTL for violation state. | + +### RateLimitCommandConfig + +`RateLimitCommandConfig` extends `RateLimitLimiterConfig` and adds: + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `limiter` | `string` | `undefined` | References a named limiter in `limiters`. | + +### Result shapes + +RateLimitStoreValue: + +| Field | Type | Meaning | +| --- | --- | --- | +| `limited` | `boolean` | `true` if any scope or window was limited. | +| `remaining` | `number` | Minimum remaining across all results. | +| `resetAt` | `number` | Latest reset timestamp across all results. | +| `retryAfter` | `number` | Max retry delay across limited results. | +| `results` | `RateLimitResult[]` | Individual results per scope and window. | + +RateLimitResult: + +| Field | Type | Meaning | +| --- | --- | --- | +| `key` | `string` | Storage key used for the limiter. | +| `scope` | `RateLimitScope` | Scope applied for the limiter. | +| `algorithm` | `RateLimitAlgorithmType` | Algorithm used for the limiter. | +| `windowId` | `string` | Present for multi-window limits. | +| `limited` | `boolean` | Whether this limiter hit its limit. | +| `remaining` | `number` | Remaining requests or capacity. | +| `resetAt` | `number` | Absolute reset timestamp in ms. | +| `retryAfter` | `number` | Delay until retry is allowed, in ms. | +| `limit` | `number` | `maxRequests` for fixed and sliding, `burst` for token and leaky buckets. | + +## Limiter resolution and role strategy + +Limiter configuration is layered in this exact order, with later layers overriding earlier ones: + +| Order | Source | Notes | +| --- | --- | --- | +| 1 | `DEFAULT_LIMITER` | Base defaults. | +| 2 | `defaultLimiter` | Runtime defaults. | +| 3 | Named limiter | When `metadata.ratelimit.limiter` is set. | +| 4 | Command overrides | `metadata.ratelimit` config. | +| 5 | Role override | Selected by role strategy. | + +### Limiter resolution diagram + +```mermaid +graph TD + A[DEFAULT_LIMITER] --> B[defaultLimiter] + B --> C[Named limiter] + C --> D[Command overrides] + D --> E[Role override (strategy)] ``` -`@commandkit/ratelimit/redis` also re-exports `RedisOptions` from `ioredis`. +Role limits are merged in this order, with later maps overriding earlier ones for the same role id: -### Fallback storage +| Order | Source | +| --- | --- | +| 1 | Plugin `roleLimits` | +| 2 | `defaultLimiter.roleLimits` | +| 3 | Named limiter `roleLimits` | +| 4 | Command `roleLimits` | -```ts -import { FallbackRateLimitStorage } from '@commandkit/ratelimit/fallback'; -import { MemoryRateLimitStorage } from '@commandkit/ratelimit/memory'; -import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; -import { setRateLimitStorage } from '@commandkit/ratelimit'; +Role strategies: -const primary = new RedisRateLimitStorage({ host: 'localhost', port: 6379 }); -const secondary = new MemoryRateLimitStorage(); +| Strategy | Selection rule | +| --- | --- | +| `highest` | Picks the role with the highest request rate (`maxRequests / intervalMs`). | +| `lowest` | Picks the role with the lowest request rate. | +| `first` | Uses insertion order of the merged role limits object. | -setRateLimitStorage(new FallbackRateLimitStorage(primary, secondary)); -``` +For multi-window limiters, the score uses the minimum rate across windows. -Fallback storage options: +## Scopes and keying -- `cooldownMs`: Log cooldown between primary errors (default 30s). +Supported scopes: -Disable default memory storage: +| Scope | Required IDs | Key format (without `keyPrefix`) | Skip behavior | +| --- | --- | --- | --- | +| `user` | `userId` | `rl:user:{userId}:{commandName}` | Skips if `userId` is missing. | +| `guild` | `guildId` | `rl:guild:{guildId}:{commandName}` | Skips if `guildId` is missing. | +| `channel` | `channelId` | `rl:channel:{channelId}:{commandName}` | Skips if `channelId` is missing. | +| `global` | none | `rl:global:{commandName}` | Never skipped. | +| `user-guild` | `userId`, `guildId` | `rl:user:{userId}:guild:{guildId}:{commandName}` | Skips if either id is missing. | +| `custom` | `keyResolver` | `keyResolver(ctx, command, source)` | Skips if resolver is missing or returns falsy. | -```ts -configureRatelimit({ - initializeDefaultStorage: false, - // or: initializeDefaultDriver: false -}); -``` +Keying notes: -## Storage interface and requirements +- `DEFAULT_KEY_PREFIX` is always included in the base format. +- `keyPrefix` is concatenated before `rl:` as-is, so include a trailing separator if you want one. +- Multi-window limits append `:w:{windowId}`. -`storage` accepts either a `RateLimitStorage` instance or `{ driver }`. +### Exemption keys -Required methods: +Temporary exemptions are stored under `rl:exempt:{scope}:{id}` (plus optional `keyPrefix`). -- `get`, `set`, `delete`. +| Exemption scope | Key format | Notes | +| --- | --- | --- | +| `user` | `rl:exempt:user:{userId}` | Resolved from the source user id. | +| `guild` | `rl:exempt:guild:{guildId}` | Resolved from the guild id. | +| `role` | `rl:exempt:role:{roleId}` | Resolved from all member roles. | +| `channel` | `rl:exempt:channel:{channelId}` | Resolved from the channel id. | +| `category` | `rl:exempt:category:{categoryId}` | Resolved from the parent category id. | -Optional methods used by features: +## Algorithms -- `incr` and `consumeFixedWindow` for fixed-window efficiency. -- `zAdd`, `zRemRangeByScore`, `zCard`, `zRangeByScore`, `consumeSlidingWindowLog` for sliding window. -- `ttl`, `expire` for expiry visibility. -- `deleteByPrefix`, `deleteByPattern`, `keysByPrefix` for resets and exemption listing. +### Algorithm matrix -## Queue mode +| Algorithm | Required config | Storage requirements | `limit` value | Notes | +| --- | --- | --- | --- | --- | +| `fixed-window` | `maxRequests`, `interval` | `consumeFixedWindow` or `incr` or `get` and `set` | `maxRequests` | Fallback uses per-process lock and optimistic versioning. | +| `sliding-window` | `maxRequests`, `interval` | `consumeSlidingWindowLog` or `zRemRangeByScore` + `zCard` + `zAdd` | `maxRequests` | Throws if sorted-set support is missing. | +| `token-bucket` | `burst`, `refillRate` | `get` and `set` | `burst` | Throws if `refillRate <= 0`. | +| `leaky-bucket` | `burst`, `leakRate` | `get` and `set` | `burst` | Throws if `leakRate <= 0`. | -```ts -configureRatelimit({ - queue: { - enabled: true, - maxSize: 3, - timeout: '30s', - deferInteraction: true, - ephemeral: true, - concurrency: 1, - }, -}); -``` +### Fixed window -Queue options: +Execution path: -- `enabled` -- `maxSize` -- `timeout` -- `deferInteraction` -- `ephemeral` -- `concurrency` +1. If `consumeFixedWindow` exists, it is used. +2. Else if `incr` exists, it is used. +3. Else a fallback uses `get` and `set` with a per-process lock. -If any queue config is provided and `enabled` is unset, it defaults to `true`. +The limiter is considered limited when `count > maxRequests`. The fallback path retries up to five times with optimistic versioning and is serialized only within the current process. -Queue defaults: +#### Fixed window fallback diagram -- `maxSize`: 3 -- `timeout`: 30s -- `deferInteraction`: true -- `ephemeral`: true -- `concurrency`: 1 +```mermaid +graph TD + A[Consume fixed-window] --> B{consumeFixedWindow?} + B -- Yes --> C[Use consumeFixedWindow] + B -- No --> D{incr?} + D -- Yes --> E[Use incr] + D -- No --> F[get + set fallback (per-process lock)] +``` -`deferInteraction` only applies to interactions (messages are ignored). +### Sliding window log -`maxSize`, `timeout`, and `concurrency` are clamped to a minimum of 1. +Execution path: -Queue resolution order is (later overrides earlier): +1. If `consumeSlidingWindowLog` exists, it is used (atomic). +2. Else a sorted-set fallback uses `zRemRangeByScore`, `zCard`, and `zAdd`. -- `queue` -- `defaultLimiter.queue` -- `named limiter queue` -- `command metadata queue` -- `role limit queue` +If sorted-set methods are missing, the algorithm throws. If `zRangeByScore` is available, it is used to compute an accurate oldest timestamp for `resetAt`; otherwise `resetAt` defaults to `now + window`. The fallback is serialized per process but is not atomic across processes. -## Role limits +#### Sliding window fallback diagram -```ts -configureRatelimit({ - roleLimits: { - ROLE_ID_1: { maxRequests: 30, interval: '1m' }, - ROLE_ID_2: { maxRequests: 5, interval: '1m' }, - }, - roleLimitStrategy: 'highest', -}); +```mermaid +graph TD + A[Consume sliding-window] --> B{consumeSlidingWindowLog?} + B -- Yes --> C[Use consumeSlidingWindowLog] + B -- No --> D{zset methods?} + D -- No --> E[Throw error] + D -- Yes --> F[zRemRangeByScore + zCard + zAdd fallback] ``` -If no strategy is provided, `roleLimitStrategy` defaults to `highest`. +### Token bucket + +Token bucket uses a stored `tokens` and `lastRefill` state. On each consume, tokens refill based on elapsed time and `refillRate`. If the bucket has fewer than one token, the request is limited and `retryAfter` is computed from the time required to refill one token. -Role scoring uses `maxRequests / intervalMs` (minimum across windows). +### Leaky bucket -## Multi-window limits +Leaky bucket uses a stored `level` and `lastLeak` state. Each request adds one token, and the bucket drains at `leakRate`. If adding would exceed `capacity`, the request is limited and `retryAfter` is computed from the time required to drain the overflow. + +### Multi-window limits + +Use `windows` to enforce multiple windows simultaneously: ```ts configureRatelimit({ @@ -304,70 +413,192 @@ configureRatelimit({ }); ``` -## Violations and escalation +If a window `id` is omitted, the plugin generates `w1`, `w2`, and so on. Window ids are part of the storage key and appear in results. + +## Storage + +### Storage interface + +Required methods: + +| Method | Used by | Notes | +| --- | --- | --- | +| `get` | All algorithms | Returns stored value or `null`. | +| `set` | All algorithms | Optional `ttlMs` controls expiry. | +| `delete` | Resets and algorithm resets | Removes stored state. | + +Optional methods and features: + +| Method | Feature | Notes | +| --- | --- | --- | +| `consumeFixedWindow` | Fixed-window atomic consume | Used before `incr` and fallback. | +| `incr` | Fixed-window efficiency | Returns count and TTL. | +| `consumeSlidingWindowLog` | Sliding-window atomic consume | Preferred over sorted-set fallback. | +| `zAdd` / `zRemRangeByScore` / `zCard` | Sliding-window fallback | Required when `consumeSlidingWindowLog` is absent. | +| `zRangeByScore` | Sliding-window reset accuracy | Improves `resetAt` computation. | +| `ttl` | Exemption listing | Used for `expiresInMs`. | +| `expire` | Sliding-window fallback | Keeps sorted-set keys from growing indefinitely. | +| `deleteByPrefix` / `deleteByPattern` | Resets | Required by `resetAllRateLimits` and HMR. | +| `keysByPrefix` | Exemption listing | Required for listing without a specific id. | + +### Capability matrix + +| Feature | Requires | Memory | Redis | Fallback | +| --- | --- | --- | --- | --- | +| Fixed-window atomic consume | `consumeFixedWindow` | Yes | Yes | Conditional (both storages) | +| Fixed-window `incr` | `incr` | Yes | Yes | Conditional (both storages) | +| Sliding-window atomic consume | `consumeSlidingWindowLog` | Yes | Yes | Conditional (both storages) | +| Sliding-window fallback | `zAdd` + `zRemRangeByScore` + `zCard` | Yes | Yes | Conditional (both storages) | +| TTL visibility | `ttl` | Yes | Yes | Conditional (both storages) | +| Prefix or pattern deletes | `deleteByPrefix` or `deleteByPattern` | Yes | Yes | Conditional (both storages) | +| Exemption listing | `keysByPrefix` | Yes | Yes | Conditional (both storages) | + +### Capability overview diagram + +```mermaid +graph TD + A[Storage API] --> B[Required: get / set / delete] + A --> C[Optional methods] + C --> D[Fixed window atomic: consumeFixedWindow / incr] + C --> E[Sliding window atomic: consumeSlidingWindowLog] + C --> F[Sliding window fallback: zAdd + zRemRangeByScore + zCard] + C --> G[Listing & TTL: keysByPrefix / ttl] + C --> H[Bulk reset: deleteByPrefix / deleteByPattern] + I[Fallback storage] --> J[Uses primary + secondary] + J --> K[Each optional method must exist on both] +``` + +### Memory storage ```ts -configureRatelimit({ - defaultLimiter: { - maxRequests: 1, - interval: '10s', - violations: { - maxViolations: 5, - escalationMultiplier: 2, - resetAfter: '1h', - }, - }, -}); +import { MemoryRateLimitStorage, setRateLimitStorage } from '@commandkit/ratelimit'; + +setRateLimitStorage(new MemoryRateLimitStorage()); ``` -Violation defaults and flags: +Notes: + +- In-memory only; not safe for multi-process deployments. +- Implements TTL and sorted-set helpers. +- `deleteByPattern` supports a simple `*` wildcard, not full glob syntax. + +:::warning Single-process only -- `escalate`: Defaults to true when `violations` is set. Set `false` to disable escalation. -- `maxViolations`: Default 5. -- `escalationMultiplier`: Default 2. -- `resetAfter`: Default 1h. +Memory storage is per process. For multiple bot shards or instances, use a shared storage like Redis. -## Hooks +::: + +### Redis storage ```ts -configureRatelimit({ - hooks: { - onAllowed: ({ key, result }) => { - console.log('allowed', key, result.remaining); - }, - onRateLimited: ({ key, result }) => { - console.log('limited', key, result.retryAfter); - }, - onViolation: (key, count) => { - console.log('violation', key, count); - }, - onReset: (key) => { - console.log('reset', key); - }, - onStorageError: (error, fallbackUsed) => { - console.error('storage error', error, fallbackUsed); - }, - }, -}); +import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; +import { setRateLimitStorage } from '@commandkit/ratelimit'; + +setRateLimitStorage( + new RedisRateLimitStorage({ host: 'localhost', port: 6379 }), +); ``` -## Analytics events +Notes: -- `ratelimit_allowed` -- `ratelimit_hit` -- `ratelimit_violation` +- Stores values as JSON. +- Uses Lua scripts for atomic fixed and sliding windows. +- Uses `SCAN` for prefix and pattern deletes and listing. -## Events +### Fallback storage ```ts -commandkit.events - .to('ratelimits') - .on('ratelimited', ({ key, result, source, aggregate, commandName, queued }) => { - console.log('ratelimited', key, commandName, queued, aggregate.retryAfter); - }); +import { FallbackRateLimitStorage } from '@commandkit/ratelimit/fallback'; +import { MemoryRateLimitStorage } from '@commandkit/ratelimit/memory'; +import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; +import { setRateLimitStorage } from '@commandkit/ratelimit'; + +const primary = new RedisRateLimitStorage({ host: 'localhost', port: 6379 }); +const secondary = new MemoryRateLimitStorage(); + +setRateLimitStorage(new FallbackRateLimitStorage(primary, secondary)); ``` -## Bypass rules +Notes: + +- Every optional method must exist on both storages or the fallback wrapper throws. +- Primary errors are logged at most once per `cooldownMs` window (default 30s). + +## Queue mode + +Queue mode retries commands instead of rejecting immediately. + +:::tip Use queueing to smooth bursts + +Queueing is useful for smoothing short bursts, but it changes response timing. Disable it with `queue: { enabled: false }` if you want strict, immediate rate-limit responses. + +::: + +### Queue defaults and clamps + +| Field | Default | Clamp | Notes | +| --- | --- | --- | --- | +| `enabled` | `true` if any queue config exists | n/a | Otherwise `false`. | +| `maxSize` | `3` | `>= 1` | Queue size is pending plus running. | +| `timeout` | `30s` | `>= 1ms` | Per queued task. | +| `deferInteraction` | `true` | n/a | Only applies to interactions. | +| `ephemeral` | `true` | n/a | Applies to deferred replies. | +| `concurrency` | `1` | `>= 1` | Per queue key. | + +### Queue flow + +1. Rate limit is evaluated and an aggregate result is computed. +2. If limited and queueing is enabled, the plugin tries to enqueue. +3. If the queue is full, it falls back to immediate rate-limit handling. +4. When queued, the interaction is deferred if it is repliable and not already replied or deferred. +5. The queued task waits `retryAfter`, then re-checks the limiter; if still limited it waits at least 250ms and retries until timeout. + +### Queue flow diagram + +```mermaid +graph TD + A[Evaluate limiter] --> B{Limited?} + B -- No --> C[Allow command] + B -- Yes --> D{Queue enabled?} + D -- No --> E[Rate-limit response] + D -- Yes --> F{Queue has capacity?} + F -- No --> E + F -- Yes --> G[Enqueue + defer if repliable] + G --> H[Wait retryAfter] + H --> I{Still limited?} + I -- No --> C + I -- Yes --> J[Wait >= 250ms] + J --> K{Timed out?} + K -- No --> H + K -- Yes --> E +``` + +## Violations and escalation + +Violation escalation is stored under `violation:{key}` and uses these defaults: + +| Option | Default | Meaning | +| --- | --- | --- | +| `maxViolations` | `5` | Maximum escalation steps. | +| `escalationMultiplier` | `2` | Multiplier per repeated violation. | +| `resetAfter` | `1h` | TTL for violation state. | +| `escalate` | `true` when `violations` is set | Set `false` to disable escalation. | + +Formula: + +`cooldown = baseRetryAfter * multiplier^(count - 1)` + +If escalation produces a later `resetAt` than the algorithm returned, the result is updated so `resetAt` and `retryAfter` stay accurate. + +## Bypass and exemptions + +Bypass order is always: + +1. `bypass.userIds`, `bypass.guildIds`, and `bypass.roleIds`. +2. Temporary exemptions stored in storage. +3. `bypass.check(source)`. + +Bypass example: ```ts configureRatelimit({ @@ -380,109 +611,117 @@ configureRatelimit({ }); ``` -## Custom rate-limited response - -```ts -configureRatelimit({ - onRateLimited: async (ctx, info) => { - await ctx.reply(`Cooldown: ${Math.ceil(info.retryAfter / 1000)}s`); - }, -}); -``` - -## Exemptions and resets +Temporary exemptions: ```ts -import { - grantRateLimitExemption, - revokeRateLimitExemption, - listRateLimitExemptions, - resetRateLimit, - resetAllRateLimits, -} from '@commandkit/ratelimit'; +import { grantRateLimitExemption } from '@commandkit/ratelimit'; await grantRateLimitExemption({ scope: 'user', id: 'USER_ID', duration: '1h', }); +``` -await revokeRateLimitExemption({ - scope: 'user', - id: 'USER_ID', -}); +Listing behavior: -const exemptions = await listRateLimitExemptions({ scope: 'user' }); -``` +- `listRateLimitExemptions({ scope, id })` reads a single key directly. +- `listRateLimitExemptions({ scope })` scans by prefix and requires `keysByPrefix`. +- `expiresInMs` is `null` when `ttl` is not supported. -All exemption helpers accept an optional `keyPrefix`. +## Responses, hooks, and events -```ts -await resetRateLimit({ key: 'rl:user:USER_ID:ping' }); -await resetAllRateLimits({ commandName: 'ping' }); -``` +### Default response behavior + +| Source | Conditions | Action | +| --- | --- | --- | +| Message | Channel is sendable | `reply()` with cooldown embed. | +| Interaction | Repliable and not replied/deferred | `reply()` with ephemeral cooldown embed. | +| Interaction | Repliable and already replied/deferred | `followUp()` with ephemeral cooldown embed. | +| Interaction | Not repliable | No response. | -Reset parameter notes: +The default embed title is `:hourglass_flowing_sand: You are on cooldown` and the description uses a relative timestamp based on `resetAt`. -- `resetRateLimit` accepts either `key` or (`scope` + `commandName` + required IDs). -- `resetAllRateLimits` accepts `pattern`, `prefix`, `commandName`, or `scope` + IDs. -- `keyPrefix` can be passed to both reset helpers. +### Hooks -Listing notes: +| Hook | Called when | Notes | +| --- | --- | --- | +| `onAllowed` | Command is allowed | Receives the first result. | +| `onRateLimited` | Command is limited | Receives the first limited result. | +| `onViolation` | A violation is recorded | Receives key and violation count. | +| `onReset` | `resetRateLimit` succeeds | Not called by `resetAllRateLimits`. | +| `onStorageError` | Storage operation fails | `fallbackUsed` is `false` in runtime plugin paths. | -- `listRateLimitExemptions({ scope, id })` checks a single key directly. -- `listRateLimitExemptions({ scope })` scans by prefix if supported. -- `limit` caps the number of results. -- `expiresInMs` is `null` if `ttl` is not supported. +### Analytics events -Supported exemption scopes: +The runtime plugin calls `ctx.commandkit.analytics.track(...)` with: -- `user` -- `guild` -- `role` -- `channel` -- `category` +| Event name | When | +| --- | --- | +| `ratelimit_allowed` | After an allowed consume. | +| `ratelimit_hit` | After a limited consume. | +| `ratelimit_violation` | When escalation records a violation. | -## Runtime helpers +### Event bus + +A `ratelimited` event is emitted on the `ratelimits` channel: ```ts -import { - getRateLimitInfo, - getRateLimitConfig, - isRateLimitConfigured, - setRateLimitStorage, - getRateLimitStorage, - setDriver, - getDriver, - setRateLimitRuntime, - getRateLimitRuntime, -} from '@commandkit/ratelimit'; - -export const chatInput = async (ctx) => { - const info = getRateLimitInfo(ctx); - if (info?.limited) { - console.log(info.retryAfter); - } -}; +commandkit.events + .to('ratelimits') + .on('ratelimited', ({ key, result, source, aggregate, commandName, queued }) => { + console.log(key, commandName, queued, aggregate.retryAfter); + }); ``` -Advanced runtime access is available via `setRateLimitRuntime` and -`getRateLimitRuntime`. +Payload fields include `key`, `result`, `source`, `aggregate`, `commandName`, and `queued`. + +## Resets and HMR + +### `resetRateLimit` + +`resetRateLimit` clears the base key, its `violation:` key, and any window variants. It accepts either a raw `key` or a scope-derived key. + +| Mode | Required params | Notes | +| --- | --- | --- | +| Direct | `key` | Resets `key`, `violation:key`, and window variants. | +| Scoped | `scope` + `commandName` + required ids | Throws if identifiers are missing. | + +### `resetAllRateLimits` + +`resetAllRateLimits` supports several modes and requires storage delete helpers: -## Result shape +| Mode | Required params | Storage requirement | +| --- | --- | --- | +| Pattern | `pattern` | `deleteByPattern` | +| Prefix | `prefix` | `deleteByPrefix` | +| Command name | `commandName` | `deleteByPattern` | +| Scope | `scope` + required ids | `deleteByPrefix` | -`RateLimitStoreValue` includes: +### HMR reset behavior -- `limited`, `remaining`, `resetAt`, `retryAfter`. -- `results`: array of `RateLimitResult` entries. +When a command file is hot-reloaded, the plugin deletes keys that match: -Each `RateLimitResult` includes: +- `*:{commandName}` +- `violation:*:{commandName}` +- `*:{commandName}:w:*` +- `violation:*:{commandName}:w:*` -- `key`, `scope`, `algorithm`, optional `windowId`. -- `limited`, `remaining`, `resetAt`, `retryAfter`, `limit`. +HMR reset requires `deleteByPattern`. If the storage does not support pattern deletes, nothing is cleared. ## Directive: `use ratelimit` +The compiler plugin (`UseRateLimitDirectivePlugin`) uses `CommonDirectiveTransformer` with `directive = "use ratelimit"` and `importName = "$ckitirl"`. It transforms async functions only. + +The runtime wrapper: + +- Uses the runtime default limiter (merged with `DEFAULT_LIMITER`). +- Generates a per-function key `rl:fn:{uuid}` and applies `keyPrefix` if present. +- Aggregates results across windows and throws `RateLimitError` when limited. +- Caches the wrapper per function and exposes it as `globalThis.$ckitirl`. + +Example: + ```ts import { RateLimitError } from '@commandkit/ratelimit'; @@ -500,144 +739,60 @@ try { } ``` -The directive applies only to async functions. - -## RateLimitEngine reset - -`RateLimitEngine.reset(key)` removes both the main key and -`violation:{key}`. - -## HMR reset behavior - -When a command file is hot-reloaded, the runtime plugin clears that command's -rate-limit keys using `deleteByPattern` (including `violation:` and `:w:` variants). -If the storage does not support pattern deletes, nothing is cleared. - -## Behavior details and edge cases - -- `ratelimit()` returns `[UseRateLimitDirectivePlugin, RateLimitPlugin]` in that order. -- If required IDs are missing for a scope (for example no guild in DMs), that scope is skipped. -- `interval` is clamped to at least 1ms when resolving limiter config. -- `RateLimitResult.limit` is `burst` for token/leaky buckets and `maxRequests` for fixed/sliding windows. -- Default rate-limit response uses an embed titled `:hourglass_flowing_sand: You are on cooldown` with a relative timestamp. Interactions reply ephemerally (or follow up if already replied/deferred). Non-repliable interactions are skipped. Messages reply only if the channel is sendable. -- Queue behavior: queue size is pending + running; if `maxSize` is reached, the command is not queued and falls back to immediate rate-limit handling. Queued tasks stop after `timeout` and log a warning. After the initial delay, retries wait at least 250ms between checks. When queued, `ctx.capture()` and `onRateLimited`/`onViolation` hooks still run. -- Bypass order is user/guild/role lists, then temporary exemptions, then `bypass.check`. -- `roleLimitStrategy: 'first'` respects object insertion order. Role limits merge in this order: plugin `roleLimits` -> `defaultLimiter.roleLimits` -> named limiter `roleLimits` -> command overrides. -- `resetRateLimit` triggers `hooks.onReset` for the key; `resetAllRateLimits` does not. -- `onStorageError` is invoked with `fallbackUsed = false` from runtime plugin calls. -- `grantRateLimitExemption` uses the runtime `keyPrefix` by default unless `keyPrefix` is provided. -- `RateLimitError` defaults to message `Rate limit exceeded`. -- If no storage is configured and default storage is disabled, the plugin logs once and stores an empty `RateLimitStoreValue` without limiting. -- `FallbackRateLimitStorage` throws if either storage does not support an optional operation. -- `MemoryRateLimitStorage.deleteByPattern` supports `*` wildcards (simple glob). - -## Duration units - -String durations support `ms`, `s`, `m`, `h`, `d` via `ms`, plus: - -- `w`, `week`, `weeks` -- `mo`, `month`, `months` - -## Constants - -- `RATELIMIT_STORE_KEY`: `ratelimit` store key for aggregated results. -- `DEFAULT_KEY_PREFIX`: `rl:` prefix used in generated keys. - -## Type reference (exported) - -- `RateLimitScope` and `RATE_LIMIT_SCOPES`: Scope values used in keys. -- `RateLimitExemptionScope` and `RATE_LIMIT_EXEMPTION_SCOPES`: Exemption scopes. -- `RateLimitAlgorithmType` and `RATE_LIMIT_ALGORITHMS`: Algorithm identifiers. -- `DurationLike`: Number in ms or duration string. -- `RateLimitQueueOptions`: Queue settings for retries. -- `RateLimitRoleLimitStrategy`: `highest`, `lowest`, or `first`. -- `RateLimitResult`: Result for a single limiter/window. -- `RateLimitAlgorithm`: Interface for algorithm implementations. -- `FixedWindowConsumeResult` and `SlidingWindowConsumeResult`: Storage consume return types. -- `RateLimitStorage` and `RateLimitStorageConfig`: Storage interface and wrapper. -- `ViolationOptions`: Escalation controls. -- `RateLimitWindowConfig`: Per-window limiter config. -- `RateLimitKeyResolver`: Custom scope key resolver signature. -- `RateLimitLimiterConfig`: Base limiter configuration. -- `RateLimitCommandConfig`: Limiter config plus `limiter` name. -- `RateLimitBypassOptions`: Bypass lists and optional `check`. -- `RateLimitExemptionGrantParams`, `RateLimitExemptionRevokeParams`, `RateLimitExemptionListParams`: Exemption helper params. -- `RateLimitExemptionInfo`: Exemption listing entry shape. -- `RateLimitHookContext` and `RateLimitHooks`: Hook payloads and callbacks. -- `RateLimitResponseHandler`: `onRateLimited` handler signature. -- `RateLimitPluginOptions`: Runtime plugin options. -- `RateLimitStoreValue`: Aggregated results stored in `env.store`. -- `ResolvedLimiterConfig`: Resolved limiter config with defaults and `intervalMs`. -- `RateLimitRuntimeContext`: Active runtime state. +## Defaults and edge cases -## Exports +### Defaults + +| Setting | Default | +| --- | --- | +| `maxRequests` | `10` | +| `interval` | `60s` | +| `algorithm` | `fixed-window` | +| `scope` | `user` | +| `DEFAULT_KEY_PREFIX` | `rl:` | +| `RATELIMIT_STORE_KEY` | `ratelimit` | +| `roleLimitStrategy` | `highest` | +| `queue.maxSize` | `3` | +| `queue.timeout` | `30s` | +| `queue.deferInteraction` | `true` | +| `queue.ephemeral` | `true` | +| `queue.concurrency` | `1` | +| `initializeDefaultStorage` | `true` | -- `ratelimit` plugin factory (compiler + runtime). -- `RateLimitPlugin` and `UseRateLimitDirectivePlugin`. -- `RateLimitEngine`, algorithm classes, and `ViolationTracker`. -- Storage implementations and helpers. -- Runtime helpers and API helpers. -- `RateLimitError`. +### Edge cases -## Defaults +1. If no storage is configured and default storage is disabled, the plugin logs once and stores an empty result without limiting. +2. If no scope key can be resolved, the plugin stores an empty result and skips limiting. +3. If storage errors occur during consume, `onStorageError` is invoked and the plugin skips limiting for that execution. +4. For token and leaky buckets, `limit` equals `burst`. For fixed and sliding windows, `limit` equals `maxRequests`. + +## Duration parsing + +`DurationLike` accepts numbers (milliseconds) or strings parsed by `ms`, plus custom units for weeks and months. + +| Unit | Meaning | +| --- | --- | +| `ms`, `s`, `m`, `h`, `d` | Standard `ms` units. | +| `w`, `week`, `weeks` | 7 days. | +| `mo`, `month`, `months` | 30 days. | + +## Exports -- `maxRequests`: 10 -- `interval`: 60s -- `algorithm`: `fixed-window` -- `scope`: `user` -- `initializeDefaultStorage`: true +| Export | Description | +| --- | --- | +| `ratelimit` | Plugin factory returning compiler + runtime plugins. | +| `RateLimitPlugin` | Runtime plugin class. | +| `UseRateLimitDirectivePlugin` | Compiler plugin for `use ratelimit`. | +| `RateLimitEngine` | Algorithm coordinator with escalation handling. | +| Algorithm classes | Fixed, sliding, token bucket, and leaky bucket implementations. | +| Storage classes | Memory, Redis, and fallback storage. | +| Runtime helpers | `configureRatelimit`, `setRateLimitStorage`, `getRateLimitRuntime`, and more. | +| API helpers | `getRateLimitInfo`, resets, and exemption helpers. | +| `RateLimitError` | Error thrown by the directive wrapper. | -## Subpath exports +Subpath exports: - `@commandkit/ratelimit/redis` - `@commandkit/ratelimit/memory` - `@commandkit/ratelimit/fallback` -## Manual testing - -- Configure `maxRequests: 1` and `interval: '5s'`. -- Trigger the command twice and confirm a cooldown response. -- Enable queue mode and verify the second call is deferred and executes later. -- Grant an exemption and verify the user bypasses limits. -- Reset the command and verify the cooldown clears immediately. - -## Complete source map (packages/ratelimit) - -
-All source files and what they provide - -- `packages/ratelimit/src/index.ts`: Package entrypoint and public re-exports. -- `packages/ratelimit/src/augmentation.ts`: `CommandMetadata` augmentation for `metadata.ratelimit`. -- `packages/ratelimit/src/configure.ts`: `configureRatelimit`, `getRateLimitConfig`, `isRateLimitConfigured`, runtime updates. -- `packages/ratelimit/src/runtime.ts`: Runtime storage accessors plus `setDriver`/`getDriver`. -- `packages/ratelimit/src/plugin.ts`: Runtime plugin logic (resolution, queueing, hooks, responses, analytics/events, HMR resets). -- `packages/ratelimit/src/directive/use-ratelimit-directive.ts`: Compiler plugin for `"use ratelimit"`. -- `packages/ratelimit/src/directive/use-ratelimit.ts`: Runtime directive wrapper using `RateLimitEngine`. -- `packages/ratelimit/src/api.ts`: Public helpers for info, resets, and exemptions. -- `packages/ratelimit/src/types.ts`: All exported config/result/storage types. -- `packages/ratelimit/src/constants.ts`: `RATELIMIT_STORE_KEY` and `DEFAULT_KEY_PREFIX`. -- `packages/ratelimit/src/errors.ts`: `RateLimitError`. -- `packages/ratelimit/src/engine/RateLimitEngine.ts`: Algorithm selection and violation escalation. -- `packages/ratelimit/src/engine/violations.ts`: `ViolationTracker` and escalation state. -- `packages/ratelimit/src/engine/algorithms/fixed-window.ts`: Fixed-window algorithm. -- `packages/ratelimit/src/engine/algorithms/sliding-window.ts`: Sliding-window log algorithm. -- `packages/ratelimit/src/engine/algorithms/token-bucket.ts`: Token-bucket algorithm. -- `packages/ratelimit/src/engine/algorithms/leaky-bucket.ts`: Leaky-bucket algorithm. -- `packages/ratelimit/src/storage/memory.ts`: In-memory storage with TTL and sorted-set helpers. -- `packages/ratelimit/src/storage/redis.ts`: Redis storage with Lua scripts for atomic windows. -- `packages/ratelimit/src/storage/fallback.ts`: Fallback storage wrapper with cooldown logging. -- `packages/ratelimit/src/providers/memory.ts`: Subpath export for memory storage. -- `packages/ratelimit/src/providers/redis.ts`: Subpath export for Redis storage. -- `packages/ratelimit/src/providers/fallback.ts`: Subpath export for fallback storage. -- `packages/ratelimit/src/utils/config.ts`: Defaults, normalization, multi-window resolution, role-limit merging. -- `packages/ratelimit/src/utils/keys.ts`: Key building and parsing for scopes/exemptions. -- `packages/ratelimit/src/utils/time.ts`: Duration parsing and clamp helpers. -- `packages/ratelimit/src/utils/locking.ts`: Per-storage keyed mutex for fallback algorithm serialization. -- `packages/ratelimit/spec/setup.ts`: Shared test setup for vitest. -- `packages/ratelimit/spec/helpers.ts`: Test helpers and stubs. -- `packages/ratelimit/spec/algorithms.test.ts`: Algorithm integration tests. -- `packages/ratelimit/spec/engine.test.ts`: Engine + violation behavior tests. -- `packages/ratelimit/spec/api.test.ts`: API helper tests. -- `packages/ratelimit/spec/plugin.test.ts`: Runtime plugin behavior tests. - -
diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 1ab46d5b..6bd3e221 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -1,19 +1,45 @@ # @commandkit/ratelimit -Advanced rate limiting for CommandKit with multiple algorithms, queueing, -role limits, multi-window policies, and temporary exemptions. +`@commandkit/ratelimit` is the official CommandKit plugin for advanced rate limiting. It provides multi-window policies, role overrides, queueing, exemptions, and multiple algorithms while keeping command handlers lean. + +The `ratelimit()` factory returns two plugins in order: the compiler plugin for the "use ratelimit" directive and the runtime plugin that enforces limits. Runtime options must be configured before the runtime plugin activates. + +## Table of contents + +1. [Installation](#installation) +2. [Setup](#setup) +3. [Runtime configuration lifecycle](#runtime-configuration-lifecycle) +4. [Basic usage](#basic-usage) +5. [Configuration reference](#configuration-reference) +6. [Limiter resolution and role strategy](#limiter-resolution-and-role-strategy) +7. [Scopes and keying](#scopes-and-keying) +8. [Algorithms](#algorithms) +9. [Storage](#storage) +10. [Queue mode](#queue-mode) +11. [Violations and escalation](#violations-and-escalation) +12. [Bypass and exemptions](#bypass-and-exemptions) +13. [Responses, hooks, and events](#responses-hooks-and-events) +14. [Resets and HMR](#resets-and-hmr) +15. [Directive: `use ratelimit`](#directive-use-ratelimit) +16. [Defaults and edge cases](#defaults-and-edge-cases) +17. [Duration parsing](#duration-parsing) +18. [Exports](#exports) ## Installation +Install the ratelimit plugin to get started: + ```bash npm install @commandkit/ratelimit ``` -## Quick start +## Setup + +Add the ratelimit plugin to your CommandKit configuration and define a runtime config file. + +### Quick start -Create the auto-loaded `ratelimit.ts`/`ratelimit.js` file and call -`configureRatelimit(...)` there so runtime settings are available before the -plugin evaluates any commands: +Create an auto-loaded runtime config file (for example `ratelimit.ts`) and configure the default limiter: ```ts // ratelimit.ts @@ -29,6 +55,8 @@ configureRatelimit({ }); ``` +Register the plugin in your config: + ```ts // commandkit.config.ts import { defineConfig } from 'commandkit'; @@ -39,363 +67,540 @@ export default defineConfig({ }); ``` -The runtime plugin auto-loads `ratelimit.ts`/`ratelimit.js` on startup. +The runtime plugin auto-loads `ratelimit.ts` or `ratelimit.js` on startup before commands execute. -Enable rate limiting on a command: +## Runtime configuration lifecycle -```ts -export const metadata = { - ratelimit: { - maxRequests: 3, - interval: '10s', - scope: 'user', - algorithm: 'sliding-window', - }, -}; +### Runtime lifecycle diagram + +```mermaid +graph TD + A[App startup] --> B[Auto-load ratelimit.ts/js] + B --> C[configureRatelimit()] + C --> D[Runtime plugin activate()] + D --> E[Resolve storage] + E --> F[Resolve limiter config] + F --> G[Consume algorithm] + G --> H[Aggregate result] + H --> I[Default response / hooks / events] ``` -## `ratelimit()` options +### `configureRatelimit` is required -The `ratelimit()` factory returns the compiler and runtime plugins and accepts: +`RateLimitPlugin.activate()` throws if `configureRatelimit()` was not called. This is enforced to avoid silently running without your intended defaults. -- `compiler`: Options for the `"use ratelimit"` directive transformer. +### How configuration is stored -Runtime options are configured via `configureRatelimit()`. +`configureRatelimit()` merges your config into an in-memory object and sets the configured flag. `getRateLimitConfig()` returns the current object, and `isRateLimitConfigured()` returns whether initialization has happened. If a runtime context is already active, `configureRatelimit()` updates it immediately. -Example: +### Runtime storage selection -```ts -ratelimit({ - compiler: { enabled: true }, -}); -``` +Storage is resolved in this order: -## How keys and scopes work +| Order | Source | Notes | +| --- | --- | --- | +| 1 | Limiter `storage` override | `RateLimitLimiterConfig.storage` for the command being executed. | +| 2 | Plugin `storage` option | `RateLimitPluginOptions.storage`. | +| 3 | Process default | Set via `setRateLimitStorage()` or `setDriver()`. | +| 4 | Default memory storage | Used unless `initializeDefaultStorage` or `initializeDefaultDriver` is `false`. | -Scopes determine how keys are generated: +If no storage is resolved and defaults are disabled, the plugin logs once and stores an empty result without limiting. -- `user` -> `rl:user:{userId}:{commandName}` -- `guild` -> `rl:guild:{guildId}:{commandName}` -- `channel` -> `rl:channel:{channelId}:{commandName}` -- `global` -> `rl:global:{commandName}` -- `user-guild` -> `rl:user:{userId}:guild:{guildId}:{commandName}` -- `custom` -> `keyResolver(ctx, command, source)` +### Runtime helpers -If `keyPrefix` is provided, it is prepended before the `rl:` prefix: +These helpers are process-wide: -- `keyPrefix: 'prod:'` -> `prod:rl:user:{userId}:{commandName}` +| Helper | Purpose | +| --- | --- | +| `configureRatelimit` | Set runtime options and update active runtime state. | +| `getRateLimitConfig` | Read the merged in-memory runtime config. | +| `isRateLimitConfigured` | Check whether `configureRatelimit()` was called. | +| `setRateLimitStorage` | Set the default storage for the process. | +| `getRateLimitStorage` | Get the process default storage (or `null`). | +| `setDriver` / `getDriver` | Aliases for `setRateLimitStorage` / `getRateLimitStorage`. | +| `setRateLimitRuntime` | Set the active runtime context for APIs and directives. | +| `getRateLimitRuntime` | Get the active runtime context (or `null`). | -Multi-window limits append a suffix: +## Basic usage -- `:w:{windowId}` (for example `rl:user:123:ping:w:short`) +Use command metadata or the `use ratelimit` directive to enable rate limiting. +This section focuses on command metadata; see the directive section for +function-level usage. -For `custom` scope you must provide `keyResolver`: +### Command metadata and enablement -```ts -import type { RateLimitKeyResolver } from '@commandkit/ratelimit'; +Enable rate limiting by setting `metadata.ratelimit`: -const keyResolver: RateLimitKeyResolver = (ctx, command, source) => { - return `custom:${ctx.commandName}:${source.user?.id ?? 'unknown'}`; +```ts +export const metadata = { + ratelimit: { + maxRequests: 3, + interval: '10s', + scope: 'user', + algorithm: 'sliding-window', + }, }; ``` -If `keyResolver` returns a falsy value, the limiter is skipped for that scope. +`metadata.ratelimit` can be one of: + +| Value | Meaning | +| --- | --- | +| `false` or `undefined` | Plugin does nothing for this command. | +| `true` | Enable rate limiting using resolved defaults. | +| `RateLimitCommandConfig` | Enable rate limiting with command-level overrides. | -Exemption keys use: +If `env.context` is missing in the execution environment, the plugin skips rate limiting. -- `rl:exempt:{scope}:{id}` (plus optional `keyPrefix`). +### Named limiter example -## Plugin options (RateLimitPluginOptions) +```ts +configureRatelimit({ + limiters: { + heavy: { maxRequests: 1, interval: '10s', algorithm: 'fixed-window' }, + }, +}); +``` + +```ts +export const metadata = { + ratelimit: { + limiter: 'heavy', + scope: 'user', + }, +}; +``` -- `defaultLimiter`: Default limiter settings used when a command does not specify a limiter. -- `limiters`: Named limiter presets referenced by command metadata using `limiter: 'name'`. -- `storage`: Storage driver or `{ driver }` wrapper used for rate limit state. -- `keyPrefix`: Optional prefix prepended to all keys. -- `keyResolver`: Resolver used for `custom` scope keys. -- `bypass`: Bypass rules for users, roles, guilds, or a custom check. -- `hooks`: Lifecycle hooks for allowed, limited, reset, violation, and storage error events. -- `onRateLimited`: Custom response handler that replaces the default reply. -- `queue`: Queue settings for retrying instead of rejecting. -- `roleLimits`: Role-specific limiter overrides. -- `roleLimitStrategy`: `highest`, `lowest`, or `first` to resolve matching role limits. -- `initializeDefaultStorage`: When true, initializes in-memory storage if no storage is set. -- `initializeDefaultDriver`: Alias for `initializeDefaultStorage`. +## Configuration reference + +### RateLimitPluginOptions + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `defaultLimiter` | `RateLimitLimiterConfig` | `DEFAULT_LIMITER` when unset | Base limiter for all commands and directives. | +| `limiters` | `Record` | `undefined` | Named limiter presets. | +| `storage` | `RateLimitStorageConfig` | `undefined` | Resolved before default storage. | +| `keyPrefix` | `string` | `undefined` | Prepended before `rl:`. | +| `keyResolver` | `RateLimitKeyResolver` | `undefined` | Used for `custom` scope when the limiter does not override it. | +| `bypass` | `RateLimitBypassOptions` | `undefined` | Permanent allowlists and optional check. | +| `hooks` | `RateLimitHooks` | `undefined` | Lifecycle callbacks. | +| `onRateLimited` | `RateLimitResponseHandler` | `undefined` | Overrides default reply. | +| `queue` | `RateLimitQueueOptions` | `undefined` | If any queue config exists, `enabled` defaults to `true`. | +| `roleLimits` | `Record` | `undefined` | Base role limits. | +| `roleLimitStrategy` | `RateLimitRoleLimitStrategy` | `highest` when resolving | Used when multiple roles match. | +| `initializeDefaultStorage` | `boolean` | `true` | Disable to prevent memory fallback. | +| `initializeDefaultDriver` | `boolean` | `true` | Alias for `initializeDefaultStorage`. | + +### RateLimitLimiterConfig + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `maxRequests` | `number` | `10` when missing or `<= 0` | Used by fixed and sliding windows. | +| `interval` | `DurationLike` | `60s` when missing or invalid | Parsed and clamped to `>= 1ms`. | +| `scope` | `RateLimitScope` or `RateLimitScope[]` | `user` | Arrays are deduplicated. | +| `algorithm` | `RateLimitAlgorithmType` | `fixed-window` | Unknown values fall back to fixed-window. | +| `burst` | `number` | `maxRequests` when missing or `<= 0` | Capacity for token or leaky buckets. | +| `refillRate` | `number` | `maxRequests / intervalSeconds` | Must be `> 0` for token bucket. | +| `leakRate` | `number` | `maxRequests / intervalSeconds` | Must be `> 0` for leaky bucket. | +| `keyResolver` | `RateLimitKeyResolver` | `undefined` | Used only for `custom` scope. | +| `keyPrefix` | `string` | `undefined` | Overrides plugin prefix for this limiter. | +| `storage` | `RateLimitStorageConfig` | `undefined` | Overrides storage for this limiter. | +| `violations` | `ViolationOptions` | `undefined` | Enables escalation unless `escalate` is `false`. | +| `queue` | `RateLimitQueueOptions` | `undefined` | Overrides queue settings at this layer. | +| `windows` | `RateLimitWindowConfig[]` | `undefined` | Enables multi-window behavior. | +| `roleLimits` | `Record` | `undefined` | Role overrides at this layer. | +| `roleLimitStrategy` | `RateLimitRoleLimitStrategy` | `highest` when resolving | Used when role limits match. | + +### RateLimitWindowConfig + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `id` | `string` | `w1`, `w2`, ... | Auto-generated if empty or missing. | +| `maxRequests` | `number` | Inherits from base limiter | Applies only to this window. | +| `interval` | `DurationLike` | Inherits from base limiter | Parsed like the base limiter. | +| `algorithm` | `RateLimitAlgorithmType` | Inherits from base limiter | Usually keep consistent across windows. | +| `burst` | `number` | Inherits from base limiter | Used for token or leaky buckets. | +| `refillRate` | `number` | Inherits from base limiter | Must be `> 0` for token bucket. | +| `leakRate` | `number` | Inherits from base limiter | Must be `> 0` for leaky bucket. | +| `violations` | `ViolationOptions` | Inherits from base limiter | Overrides escalation for this window. | + +### RateLimitQueueOptions + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `true` when any queue config exists | Otherwise `false`. | +| `maxSize` | `number` | `3` and clamped to `>= 1` | Queue size is pending plus running. | +| `timeout` | `DurationLike` | `30s` and clamped to `>= 1ms` | Per queued task. | +| `deferInteraction` | `boolean` | `true` unless explicitly `false` | Only used for interactions. | +| `ephemeral` | `boolean` | `true` unless explicitly `false` | Applies to deferred replies. | +| `concurrency` | `number` | `1` and clamped to `>= 1` | Per queue key. | + +### ViolationOptions + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `escalate` | `boolean` | `true` when `violations` is set | Set `false` to disable escalation. | +| `maxViolations` | `number` | `5` | Maximum escalation steps. | +| `escalationMultiplier` | `number` | `2` | Multiplies cooldown per repeated violation. | +| `resetAfter` | `DurationLike` | `1h` | TTL for violation state. | + +### RateLimitCommandConfig + +`RateLimitCommandConfig` extends `RateLimitLimiterConfig` and adds: + +| Field | Type | Default or resolution | Notes | +| --- | --- | --- | --- | +| `limiter` | `string` | `undefined` | References a named limiter in `limiters`. | + +### Result shapes + +RateLimitStoreValue: + +| Field | Type | Meaning | +| --- | --- | --- | +| `limited` | `boolean` | `true` if any scope or window was limited. | +| `remaining` | `number` | Minimum remaining across all results. | +| `resetAt` | `number` | Latest reset timestamp across all results. | +| `retryAfter` | `number` | Max retry delay across limited results. | +| `results` | `RateLimitResult[]` | Individual results per scope and window. | + +RateLimitResult: + +| Field | Type | Meaning | +| --- | --- | --- | +| `key` | `string` | Storage key used for the limiter. | +| `scope` | `RateLimitScope` | Scope applied for the limiter. | +| `algorithm` | `RateLimitAlgorithmType` | Algorithm used for the limiter. | +| `windowId` | `string` | Present for multi-window limits. | +| `limited` | `boolean` | Whether this limiter hit its limit. | +| `remaining` | `number` | Remaining requests or capacity. | +| `resetAt` | `number` | Absolute reset timestamp in ms. | +| `retryAfter` | `number` | Delay until retry is allowed, in ms. | +| `limit` | `number` | `maxRequests` for fixed and sliding, `burst` for token and leaky buckets. | + +## Limiter resolution and role strategy + +Limiter configuration is layered in this exact order, with later layers overriding earlier ones: + +| Order | Source | Notes | +| --- | --- | --- | +| 1 | `DEFAULT_LIMITER` | Base defaults. | +| 2 | `defaultLimiter` | Runtime defaults. | +| 3 | Named limiter | When `metadata.ratelimit.limiter` is set. | +| 4 | Command overrides | `metadata.ratelimit` config. | +| 5 | Role override | Selected by role strategy. | + +### Limiter resolution diagram + +```mermaid +graph TD + A[DEFAULT_LIMITER] --> B[defaultLimiter] + B --> C[Named limiter] + C --> D[Command overrides] + D --> E[Role override (strategy)] +``` -## Limiter options (RateLimitLimiterConfig) +Role limits are merged in this order, with later maps overriding earlier ones for the same role id: -- `maxRequests`: Requests allowed per interval (default 10). -- `interval`: Duration for the limit window (number in ms or string). -- `scope`: Single scope or list of scopes. -- `algorithm`: `fixed-window`, `sliding-window`, `token-bucket`, `leaky-bucket`. -- `burst`: Capacity for token/leaky bucket (defaults to `maxRequests`). -- `refillRate`: Tokens per second for token bucket (defaults to `maxRequests / intervalSeconds`). -- `leakRate`: Tokens per second for leaky bucket (defaults to `maxRequests / intervalSeconds`). -- `keyResolver`: Custom key resolver for `custom` scope. -- `keyPrefix`: Prefix override for this limiter. -- `storage`: Storage override for this limiter. -- `violations`: Escalation settings for repeated limits. -- `queue`: Queue override for this limiter. -- `windows`: Multi-window configuration. -- `roleLimits`: Role-specific overrides scoped to this limiter. -- `roleLimitStrategy`: Role limit resolution strategy scoped to this limiter. +| Order | Source | +| --- | --- | +| 1 | Plugin `roleLimits` | +| 2 | `defaultLimiter.roleLimits` | +| 3 | Named limiter `roleLimits` | +| 4 | Command `roleLimits` | -## Command metadata options (RateLimitCommandConfig) +Role strategies: -Command metadata extends limiter options and adds: +| Strategy | Selection rule | +| --- | --- | +| `highest` | Picks the role with the highest request rate (`maxRequests / intervalMs`). | +| `lowest` | Picks the role with the lowest request rate. | +| `first` | Uses insertion order of the merged role limits object. | -- `limiter`: Name of a limiter defined in `limiters`. +For multi-window limiters, the score uses the minimum rate across windows. -## Resolution order +## Scopes and keying -Limiter resolution order (later overrides earlier): +Supported scopes: -- Built-in defaults (`DEFAULT_LIMITER`). -- `defaultLimiter`. -- Named limiter (if `metadata.ratelimit.limiter` is set). -- Command metadata overrides. -- Role limit overrides (when matched). +| Scope | Required IDs | Key format (without `keyPrefix`) | Skip behavior | +| --- | --- | --- | --- | +| `user` | `userId` | `rl:user:{userId}:{commandName}` | Skips if `userId` is missing. | +| `guild` | `guildId` | `rl:guild:{guildId}:{commandName}` | Skips if `guildId` is missing. | +| `channel` | `channelId` | `rl:channel:{channelId}:{commandName}` | Skips if `channelId` is missing. | +| `global` | none | `rl:global:{commandName}` | Never skipped. | +| `user-guild` | `userId`, `guildId` | `rl:user:{userId}:guild:{guildId}:{commandName}` | Skips if either id is missing. | +| `custom` | `keyResolver` | `keyResolver(ctx, command, source)` | Skips if resolver is missing or returns falsy. | -## Algorithms +Keying notes: -### Fixed window +- `DEFAULT_KEY_PREFIX` is always included in the base format. +- `keyPrefix` is concatenated before `rl:` as-is, so include a trailing separator if you want one. +- Multi-window limits append `:w:{windowId}`. -- Uses a counter per interval. -- Required: `maxRequests`, `interval`. -- Storage: `consumeFixedWindow` or `incr`, otherwise falls back to `get/set`. +### Exemption keys -### Sliding window log +Temporary exemptions are stored under `rl:exempt:{scope}:{id}` (plus optional `keyPrefix`). -- Tracks timestamps in a sorted set. -- Required: `maxRequests`, `interval`. -- Storage: `consumeSlidingWindowLog` or `zAdd`, `zRemRangeByScore`, `zCard` and optional `zRangeByScore`. -- If sorted-set ops are missing, it throws an error. -- The non-atomic sorted-set fallback can race under concurrency; implement `consumeSlidingWindowLog` for strict enforcement. +| Exemption scope | Key format | Notes | +| --- | --- | --- | +| `user` | `rl:exempt:user:{userId}` | Resolved from the source user id. | +| `guild` | `rl:exempt:guild:{guildId}` | Resolved from the guild id. | +| `role` | `rl:exempt:role:{roleId}` | Resolved from all member roles. | +| `channel` | `rl:exempt:channel:{channelId}` | Resolved from the channel id. | +| `category` | `rl:exempt:category:{categoryId}` | Resolved from the parent category id. | -### Token bucket +## Algorithms -- Refills tokens continuously. -- Required: `burst` (capacity), `refillRate` (tokens/sec). -- Storage: `get/set`. -- `refillRate` must be greater than 0. +### Algorithm matrix -### Leaky bucket +| Algorithm | Required config | Storage requirements | `limit` value | Notes | +| --- | --- | --- | --- | --- | +| `fixed-window` | `maxRequests`, `interval` | `consumeFixedWindow` or `incr` or `get` and `set` | `maxRequests` | Fallback uses per-process lock and optimistic versioning. | +| `sliding-window` | `maxRequests`, `interval` | `consumeSlidingWindowLog` or `zRemRangeByScore` + `zCard` + `zAdd` | `maxRequests` | Throws if sorted-set support is missing. | +| `token-bucket` | `burst`, `refillRate` | `get` and `set` | `burst` | Throws if `refillRate <= 0`. | +| `leaky-bucket` | `burst`, `leakRate` | `get` and `set` | `burst` | Throws if `leakRate <= 0`. | -- Leaks tokens continuously. -- Required: `burst` (capacity), `leakRate` (tokens/sec). -- Storage: `get/set`. -- `leakRate` must be greater than 0. +### Fixed window -## Storage drivers +Execution path: -### MemoryRateLimitStorage +1. If `consumeFixedWindow` exists, it is used. +2. Else if `incr` exists, it is used. +3. Else a fallback uses `get` and `set` with a per-process lock. -- In-memory store with TTL support. -- Implements `consumeFixedWindow`, `consumeSlidingWindowLog`, sorted-set ops, - prefix/pattern deletes, and key listing. -- Not shared across processes (single-node only). +The limiter is considered limited when `count > maxRequests`. The fallback path retries up to five times with optimistic versioning and is serialized only within the current process. -### RedisRateLimitStorage +#### Fixed window fallback diagram -- Uses Redis with Lua scripts for fixed and sliding windows. -- Stores values as JSON. -- Supports `deleteByPattern`, `deleteByPrefix`, and `keysByPrefix` via `SCAN`. -- `@commandkit/ratelimit/redis` also re-exports `RedisOptions` from `ioredis`. +```mermaid +graph TD + A[Consume fixed-window] --> B{consumeFixedWindow?} + B -- Yes --> C[Use consumeFixedWindow] + B -- No --> D{incr?} + D -- Yes --> E[Use incr] + D -- No --> F[get + set fallback (per-process lock)] +``` -### FallbackRateLimitStorage +### Sliding window log -- Wraps a primary and secondary storage. -- On failure, falls back to the secondary and logs at most once per cooldown window. -- Options: `cooldownMs` (default 30s). +Execution path: -Disable the default memory storage: +1. If `consumeSlidingWindowLog` exists, it is used (atomic). +2. Else a sorted-set fallback uses `zRemRangeByScore`, `zCard`, and `zAdd`. -```ts -configureRatelimit({ - initializeDefaultStorage: false, - // or: initializeDefaultDriver: false -}); -``` +If sorted-set methods are missing, the algorithm throws. If `zRangeByScore` is available, it is used to compute an accurate oldest timestamp for `resetAt`; otherwise `resetAt` defaults to `now + window`. The fallback is serialized per process but is not atomic across processes. -## Storage interface and requirements +#### Sliding window fallback diagram -`storage` accepts either a `RateLimitStorage` instance or `{ driver }`. +```mermaid +graph TD + A[Consume sliding-window] --> B{consumeSlidingWindowLog?} + B -- Yes --> C[Use consumeSlidingWindowLog] + B -- No --> D{zset methods?} + D -- No --> E[Throw error] + D -- Yes --> F[zRemRangeByScore + zCard + zAdd fallback] +``` -Required methods: +### Token bucket -- `get`, `set`, `delete`. +Token bucket uses a stored `tokens` and `lastRefill` state. On each consume, tokens refill based on elapsed time and `refillRate`. If the bucket has fewer than one token, the request is limited and `retryAfter` is computed from the time required to refill one token. -Optional methods used by features: +### Leaky bucket -- `incr` and `consumeFixedWindow` for fixed-window efficiency. -- `zAdd`, `zRemRangeByScore`, `zCard`, `zRangeByScore`, `consumeSlidingWindowLog` for sliding window. -- `ttl`, `expire` for expiry visibility. -- `deleteByPrefix`, `deleteByPattern`, `keysByPrefix` for resets and exemption listing. +Leaky bucket uses a stored `level` and `lastLeak` state. Each request adds one token, and the bucket drains at `leakRate`. If adding would exceed `capacity`, the request is limited and `retryAfter` is computed from the time required to drain the overflow. -## Queue mode +### Multi-window limits -Queue mode retries commands instead of rejecting immediately: +Use `windows` to enforce multiple windows simultaneously: ```ts configureRatelimit({ - queue: { - enabled: true, - maxSize: 3, - timeout: '30s', - deferInteraction: true, - ephemeral: true, - concurrency: 1, + defaultLimiter: { + scope: 'user', + algorithm: 'sliding-window', + windows: [ + { id: 'short', maxRequests: 10, interval: '1m' }, + { id: 'long', maxRequests: 1000, interval: '1d' }, + ], }, }); ``` - -Queue options: -- `enabled` -- `maxSize` -- `timeout` -- `deferInteraction` -- `ephemeral` -- `concurrency` +If a window `id` is omitted, the plugin generates `w1`, `w2`, and so on. Window ids are part of the storage key and appear in results. -If any queue config is provided and `enabled` is unset, it defaults to `true`. +## Storage -Queue size counts pending plus running tasks. If the queue is full, the plugin -falls back to immediate rate-limit handling. +### Storage interface -Queue defaults: +Required methods: -- `maxSize`: 3 -- `timeout`: 30s -- `deferInteraction`: true -- `ephemeral`: true -- `concurrency`: 1 +| Method | Used by | Notes | +| --- | --- | --- | +| `get` | All algorithms | Returns stored value or `null`. | +| `set` | All algorithms | Optional `ttlMs` controls expiry. | +| `delete` | Resets and algorithm resets | Removes stored state. | + +Optional methods and features: + +| Method | Feature | Notes | +| --- | --- | --- | +| `consumeFixedWindow` | Fixed-window atomic consume | Used before `incr` and fallback. | +| `incr` | Fixed-window efficiency | Returns count and TTL. | +| `consumeSlidingWindowLog` | Sliding-window atomic consume | Preferred over sorted-set fallback. | +| `zAdd` / `zRemRangeByScore` / `zCard` | Sliding-window fallback | Required when `consumeSlidingWindowLog` is absent. | +| `zRangeByScore` | Sliding-window reset accuracy | Improves `resetAt` computation. | +| `ttl` | Exemption listing | Used for `expiresInMs`. | +| `expire` | Sliding-window fallback | Keeps sorted-set keys from growing indefinitely. | +| `deleteByPrefix` / `deleteByPattern` | Resets | Required by `resetAllRateLimits` and HMR. | +| `keysByPrefix` | Exemption listing | Required for listing without a specific id. | + +### Capability matrix + +| Feature | Requires | Memory | Redis | Fallback | +| --- | --- | --- | --- | --- | +| Fixed-window atomic consume | `consumeFixedWindow` | Yes | Yes | Conditional (both storages) | +| Fixed-window `incr` | `incr` | Yes | Yes | Conditional (both storages) | +| Sliding-window atomic consume | `consumeSlidingWindowLog` | Yes | Yes | Conditional (both storages) | +| Sliding-window fallback | `zAdd` + `zRemRangeByScore` + `zCard` | Yes | Yes | Conditional (both storages) | +| TTL visibility | `ttl` | Yes | Yes | Conditional (both storages) | +| Prefix or pattern deletes | `deleteByPrefix` or `deleteByPattern` | Yes | Yes | Conditional (both storages) | +| Exemption listing | `keysByPrefix` | Yes | Yes | Conditional (both storages) | + +### Capability overview diagram + +```mermaid +graph TD + A[Storage API] --> B[Required: get / set / delete] + A --> C[Optional methods] + C --> D[Fixed window atomic: consumeFixedWindow / incr] + C --> E[Sliding window atomic: consumeSlidingWindowLog] + C --> F[Sliding window fallback: zAdd + zRemRangeByScore + zCard] + C --> G[Listing & TTL: keysByPrefix / ttl] + C --> H[Bulk reset: deleteByPrefix / deleteByPattern] + I[Fallback storage] --> J[Uses primary + secondary] + J --> K[Each optional method must exist on both] +``` -`deferInteraction` only applies to interactions (messages are ignored). +### Memory storage -`maxSize`, `timeout`, and `concurrency` are clamped to a minimum of 1. +```ts +import { MemoryRateLimitStorage, setRateLimitStorage } from '@commandkit/ratelimit'; -Queue resolution order is (later overrides earlier): +setRateLimitStorage(new MemoryRateLimitStorage()); +``` -- `queue` -- `defaultLimiter.queue` -- `named limiter queue` -- `command metadata queue` -- `role limit queue` +Notes: -## Role limits +- In-memory only; not safe for multi-process deployments. +- Implements TTL and sorted-set helpers. +- `deleteByPattern` supports a simple `*` wildcard, not full glob syntax. -Role limits override the base limiter if the user has a matching role: +### Redis storage ```ts -configureRatelimit({ - roleLimits: { - 'ROLE_ID_1': { maxRequests: 30, interval: '1m' }, - 'ROLE_ID_2': { maxRequests: 5, interval: '1m' }, - }, - roleLimitStrategy: 'highest', -}); -``` +import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; +import { setRateLimitStorage } from '@commandkit/ratelimit'; -If no strategy is provided, `roleLimitStrategy` defaults to `highest`. +setRateLimitStorage( + new RedisRateLimitStorage({ host: 'localhost', port: 6379 }), +); +``` -Role scoring is based on `maxRequests / intervalMs` (minimum across windows). +Notes: -## Multi-window limits +- Stores values as JSON. +- Uses Lua scripts for atomic fixed and sliding windows. +- Uses `SCAN` for prefix and pattern deletes and listing. -Use `windows` to enforce multiple windows at the same time: +### Fallback storage ```ts -configureRatelimit({ - defaultLimiter: { - scope: 'user', - algorithm: 'sliding-window', - windows: [ - { id: 'short', maxRequests: 10, interval: '1m' }, - { id: 'long', maxRequests: 1000, interval: '1d' }, - ], - }, -}); -``` - -If a window `id` is omitted, it auto-generates `w1`, `w2`, and so on. +import { FallbackRateLimitStorage } from '@commandkit/ratelimit/fallback'; +import { MemoryRateLimitStorage } from '@commandkit/ratelimit/memory'; +import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis'; +import { setRateLimitStorage } from '@commandkit/ratelimit'; -## Violations and escalation +const primary = new RedisRateLimitStorage({ host: 'localhost', port: 6379 }); +const secondary = new MemoryRateLimitStorage(); -Escalate cooldowns after repeated rate limit violations: - -```ts -configureRatelimit({ - defaultLimiter: { - maxRequests: 1, - interval: '10s', - violations: { - maxViolations: 5, - escalationMultiplier: 2, - resetAfter: '1h', - }, - }, -}); +setRateLimitStorage(new FallbackRateLimitStorage(primary, secondary)); ``` -If an escalation cooldown extends beyond the normal reset, the plugin -uses the longer cooldown. +Notes: -Violation defaults and flags: +- Every optional method must exist on both storages or the fallback wrapper throws. +- Primary errors are logged at most once per `cooldownMs` window (default 30s). -- `escalate`: Defaults to true when `violations` is set. Set `false` to disable escalation. -- `maxViolations`: Default 5. -- `escalationMultiplier`: Default 2. -- `resetAfter`: Default 1h. - -## Hooks +## Queue mode -```ts -configureRatelimit({ - hooks: { - onAllowed: ({ key, result }) => { - console.log('allowed', key, result.remaining); - }, - onRateLimited: ({ key, result }) => { - console.log('limited', key, result.retryAfter); - }, - onViolation: (key, count) => { - console.log('violation', key, count); - }, - onReset: (key) => { - console.log('reset', key); - }, - onStorageError: (error, fallbackUsed) => { - console.error('storage error', error, fallbackUsed); - }, - }, -}); +Queue mode retries commands instead of rejecting immediately. + +### Queue defaults and clamps + +| Field | Default | Clamp | Notes | +| --- | --- | --- | --- | +| `enabled` | `true` if any queue config exists | n/a | Otherwise `false`. | +| `maxSize` | `3` | `>= 1` | Queue size is pending plus running. | +| `timeout` | `30s` | `>= 1ms` | Per queued task. | +| `deferInteraction` | `true` | n/a | Only applies to interactions. | +| `ephemeral` | `true` | n/a | Applies to deferred replies. | +| `concurrency` | `1` | `>= 1` | Per queue key. | + +### Queue flow + +1. Rate limit is evaluated and an aggregate result is computed. +2. If limited and queueing is enabled, the plugin tries to enqueue. +3. If the queue is full, it falls back to immediate rate-limit handling. +4. When queued, the interaction is deferred if it is repliable and not already replied or deferred. +5. The queued task waits `retryAfter`, then re-checks the limiter; if still limited it waits at least 250ms and retries until timeout. + +### Queue flow diagram + +```mermaid +graph TD + A[Evaluate limiter] --> B{Limited?} + B -- No --> C[Allow command] + B -- Yes --> D{Queue enabled?} + D -- No --> E[Rate-limit response] + D -- Yes --> F{Queue has capacity?} + F -- No --> E + F -- Yes --> G[Enqueue + defer if repliable] + G --> H[Wait retryAfter] + H --> I{Still limited?} + I -- No --> C + I -- Yes --> J[Wait >= 250ms] + J --> K{Timed out?} + K -- No --> H + K -- Yes --> E ``` -## Analytics events +## Violations and escalation -The runtime plugin emits analytics events (if analytics is configured): +Violation escalation is stored under `violation:{key}` and uses these defaults: -- `ratelimit_allowed` -- `ratelimit_hit` -- `ratelimit_violation` +| Option | Default | Meaning | +| --- | --- | --- | +| `maxViolations` | `5` | Maximum escalation steps. | +| `escalationMultiplier` | `2` | Multiplier per repeated violation. | +| `resetAfter` | `1h` | TTL for violation state. | +| `escalate` | `true` when `violations` is set | Set `false` to disable escalation. | -## Events +Formula: -Listen to runtime rate-limit events via CommandKit events: +`cooldown = baseRetryAfter * multiplier^(count - 1)` -```ts -commandkit.events - .to('ratelimits') - .on('ratelimited', ({ key, result, source, aggregate, commandName, queued }) => { - console.log('ratelimited', key, commandName, queued, aggregate.retryAfter); - }); -``` +If escalation produces a later `resetAt` than the algorithm returned, the result is updated so `resetAt` and `retryAfter` stay accurate. + +## Bypass and exemptions + +Bypass order is always: -In CommandKit apps, you can register the listener via the events router by -placing a handler under `src/app/events/(ratelimits)/ratelimited/` (for example -`logger.ts`). +1. `bypass.userIds`, `bypass.guildIds`, and `bypass.roleIds`. +2. Temporary exemptions stored in storage. +3. `bypass.check(source)`. -## Bypass rules +Bypass example: ```ts configureRatelimit({ @@ -408,144 +613,116 @@ configureRatelimit({ }); ``` -## Custom rate-limited response - -Override the default ephemeral cooldown reply: +Temporary exemptions: ```ts -import type { RateLimitStoreValue } from '@commandkit/ratelimit'; - -configureRatelimit({ - onRateLimited: async (ctx, info: RateLimitStoreValue) => { - await ctx.reply(`Cooldown: ${Math.ceil(info.retryAfter / 1000)}s`); - }, -}); -``` - -## Temporary exemptions - -```ts -import { - grantRateLimitExemption, - revokeRateLimitExemption, - listRateLimitExemptions, -} from '@commandkit/ratelimit'; +import { grantRateLimitExemption } from '@commandkit/ratelimit'; await grantRateLimitExemption({ scope: 'user', id: 'USER_ID', duration: '1h', }); - -await revokeRateLimitExemption({ - scope: 'user', - id: 'USER_ID', -}); - -const exemptions = await listRateLimitExemptions({ - scope: 'user', - id: 'USER_ID', -}); ``` -All exemption helpers accept an optional `keyPrefix`. +Listing behavior: -Listing notes: +- `listRateLimitExemptions({ scope, id })` reads a single key directly. +- `listRateLimitExemptions({ scope })` scans by prefix and requires `keysByPrefix`. +- `expiresInMs` is `null` when `ttl` is not supported. -- `listRateLimitExemptions({ scope, id })` checks a single key directly. -- `listRateLimitExemptions({ scope })` scans by prefix if supported. -- `limit` caps the number of results. -- `expiresInMs` is `null` if the storage does not support `ttl`. +## Responses, hooks, and events -Supported exemption scopes: +### Default response behavior -- `user` -- `guild` -- `role` -- `channel` -- `category` +| Source | Conditions | Action | +| --- | --- | --- | +| Message | Channel is sendable | `reply()` with cooldown embed. | +| Interaction | Repliable and not replied/deferred | `reply()` with ephemeral cooldown embed. | +| Interaction | Repliable and already replied/deferred | `followUp()` with ephemeral cooldown embed. | +| Interaction | Not repliable | No response. | -## Runtime helpers and API +The default embed title is `:hourglass_flowing_sand: You are on cooldown` and the description uses a relative timestamp based on `resetAt`. -### Runtime configuration +### Hooks -```ts -import { configureRatelimit } from '@commandkit/ratelimit'; +| Hook | Called when | Notes | +| --- | --- | --- | +| `onAllowed` | Command is allowed | Receives the first result. | +| `onRateLimited` | Command is limited | Receives the first limited result. | +| `onViolation` | A violation is recorded | Receives key and violation count. | +| `onReset` | `resetRateLimit` succeeds | Not called by `resetAllRateLimits`. | +| `onStorageError` | Storage operation fails | `fallbackUsed` is `false` in runtime plugin paths. | -configureRatelimit({ - defaultLimiter: { maxRequests: 5, interval: '1m' }, -}); -``` +### Analytics events -Use `getRateLimitConfig()` to read the active configuration and -`isRateLimitConfigured()` to guard flows that depend on runtime setup. +The runtime plugin calls `ctx.commandkit.analytics.track(...)` with: -### Storage helpers +| Event name | When | +| --- | --- | +| `ratelimit_allowed` | After an allowed consume. | +| `ratelimit_hit` | After a limited consume. | +| `ratelimit_violation` | When escalation records a violation. | -```ts -import { - setRateLimitStorage, - getRateLimitStorage, - setDriver, - getDriver, -} from '@commandkit/ratelimit'; -``` +### Event bus -### Runtime access +A `ratelimited` event is emitted on the `ratelimits` channel: ```ts -import { getRateLimitRuntime, setRateLimitRuntime } from '@commandkit/ratelimit'; +commandkit.events + .to('ratelimits') + .on('ratelimited', ({ key, result, source, aggregate, commandName, queued }) => { + console.log(key, commandName, queued, aggregate.retryAfter); + }); ``` -### Accessing results inside commands +Payload fields include `key`, `result`, `source`, `aggregate`, `commandName`, and `queued`. -```ts -import { getRateLimitInfo } from '@commandkit/ratelimit'; +## Resets and HMR -export const chatInput = async (ctx) => { - const info = getRateLimitInfo(ctx); - if (info?.limited) { - console.log(info.retryAfter); - } -}; -``` +### `resetRateLimit` -### Result shape +`resetRateLimit` clears the base key, its `violation:` key, and any window variants. It accepts either a raw `key` or a scope-derived key. -`RateLimitStoreValue` includes: +| Mode | Required params | Notes | +| --- | --- | --- | +| Direct | `key` | Resets `key`, `violation:key`, and window variants. | +| Scoped | `scope` + `commandName` + required ids | Throws if identifiers are missing. | -- `limited`: Whether any limiter hit. -- `remaining`: Minimum remaining across all results. -- `resetAt`: Latest reset timestamp across all results. -- `retryAfter`: Max retry delay when limited. -- `results`: Array of `RateLimitResult` entries. +### `resetAllRateLimits` -Each `RateLimitResult` includes: +`resetAllRateLimits` supports several modes and requires storage delete helpers: -- `key`, `scope`, `algorithm`, `windowId?`. -- `limited`, `remaining`, `resetAt`, `retryAfter`, `limit`. +| Mode | Required params | Storage requirement | +| --- | --- | --- | +| Pattern | `pattern` | `deleteByPattern` | +| Prefix | `prefix` | `deleteByPrefix` | +| Command name | `commandName` | `deleteByPattern` | +| Scope | `scope` + required ids | `deleteByPrefix` | -### Reset helpers +### HMR reset behavior -```ts -import { resetRateLimit, resetAllRateLimits } from '@commandkit/ratelimit'; +When a command file is hot-reloaded, the plugin deletes keys that match: -await resetRateLimit({ key: 'rl:user:USER_ID:ping' }); +- `*:{commandName}` +- `violation:*:{commandName}` +- `*:{commandName}:w:*` +- `violation:*:{commandName}:w:*` -await resetAllRateLimits({ commandName: 'ping' }); +HMR reset requires `deleteByPattern`. If the storage does not support pattern deletes, nothing is cleared. -await resetAllRateLimits({ scope: 'guild', guildId: 'GUILD_ID' }); -``` +## Directive: `use ratelimit` -Reset parameter notes: +The compiler plugin (`UseRateLimitDirectivePlugin`) uses `CommonDirectiveTransformer` with `directive = "use ratelimit"` and `importName = "$ckitirl"`. It transforms async functions only. -- `resetRateLimit` accepts either `key` or (`scope` + `commandName` + required IDs). -- `resetAllRateLimits` accepts `pattern`, `prefix`, `commandName`, or `scope` + IDs. -- `keyPrefix` can be passed to both reset helpers. +The runtime wrapper: -## Directive: `use ratelimit` +- Uses the runtime default limiter (merged with `DEFAULT_LIMITER`). +- Generates a per-function key `rl:fn:{uuid}` and applies `keyPrefix` if present. +- Aggregates results across windows and throws `RateLimitError` when limited. +- Caches the wrapper per function and exposes it as `globalThis.$ckitirl`. -Use the directive in async functions to rate-limit function execution: +Example: ```ts import { RateLimitError } from '@commandkit/ratelimit'; @@ -564,147 +741,61 @@ try { } ``` -The compiler plugin injects `$ckitirl` at build time. The runtime -wrapper uses a per-function key and the runtime default limiter. - -The directive is applied only to async functions. - -## RateLimitEngine reset - -`RateLimitEngine.reset(key)` removes both the main key and the -`violation:{key}` entry. - -## HMR reset behavior - -When a command file is hot-reloaded, the runtime plugin clears that command's -rate-limit keys using `deleteByPattern` (including `violation:` and `:w:` variants). -If the storage does not support pattern deletes, nothing is cleared. - -## Behavior details and edge cases - -- `ratelimit()` returns `[UseRateLimitDirectivePlugin, RateLimitPlugin]` in that order. -- If required IDs are missing for a scope (for example no guild in DMs), that scope is skipped. -- `interval` is clamped to at least 1ms when resolving limiter config. -- `RateLimitResult.limit` is `burst` for token/leaky buckets and `maxRequests` for fixed/sliding windows. -- Default rate-limit response uses an embed titled `:hourglass_flowing_sand: You are on cooldown` with a relative timestamp. Interactions reply ephemerally (or follow up if already replied/deferred). Non-repliable interactions are skipped. Messages reply only if the channel is sendable. -- Queue behavior: queue size is pending + running; if `maxSize` is reached, the command is not queued and falls back to immediate rate-limit handling. Queued tasks stop after `timeout` and log a warning. After the initial delay, retries wait at least 250ms between checks. When queued, `ctx.capture()` and `onRateLimited`/`onViolation` hooks still run. -- Bypass order is user/guild/role lists, then temporary exemptions, then `bypass.check`. -- `roleLimitStrategy: 'first'` respects object insertion order. Role limits merge in this order: plugin `roleLimits` -> `defaultLimiter.roleLimits` -> named limiter `roleLimits` -> command overrides. -- `resetRateLimit` triggers `hooks.onReset` for the key; `resetAllRateLimits` does not. -- `onStorageError` is invoked with `fallbackUsed = false` from runtime plugin calls. -- `grantRateLimitExemption` uses the runtime `keyPrefix` by default unless `keyPrefix` is provided. -- `RateLimitError` defaults to message `Rate limit exceeded`. -- If no storage is configured and default storage is disabled, the plugin logs once and stores an empty `RateLimitStoreValue` without limiting. -- `FallbackRateLimitStorage` throws if either storage does not support an optional operation. -- `MemoryRateLimitStorage.deleteByPattern` supports `*` wildcards (simple glob). - -## Constants - -- `RATELIMIT_STORE_KEY`: `ratelimit` (store key for aggregated results). -- `DEFAULT_KEY_PREFIX`: `rl:` (prefix used in generated keys). - -## Type reference (exported) - -- `RateLimitScope` and `RATE_LIMIT_SCOPES`: Scope values used in keys. -- `RateLimitExemptionScope` and `RATE_LIMIT_EXEMPTION_SCOPES`: Exemption scopes. -- `RateLimitAlgorithmType` and `RATE_LIMIT_ALGORITHMS`: Algorithm identifiers. -- `DurationLike`: Number in ms or duration string. -- `RateLimitQueueOptions`: Queue settings for retries. -- `RateLimitRoleLimitStrategy`: `highest`, `lowest`, or `first`. -- `RateLimitResult`: Result for a single limiter/window. -- `RateLimitAlgorithm`: Interface for algorithm implementations. -- `FixedWindowConsumeResult` and `SlidingWindowConsumeResult`: Storage consume return types. -- `RateLimitStorage` and `RateLimitStorageConfig`: Storage interface and wrapper. -- `ViolationOptions`: Escalation controls. -- `RateLimitWindowConfig`: Per-window limiter config. -- `RateLimitKeyResolver`: Custom scope key resolver signature. -- `RateLimitLimiterConfig`: Base limiter configuration. -- `RateLimitCommandConfig`: Limiter config plus `limiter` name. -- `RateLimitBypassOptions`: Bypass lists and optional `check`. -- `RateLimitExemptionGrantParams`, `RateLimitExemptionRevokeParams`, `RateLimitExemptionListParams`: Exemption helper params. -- `RateLimitExemptionInfo`: Exemption listing entry shape. -- `RateLimitHookContext` and `RateLimitHooks`: Hook payloads and callbacks. -- `RateLimitResponseHandler`: `onRateLimited` handler signature. -- `RateLimitPluginOptions`: Runtime plugin options. -- `RateLimitStoreValue`: Aggregated results stored in `env.store`. -- `ResolvedLimiterConfig`: Resolved limiter config with defaults and `intervalMs`. -- `RateLimitRuntimeContext`: Active runtime state. +## Defaults and edge cases -## Exports +### Defaults -- `ratelimit` plugin factory (compiler + runtime). -- `RateLimitPlugin` and `UseRateLimitDirectivePlugin`. -- `RateLimitEngine`, algorithm classes, and `ViolationTracker`. -- Storage implementations: `MemoryRateLimitStorage`, `RedisRateLimitStorage`, `FallbackRateLimitStorage`. -- Runtime helpers: `configureRatelimit`, `setRateLimitStorage`, `getRateLimitStorage`, `setDriver`, `getDriver`, `getRateLimitRuntime`, `setRateLimitRuntime`. -- API helpers: `getRateLimitInfo`, `resetRateLimit`, `resetAllRateLimits`, `grantRateLimitExemption`, `revokeRateLimitExemption`, `listRateLimitExemptions`. -- Errors: `RateLimitError`. +| Setting | Default | +| --- | --- | +| `maxRequests` | `10` | +| `interval` | `60s` | +| `algorithm` | `fixed-window` | +| `scope` | `user` | +| `DEFAULT_KEY_PREFIX` | `rl:` | +| `RATELIMIT_STORE_KEY` | `ratelimit` | +| `roleLimitStrategy` | `highest` | +| `queue.maxSize` | `3` | +| `queue.timeout` | `30s` | +| `queue.deferInteraction` | `true` | +| `queue.ephemeral` | `true` | +| `queue.concurrency` | `1` | +| `initializeDefaultStorage` | `true` | -## Defaults +### Edge cases -- `maxRequests`: 10 -- `interval`: 60s -- `algorithm`: `fixed-window` -- `scope`: `user` -- `keyPrefix`: none (but keys always include `rl:`) -- `initializeDefaultStorage`: true +1. If no storage is configured and default storage is disabled, the plugin logs once and stores an empty result without limiting. +2. If no scope key can be resolved, the plugin stores an empty result and skips limiting. +3. If storage errors occur during consume, `onStorageError` is invoked and the plugin skips limiting for that execution. +4. For token and leaky buckets, `limit` equals `burst`. For fixed and sliding windows, `limit` equals `maxRequests`. -## Duration units +## Duration parsing -String durations support `ms`, `s`, `m`, `h`, `d` via `ms`, plus: +`DurationLike` accepts numbers (milliseconds) or strings parsed by `ms`, plus custom units for weeks and months. -- `w`, `week`, `weeks` -- `mo`, `month`, `months` +| Unit | Meaning | +| --- | --- | +| `ms`, `s`, `m`, `h`, `d` | Standard `ms` units. | +| `w`, `week`, `weeks` | 7 days. | +| `mo`, `month`, `months` | 30 days. | -## Subpath exports +## Exports + +| Export | Description | +| --- | --- | +| `ratelimit` | Plugin factory returning compiler + runtime plugins. | +| `RateLimitPlugin` | Runtime plugin class. | +| `UseRateLimitDirectivePlugin` | Compiler plugin for `use ratelimit`. | +| `RateLimitEngine` | Algorithm coordinator with escalation handling. | +| Algorithm classes | Fixed, sliding, token bucket, and leaky bucket implementations. | +| Storage classes | Memory, Redis, and fallback storage. | +| Runtime helpers | `configureRatelimit`, `setRateLimitStorage`, `getRateLimitRuntime`, and more. | +| API helpers | `getRateLimitInfo`, resets, and exemption helpers. | +| `RateLimitError` | Error thrown by the directive wrapper. | + +Subpath exports: - `@commandkit/ratelimit/redis` - `@commandkit/ratelimit/memory` - `@commandkit/ratelimit/fallback` -## Source map (packages/ratelimit/src) - -- `src/index.ts`: Package entrypoint that re-exports the public API. -- `src/augmentation.ts`: Extends `CommandMetadata` with `metadata.ratelimit`. -- `src/configure.ts`: `configureRatelimit`, `getRateLimitConfig`, `isRateLimitConfigured`, and runtime updates. -- `src/runtime.ts`: Process-wide storage/runtime accessors, plus `setDriver`/`getDriver` aliases. -- `src/plugin.ts`: Runtime plugin: config resolution, queueing, hooks, analytics/events, responses, and HMR resets. -- `src/directive/use-ratelimit-directive.ts`: Compiler plugin for the `"use ratelimit"` directive. -- `src/directive/use-ratelimit.ts`: Runtime directive wrapper; uses `RateLimitEngine` and throws `RateLimitError`. -- `src/api.ts`: Public helpers for `getRateLimitInfo`, resets, and exemptions, plus param types. -- `src/types.ts`: Public config/result/storage types. -- `src/constants.ts`: `RATELIMIT_STORE_KEY` and `DEFAULT_KEY_PREFIX`. -- `src/errors.ts`: `RateLimitError` type. -- `src/engine/RateLimitEngine.ts`: Algorithm selection plus violation escalation. -- `src/engine/violations.ts`: `ViolationTracker` and escalation state. -- `src/engine/algorithms/fixed-window.ts`: Fixed-window algorithm. -- `src/engine/algorithms/sliding-window.ts`: Sliding-window log algorithm. -- `src/engine/algorithms/token-bucket.ts`: Token-bucket algorithm. -- `src/engine/algorithms/leaky-bucket.ts`: Leaky-bucket algorithm. -- `src/storage/memory.ts`: In-memory storage with TTL and sorted-set helpers. -- `src/storage/redis.ts`: Redis storage with Lua scripts for atomic windows. -- `src/storage/fallback.ts`: Fallback storage wrapper with cooldown logging. -- `src/providers/memory.ts`: Subpath export for memory storage. -- `src/providers/redis.ts`: Subpath export for Redis storage. -- `src/providers/fallback.ts`: Subpath export for fallback storage. -- `src/utils/config.ts`: Defaults, normalization, multi-window resolution, and role-limit merging. -- `src/utils/keys.ts`: Key building and parsing for scopes/exemptions. -- `src/utils/time.ts`: Duration parsing and clamp helpers. -- `src/utils/locking.ts`: Per-storage keyed mutex for fallback algorithm serialization. - -## Spec map (packages/ratelimit/spec) - -- `spec/setup.ts`: Shared test setup for vitest. -- `spec/helpers.ts`: Test helpers and stubs. -- `spec/algorithms.test.ts`: Algorithm integration tests. -- `spec/engine.test.ts`: Engine + violation behavior tests. -- `spec/api.test.ts`: API helper tests (resets, exemptions, info). -- `spec/plugin.test.ts`: Runtime plugin behavior tests. - -## Manual testing - -- Configure `maxRequests: 1` and `interval: '5s'`. -- Call the command twice and verify the cooldown response. -- Enable queue mode and confirm the second call is deferred and executes later. -- Grant an exemption and verify the user bypasses limits. -- Reset the command and verify the cooldown clears immediately. + From 9a0927917927964968de946d08332a256d26ce88 Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Sat, 14 Mar 2026 22:01:19 +0100 Subject: [PATCH 09/10] fix: quote mermaid labels in ratelimit docs --- .../guide/05-official-plugins/07-commandkit-ratelimit.mdx | 8 ++++---- packages/ratelimit/README.md | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx b/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx index a9b3ce5e..53d488c2 100644 --- a/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx +++ b/apps/website/docs/guide/05-official-plugins/07-commandkit-ratelimit.mdx @@ -56,8 +56,8 @@ The runtime plugin auto-loads `ratelimit.ts` or `ratelimit.js` on startup before ```mermaid graph TD A[App startup] --> B[Auto-load ratelimit.ts/js] - B --> C[configureRatelimit()] - C --> D[Runtime plugin activate()] + B --> C["configureRatelimit()"] + C --> D["Runtime plugin activate()"] D --> E[Resolve storage] E --> F[Resolve limiter config] F --> G[Consume algorithm] @@ -283,7 +283,7 @@ graph TD A[DEFAULT_LIMITER] --> B[defaultLimiter] B --> C[Named limiter] C --> D[Command overrides] - D --> E[Role override (strategy)] + D --> E["Role override (strategy)"] ``` Role limits are merged in this order, with later maps overriding earlier ones for the same role id: @@ -365,7 +365,7 @@ graph TD B -- Yes --> C[Use consumeFixedWindow] B -- No --> D{incr?} D -- Yes --> E[Use incr] - D -- No --> F[get + set fallback (per-process lock)] + D -- No --> F["get + set fallback (per-process lock)"] ``` ### Sliding window log diff --git a/packages/ratelimit/README.md b/packages/ratelimit/README.md index 6bd3e221..1eb30eaf 100644 --- a/packages/ratelimit/README.md +++ b/packages/ratelimit/README.md @@ -76,8 +76,8 @@ The runtime plugin auto-loads `ratelimit.ts` or `ratelimit.js` on startup before ```mermaid graph TD A[App startup] --> B[Auto-load ratelimit.ts/js] - B --> C[configureRatelimit()] - C --> D[Runtime plugin activate()] + B --> C["configureRatelimit()"] + C --> D["Runtime plugin activate()"] D --> E[Resolve storage] E --> F[Resolve limiter config] F --> G[Consume algorithm] @@ -297,7 +297,7 @@ graph TD A[DEFAULT_LIMITER] --> B[defaultLimiter] B --> C[Named limiter] C --> D[Command overrides] - D --> E[Role override (strategy)] + D --> E["Role override (strategy)"] ``` Role limits are merged in this order, with later maps overriding earlier ones for the same role id: @@ -379,7 +379,7 @@ graph TD B -- Yes --> C[Use consumeFixedWindow] B -- No --> D{incr?} D -- Yes --> E[Use incr] - D -- No --> F[get + set fallback (per-process lock)] + D -- No --> F["get + set fallback (per-process lock)"] ``` ### Sliding window log From 2102f9cc132a3dea0fa67202490d9378a297a39f Mon Sep 17 00:00:00 2001 From: Ray <168236487+ItsRayanM@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:50:21 +0100 Subject: [PATCH 10/10] chore: regenerate pnpm lockfile --- pnpm-lock.yaml | 65 ++++++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 31 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df86a677..61933c47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -667,6 +667,9 @@ importers: commandkit: specifier: workspace:* version: link:../commandkit + directive-to-hof: + specifier: ^0.0.3 + version: 0.0.3 discord.js: specifier: catalog:discordjs version: 14.25.1 @@ -6866,7 +6869,7 @@ packages: resolution: {integrity: sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==} engines: {node: '>= 10.13.0'} peerDependencies: - webpack: '>=5.104.1' + webpack: '>=5.104.0' file-type@20.5.0: resolution: {integrity: sha512-BfHZtG/l9iMm4Ecianu7P8HRD2tBHLtjXinm4X62XBOYzi7CYA7jyqfJzOvXHqzVrVPYqBo2/GvbARMaaJkKVg==} @@ -7061,7 +7064,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-dirs@3.0.1: resolution: {integrity: sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==} @@ -10551,7 +10554,7 @@ packages: engines: {node: '>= 10.13.0'} peerDependencies: file-loader: '*' - webpack: '>=5.104.1' + webpack: '>=5.104.0' peerDependenciesMeta: file-loader: optional: true @@ -11320,7 +11323,7 @@ snapshots: '@babel/code-frame@7.27.1': dependencies: - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 @@ -11337,7 +11340,7 @@ snapshots: '@babel/parser': 7.28.4 '@babel/template': 7.27.2 '@babel/traverse': 7.28.3 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@8.1.1) gensync: 1.0.0-beta.2 @@ -11349,7 +11352,7 @@ snapshots: '@babel/generator@7.28.3': dependencies: '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -11365,13 +11368,13 @@ snapshots: '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@babel/helper-compilation-targets@7.27.2': dependencies: '@babel/compat-data': 7.27.3 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.25.0 + browserslist: 4.28.1 lru-cache: 5.1.1 semver: 6.3.1 @@ -11418,7 +11421,7 @@ snapshots: '@babel/helper-module-imports@7.27.1': dependencies: '@babel/traverse': 7.28.3 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -11426,7 +11429,7 @@ snapshots: dependencies: '@babel/core': 7.28.3 '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 '@babel/traverse': 7.28.3 transitivePeerDependencies: - supports-color @@ -11458,7 +11461,7 @@ snapshots: '@babel/helper-skip-transparent-expression-wrappers@7.27.1': dependencies: '@babel/traverse': 7.28.3 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -11485,11 +11488,11 @@ snapshots: '@babel/helpers@7.28.3': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@babel/parser@7.28.4': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@babel/parser@8.0.0-rc.1': dependencies: @@ -12056,7 +12059,7 @@ snapshots: dependencies: '@babel/code-frame': 7.27.1 '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@babel/traverse@7.28.3': dependencies: @@ -12065,7 +12068,7 @@ snapshots: '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.4 '@babel/template': 7.27.2 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -15701,23 +15704,23 @@ snapshots: '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@types/babel__template@7.4.4': dependencies: '@babel/parser': 7.28.4 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 '@types/body-parser@1.19.6': dependencies: @@ -16922,7 +16925,7 @@ snapshots: '@babel/core': 7.28.3 '@babel/parser': 7.28.4 '@babel/traverse': 7.28.3 - '@babel/types': 7.28.4 + '@babel/types': 7.28.6 transitivePeerDependencies: - supports-color @@ -17220,7 +17223,7 @@ snapshots: caniuse-api@3.0.0: dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 caniuse-lite: 1.0.30001718 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 @@ -17525,7 +17528,7 @@ snapshots: core-js-compat@3.42.0: dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 core-js-pure@3.45.1: {} @@ -17658,7 +17661,7 @@ snapshots: cssnano-preset-advanced@6.1.2(postcss@8.5.6): dependencies: autoprefixer: 10.4.27(postcss@8.5.6) - browserslist: 4.25.0 + browserslist: 4.28.1 cssnano-preset-default: 6.1.2(postcss@8.5.6) postcss: 8.5.6 postcss-discard-unused: 6.0.5(postcss@8.5.6) @@ -17668,7 +17671,7 @@ snapshots: cssnano-preset-default@6.1.2(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 css-declaration-sorter: 7.2.0(postcss@8.5.6) cssnano-utils: 4.0.2(postcss@8.5.6) postcss: 8.5.6 @@ -20896,7 +20899,7 @@ snapshots: postcss-colormin@6.1.0(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 caniuse-api: 3.0.0 colord: 2.9.3 postcss: 8.5.6 @@ -20904,7 +20907,7 @@ snapshots: postcss-convert-values@6.1.0(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -21049,7 +21052,7 @@ snapshots: postcss-merge-rules@6.1.1(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 caniuse-api: 3.0.0 cssnano-utils: 4.0.2(postcss@8.5.6) postcss: 8.5.6 @@ -21069,7 +21072,7 @@ snapshots: postcss-minify-params@6.1.0(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 cssnano-utils: 4.0.2(postcss@8.5.6) postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -21143,7 +21146,7 @@ snapshots: postcss-normalize-unicode@6.1.0(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 postcss: 8.5.6 postcss-value-parser: 4.2.0 @@ -21261,7 +21264,7 @@ snapshots: postcss-reduce-initial@6.1.0(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 caniuse-api: 3.0.0 postcss: 8.5.6 @@ -22327,7 +22330,7 @@ snapshots: stylehacks@6.1.1(postcss@8.5.6): dependencies: - browserslist: 4.25.0 + browserslist: 4.28.1 postcss: 8.5.6 postcss-selector-parser: 6.1.2