@clov-std/elysia-ratelimit 1.2.2 → 3.1.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,40 @@
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
+ status: t.Literal(429, {
17
+ description: "HTTP status code, repeated in the body per RFC 9457."
18
+ }),
19
+ detail: t.Optional(t.String({
20
+ description: "Explanation specific to this occurrence."
21
+ }))
22
+ });
23
+
24
+ class RateLimitException extends Exception {
25
+ status = 429;
26
+ response = {
27
+ type: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
28
+ title: "Too Many Requests",
29
+ status: 429
30
+ };
31
+ constructor(cause) {
32
+ super("Too Many Requests", {
33
+ key: RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED,
34
+ cause
35
+ });
36
+ }
37
+ }
21
38
  var extractClientIp = (request, server) => {
22
39
  const forwarded = request.headers.get("x-forwarded-for");
23
40
  if (forwarded) {
@@ -27,16 +44,9 @@ var extractClientIp = (request, server) => {
27
44
  }
28
45
  return request.headers.get("x-real-ip") ?? server?.requestIP(request)?.address ?? "127.0.0.1";
29
46
  };
30
- var rateLimitPlugin = (store = new MemoryStore) => new Elysia({
31
- name: "rateLimitPlugin",
32
- seed: store
33
- }).model({
34
- RateLimitError: rateLimitContract[429]
35
- }).macro({
47
+ var rateLimitPlugin = (store = new MemoryStore) => new Elysia().macro({
36
48
  rateLimit: ({ limit, window, keyGenerator }) => ({
37
- response: {
38
- 429: "RateLimitError"
39
- },
49
+ response: { 429: rateLimitResponseSchema },
40
50
  transform: async ({ set, request, server }) => {
41
51
  const route = `${request.method}:${new URL(request.url).pathname}`;
42
52
  const ip = extractClientIp(request, server);
@@ -46,24 +56,20 @@ var rateLimitPlugin = (store = new MemoryStore) => new Elysia({
46
56
  if (count === 1)
47
57
  await store.expire(key, window);
48
58
  const remaining = Math.max(0, limit - count);
49
- const resetTime = await store.ttl(key);
59
+ const reset = await store.ttl(key);
50
60
  set.headers["X-RateLimit-Limit"] = limit.toString();
51
61
  set.headers["X-RateLimit-Remaining"] = remaining.toString();
52
- set.headers["X-RateLimit-Reset"] = resetTime.toString();
62
+ set.headers["X-RateLimit-Reset"] = reset.toString();
53
63
  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
- });
64
+ set.headers["content-type"] = "application/problem+json";
65
+ throw new RateLimitException({ limit, window, reset });
60
66
  }
61
67
  }
62
68
  })
63
69
  });
64
70
  export {
65
71
  rateLimitPlugin,
66
- rateLimitContract,
67
72
  extractClientIp,
73
+ RateLimitException,
68
74
  RATE_LIMIT_ERROR_KEYS
69
75
  };
@@ -1,10 +1,49 @@
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, following RFC 9457.
19
+ */
20
+ declare const rateLimitResponseSchema: import("typebox").TObject<{
21
+ type: import("typebox").TString;
22
+ title: import("typebox").TString;
23
+ status: import("typebox").TLiteral<429>;
24
+ detail: import("typebox").TOptional<import("typebox").TString>;
25
+ }>;
26
+ /**
27
+ * Thrown when a caller exceeds its allowance.
28
+ *
29
+ * @see {@link rateLimitResponseSchema}
30
+ */
31
+ export declare class RateLimitException extends Exception<RateLimitExceeded> {
32
+ /** HTTP status used by Elysia's error fallback. */
33
+ readonly status = 429;
34
+ /** Default body used by Elysia's error fallback. */
35
+ readonly response: {
36
+ readonly type: string;
37
+ readonly title: string;
38
+ readonly status: 429;
39
+ };
40
+ /**
41
+ * Creates a new rate limit exception.
42
+ *
43
+ * @param cause - Counter state at the moment the limit was exceeded.
44
+ */
45
+ constructor(cause: RateLimitExceeded);
46
+ }
8
47
  export interface RateLimitKeyContext {
9
48
  request: Request;
10
49
  server: Server<unknown> | null;
@@ -15,38 +54,37 @@ export interface RateLimitMacroOptions {
15
54
  window: number;
16
55
  keyGenerator?: (context: RateLimitKeyContext) => string;
17
56
  }
57
+ interface RateLimitTransformContext {
58
+ set: {
59
+ headers: HTTPHeaders;
60
+ };
61
+ request: Request;
62
+ server: Server<unknown> | null;
63
+ }
64
+ type RateLimitMacro = (options: RateLimitMacroOptions) => {
65
+ response: {
66
+ 429: typeof rateLimitResponseSchema;
67
+ };
68
+ transform: (context: RateLimitTransformContext) => Promise<void>;
69
+ };
18
70
  export declare const extractClientIp: (request: Request, server: Server<unknown> | null) => string;
19
- export declare const rateLimitPlugin: (store?: KvStore) => Elysia<"rateLimitPlugin", {
71
+ export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'', 'local', {
20
72
  decorator: {};
21
73
  derive: {};
22
- resolve: {};
23
74
  store: {};
24
75
  }, {
25
- typebox: {
26
- RateLimitError: (typeof rateLimitContract)[429];
27
- };
28
- error: {};
76
+ typebox: {};
77
+ error: [];
29
78
  }, {
79
+ schema: {};
80
+ schemas: {};
30
81
  macro: Partial<{
31
82
  readonly rateLimit: RateLimitMacroOptions;
32
83
  }>;
33
84
  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
- };
85
+ rateLimit: RateLimitMacro;
47
86
  };
48
87
  parser: {};
49
88
  response: {};
50
- schema: {};
51
- standaloneSchema: {};
52
89
  }>;
90
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clov-std/elysia-ratelimit",
3
- "version": "1.2.2",
3
+ "version": "3.1.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": "^2.3.2",
44
- "@clov-std/kv-store": "^1.1.1",
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
47
  "@types/bun": "^1.3.14",
49
- "oxfmt": "0.56.0",
50
- "oxlint": "1.71.0",
51
- "oxlint-tsgolint": "0.23.0",
52
- "typescript": "^6.0.3"
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": "^2.3.2",
56
- "@clov-std/kv-store": "^1.1.1",
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
- };