@commandkit/ratelimit 0.0.0-dev.20260317060555

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +801 -0
  3. package/dist/api.d.ts +79 -0
  4. package/dist/api.js +266 -0
  5. package/dist/augmentation.d.ts +11 -0
  6. package/dist/augmentation.js +8 -0
  7. package/dist/configure.d.ts +28 -0
  8. package/dist/configure.js +85 -0
  9. package/dist/constants.d.ts +17 -0
  10. package/dist/constants.js +21 -0
  11. package/dist/directive/use-ratelimit-directive.d.ts +22 -0
  12. package/dist/directive/use-ratelimit-directive.js +38 -0
  13. package/dist/directive/use-ratelimit.d.ts +14 -0
  14. package/dist/directive/use-ratelimit.js +169 -0
  15. package/dist/engine/RateLimitEngine.d.ts +48 -0
  16. package/dist/engine/RateLimitEngine.js +137 -0
  17. package/dist/engine/algorithms/fixed-window.d.ts +44 -0
  18. package/dist/engine/algorithms/fixed-window.js +198 -0
  19. package/dist/engine/algorithms/leaky-bucket.d.ts +48 -0
  20. package/dist/engine/algorithms/leaky-bucket.js +119 -0
  21. package/dist/engine/algorithms/sliding-window.d.ts +45 -0
  22. package/dist/engine/algorithms/sliding-window.js +127 -0
  23. package/dist/engine/algorithms/token-bucket.d.ts +47 -0
  24. package/dist/engine/algorithms/token-bucket.js +118 -0
  25. package/dist/engine/violations.d.ts +55 -0
  26. package/dist/engine/violations.js +106 -0
  27. package/dist/errors.d.ts +21 -0
  28. package/dist/errors.js +28 -0
  29. package/dist/index.d.ts +31 -0
  30. package/dist/index.js +53 -0
  31. package/dist/plugin.d.ts +140 -0
  32. package/dist/plugin.js +796 -0
  33. package/dist/providers/fallback.d.ts +7 -0
  34. package/dist/providers/fallback.js +11 -0
  35. package/dist/providers/memory.d.ts +6 -0
  36. package/dist/providers/memory.js +11 -0
  37. package/dist/providers/redis.d.ts +7 -0
  38. package/dist/providers/redis.js +11 -0
  39. package/dist/runtime.d.ts +45 -0
  40. package/dist/runtime.js +67 -0
  41. package/dist/storage/fallback.d.ts +180 -0
  42. package/dist/storage/fallback.js +261 -0
  43. package/dist/storage/memory.d.ts +146 -0
  44. package/dist/storage/memory.js +304 -0
  45. package/dist/storage/redis.d.ts +130 -0
  46. package/dist/storage/redis.js +243 -0
  47. package/dist/types.d.ts +296 -0
  48. package/dist/types.js +40 -0
  49. package/dist/utils/config.d.ts +34 -0
  50. package/dist/utils/config.js +105 -0
  51. package/dist/utils/keys.d.ts +102 -0
  52. package/dist/utils/keys.js +304 -0
  53. package/dist/utils/locking.d.ts +17 -0
  54. package/dist/utils/locking.js +60 -0
  55. package/dist/utils/time.d.ts +23 -0
  56. package/dist/utils/time.js +72 -0
  57. package/package.json +65 -0
