@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 +11 -11
- package/dist/core/config.d.ts +3 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/headers.d.ts +3 -0
- package/dist/core/headers.d.ts.map +1 -0
- package/dist/core/limiter.d.ts +3 -0
- package/dist/core/limiter.d.ts.map +1 -0
- package/dist/core/response.d.ts +3 -0
- package/dist/core/response.d.ts.map +1 -0
- package/dist/core/utils.d.ts +2 -0
- package/dist/core/utils.d.ts.map +1 -0
- package/dist/elysia.d.ts +38 -0
- package/dist/elysia.d.ts.map +1 -0
- package/dist/elysia.js +211 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +429 -0
- package/dist/loggers/console.d.ts +4 -0
- package/dist/loggers/console.d.ts.map +1 -0
- package/dist/stores/memory.d.ts +3 -0
- package/dist/stores/memory.d.ts.map +1 -0
- package/dist/stores/memory.js +41 -0
- package/dist/stores/redis.d.ts +7 -0
- package/dist/stores/redis.d.ts.map +1 -0
- package/dist/stores/redis.js +37 -0
- package/dist/stores/sqlite.d.ts +6 -0
- package/dist/stores/sqlite.d.ts.map +1 -0
- package/dist/stores/sqlite.js +52 -0
- package/package.json +8 -3
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 ████████████████████████████
|
|
23
|
-
│ better-sqlite3
|
|
24
|
-
│
|
|
25
|
-
│ bun:sqlite is
|
|
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
|
-
- **
|
|
32
|
-
- **
|
|
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** -
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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"}
|
package/dist/elysia.d.ts
ADDED
|
@@ -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
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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 @@
|
|
|
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.
|
|
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.
|
|
114
|
+
"@joint-ops/hitlimit-types": "1.0.3"
|
|
110
115
|
},
|
|
111
116
|
"peerDependencies": {
|
|
112
117
|
"elysia": ">=1.0.0",
|