@clov-std/elysia-ratelimit 3.1.1 → 4.0.1

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
@@ -34,7 +34,7 @@ Storage is handled by `@clov-std/kv-store`, so you start with in-memory and move
34
34
  - 🗃️ **KvStore-agnostic** : Works with `MemoryStore` out of the box; swap in `BunRedisStore` or your own adapter.
35
35
  - ⚡ **Early rejection** : Runs in `transform`, the first per-route hook, before auth guards and handlers.
36
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.
37
+ - 🧯 **Complete RFC 9457 `429`** : Answers a real problem document on its own, `detail` included, with no exception class to import.
38
38
 
39
39
  ## 🔧 Installation
40
40
 
@@ -111,57 +111,24 @@ new Elysia()
111
111
 
112
112
  ## 🧯 Error handling
113
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:
114
+ When the allowance is exceeded, the plugin throws Elysia's `problem()`, so the `429` is a complete
115
+ RFC 9457 document with no exception class and no error dependency involved:
124
116
 
125
117
  ```json
126
- { "type": "elysia-ratelimit.exceeded", "title": "Too Many Requests" }
118
+ {
119
+ "type": "rate-limit.quota.exceeded",
120
+ "title": "Too Many Requests",
121
+ "status": 429,
122
+ "detail": "Limit of 100 requests per 60s exceeded. Retry in 42s."
123
+ }
127
124
  ```
128
125
 
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
- ```
126
+ `type` is always `RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED`. `title` is derived by Elysia from the
127
+ status reason phrase. `detail` carries the live counter state, so it changes per occurrence.
128
+ The content type is `application/problem+json`, and the `X-RateLimit-*` headers are set before
129
+ the throw so they survive on the `429`.
162
130
 
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.
131
+ This is also the response the macro declares for OpenAPI, so a typed client sees it.
165
132
 
166
133
  ## 📚 API Reference
167
134
 
package/dist/index.d.ts CHANGED
@@ -1 +1 @@
1
- export { RATE_LIMIT_ERROR_KEYS, RateLimitException, extractClientIp, rateLimitPlugin, type RateLimitExceeded, type RateLimitKeyContext, type RateLimitMacroOptions } from './rate-limit';
1
+ export { RATE_LIMIT_ERROR_CODES, extractClientIp, rateLimitPlugin, type RateLimitKeyContext, type RateLimitMacroOptions } from './rate-limit';
package/dist/index.js CHANGED
@@ -1,14 +1,13 @@
1
1
  // @bun
2
2
  // src/rate-limit.ts
3
- import { Exception } from "@clov-std/error";
4
3
  import { MemoryStore } from "@clov-std/kv-store";
5
- import { Elysia, t } from "elysia";
6
- var RATE_LIMIT_ERROR_KEYS = {
7
- RATE_LIMIT_EXCEEDED: "elysia-ratelimit.exceeded"
4
+ import { Elysia, problem, t } from "elysia";
5
+ var RATE_LIMIT_ERROR_CODES = {
6
+ QUOTA_EXCEEDED: "rate-limit.quota.exceeded"
8
7
  };
9
8
  var rateLimitResponseSchema = t.Object({
10
9
  type: t.String({
11
- description: `Error key. Defaults to \`${RATE_LIMIT_ERROR_KEYS.RATE_LIMIT_EXCEEDED}\`.`
10
+ description: `Error code. Always \`${RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED}\`.`
12
11
  }),
