@joint-ops/hitlimit-bun 1.0.1 → 1.0.3

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
@@ -17,23 +17,23 @@
17
17
  **hitlimit-bun uses Bun's native SQLite** - no FFI overhead, no Node.js polyfills.
18
18
 
19
19
  ```
20
- ┌────────────────────────────────────────────────────────────────┐
21
-
22
- │ bun:sqlite ████████████████████████████ 95,000 ops/s │
23
- │ better-sqlite3 ██████████░░░░░░░░░░░░░░░░░░ 35,000 ops/s │
24
-
25
- │ bun:sqlite is 2.7x faster because it's truly native
26
-
27
- └────────────────────────────────────────────────────────────────┘
20
+ ┌─────────────────────────────────────────────────────────────────┐
21
+
22
+ │ bun:sqlite ████████████████████████████ 520,000 ops/s │
23
+ │ better-sqlite3 ██████████████████░░░░░░░░░░ 400,000 ops/s │
24
+
25
+ │ bun:sqlite is 30% faster with zero FFI overhead
26
+
27
+ └─────────────────────────────────────────────────────────────────┘
28
28
  ```
29
29
 
30
30
  - **Bun Native** - Built specifically for Bun's runtime, not a Node.js port
31
- - **2.7x Faster SQLite** - Native bun:sqlite vs Node.js better-sqlite3
32
- - **95,000+ ops/sec** - With bun:sqlite persistence
31
+ - **7.2M ops/sec** - Memory store performance
32
+ - **520K ops/sec** - With bun:sqlite persistence
33
33
  - **Zero Config** - Works out of the box with sensible defaults
34
34
  - **Elysia Plugin** - First-class Elysia framework integration
35
35
  - **TypeScript First** - Full type safety and IntelliSense support
36
- - **Tiny Footprint** - Only ~5KB minified, no bloat
36
+ - **Tiny Footprint** - ~23KB total, zero runtime dependencies
37
37
 
38
38
  ## Installation
39
39
 
