nextlimiter 1.0.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.
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Built-in SaaS plan definitions.
5
+ * These can be overridden by passing `plans` in createLimiter options.
6
+ */
7
+ const DEFAULT_PLANS = {
8
+ free: {
9
+ windowMs: 60_000, // 1 minute
10
+ max: 60, // 60 req/min
11
+ burstMax: 10, // allow short burst of 10 extra
12
+ description: 'Free tier — 60 requests per minute',
13
+ },
14
+ pro: {
15
+ windowMs: 60_000,
16
+ max: 600,
17
+ burstMax: 100,
18
+ description: 'Pro tier — 600 requests per minute',
19
+ },
20
+ enterprise: {
21
+ windowMs: 60_000,
22
+ max: 6000,
23
+ burstMax: 1000,
24
+ description: 'Enterprise tier — 6000 requests per minute',
25
+ },
26
+ };
27
+
28
+ /**
29
+ * Named presets for quick configuration.
30
+ *
31
+ * @example
32
+ * createLimiter({ preset: 'strict' })
33
+ */
34
+ const PRESETS = {
35
+ strict: {
36
+ windowMs: 60_000,
37
+ max: 30,
38
+ strategy: 'sliding-window',
39
+ smart: true,
40
+ },
41
+ relaxed: {
42
+ windowMs: 60_000,
43
+ max: 300,
44
+ strategy: 'token-bucket',
45
+ smart: false,
46
+ },
47
+ api: {
48
+ windowMs: 60_000,
49
+ max: 100,
50
+ strategy: 'sliding-window',
51
+ smart: true,
52
+ keyBy: 'api-key',
53
+ },
54
+ auth: {
55
+ windowMs: 15 * 60_000, // 15 minutes
56
+ max: 10, // only 10 attempts per 15 min
57
+ strategy: 'fixed-window',
58
+ smart: true,
59
+ message: 'Too many authentication attempts. Please try again in 15 minutes.',
60
+ },
61
+ };
62
+
63
+ /**
64
+ * Default configuration merged with user options.
65
+ */
66
+ const DEFAULT_CONFIG = {
67
+ windowMs: 60_000,
68
+ max: 100,
69
+ strategy: 'sliding-window',
70
+ keyBy: 'ip',
71
+ keyPrefix: 'nexlimit:',
72
+ message: 'Too many requests, please try again later.',
73
+ statusCode: 429,
74
+ headers: true,
75
+ smart: false,
76
+ smartThreshold: 2.0, // trigger smart limiting at 2x normal rate
77
+ smartCooldownMs: 60_000, // how long smart penalty lasts
78
+ smartPenaltyFactor: 0.5, // reduce limit to 50% for suspicious keys
79
+ logging: false,
80
+ logPrefix: '[NexLimit]',
81
+ skip: null, // (req) => bool — skip rate limiting
82
+ onLimitReached: null, // (req, res, info) => void
83
+ store: null, // custom store instance
84
+ plan: null, // 'free' | 'pro' | 'enterprise' | null
85
+ plans: DEFAULT_PLANS, // plan map — override to define custom plans
86
+ preset: null, // 'strict' | 'relaxed' | 'api' | 'auth'
87
+ keyGenerator: null, // (req) => string — custom key fn
88
+ };
89
+
90
+ /**
91
+ * Resolve final config by merging preset → plan → user options → defaults.
92
+ * @param {object} userOptions
93
+ * @returns {object}
94
+ */
95
+ function resolveConfig(userOptions = {}) {
96
+ let base = { ...DEFAULT_CONFIG };
97
+
98
+ // Apply named preset first (lowest priority)
99
+ if (userOptions.preset && PRESETS[userOptions.preset]) {
100
+ base = { ...base, ...PRESETS[userOptions.preset] };
101
+ }
102
+
103
+ // Merge user options
104
+ base = { ...base, ...userOptions };
105
+
106
+ // Apply plan limits (overrides windowMs and max if plan is set)
107
+ if (base.plan) {
108
+ const planDefs = base.plans || DEFAULT_PLANS;
109
+ const planCfg = planDefs[base.plan];
110
+ if (!planCfg) {
111
+ throw new Error(
112
+ `[NexLimit] Unknown plan "${base.plan}". Available: ${Object.keys(planDefs).join(', ')}`
113
+ );
114
+ }
115
+ base.windowMs = planCfg.windowMs;
116
+ base.max = planCfg.max;
117
+ base._burstMax = planCfg.burstMax;
118
+ }
119
+
120
+ // Validate
121
+ if (base.max <= 0) throw new Error('[NexLimit] config.max must be greater than 0');
122
+ if (base.windowMs <= 0) throw new Error('[NexLimit] config.windowMs must be greater than 0');
123
+
124
+ return base;
125
+ }
126
+
127
+ module.exports = { DEFAULT_CONFIG, DEFAULT_PLANS, PRESETS, resolveConfig };
@@ -0,0 +1,229 @@
1
+ 'use strict';
2
+
3
+ const { resolveConfig } = require('./config');
4
+ const { MemoryStore } = require('../store/memoryStore');
5
+ const { fixedWindowCheck } = require('../strategies/fixedWindow');
6
+ const { slidingWindowCheck } = require('../strategies/slidingWindow');
7
+ const { tokenBucketCheck } = require('../strategies/tokenBucket');
8
+ const { resolveKeyGenerator } = require('../utils/keyGenerator');
9
+ const { createLogger } = require('../utils/logger');
10
+ const { AnalyticsTracker } = require('../analytics/tracker');
11
+ const { SmartDetector } = require('../smart/detector');
12
+ const { setHeaders } = require('../middleware/headers');
13
+
14
+ const STRATEGY_MAP = {
15
+ 'fixed-window': fixedWindowCheck,
16
+ 'sliding-window': slidingWindowCheck,
17
+ 'token-bucket': tokenBucketCheck,
18
+ };
19
+
20
+ /**
21
+ * NexLimit — the main Limiter class.
22
+ *
23
+ * Instantiate via `createLimiter(options)` or `autoLimit()`.
24
+ * Do not call `new Limiter()` directly in application code.
25
+ *
26
+ * @example
27
+ * const limiter = createLimiter({ windowMs: 60_000, max: 100 });
28
+ * app.use(limiter.middleware());
29
+ *
30
+ * // Programmatic check
31
+ * const result = await limiter.check('user:42');
32
+ */
33
+ class Limiter {
34
+ /**
35
+ * @param {object} options - NexLimit configuration (see config.js for defaults)
36
+ */
37
+ constructor(options = {}) {
38
+ this._config = resolveConfig(options);
39
+
40
+ // Storage backend
41
+ this._store = this._config.store || new MemoryStore();
42
+
43
+ // Strategy function
44
+ const strategyFn = STRATEGY_MAP[this._config.strategy];
45
+ if (!strategyFn) {
46
+ throw new Error(
47
+ `[NexLimit] Unknown strategy "${this._config.strategy}". ` +
48
+ `Valid options: ${Object.keys(STRATEGY_MAP).join(', ')}`
49
+ );
50
+ }
51
+ this._strategy = strategyFn;
52
+
53
+ // Key generator
54
+ const keyByFn = this._config.keyGenerator || resolveKeyGenerator(this._config.keyBy);
55
+ this._keyGenerator = keyByFn;
56
+
57
+ // Logger
58
+ this._log = createLogger(this._config.logPrefix, this._config.logging);
59
+
60
+ // Analytics
61
+ this._analytics = new AnalyticsTracker();
62
+
63
+ // Smart detector
64
+ this._smart = this._config.smart ? new SmartDetector(this._config) : null;
65
+ }
66
+
67
+ // ── Public API ─────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Returns an Express middleware function.
71
+ *
72
+ * @returns {function} (req, res, next) => void
73
+ *
74
+ * @example
75
+ * app.use('/api', limiter.middleware());
76
+ */
77
+ middleware() {
78
+ return async (req, res, next) => {
79
+ try {
80
+ // Skip check if skip() returns true
81
+ if (this._config.skip && this._config.skip(req)) {
82
+ return next();
83
+ }
84
+
85
+ const rawKey = this._keyGenerator(req);
86
+ const key = `${this._config.keyPrefix}${rawKey}`;
87
+
88
+ const result = await this._runCheck(key);
89
+
90
+ // Record analytics
91
+ this._analytics.record(key, result.allowed);
92
+
93
+ // Set headers
94
+ if (this._config.headers) {
95
+ setHeaders(res, result, !result.allowed);
96
+ }
97
+
98
+ if (!result.allowed) {
99
+ this._log.blocked(
100
+ rawKey,
101
+ result.limit - result.remaining,
102
+ result.limit,
103
+ result.strategy,
104
+ result.smartBlocked
105
+ );
106
+
107
+ // Custom handler
108
+ if (this._config.onLimitReached) {
109
+ return this._config.onLimitReached(req, res, result);
110
+ }
111
+
112
+ return res.status(this._config.statusCode).json({
113
+ error: 'Too Many Requests',
114
+ message: this._config.message,
115
+ retryAfter: result.retryAfter,
116
+ limit: result.limit,
117
+ resetAt: new Date(result.resetAt).toISOString(),
118
+ });
119
+ }
120
+
121
+ next();
122
+ } catch (err) {
123
+ // Never let rate limiter errors take down the application
124
+ this._log.warn(`Error in rate limiter: ${err.message}. Failing open.`);
125
+ next();
126
+ }
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Programmatic rate limit check — use outside of HTTP middleware context.
132
+ *
133
+ * @param {string} key - The rate limit key to check
134
+ * @returns {Promise<import('../core/result').RateLimitResult>}
135
+ *
136
+ * @example
137
+ * const result = await limiter.check('user:42');
138
+ * if (!result.allowed) throw new Error('Rate limit exceeded');
139
+ */
140
+ async check(key) {
141
+ const fullKey = `${this._config.keyPrefix}${key}`;
142
+ const result = await this._runCheck(fullKey);
143
+ this._analytics.record(fullKey, result.allowed);
144
+ return result;
145
+ }
146
+
147
+ /**
148
+ * Manually reset the rate limit for a specific key.
149
+ * Useful after a user upgrades their plan, or for admin overrides.
150
+ *
151
+ * @param {string} key
152
+ */
153
+ async reset(key) {
154
+ const fullKey = `${this._config.keyPrefix}${key}`;
155
+ await this._store.delete(fullKey);
156
+ if (this._smart) this._smart.reset();
157
+ this._log.info(`Reset key: ${fullKey}`);
158
+ }
159
+
160
+ /**
161
+ * Get analytics snapshot.
162
+ *
163
+ * @returns {object}
164
+ *
165
+ * @example
166
+ * const stats = limiter.getStats();
167
+ * console.log(stats.totalRequests, stats.blockRate);
168
+ */
169
+ getStats() {
170
+ const stats = this._analytics.getStats();
171
+
172
+ if (this._smart) {
173
+ stats.smartLimiting = {
174
+ enabled: true,
175
+ penalizedKeys: this._smart.getPenalizedKeys(),
176
+ };
177
+ }
178
+
179
+ stats.config = {
180
+ strategy: this._config.strategy,
181
+ windowMs: this._config.windowMs,
182
+ max: this._config.max,
183
+ keyBy: this._config.keyBy,
184
+ plan: this._config.plan || 'custom',
185
+ smart: this._config.smart,
186
+ };
187
+
188
+ return stats;
189
+ }
190
+
191
+ /**
192
+ * Reset all analytics counters.
193
+ */
194
+ resetStats() {
195
+ this._analytics.reset();
196
+ }
197
+
198
+ /**
199
+ * Expose the resolved configuration (read-only).
200
+ * @returns {object}
201
+ */
202
+ get config() {
203
+ return Object.freeze({ ...this._config });
204
+ }
205
+
206
+ // ── Private ────────────────────────────────────────────────────────────────
207
+
208
+ /**
209
+ * Run the strategy check, applying smart penalty if enabled.
210
+ * @param {string} key - Fully-prefixed key
211
+ * @returns {import('../core/result').RateLimitResult}
212
+ */
213
+ _runCheck(key) {
214
+ let effectiveConfig = this._config;
215
+
216
+ // Apply smart penalty if relevant
217
+ if (this._smart) {
218
+ const { penalized, effectiveMax } = this._smart.check(key);
219
+ if (penalized) {
220
+ // Create a shallow config override with reduced max
221
+ effectiveConfig = { ...this._config, max: effectiveMax };
222
+ }
223
+ }
224
+
225
+ return this._strategy(key, effectiveConfig, this._store);
226
+ }
227
+ }
228
+
229
+ module.exports = { Limiter };
@@ -0,0 +1,49 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Immutable result object returned by every strategy check.
5
+ * Passed to middleware for header generation and response decisions.
6
+ */
7
+ class RateLimitResult {
8
+ /**
9
+ * @param {object} params
10
+ * @param {boolean} params.allowed - Whether the request should proceed
11
+ * @param {number} params.limit - Max requests allowed in window
12
+ * @param {number} params.remaining - Remaining requests in current window
13
+ * @param {number} params.resetAt - Unix timestamp (ms) when window resets
14
+ * @param {number} params.retryAfter - Seconds until next request allowed (0 if allowed)
15
+ * @param {string} params.key - The resolved rate limit key
16
+ * @param {string} params.strategy - Strategy name used
17
+ * @param {boolean} params.smartBlocked - True if blocked by smart limiting
18
+ */
19
+ constructor({ allowed, limit, remaining, resetAt, retryAfter = 0, key, strategy, smartBlocked = false }) {
20
+ this.allowed = allowed;
21
+ this.limit = limit;
22
+ this.remaining = Math.max(0, remaining);
23
+ this.resetAt = resetAt;
24
+ this.retryAfter = retryAfter;
25
+ this.key = key;
26
+ this.strategy = strategy;
27
+ this.smartBlocked = smartBlocked;
28
+
29
+ // Freeze so nothing accidentally mutates the result downstream
30
+ Object.freeze(this);
31
+ }
32
+
33
+ /** Seconds until window resets (for Retry-After header) */
34
+ get retryAfterSeconds() {
35
+ return Math.ceil((this.resetAt - Date.now()) / 1000);
36
+ }
37
+
38
+ toJSON() {
39
+ return {
40
+ allowed: this.allowed,
41
+ limit: this.limit,
42
+ remaining: this.remaining,
43
+ resetAt: this.resetAt,
44
+ retryAfter: this.retryAfter,
45
+ };
46
+ }
47
+ }
48
+
49
+ module.exports = { RateLimitResult };
package/src/index.js ADDED
@@ -0,0 +1,106 @@
1
+ 'use strict';
2
+
3
+ const { Limiter } = require('./core/limiter');
4
+ const { PRESETS, DEFAULT_PLANS } = require('./core/config');
5
+ const { MemoryStore } = require('./store/memoryStore');
6
+
7
+ /**
8
+ * Create a fully configured rate limiter instance.
9
+ *
10
+ * @param {object} [options]
11
+ * @param {number} [options.windowMs=60000] - Time window in ms
12
+ * @param {number} [options.max=100] - Max requests per window
13
+ * @param {string} [options.strategy='sliding-window'] - 'fixed-window' | 'sliding-window' | 'token-bucket'
14
+ * @param {string} [options.keyBy='ip'] - 'ip' | 'user-id' | 'api-key' | function
15
+ * @param {string} [options.plan] - 'free' | 'pro' | 'enterprise'
16
+ * @param {string} [options.preset] - 'strict' | 'relaxed' | 'api' | 'auth'
17
+ * @param {boolean} [options.smart=false] - Enable behavior-based smart limiting
18
+ * @param {boolean} [options.logging=false] - Enable request logging
19
+ * @param {boolean} [options.headers=true] - Send X-RateLimit-* headers
20
+ * @param {string} [options.message] - Custom 429 message
21
+ * @param {number} [options.statusCode=429] - HTTP status for blocked requests
22
+ * @param {object} [options.store] - Custom store instance
23
+ * @param {function} [options.skip] - (req) => bool — skip rate limiting
24
+ * @param {function} [options.onLimitReached] - (req, res, result) => void
25
+ * @param {function} [options.keyGenerator] - (req) => string — custom key function
26
+ * @param {object} [options.plans] - Custom plan definitions
27
+ *
28
+ * @returns {Limiter}
29
+ *
30
+ * @example
31
+ * const limiter = createLimiter({ windowMs: 60_000, max: 100 });
32
+ * app.use(limiter.middleware());
33
+ */
34
+ function createLimiter(options = {}) {
35
+ return new Limiter(options);
36
+ }
37
+
38
+ /**
39
+ * Zero-config rate limiter.
40
+ * Returns an Express middleware directly — no .middleware() call needed.
41
+ *
42
+ * Defaults: 100 requests per minute, sliding window, IP-based, no logging.
43
+ *
44
+ * @param {object} [options] - Optional overrides
45
+ * @returns {function} Express middleware
46
+ *
47
+ * @example
48
+ * app.use(autoLimit());
49
+ * app.use(autoLimit({ max: 50, logging: true }));
50
+ */
51
+ function autoLimit(options = {}) {
52
+ const limiter = new Limiter({ logging: true, ...options });
53
+ return limiter.middleware();
54
+ }
55
+
56
+ /**
57
+ * Create a rate limiter pre-configured for a SaaS plan.
58
+ *
59
+ * @param {string} planName - 'free' | 'pro' | 'enterprise'
60
+ * @param {object} [options] - Additional options to merge
61
+ * @returns {Limiter}
62
+ *
63
+ * @example
64
+ * const limiter = createPlanLimiter('pro', { keyBy: 'api-key', logging: true });
65
+ * app.use('/api', limiter.middleware());
66
+ */
67
+ function createPlanLimiter(planName, options = {}) {
68
+ return new Limiter({ ...options, plan: planName });
69
+ }
70
+
71
+ /**
72
+ * Create a rate limiter from a named preset.
73
+ *
74
+ * @param {string} presetName - 'strict' | 'relaxed' | 'api' | 'auth'
75
+ * @param {object} [options] - Overrides applied on top of the preset
76
+ * @returns {Limiter}
77
+ *
78
+ * @example
79
+ * app.post('/login', createPresetLimiter('auth').middleware());
80
+ */
81
+ function createPresetLimiter(presetName, options = {}) {
82
+ if (!PRESETS[presetName]) {
83
+ throw new Error(
84
+ `[NexLimit] Unknown preset "${presetName}". Available: ${Object.keys(PRESETS).join(', ')}`
85
+ );
86
+ }
87
+ return new Limiter({ ...options, preset: presetName });
88
+ }
89
+
90
+ module.exports = {
91
+ // Core factory functions
92
+ createLimiter,
93
+ autoLimit,
94
+ createPlanLimiter,
95
+ createPresetLimiter,
96
+
97
+ // Class — for advanced usage / subclassing
98
+ Limiter,
99
+
100
+ // Storage
101
+ MemoryStore,
102
+
103
+ // Constants
104
+ PRESETS,
105
+ DEFAULT_PLANS,
106
+ };
@@ -0,0 +1,29 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Apply standard rate limit headers to an Express response.
5
+ *
6
+ * Headers set:
7
+ * X-RateLimit-Limit — max requests per window
8
+ * X-RateLimit-Remaining — remaining requests in current window
9
+ * X-RateLimit-Reset — Unix timestamp (seconds) when window resets
10
+ * X-RateLimit-Strategy — algorithm name (informational)
11
+ * Retry-After — seconds to wait (only on 429 responses)
12
+ *
13
+ * @param {object} res - Express response object
14
+ * @param {object} result - RateLimitResult instance
15
+ * @param {boolean} includeRetry - Whether to add Retry-After header
16
+ */
17
+ function setHeaders(res, result, includeRetry = false) {
18
+ res.setHeader('X-RateLimit-Limit', result.limit);
19
+ res.setHeader('X-RateLimit-Remaining', result.remaining);
20
+ res.setHeader('X-RateLimit-Reset', Math.ceil(result.resetAt / 1000));
21
+ res.setHeader('X-RateLimit-Strategy', result.strategy);
22
+
23
+ if (includeRetry && !result.allowed) {
24
+ const retrySeconds = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
25
+ res.setHeader('Retry-After', retrySeconds);
26
+ }
27
+ }
28
+
29
+ module.exports = { setHeaders };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * SmartDetector — behavior-based dynamic rate limiting.
5
+ *
6
+ * Detects anomalous traffic patterns and temporarily reduces the limit
7
+ * for flagged keys without blocking them entirely.
8
+ *
9
+ * Detection logic:
10
+ * 1. Track request rate for each key over a short observation window
11
+ * 2. If the observed rate exceeds (normalRate × smartThreshold), flag the key
12
+ * 3. Flagged keys get a reduced limit: floor(max × smartPenaltyFactor)
13
+ * 4. The penalty expires after smartCooldownMs
14
+ *
15
+ * This lets legitimate burst-y users (e.g. someone running a data export)
16
+ * slow down gracefully rather than hitting a hard wall.
17
+ */
18
+ class SmartDetector {
19
+ /**
20
+ * @param {object} config - Resolved NexLimit config
21
+ */
22
+ constructor(config) {
23
+ this._config = config;
24
+
25
+ // Map: key → { windowStart, count, penalized, penaltyExpires }
26
+ /** @type {Map<string, object>} */
27
+ this._state = new Map();
28
+
29
+ // Observation window = 10% of the rate limit window (min 1s, max 30s)
30
+ this._observationMs = Math.min(
31
+ 30_000,
32
+ Math.max(1_000, Math.floor(config.windowMs * 0.1))
33
+ );
34
+ }
35
+
36
+ /**
37
+ * Check whether a key should be penalized and what its effective limit is.
38
+ *
39
+ * @param {string} key
40
+ * @returns {{ penalized: boolean, effectiveMax: number }}
41
+ */
42
+ check(key) {
43
+ const now = Date.now();
44
+ const config = this._config;
45
+ let state = this._state.get(key);
46
+
47
+ if (!state) {
48
+ state = {
49
+ windowStart: now,
50
+ count: 0,
51
+ penalized: false,
52
+ penaltyExpires: 0,
53
+ };
54
+ this._state.set(key, state);
55
+ }
56
+
57
+ // --- Record this request in the observation window ---
58
+ if (now - state.windowStart > this._observationMs) {
59
+ // Slide the window
60
+ const normalRatePerObsWindow =
61
+ (config.max / config.windowMs) * this._observationMs;
62
+
63
+ // Was the previous window anomalous?
64
+ if (state.count > normalRatePerObsWindow * config.smartThreshold) {
65
+ state.penalized = true;
66
+ state.penaltyExpires = now + config.smartCooldownMs;
67
+ }
68
+
69
+ // Reset observation window
70
+ state.windowStart = now;
71
+ state.count = 0;
72
+ }
73
+
74
+ state.count++;
75
+
76
+ // --- Check if currently under penalty ---
77
+ if (state.penalized && now < state.penaltyExpires) {
78
+ return {
79
+ penalized: true,
80
+ effectiveMax: Math.max(1, Math.floor(config.max * config.smartPenaltyFactor)),
81
+ };
82
+ }
83
+
84
+ // Penalty expired — lift it
85
+ if (state.penalized && now >= state.penaltyExpires) {
86
+ state.penalized = false;
87
+ }
88
+
89
+ return { penalized: false, effectiveMax: config.max };
90
+ }
91
+
92
+ /**
93
+ * Return a snapshot of currently penalized keys.
94
+ * @returns {Array<{key: string, expiresAt: number, remainingMs: number}>}
95
+ */
96
+ getPenalizedKeys() {
97
+ const now = Date.now();
98
+ const result = [];
99
+ for (const [key, state] of this._state) {
100
+ if (state.penalized && now < state.penaltyExpires) {
101
+ result.push({
102
+ key,
103
+ expiresAt: state.penaltyExpires,
104
+ remainingMs: state.penaltyExpires - now,
105
+ });
106
+ }
107
+ }
108
+ return result;
109
+ }
110
+
111
+ /** Clear all tracking state. */
112
+ reset() {
113
+ this._state.clear();
114
+ }
115
+ }
116
+
117
+ module.exports = { SmartDetector };