@clov-std/elysia-ratelimit 1.2.1 → 3.0.0

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.
package/README.md CHANGED
@@ -22,6 +22,7 @@ Storage is handled by `@clov-std/kv-store`, so you start with in-memory and move
22
22
  - [Features](#-features)
23
23
  - [Installation](#-installation)
24
24
  - [Usage](#-usage)
25
+ - [Error handling](#-error-handling)
25
26
  - [API Reference](#-api-reference)
26
27
  - [License](#-license)
27
28
  - [Contact](#-contact)
@@ -33,14 +34,15 @@ Storage is handled by `@clov-std/kv-store`, so you start with in-memory and move
33
34
  - 🗃️ **KvStore-agnostic** : Works with `MemoryStore` out of the box; swap in `BunRedisStore` or your own adapter.
34
35
  - ⚡ **Early rejection** : Runs in `transform`, the first per-route hook, before auth guards and handlers.
35
36
  - 📡 **Standard headers** : Automatically sets `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset`.
37
+ - 🧯 **Works with or without an error plugin** : Answers a real `429` on its own, and steps aside when your application handles the error itself.
36
38
 
37
39
  ## 🔧 Installation
38
40
 
39
41
  ```bash
40
- bun add @clov-std/elysia-ratelimit
42
+ bun add @clov-std/elysia-ratelimit elysia typebox
41
43
  ```
42
44
 
43
- > **Peer dependencies:** `elysia` must be installed alongside.
45
+ > **Peer dependencies:** `elysia` (v2) and `typebox` must be installed alongside.
44
46
 
45
47
  ## ⚙️ Usage
46
48
 
@@ -54,9 +56,11 @@ import { Elysia } from 'elysia';
54
56
 
55
57
  new Elysia()
56
58
  .use(rateLimitPlugin())
57
- .post('/auth/login', () => authenticate(), {
58
- rateLimit: { limit: 10, window: 60 } // 10 requests per minute per IP
59
- })
59
+ .post(
60
+ '/auth/login',
61
+ { rateLimit: { limit: 10, window: 60 } }, // 10 requests per minute per IP
62
+ () => authenticate()
63
+ )
60
64
  .listen(3000);
61
65
  ```
62
66
 
@@ -73,9 +77,7 @@ const store = new BunRedisStore('redis://localhost:6379');
73
77
 
74
78
  new Elysia()
75
79
  .use(rateLimitPlugin(store))
76
- .post('/auth/login', () => authenticate(), {
77
- rateLimit: { limit: 10, window: 60 }
78
- })
80
+ .post('/auth/login', { rateLimit: { limit: 10, window: 60 } }, () => authenticate())
79
81
  .listen(3000);
80
82
  ```
81
83
 
@@ -90,18 +92,77 @@ import { Elysia } from 'elysia';
90
92
 
91
93
  new Elysia()
92
94
  .use(rateLimitPlugin())
93
- .get('/api/data', () => getData(), {
94
- rateLimit: {
95
- limit: 100,
96
- window: 60,
97
- keyGenerator: ({ ip, request }) => `${ip}:${request.headers.get('authorization') ?? ip}`
98
- }
99
- })
95
+ .get(
96
+ '/api/data',
97
+ {
98
+ rateLimit: {
99
+ limit: 100,
100
+ window: 60,
101
+ keyGenerator: ({ ip, request }) =>
102
+ `${ip}:${request.headers.get('authorization') ?? ip}`
103
+ }
104
+ },
105
+ () => getData()
106
+ )
100
107
  .listen(3000);
101
108
  ```
102
109
 
103
110
  > **Tip:** `extractClientIp` is exported if you need it inside your own `keyGenerator`.
104
111
 
112
+ ## 🧯 Error handling
113
+
114
+ When the allowance is exceeded, the plugin throws a `RateLimitException` carrying:
115
+
116
+ - `key` — always `RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED`, i.e. `'elysia-ratelimit.exceeded'`
117
+ - `cause` — `{ limit, window, reset }`, the counter state at that moment
118
+ - `status` and `response` — the default answer, read by Elysia's error fallback
119
+
120
+ ### Without an error plugin
121
+
122
+ The default applies and the route answers a real `429`, which is also the response the macro
123
+ declares for OpenAPI:
124
+
125
+ ```json
126
+ { "type": "elysia-ratelimit.exceeded", "title": "Too Many Requests" }
127
+ ```
128
+
129
+ The `X-RateLimit-*` headers are set before the throw, so they are present on the `429` as well.
130
+
131
+ ### With an error plugin
132
+
133
+ That default is only a fallback. Any `.error()` handler your application registers runs first and
134
+ wins, which is how an API answers with its own localized body.
135
+
136
+ ```ts
137
+ import { RateLimitException, rateLimitPlugin } from '@clov-std/elysia-ratelimit';
138
+ import { Elysia, status } from 'elysia';
139
+
140
+ new Elysia()
141
+ .error(RateLimitException, ({ error }) =>
142
+ status(429, {
143
+ type: 'request.rateLimitExceeded',
144
+ title: 'Too many requests. Please try again later.',
145
+ detail: `Retry in ${error.cause?.reset ?? 0}s.`
146
+ })
147
+ )
148
+ .use(rateLimitPlugin())
149
+ .get('/api/data', { rateLimit: { limit: 100, window: 60 } }, () => getData())
150
+ .listen(3000);
151
+ ```
152
+
153
+ Matching on the key works too, if you would rather not import the class:
154
+
155
+ ```ts
156
+ .error(({ error }) =>
157
+ (error as { key?: string }).key === RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED
158
+ ? status(429, { type: 'request.rateLimitExceeded', title: 'Too many requests.' })
159
+ : status(500, { type: 'internal', title: 'Internal Server Error' })
160
+ )
161
+ ```
162
+
163
+ > **Note:** Elysia runs the first matching `.error()` handler. If your error plugin registers a
164
+ > catch-all, register it after the specific handlers, otherwise it swallows them.
165
+
105
166
  ## 📚 API Reference
106
167
 
107
168
  Full docs: [https://clovlabs.github.io/std/](https://clovlabs.github.io/std/)
package/dist/index.d.ts CHANGED
@@ -1,2 +1 @@
1
- export { extractClientIp, RATE_LIMIT_ERROR_KEYS, rateLimitPlugin, type RateLimitKeyContext, type RateLimitMacroOptions } from './rate-limit';
2
- export { rateLimitContract } from './rate-limit.contract';
1
+ export { RATE_LIMIT_ERROR_KEYS, RateLimitException, extractClientIp, rateLimitPlugin, type RateLimitExceeded, type RateLimitKeyContext, type RateLimitMacroOptions } from './rate-limit';
package/dist/index.js CHANGED
@@ -1,23 +1,36 @@
1
1
  // @bun
2
2
  // src/rate-limit.ts
3
- import { HttpException } from "@clov-std/error";
3
+ import { Exception } from "@clov-std/error";
4
4
  import { MemoryStore } from "@clov-std/kv-store";
5
- import { Elysia } from "elysia";
6
-
7
- // src/rate-limit.contract.ts
8
- import { t } from "elysia";
9
- var rateLimitContract = {
10
- 429: t.Object({
11
- message: t.String({
12
- description: "Error message indicating that the rate limit has been exceeded."
13
- })
14
- })
15
- };
16
-
17
- // src/rate-limit.ts
5
+ import { Elysia, t } from "elysia";
18
6
  var RATE_LIMIT_ERROR_KEYS = {
19
7
  RATE_LIMIT_EXCEEDED: "elysia-ratelimit.exceeded"
20
8
  };
9
+ var rateLimitResponseSchema = t.Object({
10
+ type: t.String({
11
+ description: `Error key. Defaults to \`${RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED}\`.`
12
+ }),
13
+ title: t.String({
14
+ description: "Short, human-readable summary of the error."
15
+ }),
16
+ detail: t.Optional(t.String({
17
+ description: "Explanation specific to this occurrence."
18
+ }))
19
+ });
20
+
21
+ class RateLimitException extends Exception {
22
+ status = 429;
23
+ response = {
24
+ type: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
25
+ title: "Too Many Requests"
26
+ };
27
+ constructor(cause) {
28
+ super("Too Many Requests", {
29
+ key: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
30
+ cause
31
+ });
32
+ }
33
+ }
21
34
  var extractClientIp = (request, server) => {
22
35
  const forwarded = request.headers.get("x-forwarded-for");
23
36
  if (forwarded) {
@@ -27,16 +40,9 @@ var extractClientIp = (request, server) => {
27
40
  }
28
41
  return request.headers.get("x-real-ip") ?? server?.requestIP(request)?.address ?? "127.0.0.1";
29
42
  };
30
- var rateLimitPlugin = (store = new MemoryStore) => new Elysia({
31
- name: "rateLimitPlugin",
32
- seed: store
33
- }).model({
34
- RateLimitError: rateLimitContract[429]
35
- }).macro({
43
+ var rateLimitPlugin = (store = new MemoryStore) => new Elysia().macro({
36
44
  rateLimit: ({ limit, window, keyGenerator }) => ({
37
- response: {
38
- 429: "RateLimitError"
39
- },
45
+ response: { 429: rateLimitResponseSchema },
40
46
  transform: async ({ set, request, server }) => {
41
47
  const route = `${request.method}:${new URL(request.url).pathname}`;
42
48
  const ip = extractClientIp(request, server);
@@ -46,24 +52,18 @@ var rateLimitPlugin = (store = new MemoryStore) => new Elysia({
46
52
  if (count === 1)
47
53
  await store.expire(key, window);
48
54
  const remaining = Math.max(0, limit - count);
49
- const resetTime = await store.ttl(key);
55
+ const reset = await store.ttl(key);
50
56
  set.headers["X-RateLimit-Limit"] = limit.toString();
51
57
  set.headers["X-RateLimit-Remaining"] = remaining.toString();
52
- set.headers["X-RateLimit-Reset"] = resetTime.toString();
53
- if (count > limit) {
54
- set.status = 429;
55
- throw new HttpException("Too Many Requests", {
56
- key: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
57
- cause: { limit, window, remaining: 0, reset: resetTime },
58
- status: 429
59
- });
60
- }
58
+ set.headers["X-RateLimit-Reset"] = reset.toString();
59
+ if (count > limit)
60
+ throw new RateLimitException({ limit, window, reset });
61
61
  }
62
62
  })
63
63
  });
64
64
  export {
65
65
  rateLimitPlugin,
66
- rateLimitContract,
67
66
  extractClientIp,
67
+ RateLimitException,
68
68
  RATE_LIMIT_ERROR_KEYS
69
69
  };
@@ -1,10 +1,55 @@
1
+ import { Exception } from '@clov-std/error';
1
2
  import { type KvStore } from '@clov-std/kv-store';
2
3
  import type { Server } from 'bun';
3
- import { Elysia, type HTTPHeaders, type StatusMap } from 'elysia';
4
- import { rateLimitContract } from './rate-limit.contract';
4
+ import { Elysia, type HTTPHeaders } from 'elysia';
5
5
  export declare const RATE_LIMIT_ERROR_KEYS: {
6
- readonly RATE_LIMIT_EXCEEDED: "elysia-ratelimit.exceeded";
6
+ readonly RATE_LIMIT_EXCEEDED: 'elysia-ratelimit.exceeded';
7
7
  };
8
+ /** Counter state at the moment the limit was exceeded, attached as the exception `cause`. */
9
+ export interface RateLimitExceeded {
10
+ /** Maximum requests allowed within the window. */
11
+ readonly limit: number;
12
+ /** Window length in seconds. */
13
+ readonly window: number;
14
+ /** Seconds until the counter resets. */
15
+ readonly reset: number;
16
+ }
17
+ /**
18
+ * Shape of the `429` body.
19
+ *
20
+ * `type` is a plain string, not a literal: an application error handler is free to answer
21
+ * with its own key (a localized catalog entry, for instance). `detail` is optional for the
22
+ * same reason, the plugin never sets it.
23
+ */
24
+ declare const rateLimitResponseSchema: import("typebox").TObject<{
25
+ type: import("typebox").TString;
26
+ title: import("typebox").TString;
27
+ detail: import("typebox").TOptional<import("typebox").TString>;
28
+ }>;
29
+ /**
30
+ * Thrown when a caller exceeds its allowance.
31
+ *
32
+ * `status` and `response` are read by Elysia's error fallback, so an application that
33
+ * installs no error handler still answers a real `429` with the documented body. They are
34
+ * only a default: any `.error()` handler registered by the application runs first and wins,
35
+ * which is what lets an API localize this error from its own catalog. Match on
36
+ * {@link Exception.key}, not on the message.
37
+ */
38
+ export declare class RateLimitException extends Exception<RateLimitExceeded> {
39
+ /** HTTP status used by Elysia's error fallback. */
40
+ readonly status = 429;
41
+ /** Default body used by Elysia's error fallback. */
42
+ readonly response: {
43
+ readonly type: string;
44
+ readonly title: string;
45
+ };
46
+ /**
47
+ * Creates a new rate limit exception.
48
+ *
49
+ * @param cause - Counter state at the moment the limit was exceeded.
50
+ */
51
+ constructor(cause: RateLimitExceeded);
52
+ }
8
53
  export interface RateLimitKeyContext {
9
54
  request: Request;
10
55
  server: Server<unknown> | null;
@@ -15,38 +60,37 @@ export interface RateLimitMacroOptions {
15
60
  window: number;
16
61
  keyGenerator?: (context: RateLimitKeyContext) => string;
17
62
  }
63
+ interface RateLimitTransformContext {
64
+ set: {
65
+ headers: HTTPHeaders;
66
+ };
67
+ request: Request;
68
+ server: Server<unknown> | null;
69
+ }
70
+ type RateLimitMacro = (options: RateLimitMacroOptions) => {
71
+ response: {
72
+ 429: typeof rateLimitResponseSchema;
73
+ };
74
+ transform: (context: RateLimitTransformContext) => Promise<void>;
75
+ };
18
76
  export declare const extractClientIp: (request: Request, server: Server<unknown> | null) => string;
19
- export declare const rateLimitPlugin: (store?: KvStore) => Elysia<"rateLimitPlugin", {
77
+ export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'', 'local', {
20
78
  decorator: {};
21
79
  derive: {};
22
- resolve: {};
23
80
  store: {};
24
81
  }, {
25
- typebox: {
26
- RateLimitError: (typeof rateLimitContract)[429];
27
- };
28
- error: {};
82
+ typebox: {};
83
+ error: [];
29
84
  }, {
85
+ schema: {};
86
+ schemas: {};
30
87
  macro: Partial<{
31
88
  readonly rateLimit: RateLimitMacroOptions;
32
89
  }>;
33
90
  macroFn: {
34
- rateLimit: (options: RateLimitMacroOptions) => {
35
- response: {
36
- 429: "RateLimitError";
37
- };
38
- transform: ({ set, request, server }: {
39
- set: {
40
- headers: HTTPHeaders;
41
- status?: number | keyof StatusMap;
42
- };
43
- request: Request;
44
- server: Server<unknown> | null;
45
- }) => Promise<void>;
46
- };
91
+ rateLimit: RateLimitMacro;
47
92
  };
48
93
  parser: {};
49
94
  response: {};
50
- schema: {};
51
- standaloneSchema: {};
52
95
  }>;
96
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clov-std/elysia-ratelimit",
3
- "version": "1.2.1",
3
+ "version": "3.0.0",
4
4
  "description": "Rate limiting plugin for Elysia framework.",
5
5
  "keywords": [
6
6
  "bun",
@@ -40,20 +40,20 @@
40
40
  "test": "bun test --pass-with-no-tests --coverage"
41
41
  },
42
42
  "dependencies": {
43
- "@clov-std/error": "workspace:^",
44
- "@clov-std/kv-store": "workspace:^",
45
- "elysia": "^1.4.29"
43
+ "@clov-std/error": "^4.0.0",
44
+ "@clov-std/kv-store": "^1.1.2"
46
45
  },
47
46
  "devDependencies": {
48
- "@types/bun": "catalog:base",
49
- "oxfmt": "catalog:base",
50
- "oxlint": "catalog:base",
51
- "oxlint-tsgolint": "catalog:base",
52
- "typescript": "catalog:base"
47
+ "@types/bun": "^1.3.14",
48
+ "oxfmt": "0.61.0",
49
+ "oxlint": "1.76.0",
50
+ "oxlint-tsgolint": "7.0.2001",
51
+ "typescript": "^7.0.2"
53
52
  },
54
53
  "peerDependencies": {
55
- "@clov-std/error": "workspace:^",
56
- "@clov-std/kv-store": "workspace:^",
57
- "elysia": "^1.4.29"
54
+ "@clov-std/error": "^4.0.0",
55
+ "@clov-std/kv-store": "^1.1.2",
56
+ "elysia": "^2.0.0-beta.1",
57
+ "typebox": "^1.3.10"
58
58
  }
59
59
  }
@@ -1,5 +0,0 @@
1
- export declare const rateLimitContract: {
2
- 429: import("@sinclair/typebox").TObject<{
3
- message: import("@sinclair/typebox").TString;
4
- }>;
5
- };