@@ -0,0 +1,3 @@
1
+ import type { HitLimitOptions, HitLimitStore, KeyGenerator, ResolvedConfig } from '@joint-ops/hitlimit-types';
2
+ export declare function resolveConfig<TRequest>(options: HitLimitOptions<TRequest>, defaultStore: HitLimitStore, defaultKey: KeyGenerator<TRequest>): ResolvedConfig<TRequest>;
3
+ //# sourceMappingURL=config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"config.d.ts","sourceRoot":"","sources":["../../src/core/config.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,eAAe,EACf,aAAa,EACb,YAAY,EACZ,cAAc,EACf,MAAM,2BAA2B,CAAA;AAGlC,wBAAgB,aAAa,CAAC,QAAQ,EACpC,OAAO,EAAE,eAAe,CAAC,QAAQ,CAAC,EAClC,YAAY,EAAE,aAAa,EAC3B,UAAU,EAAE,YAAY,CAAC,QAAQ,CAAC,GACjC,cAAc,CAAC,QAAQ,CAAC,CAiB1B"}
@@ -0,0 +1,3 @@
1
+ import type { HitLimitInfo, HeadersConfig } from '@joint-ops/hitlimit-types';
2
+ export declare function buildHeaders(info: HitLimitInfo, config: Required<HeadersConfig>, allowed: boolean): Record<string, string>;
3
+ //# sourceMappingURL=headers.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../../src/core/headers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAA;AAE5E,wBAAgB,YAAY,CAC1B,IAAI,EAAE,YAAY,EAClB,MAAM,EAAE,QAAQ,CAAC,aAAa,CAAC,EAC/B,OAAO,EAAE,OAAO,GACf,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAoBxB"}
@@ -0,0 +1,3 @@
1
+ import type { HitLimitResult, ResolvedConfig } from '@joint-ops/hitlimit-types';
2
+ export declare function checkLimit<TRequest>(config: ResolvedConfig<TRequest>, req: TRequest): Promise<HitLimitResult>;
3
+ //# sourceMappingURL=limiter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"limiter.d.ts","sourceRoot":"","sources":["../../src/core/limiter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAgB,cAAc,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAK7F,wBAAsB,UAAU,CAAC,QAAQ,EACvC,MAAM,EAAE,cAAc,CAAC,QAAQ,CAAC,EAChC,GAAG,EAAE,QAAQ,GACZ,OAAO,CAAC,cAAc,CAAC,CAiDzB"}
@@ -0,0 +1,3 @@
1
+ import type { HitLimitInfo, ResponseConfig, ResponseFormatter } from '@joint-ops/hitlimit-types';
2
+ export declare function buildBody(response: ResponseConfig | ResponseFormatter, info: HitLimitInfo): Record<string, any>;
3
+ //# sourceMappingURL=response.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response.d.ts","sourceRoot":"","sources":["../../src/core/response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAA;AAEhG,wBAAgB,SAAS,CACvB,QAAQ,EAAE,cAAc,GAAG,iBAAiB,EAC5C,IAAI,EAAE,YAAY,GACjB,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAWrB"}
@@ -0,0 +1,2 @@
1
+ export declare function parseWindow(window: string | number): number;
2
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../../src/core/utils.ts"],"names":[],"mappings":"AAOA,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAO3D"}
@@ -0,0 +1,38 @@
1
+ import { Elysia } from 'elysia';
2
+ import type { HitLimitOptions } from '@joint-ops/hitlimit-types';
3
+ export interface ElysiaHitLimitOptions extends HitLimitOptions<{
4
+ request: Request;
5
+ }> {
6
+ sqlitePath?: string;
7
+ }
8
+ export declare function hitlimit(options?: ElysiaHitLimitOptions): Elysia<"", {
9
+ decorator: {};
10
+ store: {};
11
+ derive: {};
12
+ resolve: {};
13
+ }, {
14
+ typebox: {};
15
+ error: {};
16
+ }, {
17
+ schema: {};
18
+ standaloneSchema: {};
19
+ macro: {};
20
+ macroFn: {};
21
+ parser: {};
22
+ response: {
23
+ 200: Response;
24
+ };
25
+ }, {}, {
26
+ derive: {};
27
+ resolve: {};
28
+ schema: {};
29
+ standaloneSchema: {};
30
+ response: {};
31
+ }, {
32
+ derive: {};
33
+ resolve: {};
34
+ schema: {};
35
+ standaloneSchema: {};
36
+ response: {};
37
+ }>;
38
+ //# sourceMappingURL=elysia.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"elysia.d.ts","sourceRoot":"","sources":["../src/elysia.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,QAAQ,CAAA;AAC/B,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAKhE,MAAM,WAAW,qBAAsB,SAAQ,eAAe,CAAC;IAAE,OAAO,EAAE,OAAO,CAAA;CAAE,CAAC;IAClF,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAMD,wBAAgB,QAAQ,CAAC,OAAO,GAAE,qBAA0B;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2C3D"}
package/dist/elysia.js ADDED
@@ -0,0 +1,211 @@
1
+ // @bun
2
+ // src/stores/sqlite.ts
3
+ import { Database } from "bun:sqlite";
4
+
5
+ class BunSqliteStore {
6
+ db;
7
+ hitStmt;
8
+ getStmt;
9
+ resetStmt;
10
+ cleanupTimer;
11
+ constructor(options = {}) {
12
+ this.db = new Database(options.path ?? ":memory:");
13
+ this.db.exec(`
14
+ CREATE TABLE IF NOT EXISTS hitlimit (
15
+ key TEXT PRIMARY KEY,
16
+ count INTEGER NOT NULL,
17
+ reset_at INTEGER NOT NULL
18
+ )
19
+ `);
20
+ this.hitStmt = this.db.prepare(`
21
+ INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
22
+ ON CONFLICT(key) DO UPDATE SET
23
+ count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
24
+ reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
25
+ `);
26
+ this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
27
+ this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
28
+ this.cleanupTimer = setInterval(() => {
29
+ this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(Date.now());
30
+ }, 60000);
31
+ }
32
+ hit(key, windowMs, _limit) {
33
+ const now = Date.now();
34
+ const resetAt = now + windowMs;
35
+ this.hitStmt.run(key, resetAt, now);
36
+ const row = this.getStmt.get(key);
37
+ return { count: row.count, resetAt: row.reset_at };
38
+ }
39
+ reset(key) {
40
+ this.resetStmt.run(key);
41
+ }
42
+ shutdown() {
43
+ clearInterval(this.cleanupTimer);
44
+ this.db.close();
45
+ }
46
+ }
47
+ function sqliteStore(options) {
48
+ return new BunSqliteStore(options);
49
+ }
50
+
51
+ // src/elysia.ts
52
+ import { Elysia } from "elysia";
53
+
54
+ // src/core/utils.ts
55
+ var UNITS = {
56
+ s: 1000,
57
+ m: 60 * 1000,
58
+ h: 60 * 60 * 1000,
59
+ d: 24 * 60 * 60 * 1000
60
+ };
61
+ function parseWindow(window) {
62
+ if (typeof window === "number")
63
+ return window;
64
+ const match = window.match(/^(\d+)(s|m|h|d)$/);
65
+ if (!match)
66
+ throw new Error(`Invalid window format: ${window}`);
67
+ return parseInt(match[1]) * UNITS[match[2]];
68
+ }
69
+
70
+ // src/core/config.ts
71
+ function resolveConfig(options, defaultStore, defaultKey) {
72
+ return {
73
+ limit: options.limit ?? 100,
74
+ windowMs: parseWindow(options.window ?? "1m"),
75
+ key: options.key ?? defaultKey,
76
+ tiers: options.tiers,
77
+ tier: options.tier,
78
+ response: options.response ?? { hitlimit: true, message: "Whoa there! Rate limit exceeded." },
79
+ headers: {
80
+ standard: options.headers?.standard ?? true,
81
+ legacy: options.headers?.legacy ?? true,
82
+ retryAfter: options.headers?.retryAfter ?? true
83
+ },
84
+ store: options.store ?? defaultStore,
85
+ onStoreError: options.onStoreError ?? (() => "allow"),
86
+ skip: options.skip
87
+ };
88
+ }
89
+
90
+ // src/core/headers.ts
91
+ function buildHeaders(info, config, allowed) {
92
+ const headers = {};
93
+ if (config.standard) {
94
+ headers["RateLimit-Limit"] = String(info.limit);
95
+ headers["RateLimit-Remaining"] = String(info.remaining);
96
+ headers["RateLimit-Reset"] = String(Math.ceil(info.resetAt / 1000));
97
+ }
98
+ if (config.legacy) {
99
+ headers["X-RateLimit-Limit"] = String(info.limit);
100
+ headers["X-RateLimit-Remaining"] = String(info.remaining);
101
+ headers["X-RateLimit-Reset"] = String(Math.ceil(info.resetAt / 1000));
102
+ }
103
+ if (!allowed && config.retryAfter) {
104
+ headers["Retry-After"] = String(info.resetIn);
105
+ }
106
+ return headers;
107
+ }
108
+
109
+ // src/core/response.ts
110
+ function buildBody(response, info) {
111
+ if (typeof response === "function") {
112
+ return response(info);
113
+ }
114
+ return {
115
+ ...response,
116
+ limit: info.limit,
117
+ remaining: info.remaining,
118
+ resetIn: info.resetIn
119
+ };
120
+ }
121
+
122
+ // src/core/limiter.ts
123
+ async function checkLimit(config, req) {
124
+ const key = await config.key(req);
125
+ let limit = config.limit;
126
+ let windowMs = config.windowMs;
127
+ let tierName;
128
+ if (config.tier && config.tiers) {
129
+ tierName = await config.tier(req);
130
+ const tierConfig = config.tiers[tierName];
131
+ if (tierConfig) {
132
+ limit = tierConfig.limit;
133
+ if (tierConfig.window) {
134
+ windowMs = parseWindow(tierConfig.window);
135
+ }
136
+ }
137
+ }
138
+ if (limit === Infinity) {
139
+ return {
140
+ allowed: true,
141
+ info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName },
142
+ headers: {},
143
+ body: {}
144
+ };
145
+ }
146
+ const result = await config.store.hit(key, windowMs, limit);
147
+ const now = Date.now();
148
+ const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
149
+ const remaining = Math.max(0, limit - result.count);
150
+ const allowed = result.count <= limit;
151
+ const info = {
152
+ limit,
153
+ remaining,
154
+ resetIn,
155
+ resetAt: result.resetAt,
156
+ key,
157
+ tier: tierName
158
+ };
159
+ return {
160
+ allowed,
161
+ info,
162
+ headers: buildHeaders(info, config.headers, allowed),
163
+ body: allowed ? {} : buildBody(config.response, info)
164
+ };
165
+ }
166
+
167
+ // src/elysia.ts
168
+ function getDefaultKey(_ctx) {
169
+ return "unknown";
170
+ }
171
+ function hitlimit(options = {}) {
172
+ const store = options.store ?? sqliteStore({ path: options.sqlitePath });
173
+ const config = resolveConfig(options, store, getDefaultKey);
174
+ return new Elysia({ name: "hitlimit" }).onBeforeHandle({ as: "global" }, async ({ request, set }) => {
175
+ const ctx = { request };
176
+ if (config.skip) {
177
+ const shouldSkip = await config.skip(ctx);
178
+ if (shouldSkip) {
179
+ return;
180
+ }
181
+ }
182
+ try {
183
+ const result = await checkLimit(config, ctx);
184
+ Object.entries(result.headers).forEach(([key, value]) => {
185
+ set.headers[key] = value;
186
+ });
187
+ if (!result.allowed) {
188
+ set.status = 429;
189
+ return new Response(JSON.stringify(result.body), {
190
+ status: 429,
191
+ headers: {
192
+ "Content-Type": "application/json",
193
+ ...result.headers
194
+ }
195
+ });
196
+ }
197
+ } catch (error) {
198
+ const action = await config.onStoreError(error, ctx);
199
+ if (action === "deny") {
200
+ set.status = 429;
201
+ return new Response(JSON.stringify({ hitlimit: true, message: "Rate limit error" }), {
202
+ status: 429,
203
+ headers: { "Content-Type": "application/json" }
204
+ });
205
+ }
206
+ }
207
+ });
208
+ }
209
+ export {
210
+ hitlimit
211
+ };
@@ -0,0 +1,21 @@
1
+ import type { HitLimitOptions } from '@joint-ops/hitlimit-types';
2
+ export type { HitLimitOptions, HitLimitInfo, HitLimitResult, HitLimitStore, StoreResult, TierConfig, HeadersConfig, ResolvedConfig, KeyGenerator, TierResolver, SkipFunction, StoreErrorHandler, ResponseFormatter, ResponseConfig } from '@joint-ops/hitlimit-types';
3
+ export { DEFAULT_LIMIT, DEFAULT_WINDOW, DEFAULT_WINDOW_MS, DEFAULT_MESSAGE } from '@joint-ops/hitlimit-types';
4
+ export { sqliteStore } from './stores/sqlite.js';
5
+ export { checkLimit } from './core/limiter.js';
6
+ export interface BunHitLimitOptions extends HitLimitOptions<Request> {
7
+ sqlitePath?: string;
8
+ }
9
+ type BunServer = {
10
+ requestIP(req: Request): {
11
+ address: string;
12
+ } | null;
13
+ };
14
+ type FetchHandler = (req: Request, server: BunServer) => Response | Promise<Response>;
15
+ export declare function hitlimit(options: BunHitLimitOptions, handler: FetchHandler): (req: Request, server: BunServer) => Response | Promise<Response>;
16
+ export interface HitLimiter {
17
+ check(req: Request, server: BunServer): Response | null | Promise<Response | null>;
18
+ reset(key: string): Promise<void> | void;
19
+ }
20
+ export declare function createHitLimit(options?: BunHitLimitOptions): HitLimiter;
21
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAIhE,YAAY,EAAE,eAAe,EAAE,YAAY,EAAE,cAAc,EAAE,aAAa,EAAE,WAAW,EAAE,UAAU,EAAE,aAAa,EAAE,cAAc,EAAE,YAAY,EAAE,YAAY,EAAE,YAAY,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AACrQ,OAAO,EAAE,aAAa,EAAE,cAAc,EAAE,iBAAiB,EAAE,eAAe,EAAE,MAAM,2BAA2B,CAAA;AAC7G,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAE9C,MAAM,WAAW,kBAAmB,SAAQ,eAAe,CAAC,OAAO,CAAC;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,KAAK,SAAS,GAAG;IAAE,SAAS,CAAC,GAAG,EAAE,OAAO,GAAG;QAAE,OAAO,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAAA;CAAE,CAAA;AAExE,KAAK,YAAY,GAAG,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;AAMrF,wBAAgB,QAAQ,CACtB,OAAO,EAAE,kBAAkB,EAC3B,OAAO,EAAE,YAAY,GACpB,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,KAAK,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAC,CAkLnE;AAED,MAAM,WAAW,UAAU;IACzB,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,GAAG,IAAI,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC,CAAA;IAClF,KAAK,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAA;CACzC;AAED,wBAAgB,cAAc,CAAC,OAAO,GAAE,kBAAuB,GAAG,UAAU,CAsF3E"}
package/dist/index.js ADDED
@@ -0,0 +1,429 @@
1
+ // @bun
2
+ // src/stores/sqlite.ts
3
+ import { Database } from "bun:sqlite";
4
+
5
+ class BunSqliteStore {
6
+ db;
7
+ hitStmt;
8
+ getStmt;
9
+ resetStmt;
10
+ cleanupTimer;
11
+ constructor(options = {}) {
12
+ this.db = new Database(options.path ?? ":memory:");
13
+ this.db.exec(`
14
+ CREATE TABLE IF NOT EXISTS hitlimit (
15
+ key TEXT PRIMARY KEY,
16
+ count INTEGER NOT NULL,
17
+ reset_at INTEGER NOT NULL
18
+ )
19
+ `);
20
+ this.hitStmt = this.db.prepare(`
21
+ INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
22
+ ON CONFLICT(key) DO UPDATE SET
23
+ count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
24
+ reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
25
+ `);
26
+ this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
27
+ this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
28
+ this.cleanupTimer = setInterval(() => {
29
+ this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(Date.now());
30
+ }, 60000);
31
+ }
32
+ hit(key, windowMs, _limit) {
33
+ const now = Date.now();
34
+ const resetAt = now + windowMs;
35
+ this.hitStmt.run(key, resetAt, now);
36
+ const row = this.getStmt.get(key);
37
+ return { count: row.count, resetAt: row.reset_at };
38
+ }
39
+ reset(key) {
40
+ this.resetStmt.run(key);
41
+ }
42
+ shutdown() {
43
+ clearInterval(this.cleanupTimer);
44
+ this.db.close();
45
+ }
46
+ }
47
+ function sqliteStore(options) {
48
+ return new BunSqliteStore(options);
49
+ }
50
+
51
+ // src/core/utils.ts
52
+ var UNITS = {
53
+ s: 1000,
54
+ m: 60 * 1000,
55
+ h: 60 * 60 * 1000,
56
+ d: 24 * 60 * 60 * 1000
57
+ };
58
+ function parseWindow(window) {
59
+ if (typeof window === "number")
60
+ return window;
61
+ const match = window.match(/^(\d+)(s|m|h|d)$/);
62
+ if (!match)
63
+ throw new Error(`Invalid window format: ${window}`);
64
+ return parseInt(match[1]) * UNITS[match[2]];
65
+ }
66
+
67
+ // src/core/config.ts
68
+ function resolveConfig(options, defaultStore, defaultKey) {
69
+ return {
70
+ limit: options.limit ?? 100,
71
+ windowMs: parseWindow(options.window ?? "1m"),
72
+ key: options.key ?? defaultKey,
73
+ tiers: options.tiers,
74
+ tier: options.tier,
75
+ response: options.response ?? { hitlimit: true, message: "Whoa there! Rate limit exceeded." },
76
+ headers: {
77
+ standard: options.headers?.standard ?? true,
78
+ legacy: options.headers?.legacy ?? true,
79
+ retryAfter: options.headers?.retryAfter ?? true
80
+ },
81
+ store: options.store ?? defaultStore,
82
+ onStoreError: options.onStoreError ?? (() => "allow"),
83
+ skip: options.skip
84
+ };
85
+ }
86
+
87
+ // ../types/dist/index.js
88
+ var DEFAULT_LIMIT = 100;
89
+ var DEFAULT_WINDOW = "1m";
90
+ var DEFAULT_WINDOW_MS = 60000;
91
+ var DEFAULT_MESSAGE = "Whoa there! Rate limit exceeded.";
92
+ // src/core/headers.ts
93
+ function buildHeaders(info, config, allowed) {
94
+ const headers = {};
95
+ if (config.standard) {
96
+ headers["RateLimit-Limit"] = String(info.limit);
97
+ headers["RateLimit-Remaining"] = String(info.remaining);
98
+ headers["RateLimit-Reset"] = String(Math.ceil(info.resetAt / 1000));
99
+ }
100
+ if (config.legacy) {
101
+ headers["X-RateLimit-Limit"] = String(info.limit);
102
+ headers["X-RateLimit-Remaining"] = String(info.remaining);
103
+ headers["X-RateLimit-Reset"] = String(Math.ceil(info.resetAt / 1000));
104
+ }
105
+ if (!allowed && config.retryAfter) {
106
+ headers["Retry-After"] = String(info.resetIn);
107
+ }
108
+ return headers;
109
+ }
110
+
111
+ // src/core/response.ts
112
+ function buildBody(response, info) {
113
+ if (typeof response === "function") {
114
+ return response(info);
115
+ }
116
+ return {
117
+ ...response,
118
+ limit: info.limit,
119
+ remaining: info.remaining,
120
+ resetIn: info.resetIn
121
+ };
122
+ }
123
+
124
+ // src/core/limiter.ts
125
+ async function checkLimit(config, req) {
126
+ const key = await config.key(req);
127
+ let limit = config.limit;
128
+ let windowMs = config.windowMs;
129
+ let tierName;
130
+ if (config.tier && config.tiers) {
131
+ tierName = await config.tier(req);
132
+ const tierConfig = config.tiers[tierName];
133
+ if (tierConfig) {
134
+ limit = tierConfig.limit;
135
+ if (tierConfig.window) {
136
+ windowMs = parseWindow(tierConfig.window);
137
+ }
138
+ }
139
+ }
140
+ if (limit === Infinity) {
141
+ return {
142
+ allowed: true,
143
+ info: { limit, remaining: Infinity, resetIn: 0, resetAt: 0, key, tier: tierName },
144
+ headers: {},
145
+ body: {}
146
+ };
147
+ }
148
+ const result = await config.store.hit(key, windowMs, limit);
149
+ const now = Date.now();
150
+ const resetIn = Math.max(0, Math.ceil((result.resetAt - now) / 1000));
151
+ const remaining = Math.max(0, limit - result.count);
152
+ const allowed = result.count <= limit;
153
+ const info = {
154
+ limit,
155
+ remaining,
156
+ resetIn,
157
+ resetAt: result.resetAt,
158
+ key,
159
+ tier: tierName
160
+ };
161
+ return {
162
+ allowed,
163
+ info,
164
+ headers: buildHeaders(info, config.headers, allowed),
165
+ body: allowed ? {} : buildBody(config.response, info)
166
+ };
167
+ }
168
+
169
+ // src/index.ts
170
+ function getDefaultKey(req, server) {
171
+ return server.requestIP(req)?.address || "unknown";
172
+ }
173
+ function hitlimit(options, handler) {
174
+ const store = options.store ?? sqliteStore({ path: options.sqlitePath });
175
+ const config = resolveConfig(options, store, () => "unknown");
176
+ const hasSkip = !!config.skip;
177
+ const hasTiers = !!(config.tier && config.tiers);
178
+ const standardHeaders = config.headers.standard;
179
+ const legacyHeaders = config.headers.legacy;
180
+ const retryAfterHeader = config.headers.retryAfter;
181
+ const limit = config.limit;
182
+ const windowMs = config.windowMs;
183
+ const response = config.response;
184
+ const customKey = options.key;
185
+ const blockedBody = JSON.stringify(response);
186
+ if (!hasSkip && !hasTiers) {
187
+ return async (req, server) => {
188
+ try {
189
+ const key = customKey ? await customKey(req) : getDefaultKey(req, server);
190
+ const result = await store.hit(key, windowMs, limit);
191
+ const allowed = result.count <= limit;
192
+ if (!allowed) {
193
+ const headers = { "Content-Type": "application/json" };
194
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
195
+ if (standardHeaders) {
196
+ headers["RateLimit-Limit"] = String(limit);
197
+ headers["RateLimit-Remaining"] = "0";
198
+ headers["RateLimit-Reset"] = String(resetIn);
199
+ }
200
+ if (legacyHeaders) {
201
+ headers["X-RateLimit-Limit"] = String(limit);
202
+ headers["X-RateLimit-Remaining"] = "0";
203
+ headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000));
204
+ }
205
+ if (retryAfterHeader) {
206
+ headers["Retry-After"] = String(resetIn);
207
+ }
208
+ return new Response(blockedBody, { status: 429, headers });
209
+ }
210
+ const res = await handler(req, server);
211
+ if (standardHeaders || legacyHeaders) {
212
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
213
+ const remaining = Math.max(0, limit - result.count);
214
+ const newHeaders = new Headers(res.headers);
215
+ if (standardHeaders) {
216
+ newHeaders.set("RateLimit-Limit", String(limit));
217
+ newHeaders.set("RateLimit-Remaining", String(remaining));
218
+ newHeaders.set("RateLimit-Reset", String(resetIn));
219
+ }
220
+ if (legacyHeaders) {
221
+ newHeaders.set("X-RateLimit-Limit", String(limit));
222
+ newHeaders.set("X-RateLimit-Remaining", String(remaining));
223
+ newHeaders.set("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
224
+ }
225
+ return new Response(res.body, {
226
+ status: res.status,
227
+ statusText: res.statusText,
228
+ headers: newHeaders
229
+ });
230
+ }
231
+ return res;
232
+ } catch (error) {
233
+ const action = await config.onStoreError(error, req);
234
+ if (action === "deny") {
235
+ return new Response('{"hitlimit":true,"message":"Rate limit error"}', {
236
+ status: 429,
237
+ headers: { "Content-Type": "application/json" }
238
+ });
239
+ }
240
+ return handler(req, server);
241
+ }
242
+ };
243
+ }
244
+ return async (req, server) => {
245
+ if (hasSkip) {
246
+ const shouldSkip = await config.skip(req);
247
+ if (shouldSkip) {
248
+ return handler(req, server);
249
+ }
250
+ }
251
+ try {
252
+ const key = customKey ? await customKey(req) : getDefaultKey(req, server);
253
+ let effectiveLimit = limit;
254
+ let effectiveWindowMs = windowMs;
255
+ if (hasTiers) {
256
+ const tierName = await config.tier(req);
257
+ const tierConfig = config.tiers[tierName];
258
+ if (tierConfig) {
259
+ effectiveLimit = tierConfig.limit;
260
+ if (tierConfig.window) {
261
+ effectiveWindowMs = parseWindow2(tierConfig.window);
262
+ }
263
+ }
264
+ }
265
+ if (effectiveLimit === Infinity) {
266
+ return handler(req, server);
267
+ }
268
+ const result = await store.hit(key, effectiveWindowMs, effectiveLimit);
269
+ const allowed = result.count <= effectiveLimit;
270
+ if (!allowed) {
271
+ const headers = { "Content-Type": "application/json" };
272
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
273
+ if (standardHeaders) {
274
+ headers["RateLimit-Limit"] = String(effectiveLimit);
275
+ headers["RateLimit-Remaining"] = "0";
276
+ headers["RateLimit-Reset"] = String(resetIn);
277
+ }
278
+ if (legacyHeaders) {
279
+ headers["X-RateLimit-Limit"] = String(effectiveLimit);
280
+ headers["X-RateLimit-Remaining"] = "0";
281
+ headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000));
282
+ }
283
+ if (retryAfterHeader) {
284
+ headers["Retry-After"] = String(resetIn);
285
+ }
286
+ return new Response(blockedBody, { status: 429, headers });
287
+ }
288
+ const res = await handler(req, server);
289
+ if (standardHeaders || legacyHeaders) {
290
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
291
+ const remaining = Math.max(0, effectiveLimit - result.count);
292
+ const newHeaders = new Headers(res.headers);
293
+ if (standardHeaders) {
294
+ newHeaders.set("RateLimit-Limit", String(effectiveLimit));
295
+ newHeaders.set("RateLimit-Remaining", String(remaining));
296
+ newHeaders.set("RateLimit-Reset", String(resetIn));
297
+ }
298
+ if (legacyHeaders) {
299
+ newHeaders.set("X-RateLimit-Limit", String(effectiveLimit));
300
+ newHeaders.set("X-RateLimit-Remaining", String(remaining));
301
+ newHeaders.set("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));
302
+ }
303
+ return new Response(res.body, {
304
+ status: res.status,
305
+ statusText: res.statusText,
306
+ headers: newHeaders
307
+ });
308
+ }
309
+ return res;
310
+ } catch (error) {
311
+ const action = await config.onStoreError(error, req);
312
+ if (action === "deny") {
313
+ return new Response('{"hitlimit":true,"message":"Rate limit error"}', {
314
+ status: 429,
315
+ headers: { "Content-Type": "application/json" }
316
+ });
317
+ }
318
+ return handler(req, server);
319
+ }
320
+ };
321
+ }
322
+ function createHitLimit(options = {}) {
323
+ const store = options.store ?? sqliteStore({ path: options.sqlitePath });
324
+ const config = resolveConfig(options, store, () => "unknown");
325
+ const hasSkip = !!config.skip;
326
+ const hasTiers = !!(config.tier && config.tiers);
327
+ const standardHeaders = config.headers.standard;
328
+ const legacyHeaders = config.headers.legacy;
329
+ const retryAfterHeader = config.headers.retryAfter;
330
+ const limit = config.limit;
331
+ const windowMs = config.windowMs;
332
+ const response = config.response;
333
+ const customKey = options.key;
334
+ const blockedBody = JSON.stringify(response);
335
+ return {
336
+ async check(req, server) {
337
+ if (hasSkip) {
338
+ const shouldSkip = await config.skip(req);
339
+ if (shouldSkip) {
340
+ return null;
341
+ }
342
+ }
343
+ try {
344
+ const key = customKey ? await customKey(req) : getDefaultKey(req, server);
345
+ let effectiveLimit = limit;
346
+ let effectiveWindowMs = windowMs;
347
+ if (hasTiers) {
348
+ const tierName = await config.tier(req);
349
+ const tierConfig = config.tiers[tierName];
350
+ if (tierConfig) {
351
+ effectiveLimit = tierConfig.limit;
352
+ if (tierConfig.window) {
353
+ effectiveWindowMs = parseWindow2(tierConfig.window);
354
+ }
355
+ }
356
+ }
357
+ if (effectiveLimit === Infinity) {
358
+ return null;
359
+ }
360
+ const result = await store.hit(key, effectiveWindowMs, effectiveLimit);
361
+ const allowed = result.count <= effectiveLimit;
362
+ if (!allowed) {
363
+ const headers = { "Content-Type": "application/json" };
364
+ const resetIn = Math.ceil((result.resetAt - Date.now()) / 1000);
365
+ if (standardHeaders) {
366
+ headers["RateLimit-Limit"] = String(effectiveLimit);
367
+ headers["RateLimit-Remaining"] = "0";
368
+ headers["RateLimit-Reset"] = String(resetIn);
369
+ }
370
+ if (legacyHeaders) {
371
+ headers["X-RateLimit-Limit"] = String(effectiveLimit);
372
+ headers["X-RateLimit-Remaining"] = "0";
373
+ headers["X-RateLimit-Reset"] = String(Math.ceil(result.resetAt / 1000));
374
+ }
375
+ if (retryAfterHeader) {
376
+ headers["Retry-After"] = String(resetIn);
377
+ }
378
+ return new Response(blockedBody, { status: 429, headers });
379
+ }
380
+ return null;
381
+ } catch (error) {
382
+ const action = await config.onStoreError(error, req);
383
+ if (action === "deny") {
384
+ return new Response('{"hitlimit":true,"message":"Rate limit error"}', {
385
+ status: 429,
386
+ headers: { "Content-Type": "application/json" }
387
+ });
388
+ }
389
+ return null;
390
+ }
391
+ },
392
+ reset(key) {
393
+ return store.reset(key);
394
+ }
395
+ };
396
+ }
397
+ function parseWindow2(window) {
398
+ if (typeof window === "number")
399
+ return window;
400
+ const match = window.match(/^(\d+)(ms|s|m|h|d)$/);
401
+ if (!match)
402
+ return 60000;
403
+ const value = parseInt(match[1], 10);
404
+ const unit = match[2];
405
+ switch (unit) {
406
+ case "ms":
407
+ return value;
408
+ case "s":
409
+ return value * 1000;
410
+ case "m":
411
+ return value * 60 * 1000;
412
+ case "h":
413
+ return value * 60 * 60 * 1000;
414
+ case "d":
415
+ return value * 24 * 60 * 60 * 1000;
416
+ default:
417
+ return 60000;
418
+ }
419
+ }
420
+ export {
421
+ sqliteStore,
422
+ hitlimit,
423
+ createHitLimit,
424
+ checkLimit,
425
+ DEFAULT_WINDOW_MS,
426
+ DEFAULT_WINDOW,
427
+ DEFAULT_MESSAGE,
428
+ DEFAULT_LIMIT
429
+ };
@@ -0,0 +1,4 @@
1
+ import type { HitLimitLogger } from '@joint-ops/hitlimit-types';
2
+ export declare function consoleLogger(): HitLimitLogger;
3
+ export declare function silentLogger(): HitLimitLogger;
4
+ //# sourceMappingURL=console.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"console.d.ts","sourceRoot":"","sources":["../../src/loggers/console.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,2BAA2B,CAAA;AAE/D,wBAAgB,aAAa,IAAI,cAAc,CAO9C;AAED,wBAAgB,YAAY,IAAI,cAAc,CAO7C"}
@@ -0,0 +1,3 @@
1
+ import type { HitLimitStore } from '@joint-ops/hitlimit-types';
2
+ export declare function memoryStore(): HitLimitStore;
3
+ //# sourceMappingURL=memory.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/stores/memory.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AA+D3E,wBAAgB,WAAW,IAAI,aAAa,CAE3C"}
@@ -0,0 +1,41 @@
1
+ // @bun
2
+ // src/stores/memory.ts
3
+ class MemoryStore {
4
+ hits = new Map;
5
+ hit(key, windowMs, _limit) {
6
+ const entry = this.hits.get(key);
7
+ if (entry !== undefined) {
8
+ entry.count++;
9
+ return { count: entry.count, resetAt: entry.resetAt };
10
+ }
11
+ const now = Date.now();
12
+ const resetAt = now + windowMs;
13
+ const timeoutId = setTimeout(() => {
14
+ this.hits.delete(key);
15
+ }, windowMs);
16
+ if (typeof timeoutId.unref === "function") {
17
+ timeoutId.unref();
18
+ }
19
+ this.hits.set(key, { count: 1, resetAt, timeoutId });
20
+ return { count: 1, resetAt };
21
+ }
22
+ reset(key) {
23
+ const entry = this.hits.get(key);
24
+ if (entry) {
25
+ clearTimeout(entry.timeoutId);
26
+ this.hits.delete(key);
27
+ }
28
+ }
29
+ shutdown() {
30
+ for (const [, entry] of this.hits) {
31
+ clearTimeout(entry.timeoutId);
32
+ }
33
+ this.hits.clear();
34
+ }
35
+ }
36
+ function memoryStore() {
37
+ return new MemoryStore;
38
+ }
39
+ export {
40
+ memoryStore
41
+ };
@@ -0,0 +1,7 @@
1
+ import type { HitLimitStore } from '@joint-ops/hitlimit-types';
2
+ export interface RedisStoreOptions {
3
+ url?: string;
4
+ keyPrefix?: string;
5
+ }
6
+ export declare function redisStore(options?: RedisStoreOptions): HitLimitStore;
7
+ //# sourceMappingURL=redis.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"redis.d.ts","sourceRoot":"","sources":["../../src/stores/redis.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAG3E,MAAM,WAAW,iBAAiB;IAChC,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AA2CD,wBAAgB,UAAU,CAAC,OAAO,CAAC,EAAE,iBAAiB,GAAG,aAAa,CAErE"}
@@ -0,0 +1,37 @@
1
+ // @bun
2
+ // src/stores/redis.ts
3
+ import Redis from "ioredis";
4
+
5
+ class RedisStore {
6
+ redis;
7
+ prefix;
8
+ constructor(options = {}) {
9
+ this.redis = new Redis(options.url ?? "redis://localhost:6379");
10
+ this.prefix = options.keyPrefix ?? "hitlimit:";
11
+ }
12
+ async hit(key, windowMs, _limit) {
13
+ const redisKey = this.prefix + key;
14
+ const now = Date.now();
15
+ const results = await this.redis.multi().incr(redisKey).pttl(redisKey).exec();
16
+ const count = results[0][1];
17
+ let ttl = results[1][1];
18
+ if (ttl < 0) {
19
+ await this.redis.pexpire(redisKey, windowMs);
20
+ ttl = windowMs;
21
+ }
22
+ const resetAt = now + ttl;
23
+ return { count, resetAt };
24
+ }
25
+ async reset(key) {
26
+ await this.redis.del(this.prefix + key);
27
+ }
28
+ async shutdown() {
29
+ await this.redis.quit();
30
+ }
31
+ }
32
+ function redisStore(options) {
33
+ return new RedisStore(options);
34
+ }
35
+ export {
36
+ redisStore
37
+ };
@@ -0,0 +1,6 @@
1
+ import type { HitLimitStore } from '@joint-ops/hitlimit-types';
2
+ export interface SqliteStoreOptions {
3
+ path?: string;
4
+ }
5
+ export declare function sqliteStore(options?: SqliteStoreOptions): HitLimitStore;
6
+ //# sourceMappingURL=sqlite.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sqlite.d.ts","sourceRoot":"","sources":["../../src/stores/sqlite.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,aAAa,EAAe,MAAM,2BAA2B,CAAA;AAE3E,MAAM,WAAW,kBAAkB;IACjC,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAuDD,wBAAgB,WAAW,CAAC,OAAO,CAAC,EAAE,kBAAkB,GAAG,aAAa,CAEvE"}
@@ -0,0 +1,52 @@
1
+ // @bun
2
+ // src/stores/sqlite.ts
3
+ import { Database } from "bun:sqlite";
4
+
5
+ class BunSqliteStore {
6
+ db;
7
+ hitStmt;
8
+ getStmt;
9
+ resetStmt;
10
+ cleanupTimer;
11
+ constructor(options = {}) {
12
+ this.db = new Database(options.path ?? ":memory:");
13
+ this.db.exec(`
14
+ CREATE TABLE IF NOT EXISTS hitlimit (
15
+ key TEXT PRIMARY KEY,
16
+ count INTEGER NOT NULL,
17
+ reset_at INTEGER NOT NULL
18
+ )
19
+ `);
20
+ this.hitStmt = this.db.prepare(`
21
+ INSERT INTO hitlimit (key, count, reset_at) VALUES (?1, 1, ?2)
22
+ ON CONFLICT(key) DO UPDATE SET
23
+ count = CASE WHEN reset_at <= ?3 THEN 1 ELSE count + 1 END,
24
+ reset_at = CASE WHEN reset_at <= ?3 THEN ?2 ELSE reset_at END
25
+ `);
26
+ this.getStmt = this.db.prepare("SELECT count, reset_at FROM hitlimit WHERE key = ?");
27
+ this.resetStmt = this.db.prepare("DELETE FROM hitlimit WHERE key = ?");
28
+ this.cleanupTimer = setInterval(() => {
29
+ this.db.prepare("DELETE FROM hitlimit WHERE reset_at <= ?").run(Date.now());
30
+ }, 60000);
31
+ }
32
+ hit(key, windowMs, _limit) {
33
+ const now = Date.now();
34
+ const resetAt = now + windowMs;
35
+ this.hitStmt.run(key, resetAt, now);
36
+ const row = this.getStmt.get(key);
37
+ return { count: row.count, resetAt: row.reset_at };
38
+ }
39
+ reset(key) {
40
+ this.resetStmt.run(key);
41
+ }
42
+ shutdown() {
43
+ clearInterval(this.cleanupTimer);
44
+ this.db.close();
45
+ }
46
+ }
47
+ function sqliteStore(options) {
48
+ return new BunSqliteStore(options);
49
+ }
50
+ export {
51
+ sqliteStore
52
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joint-ops/hitlimit-bun",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "Fast Bun-native rate limiting for Bun.serve & Elysia - API throttling with bun:sqlite, high performance request limiting",
5
5
  "author": {
6
6
  "name": "Shayan M Hussain",
@@ -93,6 +93,11 @@
93
93
  "types": "./dist/stores/redis.d.ts",
94
94
  "bun": "./dist/stores/redis.js",
95
95
  "import": "./dist/stores/redis.js"
96
+ },
97
+ "./stores/sqlite": {
98
+ "types": "./dist/stores/sqlite.d.ts",
99
+ "bun": "./dist/stores/sqlite.js",
100
+ "import": "./dist/stores/sqlite.js"
96
101
  }
97
102
  },
98
103
  "files": [
@@ -100,13 +105,13 @@
100
105
  ],
101
106
  "sideEffects": false,
102
107
  "scripts": {
103
- "build": "bun build ./src/index.ts ./src/elysia.ts ./src/stores/memory.ts ./src/stores/redis.ts --outdir=./dist --target=bun && tsc --emitDeclarationOnly",
108
+ "build": "bun build ./src/index.ts ./src/elysia.ts ./src/stores/memory.ts ./src/stores/redis.ts ./src/stores/sqlite.ts --outdir=./dist --target=bun --external=elysia --external=ioredis --external=@sinclair/typebox && tsc --emitDeclarationOnly",
104
109
  "clean": "rm -rf dist",
105
110
  "test": "bun test",
106
111
  "test:watch": "bun test --watch"
107
112
  },
108
113
  "dependencies": {
109
- "@joint-ops/hitlimit-types": "1.0.1"
114
+ "@joint-ops/hitlimit-types": "1.0.3"
110
115
  },
111
116
  "peerDependencies": {
112
117
  "elysia": ">=1.0.0",