kaelum 1.6.0 → 1.7.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/core/rateLimit.js +153 -0
- package/core/setConfig.js +38 -0
- package/index.d.ts +24 -0
- package/package.json +1 -1
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// core/rateLimit.js
|
|
2
|
+
// Kaelum built-in rate limiting middleware.
|
|
3
|
+
// Zero-dependency, in-memory sliding-window rate limiter.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory store for tracking request counts per key.
|
|
7
|
+
* Automatically cleans up expired entries on an interval.
|
|
8
|
+
*/
|
|
9
|
+
class MemoryStore {
|
|
10
|
+
/**
|
|
11
|
+
* @param {number} windowMs - window duration in milliseconds
|
|
12
|
+
*/
|
|
13
|
+
constructor(windowMs) {
|
|
14
|
+
this.windowMs = windowMs;
|
|
15
|
+
/** @type {Map<string, { hits: number, resetTime: number }>} */
|
|
16
|
+
this.hits = new Map();
|
|
17
|
+
// clean up expired entries every 60s (or every window if shorter)
|
|
18
|
+
this._cleanupInterval = setInterval(
|
|
19
|
+
() => this._cleanup(),
|
|
20
|
+
Math.min(windowMs, 60000)
|
|
21
|
+
);
|
|
22
|
+
// don't block process exit
|
|
23
|
+
if (this._cleanupInterval.unref) {
|
|
24
|
+
this._cleanupInterval.unref();
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Increment the hit counter for a key.
|
|
30
|
+
* @param {string} key
|
|
31
|
+
* @returns {{ totalHits: number, resetTime: number }}
|
|
32
|
+
*/
|
|
33
|
+
increment(key) {
|
|
34
|
+
const now = Date.now();
|
|
35
|
+
const entry = this.hits.get(key);
|
|
36
|
+
|
|
37
|
+
if (entry && now < entry.resetTime) {
|
|
38
|
+
entry.hits += 1;
|
|
39
|
+
return { totalHits: entry.hits, resetTime: entry.resetTime };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// new window
|
|
43
|
+
const resetTime = now + this.windowMs;
|
|
44
|
+
const record = { hits: 1, resetTime };
|
|
45
|
+
this.hits.set(key, record);
|
|
46
|
+
return { totalHits: 1, resetTime };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reset the counter for a specific key.
|
|
51
|
+
* @param {string} key
|
|
52
|
+
*/
|
|
53
|
+
resetKey(key) {
|
|
54
|
+
this.hits.delete(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Remove expired entries from the store.
|
|
59
|
+
*/
|
|
60
|
+
_cleanup() {
|
|
61
|
+
const now = Date.now();
|
|
62
|
+
for (const [key, entry] of this.hits) {
|
|
63
|
+
if (now >= entry.resetTime) {
|
|
64
|
+
this.hits.delete(key);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Shut down the store and clear the cleanup interval.
|
|
71
|
+
*/
|
|
72
|
+
shutdown() {
|
|
73
|
+
clearInterval(this._cleanupInterval);
|
|
74
|
+
this.hits.clear();
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Create a rate-limiting middleware function.
|
|
80
|
+
*
|
|
81
|
+
* @param {Object} [options]
|
|
82
|
+
* @param {number} [options.windowMs=900000] - window duration in ms (default 15 min)
|
|
83
|
+
* @param {number} [options.max=100] - max requests per window per key
|
|
84
|
+
* @param {string|Object} [options.message] - response body when limited
|
|
85
|
+
* @param {number} [options.statusCode=429] - HTTP status when limited
|
|
86
|
+
* @param {Function} [options.keyGenerator] - (req) => string (default: req.ip)
|
|
87
|
+
* @param {Function} [options.skip] - (req) => boolean (default: false)
|
|
88
|
+
* @param {boolean} [options.headers=true] - send standard rate-limit headers
|
|
89
|
+
* @param {Object} [options.store] - custom store (must implement increment/resetKey/shutdown)
|
|
90
|
+
* @returns {Function} Express middleware
|
|
91
|
+
*/
|
|
92
|
+
function createRateLimiter(options = {}) {
|
|
93
|
+
const {
|
|
94
|
+
windowMs = 15 * 60 * 1000,
|
|
95
|
+
max = 100,
|
|
96
|
+
message = { error: "Too many requests, please try again later." },
|
|
97
|
+
statusCode = 429,
|
|
98
|
+
keyGenerator = (req) => req.ip || req.connection.remoteAddress || "unknown",
|
|
99
|
+
skip = () => false,
|
|
100
|
+
headers = true,
|
|
101
|
+
store: customStore,
|
|
102
|
+
} = options;
|
|
103
|
+
|
|
104
|
+
const store = customStore || new MemoryStore(windowMs);
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Express middleware function.
|
|
108
|
+
*/
|
|
109
|
+
function rateLimitMiddleware(req, res, next) {
|
|
110
|
+
// allow skipping for certain requests
|
|
111
|
+
if (skip(req)) {
|
|
112
|
+
return next();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const key = keyGenerator(req);
|
|
116
|
+
const { totalHits, resetTime } = store.increment(key);
|
|
117
|
+
const remaining = Math.max(0, max - totalHits);
|
|
118
|
+
|
|
119
|
+
// attach rate-limit info to request for downstream use
|
|
120
|
+
req.rateLimit = { limit: max, remaining, resetTime };
|
|
121
|
+
|
|
122
|
+
// set standard headers
|
|
123
|
+
if (headers) {
|
|
124
|
+
res.setHeader("RateLimit-Limit", String(max));
|
|
125
|
+
res.setHeader("RateLimit-Remaining", String(remaining));
|
|
126
|
+
res.setHeader(
|
|
127
|
+
"RateLimit-Reset",
|
|
128
|
+
String(Math.ceil(resetTime / 1000))
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// exceeded limit
|
|
133
|
+
if (totalHits > max) {
|
|
134
|
+
if (headers) {
|
|
135
|
+
const retryAfter = Math.ceil((resetTime - Date.now()) / 1000);
|
|
136
|
+
res.setHeader("Retry-After", String(Math.max(retryAfter, 0)));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const body =
|
|
140
|
+
typeof message === "string" ? { error: message } : message;
|
|
141
|
+
return res.status(statusCode).json(body);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
next();
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// attach store reference so setConfig can call shutdown on removal
|
|
148
|
+
rateLimitMiddleware._store = store;
|
|
149
|
+
|
|
150
|
+
return rateLimitMiddleware;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
module.exports = { createRateLimiter, MemoryStore };
|
package/core/setConfig.js
CHANGED
|
@@ -90,6 +90,23 @@ function removeKaelumHelmet(app) {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Remove Kaelum-installed rate-limiting middleware (if any)
|
|
95
|
+
* Also shuts down the MemoryStore cleanup interval.
|
|
96
|
+
* @param {Object} app
|
|
97
|
+
*/
|
|
98
|
+
function removeKaelumRateLimit(app) {
|
|
99
|
+
const prev = app.locals && app.locals._kaelum_ratelimit;
|
|
100
|
+
if (prev) {
|
|
101
|
+
// shut down the store's cleanup interval if present
|
|
102
|
+
if (prev._store && typeof prev._store.shutdown === "function") {
|
|
103
|
+
prev._store.shutdown();
|
|
104
|
+
}
|
|
105
|
+
removeMiddlewareByFn(app, prev);
|
|
106
|
+
app.locals._kaelum_ratelimit = null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
93
110
|
/**
|
|
94
111
|
* Apply configuration options to the app
|
|
95
112
|
* @param {Object} app - express app instance
|
|
@@ -257,6 +274,27 @@ function setConfig(app, options = {}) {
|
|
|
257
274
|
}
|
|
258
275
|
}
|
|
259
276
|
|
|
277
|
+
// --- Rate Limiting ---
|
|
278
|
+
if (options.hasOwnProperty("rateLimit")) {
|
|
279
|
+
if (options.rateLimit) {
|
|
280
|
+
const { createRateLimiter } = require("./rateLimit");
|
|
281
|
+
const rateLimitOpts =
|
|
282
|
+
options.rateLimit === true ? {} : options.rateLimit;
|
|
283
|
+
|
|
284
|
+
// remove previous Kaelum-installed rate limiter if exists
|
|
285
|
+
removeKaelumRateLimit(app);
|
|
286
|
+
|
|
287
|
+
const rateLimitFn = createRateLimiter(rateLimitOpts);
|
|
288
|
+
app.locals._kaelum_ratelimit = rateLimitFn;
|
|
289
|
+
app.use(rateLimitFn);
|
|
290
|
+
console.log("⏱️ Rate limiting activated.");
|
|
291
|
+
} else {
|
|
292
|
+
// disable Kaelum-installed rate limiter if present
|
|
293
|
+
removeKaelumRateLimit(app);
|
|
294
|
+
console.log("⏱️ Rate limiting disabled (Kaelum-managed).");
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
260
298
|
// --- Graceful Shutdown ---
|
|
261
299
|
if (options.hasOwnProperty("gracefulShutdown")) {
|
|
262
300
|
const server = app.locals && app.locals._kaelum_server;
|
package/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ interface KaelumConfig {
|
|
|
11
11
|
views?: { engine?: string; path?: string };
|
|
12
12
|
logger?: boolean | false;
|
|
13
13
|
gracefulShutdown?: boolean | GracefulShutdownConfig;
|
|
14
|
+
rateLimit?: boolean | RateLimitConfig;
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
interface HealthOptions {
|
|
@@ -40,6 +41,29 @@ interface GracefulShutdownConfig {
|
|
|
40
41
|
signals?: string[];
|
|
41
42
|
}
|
|
42
43
|
|
|
44
|
+
interface RateLimitConfig {
|
|
45
|
+
/** Window duration in ms (default: 900000 = 15 min) */
|
|
46
|
+
windowMs?: number;
|
|
47
|
+
/** Max requests per window per key (default: 100) */
|
|
48
|
+
max?: number;
|
|
49
|
+
/** Response body when rate limited */
|
|
50
|
+
message?: string | object;
|
|
51
|
+
/** HTTP status when rate limited (default: 429) */
|
|
52
|
+
statusCode?: number;
|
|
53
|
+
/** Custom key generator (default: req.ip) */
|
|
54
|
+
keyGenerator?: (req: any) => string;
|
|
55
|
+
/** Skip rate limiting for certain requests */
|
|
56
|
+
skip?: (req: any) => boolean;
|
|
57
|
+
/** Send standard rate-limit headers (default: true) */
|
|
58
|
+
headers?: boolean;
|
|
59
|
+
/** Custom store (must implement increment, resetKey, shutdown) */
|
|
60
|
+
store?: {
|
|
61
|
+
increment(key: string): { totalHits: number; resetTime: number };
|
|
62
|
+
resetKey(key: string): void;
|
|
63
|
+
shutdown(): void;
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
43
67
|
interface RedirectEntry {
|
|
44
68
|
path: string;
|
|
45
69
|
to: string;
|