package/README.md ADDED
@@ -0,0 +1,801 @@
1
+ # @commandkit/ratelimit
2
+
3
+ `@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.
4
+
5
+ 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.
6
+
7
+ ## Table of contents
8
+
9
+ 1. [Installation](#installation)
10
+ 2. [Setup](#setup)
11
+ 3. [Runtime configuration lifecycle](#runtime-configuration-lifecycle)
12
+ 4. [Basic usage](#basic-usage)
13
+ 5. [Configuration reference](#configuration-reference)
14
+ 6. [Limiter resolution and role strategy](#limiter-resolution-and-role-strategy)
15
+ 7. [Scopes and keying](#scopes-and-keying)
16
+ 8. [Algorithms](#algorithms)
17
+ 9. [Storage](#storage)
18
+ 10. [Queue mode](#queue-mode)
19
+ 11. [Violations and escalation](#violations-and-escalation)
20
+ 12. [Bypass and exemptions](#bypass-and-exemptions)
21
+ 13. [Responses, hooks, and events](#responses-hooks-and-events)
22
+ 14. [Resets and HMR](#resets-and-hmr)
23
+ 15. [Directive: `use ratelimit`](#directive-use-ratelimit)
24
+ 16. [Defaults and edge cases](#defaults-and-edge-cases)
25
+ 17. [Duration parsing](#duration-parsing)
26
+ 18. [Exports](#exports)
27
+
28
+ ## Installation
29
+
30
+ Install the ratelimit plugin to get started:
31
+
32
+ ```bash
33
+ npm install @commandkit/ratelimit
34
+ ```
35
+
36
+ ## Setup
37
+
38
+ Add the ratelimit plugin to your CommandKit configuration and define a runtime config file.
39
+
40
+ ### Quick start
41
+
42
+ Create an auto-loaded runtime config file (for example `ratelimit.ts`) and configure the default limiter:
43
+
44
+ ```ts
45
+ // ratelimit.ts
46
+ import { configureRatelimit } from '@commandkit/ratelimit';
47
+
48
+ configureRatelimit({
49
+ defaultLimiter: {
50
+ maxRequests: 5,
51
+ interval: '1m',
52
+ scope: 'user',
53
+ algorithm: 'fixed-window',
54
+ },
55
+ });
56
+ ```
57
+
58
+ Register the plugin in your config:
59
+
60
+ ```ts
61
+ // commandkit.config.ts
62
+ import { defineConfig } from 'commandkit';
63
+ import { ratelimit } from '@commandkit/ratelimit';
64
+
65
+ export default defineConfig({
66
+ plugins: [ratelimit()],
67
+ });
68
+ ```
69
+
70
+ The runtime plugin auto-loads `ratelimit.ts` or `ratelimit.js` on startup before commands execute.
71
+
72
+ ## Runtime configuration lifecycle
73
+
74
+ ### Runtime lifecycle diagram
75
+
76
+ ```mermaid
77
+ graph TD
78
+ A[App startup] --> B[Auto-load ratelimit.ts/js]
79
+ B --> C["configureRatelimit()"]
80
+ C --> D["Runtime plugin activate()"]
81
+ D --> E[Resolve storage]
82
+ E --> F[Resolve limiter config]
83
+ F --> G[Consume algorithm]
84
+ G --> H[Aggregate result]
85
+ H --> I[Default response / hooks / events]
86
+ ```
87
+
88
+ ### `configureRatelimit` is required
89
+
90
+ `RateLimitPlugin.activate()` throws if `configureRatelimit()` was not called. This is enforced to avoid silently running without your intended defaults.
91
+
92
+ ### How configuration is stored
93
+
94
+ `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.
95
+
96
+ ### Runtime storage selection
97
+
98
+ Storage is resolved in this order:
99
+
100
+ | Order | Source | Notes |
101
+ | --- | --- | --- |
102
+ | 1 | Limiter `storage` override | `RateLimitLimiterConfig.storage` for the command being executed. |
103
+ | 2 | Plugin `storage` option | `RateLimitPluginOptions.storage`. |
104
+ | 3 | Process default | Set via `setRateLimitStorage()` or `setDriver()`. |
105
+ | 4 | Default memory storage | Used unless `initializeDefaultStorage` or `initializeDefaultDriver` is `false`. |
106
+
107
+ If no storage is resolved and defaults are disabled, the plugin logs once and stores an empty result without limiting.
108
+
109
+ ### Runtime helpers
110
+
111
+ These helpers are process-wide:
112
+
113
+ | Helper | Purpose |
114
+ | --- | --- |
115
+ | `configureRatelimit` | Set runtime options and update active runtime state. |
116
+ | `getRateLimitConfig` | Read the merged in-memory runtime config. |
117
+ | `isRateLimitConfigured` | Check whether `configureRatelimit()` was called. |
118
+ | `setRateLimitStorage` | Set the default storage for the process. |
119
+ | `getRateLimitStorage` | Get the process default storage (or `null`). |
120
+ | `setDriver` / `getDriver` | Aliases for `setRateLimitStorage` / `getRateLimitStorage`. |
121
+ | `setRateLimitRuntime` | Set the active runtime context for APIs and directives. |
122
+ | `getRateLimitRuntime` | Get the active runtime context (or `null`). |
123
+
124
+ ## Basic usage
125
+
126
+ Use command metadata or the `use ratelimit` directive to enable rate limiting.
127
+ This section focuses on command metadata; see the directive section for
128
+ function-level usage.
129
+
130
+ ### Command metadata and enablement
131
+
132
+ Enable rate limiting by setting `metadata.ratelimit`:
133
+
134
+ ```ts
135
+ export const metadata = {
136
+ ratelimit: {
137
+ maxRequests: 3,
138
+ interval: '10s',
139
+ scope: 'user',
140
+ algorithm: 'sliding-window',
141
+ },
142
+ };
143
+ ```
144
+
145
+ `metadata.ratelimit` can be one of:
146
+
147
+ | Value | Meaning |
148
+ | --- | --- |
149
+ | `false` or `undefined` | Plugin does nothing for this command. |
150
+ | `true` | Enable rate limiting using resolved defaults. |
151
+ | `RateLimitCommandConfig` | Enable rate limiting with command-level overrides. |
152
+
153
+ If `env.context` is missing in the execution environment, the plugin skips rate limiting.
154
+
155
+ ### Named limiter example
156
+
157
+ ```ts
158
+ configureRatelimit({
159
+ limiters: {
160
+ heavy: { maxRequests: 1, interval: '10s', algorithm: 'fixed-window' },
161
+ },
162
+ });
163
+ ```
164
+
165
+ ```ts
166
+ export const metadata = {
167
+ ratelimit: {
168
+ limiter: 'heavy',
169
+ scope: 'user',
170
+ },
171
+ };
172
+ ```
173
+
174
+ ## Configuration reference
175
+
176
+ ### RateLimitPluginOptions
177
+
178
+ | Field | Type | Default or resolution | Notes |
179
+ | --- | --- | --- | --- |
180
+ | `defaultLimiter` | `RateLimitLimiterConfig` | `DEFAULT_LIMITER` when unset | Base limiter for all commands and directives. |
181
+ | `limiters` | `Record<string, RateLimitLimiterConfig>` | `undefined` | Named limiter presets. |
182
+ | `storage` | `RateLimitStorageConfig` | `undefined` | Resolved before default storage. |
183
+ | `keyPrefix` | `string` | `undefined` | Prepended before `rl:`. |
184
+ | `keyResolver` | `RateLimitKeyResolver` | `undefined` | Used for `custom` scope when the limiter does not override it. |
185
+ | `bypass` | `RateLimitBypassOptions` | `undefined` | Permanent allowlists and optional check. |
186
+ | `hooks` | `RateLimitHooks` | `undefined` | Lifecycle callbacks. |
187
+ | `onRateLimited` | `RateLimitResponseHandler` | `undefined` | Overrides default reply. |
188
+ | `queue` | `RateLimitQueueOptions` | `undefined` | If any queue config exists, `enabled` defaults to `true`. |
189
+ | `roleLimits` | `Record<string, RateLimitLimiterConfig>` | `undefined` | Base role limits. |
190
+ | `roleLimitStrategy` | `RateLimitRoleLimitStrategy` | `highest` when resolving | Used when multiple roles match. |
191
+ | `initializeDefaultStorage` | `boolean` | `true` | Disable to prevent memory fallback. |
192
+ | `initializeDefaultDriver` | `boolean` | `true` | Alias for `initializeDefaultStorage`. |
193
+
194
+ ### RateLimitLimiterConfig
195
+
196
+ | Field | Type | Default or resolution | Notes |
197
+ | --- | --- | --- | --- |
198
+ | `maxRequests` | `number` | `10` when missing or `<= 0` | Used by fixed and sliding windows. |
199
+ | `interval` | `DurationLike` | `60s` when missing or invalid | Parsed and clamped to `>= 1ms`. |
200
+ | `scope` | `RateLimitScope` or `RateLimitScope[]` | `user` | Arrays are deduplicated. |
201
+ | `algorithm` | `RateLimitAlgorithmType` | `fixed-window` | Unknown values fall back to fixed-window. |
202
+ | `burst` | `number` | `maxRequests` when missing or `<= 0` | Capacity for token or leaky buckets. |
203
+ | `refillRate` | `number` | `maxRequests / intervalSeconds` | Must be `> 0` for token bucket. |
204
+ | `leakRate` | `number` | `maxRequests / intervalSeconds` | Must be `> 0` for leaky bucket. |
205
+ | `keyResolver` | `RateLimitKeyResolver` | `undefined` | Used only for `custom` scope. |
206
+ | `keyPrefix` | `string` | `undefined` | Overrides plugin prefix for this limiter. |
207
+ | `storage` | `RateLimitStorageConfig` | `undefined` | Overrides storage for this limiter. |
208
+ | `violations` | `ViolationOptions` | `undefined` | Enables escalation unless `escalate` is `false`. |
209
+ | `queue` | `RateLimitQueueOptions` | `undefined` | Overrides queue settings at this layer. |
210
+ | `windows` | `RateLimitWindowConfig[]` | `undefined` | Enables multi-window behavior. |
211
+ | `roleLimits` | `Record<string, RateLimitLimiterConfig>` | `undefined` | Role overrides at this layer. |
212
+ | `roleLimitStrategy` | `RateLimitRoleLimitStrategy` | `highest` when resolving | Used when role limits match. |
213
+
214
+ ### RateLimitWindowConfig
215
+
216
+ | Field | Type | Default or resolution | Notes |
217
+ | --- | --- | --- | --- |
218
+ | `id` | `string` | `w1`, `w2`, ... | Auto-generated if empty or missing. |
219
+ | `maxRequests` | `number` | Inherits from base limiter | Applies only to this window. |
220
+ | `interval` | `DurationLike` | Inherits from base limiter | Parsed like the base limiter. |
221
+ | `algorithm` | `RateLimitAlgorithmType` | Inherits from base limiter | Usually keep consistent across windows. |
222
+ | `burst` | `number` | Inherits from base limiter | Used for token or leaky buckets. |
223
+ | `refillRate` | `number` | Inherits from base limiter | Must be `> 0` for token bucket. |
224
+ | `leakRate` | `number` | Inherits from base limiter | Must be `> 0` for leaky bucket. |
225
+ | `violations` | `ViolationOptions` | Inherits from base limiter | Overrides escalation for this window. |
226
+
227
+ ### RateLimitQueueOptions
228
+
229
+ | Field | Type | Default or resolution | Notes |
230
+ | --- | --- | --- | --- |
231
+ | `enabled` | `boolean` | `true` when any queue config exists | Otherwise `false`. |
232
+ | `maxSize` | `number` | `3` and clamped to `>= 1` | Queue size is pending plus running. |
233
+ | `timeout` | `DurationLike` | `30s` and clamped to `>= 1ms` | Per queued task. |
234
+ | `deferInteraction` | `boolean` | `true` unless explicitly `false` | Only used for interactions. |
235
+ | `ephemeral` | `boolean` | `true` unless explicitly `false` | Applies to deferred replies. |
236
+ | `concurrency` | `number` | `1` and clamped to `>= 1` | Per queue key. |
237
+
238
+ ### ViolationOptions
239
+
240
+ | Field | Type | Default or resolution | Notes |
241
+ | --- | --- | --- | --- |
242
+ | `escalate` | `boolean` | `true` when `violations` is set | Set `false` to disable escalation. |
243
+ | `maxViolations` | `number` | `5` | Maximum escalation steps. |
244
+ | `escalationMultiplier` | `number` | `2` | Multiplies cooldown per repeated violation. |
245
+ | `resetAfter` | `DurationLike` | `1h` | TTL for violation state. |
246
+
247
+ ### RateLimitCommandConfig
248
+
249
+ `RateLimitCommandConfig` extends `RateLimitLimiterConfig` and adds:
250
+
251
+ | Field | Type | Default or resolution | Notes |
252
+ | --- | --- | --- | --- |
253
+ | `limiter` | `string` | `undefined` | References a named limiter in `limiters`. |
254
+
255
+ ### Result shapes
256
+
257
+ RateLimitStoreValue:
258
+
259
+ | Field | Type | Meaning |
260
+ | --- | --- | --- |
261
+ | `limited` | `boolean` | `true` if any scope or window was limited. |
262
+ | `remaining` | `number` | Minimum remaining across all results. |
263
+ | `resetAt` | `number` | Latest reset timestamp across all results. |
264
+ | `retryAfter` | `number` | Max retry delay across limited results. |
265
+ | `results` | `RateLimitResult[]` | Individual results per scope and window. |
266
+
267
+ RateLimitResult:
268
+
269
+ | Field | Type | Meaning |
270
+ | --- | --- | --- |
271
+ | `key` | `string` | Storage key used for the limiter. |
272
+ | `scope` | `RateLimitScope` | Scope applied for the limiter. |
273
+ | `algorithm` | `RateLimitAlgorithmType` | Algorithm used for the limiter. |
274
+ | `windowId` | `string` | Present for multi-window limits. |
275
+ | `limited` | `boolean` | Whether this limiter hit its limit. |
276
+ | `remaining` | `number` | Remaining requests or capacity. |
277
+ | `resetAt` | `number` | Absolute reset timestamp in ms. |
278
+ | `retryAfter` | `number` | Delay until retry is allowed, in ms. |
279
+ | `limit` | `number` | `maxRequests` for fixed and sliding, `burst` for token and leaky buckets. |
280
+
281
+ ## Limiter resolution and role strategy
282
+
283
+ Limiter configuration is layered in this exact order, with later layers overriding earlier ones:
284
+
285
+ | Order | Source | Notes |
286
+ | --- | --- | --- |
287
+ | 1 | `DEFAULT_LIMITER` | Base defaults. |
288
+ | 2 | `defaultLimiter` | Runtime defaults. |
289
+ | 3 | Named limiter | When `metadata.ratelimit.limiter` is set. |
290
+ | 4 | Command overrides | `metadata.ratelimit` config. |
291
+ | 5 | Role override | Selected by role strategy. |
292
+
293
+ ### Limiter resolution diagram
294
+
295
+ ```mermaid
296
+ graph TD
297
+ A[DEFAULT_LIMITER] --> B[defaultLimiter]
298
+ B --> C[Named limiter]
299
+ C --> D[Command overrides]
300
+ D --> E["Role override (strategy)"]
301
+ ```
302
+
303
+ Role limits are merged in this order, with later maps overriding earlier ones for the same role id:
304
+
305
+ | Order | Source |
306
+ | --- | --- |
307
+ | 1 | Plugin `roleLimits` |
308
+ | 2 | `defaultLimiter.roleLimits` |
309
+ | 3 | Named limiter `roleLimits` |
310
+ | 4 | Command `roleLimits` |
311
+
312
+ Role strategies:
313
+
314
+ | Strategy | Selection rule |
315
+ | --- | --- |
316
+ | `highest` | Picks the role with the highest request rate (`maxRequests / intervalMs`). |
317
+ | `lowest` | Picks the role with the lowest request rate. |
318
+ | `first` | Uses insertion order of the merged role limits object. |
319
+
320
+ For multi-window limiters, the score uses the minimum rate across windows.
321
+
322
+ ## Scopes and keying
323
+
324
+ Supported scopes:
325
+
326
+ | Scope | Required IDs | Key format (without `keyPrefix`) | Skip behavior |
327
+ | --- | --- | --- | --- |
328
+ | `user` | `userId` | `rl:user:{userId}:{commandName}` | Skips if `userId` is missing. |
329
+ | `guild` | `guildId` | `rl:guild:{guildId}:{commandName}` | Skips if `guildId` is missing. |
330
+ | `channel` | `channelId` | `rl:channel:{channelId}:{commandName}` | Skips if `channelId` is missing. |
331
+ | `global` | none | `rl:global:{commandName}` | Never skipped. |
332
+ | `user-guild` | `userId`, `guildId` | `rl:user:{userId}:guild:{guildId}:{commandName}` | Skips if either id is missing. |
333
+ | `custom` | `keyResolver` | `keyResolver(ctx, command, source)` | Skips if resolver is missing or returns falsy. |
334
+
335
+ Keying notes:
336
+
337
+ - `DEFAULT_KEY_PREFIX` is always included in the base format.
338
+ - `keyPrefix` is concatenated before `rl:` as-is, so include a trailing separator if you want one.
339
+ - Multi-window limits append `:w:{windowId}`.
340
+
341
+ ### Exemption keys
342
+
343
+ Temporary exemptions are stored under `rl:exempt:{scope}:{id}` (plus optional `keyPrefix`).
344
+
345
+ | Exemption scope | Key format | Notes |
346
+ | --- | --- | --- |
347
+ | `user` | `rl:exempt:user:{userId}` | Resolved from the source user id. |
348
+ | `guild` | `rl:exempt:guild:{guildId}` | Resolved from the guild id. |
349
+ | `role` | `rl:exempt:role:{roleId}` | Resolved from all member roles. |
350
+ | `channel` | `rl:exempt:channel:{channelId}` | Resolved from the channel id. |
351
+ | `category` | `rl:exempt:category:{categoryId}` | Resolved from the parent category id. |
352
+
353
+ ## Algorithms
354
+
355
+ ### Algorithm matrix
356
+
357
+ | Algorithm | Required config | Storage requirements | `limit` value | Notes |
358
+ | --- | --- | --- | --- | --- |
359
+ | `fixed-window` | `maxRequests`, `interval` | `consumeFixedWindow` or `incr` or `get` and `set` | `maxRequests` | Fallback uses per-process lock and optimistic versioning. |
360
+ | `sliding-window` | `maxRequests`, `interval` | `consumeSlidingWindowLog` or `zRemRangeByScore` + `zCard` + `zAdd` | `maxRequests` | Throws if sorted-set support is missing. |
361
+ | `token-bucket` | `burst`, `refillRate` | `get` and `set` | `burst` | Throws if `refillRate <= 0`. |
362
+ | `leaky-bucket` | `burst`, `leakRate` | `get` and `set` | `burst` | Throws if `leakRate <= 0`. |
363
+
364
+ ### Fixed window
365
+
366
+ Execution path:
367
+
368
+ 1. If `consumeFixedWindow` exists, it is used.
369
+ 2. Else if `incr` exists, it is used.
370
+ 3. Else a fallback uses `get` and `set` with a per-process lock.
371
+
372
+ 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.
373
+
374
+ #### Fixed window fallback diagram
375
+
376
+ ```mermaid
377
+ graph TD
378
+ A[Consume fixed-window] --> B{consumeFixedWindow?}
379
+ B -- Yes --> C[Use consumeFixedWindow]
380
+ B -- No --> D{incr?}
381
+ D -- Yes --> E[Use incr]
382
+ D -- No --> F["get + set fallback (per-process lock)"]
383
+ ```
384
+
385
+ ### Sliding window log
386
+
387
+ Execution path:
388
+
389
+ 1. If `consumeSlidingWindowLog` exists, it is used (atomic).
390
+ 2. Else a sorted-set fallback uses `zRemRangeByScore`, `zCard`, and `zAdd`.
391
+
392
+ 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.
393
+
394
+ #### Sliding window fallback diagram
395
+
396
+ ```mermaid
397
+ graph TD
398
+ A[Consume sliding-window] --> B{consumeSlidingWindowLog?}
399
+ B -- Yes --> C[Use consumeSlidingWindowLog]
400
+ B -- No --> D{zset methods?}
401
+ D -- No --> E[Throw error]
402
+ D -- Yes --> F[zRemRangeByScore + zCard + zAdd fallback]
403
+ ```
404
+
405
+ ### Token bucket
406
+
407
+ 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.
408
+
409
+ ### Leaky bucket
410
+
411
+ 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.
412
+
413
+ ### Multi-window limits
414
+
415
+ Use `windows` to enforce multiple windows simultaneously:
416
+
417
+ ```ts
418
+ configureRatelimit({
419
+ defaultLimiter: {
420
+ scope: 'user',
421
+ algorithm: 'sliding-window',
422
+ windows: [
423
+ { id: 'short', maxRequests: 10, interval: '1m' },
424
+ { id: 'long', maxRequests: 1000, interval: '1d' },
425
+ ],
426
+ },
427
+ });
428
+ ```
429
+
430
+ 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.
431
+
432
+ ## Storage
433
+
434
+ ### Storage interface
435
+
436
+ Required methods:
437
+
438
+ | Method | Used by | Notes |
439
+ | --- | --- | --- |
440
+ | `get` | All algorithms | Returns stored value or `null`. |
441
+ | `set` | All algorithms | Optional `ttlMs` controls expiry. |
442
+ | `delete` | Resets and algorithm resets | Removes stored state. |
443
+
444
+ Optional methods and features:
445
+
446
+ | Method | Feature | Notes |
447
+ | --- | --- | --- |
448
+ | `consumeFixedWindow` | Fixed-window atomic consume | Used before `incr` and fallback. |
449
+ | `incr` | Fixed-window efficiency | Returns count and TTL. |
450
+ | `consumeSlidingWindowLog` | Sliding-window atomic consume | Preferred over sorted-set fallback. |
451
+ | `zAdd` / `zRemRangeByScore` / `zCard` | Sliding-window fallback | Required when `consumeSlidingWindowLog` is absent. |
452
+ | `zRangeByScore` | Sliding-window reset accuracy | Improves `resetAt` computation. |
453
+ | `ttl` | Exemption listing | Used for `expiresInMs`. |
454
+ | `expire` | Sliding-window fallback | Keeps sorted-set keys from growing indefinitely. |
455
+ | `deleteByPrefix` / `deleteByPattern` | Resets | Required by `resetAllRateLimits` and HMR. |
456
+ | `keysByPrefix` | Exemption listing | Required for listing without a specific id. |
457
+
458
+ ### Capability matrix
459
+
460
+ | Feature | Requires | Memory | Redis | Fallback |
461
+ | --- | --- | --- | --- | --- |
462
+ | Fixed-window atomic consume | `consumeFixedWindow` | Yes | Yes | Conditional (both storages) |
463
+ | Fixed-window `incr` | `incr` | Yes | Yes | Conditional (both storages) |
464
+ | Sliding-window atomic consume | `consumeSlidingWindowLog` | Yes | Yes | Conditional (both storages) |
465
+ | Sliding-window fallback | `zAdd` + `zRemRangeByScore` + `zCard` | Yes | Yes | Conditional (both storages) |
466
+ | TTL visibility | `ttl` | Yes | Yes | Conditional (both storages) |
467
+ | Prefix or pattern deletes | `deleteByPrefix` or `deleteByPattern` | Yes | Yes | Conditional (both storages) |
468
+ | Exemption listing | `keysByPrefix` | Yes | Yes | Conditional (both storages) |
469
+
470
+ ### Capability overview diagram
471
+
472
+ ```mermaid
473
+ graph TD
474
+ A[Storage API] --> B[Required: get / set / delete]
475
+ A --> C[Optional methods]
476
+ C --> D[Fixed window atomic: consumeFixedWindow / incr]
477
+ C --> E[Sliding window atomic: consumeSlidingWindowLog]
478
+ C --> F[Sliding window fallback: zAdd + zRemRangeByScore + zCard]
479
+ C --> G[Listing & TTL: keysByPrefix / ttl]
480
+ C --> H[Bulk reset: deleteByPrefix / deleteByPattern]
481
+ I[Fallback storage] --> J[Uses primary + secondary]
482
+ J --> K[Each optional method must exist on both]
483
+ ```
484
+
485
+ ### Memory storage
486
+
487
+ ```ts
488
+ import { MemoryRateLimitStorage, setRateLimitStorage } from '@commandkit/ratelimit';
489
+
490
+ setRateLimitStorage(new MemoryRateLimitStorage());
491
+ ```
492
+
493
+ Notes:
494
+
495
+ - In-memory only; not safe for multi-process deployments.
496
+ - Implements TTL and sorted-set helpers.
497
+ - `deleteByPattern` supports a simple `*` wildcard, not full glob syntax.
498
+
499
+ ### Redis storage
500
+
501
+ ```ts
502
+ import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis';
503
+ import { setRateLimitStorage } from '@commandkit/ratelimit';
504
+
505
+ setRateLimitStorage(
506
+ new RedisRateLimitStorage({ host: 'localhost', port: 6379 }),
507
+ );
508
+ ```
509
+
510
+ Notes:
511
+
512
+ - Stores values as JSON.
513
+ - Uses Lua scripts for atomic fixed and sliding windows.
514
+ - Uses `SCAN` for prefix and pattern deletes and listing.
515
+
516
+ ### Fallback storage
517
+
518
+ ```ts
519
+ import { FallbackRateLimitStorage } from '@commandkit/ratelimit/fallback';
520
+ import { MemoryRateLimitStorage } from '@commandkit/ratelimit/memory';
521
+ import { RedisRateLimitStorage } from '@commandkit/ratelimit/redis';
522
+ import { setRateLimitStorage } from '@commandkit/ratelimit';
523
+
524
+ const primary = new RedisRateLimitStorage({ host: 'localhost', port: 6379 });
525
+ const secondary = new MemoryRateLimitStorage();
526
+
527
+ setRateLimitStorage(new FallbackRateLimitStorage(primary, secondary));
528
+ ```
529
+
530
+ Notes:
531
+
532
+ - Every optional method must exist on both storages or the fallback wrapper throws.
533
+ - Primary errors are logged at most once per `cooldownMs` window (default 30s).
534
+
535
+ ## Queue mode
536
+
537
+ Queue mode retries commands instead of rejecting immediately.
538
+
539
+ ### Queue defaults and clamps
540
+
541
+ | Field | Default | Clamp | Notes |
542
+ | --- | --- | --- | --- |
543
+ | `enabled` | `true` if any queue config exists | n/a | Otherwise `false`. |
544
+ | `maxSize` | `3` | `>= 1` | Queue size is pending plus running. |
545
+ | `timeout` | `30s` | `>= 1ms` | Per queued task. |
546
+ | `deferInteraction` | `true` | n/a | Only applies to interactions. |
547
+ | `ephemeral` | `true` | n/a | Applies to deferred replies. |
548
+ | `concurrency` | `1` | `>= 1` | Per queue key. |
549
+
550
+ ### Queue flow
551
+
552
+ 1. Rate limit is evaluated and an aggregate result is computed.
553
+ 2. If limited and queueing is enabled, the plugin tries to enqueue.
554
+ 3. If the queue is full, it falls back to immediate rate-limit handling.
555
+ 4. When queued, the interaction is deferred if it is repliable and not already replied or deferred.
556
+ 5. The queued task waits `retryAfter`, then re-checks the limiter; if still limited it waits at least 250ms and retries until timeout.
557
+
558
+ ### Queue flow diagram
559
+
560
+ ```mermaid
561
+ graph TD
562
+ A[Evaluate limiter] --> B{Limited?}
563
+ B -- No --> C[Allow command]
564
+ B -- Yes --> D{Queue enabled?}
565
+ D -- No --> E[Rate-limit response]
566
+ D -- Yes --> F{Queue has capacity?}
567
+ F -- No --> E
568
+ F -- Yes --> G[Enqueue + defer if repliable]
569
+ G --> H[Wait retryAfter]
570
+ H --> I{Still limited?}
571
+ I -- No --> C
572
+ I -- Yes --> J[Wait >= 250ms]
573
+ J --> K{Timed out?}
574
+ K -- No --> H
575
+ K -- Yes --> E
576
+ ```
577
+
578
+ ## Violations and escalation
579
+
580
+ Violation escalation is stored under `violation:{key}` and uses these defaults:
581
+
582
+ | Option | Default | Meaning |
583
+ | --- | --- | --- |
584
+ | `maxViolations` | `5` | Maximum escalation steps. |
585
+ | `escalationMultiplier` | `2` | Multiplier per repeated violation. |
586
+ | `resetAfter` | `1h` | TTL for violation state. |
587
+ | `escalate` | `true` when `violations` is set | Set `false` to disable escalation. |
588
+
589
+ Formula:
590
+
591
+ `cooldown = baseRetryAfter * multiplier^(count - 1)`
592
+
593
+ If escalation produces a later `resetAt` than the algorithm returned, the result is updated so `resetAt` and `retryAfter` stay accurate.
594
+
595
+ ## Bypass and exemptions
596
+
597
+ Bypass order is always:
598
+
599
+ 1. `bypass.userIds`, `bypass.guildIds`, and `bypass.roleIds`.
600
+ 2. Temporary exemptions stored in storage.
601
+ 3. `bypass.check(source)`.
602
+
603
+ Bypass example:
604
+
605
+ ```ts
606
+ configureRatelimit({
607
+ bypass: {
608
+ userIds: ['USER_ID'],
609
+ guildIds: ['GUILD_ID'],
610
+ roleIds: ['ROLE_ID'],
611
+ check: (source) => source.channelId === 'ALLOWLIST_CHANNEL',
612
+ },
613
+ });
614
+ ```
615
+
616
+ Temporary exemptions:
617
+
618
+ ```ts
619
+ import { grantRateLimitExemption } from '@commandkit/ratelimit';
620
+
621
+ await grantRateLimitExemption({
622
+ scope: 'user',
623
+ id: 'USER_ID',
624
+ duration: '1h',
625
+ });
626
+ ```
627
+
628
+ Listing behavior:
629
+
630
+ - `listRateLimitExemptions({ scope, id })` reads a single key directly.
631
+ - `listRateLimitExemptions({ scope })` scans by prefix and requires `keysByPrefix`.
632
+ - `expiresInMs` is `null` when `ttl` is not supported.
633
+
634
+ ## Responses, hooks, and events
635
+
636
+ ### Default response behavior
637
+
638
+ | Source | Conditions | Action |
639
+ | --- | --- | --- |
640
+ | Message | Channel is sendable | `reply()` with cooldown embed. |
641
+ | Interaction | Repliable and not replied/deferred | `reply()` with ephemeral cooldown embed. |
642
+ | Interaction | Repliable and already replied/deferred | `followUp()` with ephemeral cooldown embed. |
643
+ | Interaction | Not repliable | No response. |
644
+
645
+ The default embed title is `:hourglass_flowing_sand: You are on cooldown` and the description uses a relative timestamp based on `resetAt`.
646
+
647
+ ### Hooks
648
+
649
+ | Hook | Called when | Notes |
650
+ | --- | --- | --- |
651
+ | `onAllowed` | Command is allowed | Receives the first result. |
652
+ | `onRateLimited` | Command is limited | Receives the first limited result. |
653
+ | `onViolation` | A violation is recorded | Receives key and violation count. |
654
+ | `onReset` | `resetRateLimit` succeeds | Not called by `resetAllRateLimits`. |
655
+ | `onStorageError` | Storage operation fails | `fallbackUsed` is `false` in runtime plugin paths. |
656
+
657
+ ### Analytics events
658
+
659
+ The runtime plugin calls `ctx.commandkit.analytics.track(...)` with:
660
+
661
+ | Event name | When |
662
+ | --- | --- |
663
+ | `ratelimit_allowed` | After an allowed consume. |
664
+ | `ratelimit_hit` | After a limited consume. |
665
+ | `ratelimit_violation` | When escalation records a violation. |
666
+
667
+ ### Event bus
668
+
669
+ A `ratelimited` event is emitted on the `ratelimits` channel:
670
+
671
+ ```ts
672
+ commandkit.events
673
+ .to('ratelimits')
674
+ .on('ratelimited', ({ key, result, source, aggregate, commandName, queued }) => {
675
+ console.log(key, commandName, queued, aggregate.retryAfter);
676
+ });
677
+ ```
678
+
679
+ Payload fields include `key`, `result`, `source`, `aggregate`, `commandName`, and `queued`.
680
+
681
+ ## Resets and HMR
682
+
683
+ ### `resetRateLimit`
684
+
685
+ `resetRateLimit` clears the base key, its `violation:` key, and any window variants. It accepts either a raw `key` or a scope-derived key.
686
+
687
+ | Mode | Required params | Notes |
688
+ | --- | --- | --- |
689
+ | Direct | `key` | Resets `key`, `violation:key`, and window variants. |
690
+ | Scoped | `scope` + `commandName` + required ids | Throws if identifiers are missing. |
691
+
692
+ ### `resetAllRateLimits`
693
+
694
+ `resetAllRateLimits` supports several modes and requires storage delete helpers:
695
+
696
+ | Mode | Required params | Storage requirement |
697
+ | --- | --- | --- |
698
+ | Pattern | `pattern` | `deleteByPattern` |
699
+ | Prefix | `prefix` | `deleteByPrefix` |
700
+ | Command name | `commandName` | `deleteByPattern` |
701
+ | Scope | `scope` + required ids | `deleteByPrefix` |
702
+
703
+ ### HMR reset behavior
704
+
705
+ When a command file is hot-reloaded, the plugin deletes keys that match:
706
+
707
+ - `*:{commandName}`
708
+ - `violation:*:{commandName}`
709
+ - `*:{commandName}:w:*`
710
+ - `violation:*:{commandName}:w:*`
711
+
712
+ HMR reset requires `deleteByPattern`. If the storage does not support pattern deletes, nothing is cleared.
713
+
714
+ ## Directive: `use ratelimit`
715
+
716
+ The compiler plugin (`UseRateLimitDirectivePlugin`) uses `CommonDirectiveTransformer` with `directive = "use ratelimit"` and `importName = "$ckitirl"`. It transforms async functions only.
717
+
718
+ The runtime wrapper:
719
+
720
+ - Uses the runtime default limiter (merged with `DEFAULT_LIMITER`).
721
+ - Generates a per-function key `rl:fn:{uuid}` and applies `keyPrefix` if present.
722
+ - Aggregates results across windows and throws `RateLimitError` when limited.
723
+ - Caches the wrapper per function and exposes it as `globalThis.$ckitirl`.
724
+
725
+ Example:
726
+
727
+ ```ts
728
+ import { RateLimitError } from '@commandkit/ratelimit';
729
+
730
+ const heavy = async () => {
731
+ 'use ratelimit';
732
+ return 'ok';
733
+ };
734
+
735
+ try {
736
+ await heavy();
737
+ } catch (error) {
738
+ if (error instanceof RateLimitError) {
739
+ console.log(error.result.retryAfter);
740
+ }
741
+ }
742
+ ```
743
+
744
+ ## Defaults and edge cases
745
+
746
+ ### Defaults
747
+
748
+ | Setting | Default |
749
+ | --- | --- |
750
+ | `maxRequests` | `10` |
751
+ | `interval` | `60s` |
752
+ | `algorithm` | `fixed-window` |
753
+ | `scope` | `user` |
754
+ | `DEFAULT_KEY_PREFIX` | `rl:` |
755
+ | `RATELIMIT_STORE_KEY` | `ratelimit` |
756
+ | `roleLimitStrategy` | `highest` |
757
+ | `queue.maxSize` | `3` |
758
+ | `queue.timeout` | `30s` |
759
+ | `queue.deferInteraction` | `true` |
760
+ | `queue.ephemeral` | `true` |
761
+ | `queue.concurrency` | `1` |
762
+ | `initializeDefaultStorage` | `true` |
763
+
764
+ ### Edge cases
765
+
766
+ 1. If no storage is configured and default storage is disabled, the plugin logs once and stores an empty result without limiting.
767
+ 2. If no scope key can be resolved, the plugin stores an empty result and skips limiting.
768
+ 3. If storage errors occur during consume, `onStorageError` is invoked and the plugin skips limiting for that execution.
769
+ 4. For token and leaky buckets, `limit` equals `burst`. For fixed and sliding windows, `limit` equals `maxRequests`.
770
+
771
+ ## Duration parsing
772
+
773
+ `DurationLike` accepts numbers (milliseconds) or strings parsed by `ms`, plus custom units for weeks and months.
774
+
775
+ | Unit | Meaning |
776
+ | --- | --- |
777
+ | `ms`, `s`, `m`, `h`, `d` | Standard `ms` units. |
778
+ | `w`, `week`, `weeks` | 7 days. |
779
+ | `mo`, `month`, `months` | 30 days. |
780
+
781
+ ## Exports
782
+
783
+ | Export | Description |
784
+ | --- | --- |
785
+ | `ratelimit` | Plugin factory returning compiler + runtime plugins. |
786
+ | `RateLimitPlugin` | Runtime plugin class. |
787
+ | `UseRateLimitDirectivePlugin` | Compiler plugin for `use ratelimit`. |
788
+ | `RateLimitEngine` | Algorithm coordinator with escalation handling. |
789
+ | Algorithm classes | Fixed, sliding, token bucket, and leaky bucket implementations. |
790
+ | Storage classes | Memory, Redis, and fallback storage. |
791
+ | Runtime helpers | `configureRatelimit`, `setRateLimitStorage`, `getRateLimitRuntime`, and more. |
792
+ | API helpers | `getRateLimitInfo`, resets, and exemption helpers. |
793
+ | `RateLimitError` | Error thrown by the directive wrapper. |
794
+
795
+ Subpath exports:
796
+
797
+ - `@commandkit/ratelimit/redis`
798
+ - `@commandkit/ratelimit/memory`
799
+ - `@commandkit/ratelimit/fallback`
800
+
801
+