13
12
  title: t.String({
14
13
  description: "Short, human-readable summary of the error."
@@ -16,25 +15,10 @@ var rateLimitResponseSchema = t.Object({
16
15
  status: t.Literal(429, {
17
16
  description: "HTTP status code, repeated in the body per RFC 9457."
18
17
  }),
19
- detail: t.Optional(t.String({
20
- description: "Explanation specific to this occurrence."
21
- }))
18
+ detail: t.String({
19
+ description: "Explanation specific to this occurrence, including when to retry."
20
+ })
22
21
  });
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
- }
38
22
  var extractClientIp = (request, server) => {
39
23
  const forwarded = request.headers.get("x-forwarded-for");
40
24
  if (forwarded) {
@@ -44,7 +28,7 @@ var extractClientIp = (request, server) => {
44
28
  }
45
29
  return request.headers.get("x-real-ip") ?? server?.requestIP(request)?.address ?? "127.0.0.1";
46
30
  };
47
- var rateLimitPlugin = (store = new MemoryStore) => new Elysia().macro({
31
+ var rateLimitPlugin = (store = new MemoryStore) => new Elysia({ name: "rateLimitPlugin" }).macro({
48
32
  rateLimit: ({
49
33
  limit,
50
34
  window,
@@ -64,16 +48,17 @@ var rateLimitPlugin = (store = new MemoryStore) => new Elysia().macro({
64
48
  set.headers["X-RateLimit-Limit"] = limit.toString();
65
49
  set.headers["X-RateLimit-Remaining"] = remaining.toString();
66
50
  set.headers["X-RateLimit-Reset"] = reset.toString();
67
- if (count > limit) {
68
- set.headers["content-type"] = "application/problem+json";
69
- throw new RateLimitException({ limit, window, reset });
70
- }
51
+ if (count > limit)
52
+ throw problem({
53
+ type: RATE_LIMIT_ERROR_CODES.QUOTA_EXCEEDED,
54
+ status: 429,
55
+ detail: `Limit of ${limit} requests per ${window}s exceeded. Retry in ${reset}s.`
56
+ });
71
57
  }
72
58
  })
73
59
  });
74
60
  export {
75
61
  rateLimitPlugin,
76
62
  extractClientIp,
77
- RateLimitException,
78
- RATE_LIMIT_ERROR_KEYS
63
+ RATE_LIMIT_ERROR_CODES
79
64
  };
@@ -1,50 +1,22 @@
1
- import { Exception } from '@clov-std/error';
2
1
  import { type KvStore } from '@clov-std/kv-store';
3
2
  import type { Server } from 'bun';
4
3
  import { Elysia, type HTTPHeaders } from 'elysia';
5
- import type { TLiteral, TObject, TOptional, TString } from 'typebox/type';
6
- export declare const RATE_LIMIT_ERROR_KEYS: {
7
- readonly RATE_LIMIT_EXCEEDED: 'elysia-ratelimit.exceeded';
4
+ import type { TLiteral, TObject, TString } from 'typebox/type';
5
+ export declare const RATE_LIMIT_ERROR_CODES: {
6
+ readonly QUOTA_EXCEEDED: 'rate-limit.quota.exceeded';
8
7
  };
9
- /** Counter state at the moment the limit was exceeded, attached as the exception `cause`. */
10
- export interface RateLimitExceeded {
11
- /** Maximum requests allowed within the window. */
12
- readonly limit: number;
13
- /** Window length in seconds. */
14
- readonly window: number;
15
- /** Seconds until the counter resets. */
16
- readonly reset: number;
17
- }
18
8
  /**
19
- * Shape of the `429` body, following RFC 9457.
9
+ * Shape of the `429` body, an RFC 9457 problem document.
10
+ *
11
+ * `title` is filled by Elysia from the status reason phrase, so the plugin only supplies
12
+ * `type`, `status`, and `detail`.
20
13
  */
21
14
  declare const rateLimitResponseSchema: TObject<{
22
15
  type: TString;
23
16
  title: TString;
24
17
  status: TLiteral<429>;
25
- detail: TOptional<TString>;
18
+ detail: TString;
26
19
  }>;
27
- /**
28
- * Thrown when a caller exceeds its allowance.
29
- *
30
- * @see {@link rateLimitResponseSchema}
31
- */
32
- export declare class RateLimitException extends Exception<RateLimitExceeded> {
33
- /** HTTP status used by Elysia's error fallback. */
34
- readonly status = 429;
35
- /** Default body used by Elysia's error fallback. */
36
- readonly response: {
37
- readonly type: string;
38
- readonly title: string;
39
- readonly status: 429;
40
- };
41
- /**
42
- * Creates a new rate limit exception.
43
- *
44
- * @param cause - Counter state at the moment the limit was exceeded.
45
- */
46
- constructor(cause: RateLimitExceeded);
47
- }
48
20
  export interface RateLimitKeyContext {
49
21
  request: Request;
50
22
  server: Server<unknown> | null;
@@ -69,7 +41,7 @@ type RateLimitMacro = (options: RateLimitMacroOptions) => {
69
41
  transform: (context: RateLimitTransformContext) => Promise<void>;
70
42
  };
71
43
  export declare const extractClientIp: (request: Request, server: Server<unknown> | null) => string;
72
- export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'', 'local', {
44
+ export declare const rateLimitPlugin: (store?: KvStore) => Elysia<'rateLimitPlugin', 'local', {
73
45
  decorator: {};
74
46
  derive: {};
75
47
  store: {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clov-std/elysia-ratelimit",
3
- "version": "3.1.1",
3
+ "version": "4.0.1",
4
4
  "description": "Rate limiting plugin for Elysia framework.",
5
5
  "keywords": [
6
6
  "bun",
@@ -40,19 +40,17 @@
40
40
  "test": "bun test --pass-with-no-tests --coverage"
41
41
  },
42
42
  "dependencies": {
43
- "@clov-std/error": "^4.0.0",
44
- "@clov-std/kv-store": "^1.1.2"
43
+ "@clov-std/kv-store": "^2.0.0"
45
44
  },
46
45
  "devDependencies": {
47
46
  "@types/bun": "^1.3.14",
48
- "oxfmt": "0.61.0",
49
- "oxlint": "1.76.0",
47
+ "oxfmt": "0.62.0",
48
+ "oxlint": "1.77.0",
50
49
  "oxlint-tsgolint": "7.0.2001",
51
50
  "typescript": "^7.0.2"
52
51
  },
53
52
  "peerDependencies": {
54
- "@clov-std/error": "^4.0.0",
55
- "@clov-std/kv-store": "^1.1.2",
53
+ "@clov-std/kv-store": "^2.0.0",
56
54
  "elysia": "^2.0.0-beta.1",
57
55
  "typebox": "^1.3.10"
58
56
  }