nextlimiter 1.1.0 → 1.2.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/package.json +1 -1
- package/src/core/config.js +83 -8
- package/src/core/limiter.js +54 -9
- package/src/core/ruleEngine.js +89 -0
- package/src/core/scheduler.js +49 -0
- package/src/webhook/sender.js +93 -0
- package/types/index.d.ts +43 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nextlimiter",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Production-ready rate limiting for Node.js — sliding window, token bucket, SaaS plans, smart limiting, and built-in analytics.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "types/index.d.ts",
|
package/src/core/config.js
CHANGED
|
@@ -88,6 +88,15 @@ const DEFAULT_CONFIG = {
|
|
|
88
88
|
whitelist: null, // string[] — IPs/CIDRs that bypass rate limiting
|
|
89
89
|
blacklist: null, // string[] — IPs/CIDRs that always get 403
|
|
90
90
|
statsInterval: undefined, // ms interval to emit 'stats' event
|
|
91
|
+
|
|
92
|
+
rules: null, // RuleConfig[] — multiple rate limit constraints
|
|
93
|
+
schedule: null, // ScheduleEntry[] — time-based config overrides
|
|
94
|
+
|
|
95
|
+
webhook: null,
|
|
96
|
+
webhookRetries: 3,
|
|
97
|
+
webhookBackoff: 'exponential',
|
|
98
|
+
webhookTimeout: 5000,
|
|
99
|
+
webhookSecret: null,
|
|
91
100
|
};
|
|
92
101
|
|
|
93
102
|
/**
|
|
@@ -98,13 +107,31 @@ const DEFAULT_CONFIG = {
|
|
|
98
107
|
function resolveConfig(userOptions = {}) {
|
|
99
108
|
let base = { ...DEFAULT_CONFIG };
|
|
100
109
|
|
|
101
|
-
// Apply
|
|
102
|
-
if (userOptions.preset
|
|
103
|
-
|
|
110
|
+
// Apply preset
|
|
111
|
+
if (userOptions.preset) { // Use userOptions.preset here to ensure it's applied
|
|
112
|
+
const presetCfg = PRESETS[userOptions.preset];
|
|
113
|
+
if (!presetCfg) throw new Error(`[NextLimiter] Unknown preset: ${userOptions.preset}`);
|
|
114
|
+
// Merge preset config into base, allowing userOptions to override later
|
|
115
|
+
base = { ...base, ...presetCfg };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Mutually exclusive core configurations
|
|
119
|
+
const hasRules = userOptions.rules !== undefined && userOptions.rules !== null;
|
|
120
|
+
const hasSchedule = userOptions.schedule !== undefined && userOptions.schedule !== null;
|
|
121
|
+
const hasMax = userOptions.max !== undefined && userOptions.max !== null;
|
|
122
|
+
|
|
123
|
+
if (hasRules && hasSchedule) {
|
|
124
|
+
throw new Error('[NextLimiter] config.rules and config.schedule are mutually exclusive.');
|
|
125
|
+
}
|
|
126
|
+
if (hasRules && hasMax) {
|
|
127
|
+
throw new Error('[NextLimiter] config.max/windowMs and config.rules are mutually exclusive.');
|
|
128
|
+
}
|
|
129
|
+
if (hasSchedule && hasMax) {
|
|
130
|
+
throw new Error('[NextLimiter] config.max/windowMs and config.schedule are mutually exclusive.');
|
|
104
131
|
}
|
|
105
132
|
|
|
106
|
-
//
|
|
107
|
-
base
|
|
133
|
+
// Apply user overrides
|
|
134
|
+
Object.assign(base, userOptions);
|
|
108
135
|
|
|
109
136
|
// Apply plan limits (overrides windowMs and max if plan is set)
|
|
110
137
|
if (base.plan) {
|
|
@@ -120,9 +147,45 @@ function resolveConfig(userOptions = {}) {
|
|
|
120
147
|
base._burstMax = planCfg.burstMax;
|
|
121
148
|
}
|
|
122
149
|
|
|
123
|
-
//
|
|
124
|
-
if (base.
|
|
125
|
-
|
|
150
|
+
// Deep validate
|
|
151
|
+
if (base.rules) {
|
|
152
|
+
if (!Array.isArray(base.rules) || base.rules.length === 0) {
|
|
153
|
+
throw new Error('[NextLimiter] config.rules must be a non-empty array.');
|
|
154
|
+
}
|
|
155
|
+
for (const rule of base.rules) {
|
|
156
|
+
if (!rule.keyBy) throw new Error('[NextLimiter] Each rule must have a keyBy property.');
|
|
157
|
+
if (typeof rule.max !== 'number' || rule.max <= 0) throw new Error('[NextLimiter] Each rule must have a positive max integer.');
|
|
158
|
+
if (typeof rule.windowMs !== 'number' || rule.windowMs <= 0) throw new Error('[NextLimiter] Each rule must have a positive windowMs integer.');
|
|
159
|
+
}
|
|
160
|
+
} else if (base.schedule) {
|
|
161
|
+
if (!Array.isArray(base.schedule) || base.schedule.length === 0) {
|
|
162
|
+
throw new Error('[NextLimiter] config.schedule must be a non-empty array.');
|
|
163
|
+
}
|
|
164
|
+
let prevEnd = -1;
|
|
165
|
+
for (let i = 0; i < base.schedule.length; i++) {
|
|
166
|
+
const entry = base.schedule[i];
|
|
167
|
+
if (!entry.hours) throw new Error('[NextLimiter] Each schedule entry must have an hours string (e.g., "9-17").');
|
|
168
|
+
if (typeof entry.max !== 'number' || entry.max <= 0) throw new Error('[NextLimiter] Each schedule entry must have a positive max integer.');
|
|
169
|
+
|
|
170
|
+
const match = /^\s*(\d{1,2})\s*-\s*(\d{1,2})\s*$/.exec(entry.hours);
|
|
171
|
+
if (!match) throw new Error(`[NextLimiter] Invalid hours format: ${entry.hours}`);
|
|
172
|
+
let s = parseInt(match[1], 10), e = parseInt(match[2], 10);
|
|
173
|
+
if (s < 0 || s > 23 || e < 0 || e > 23) throw new Error('[NextLimiter] Schedule hours must be between 0 and 23. Got: ' + entry.hours);
|
|
174
|
+
if (e < s) throw new Error(`[NextLimiter] Invalid schedule: end hour (${e}) cannot be less than start hour (${s}).`);
|
|
175
|
+
|
|
176
|
+
// Basic overlap check
|
|
177
|
+
if (s <= prevEnd) console.warn(`[NextLimiter] Warning: Overlapping schedule entries detected.`);
|
|
178
|
+
prevEnd = e;
|
|
179
|
+
}
|
|
180
|
+
if (prevEnd < 23) console.warn(`[NextLimiter] Warning: Schedule entries do not cover a full 24-hr cycle.`);
|
|
181
|
+
// Base max/windowMs must still be valid for fallback
|
|
182
|
+
if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
|
|
183
|
+
if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
|
|
184
|
+
} else {
|
|
185
|
+
// Standard mode validation
|
|
186
|
+
if (base.max <= 0) throw new Error('[NextLimiter] config.max must be greater than 0');
|
|
187
|
+
if (base.windowMs <= 0) throw new Error('[NextLimiter] config.windowMs must be greater than 0');
|
|
188
|
+
}
|
|
126
189
|
|
|
127
190
|
// Validate whitelist / blacklist (warn, never throw)
|
|
128
191
|
for (const listName of ['whitelist', 'blacklist']) {
|
|
@@ -159,6 +222,18 @@ function resolveConfig(userOptions = {}) {
|
|
|
159
222
|
}
|
|
160
223
|
}
|
|
161
224
|
|
|
225
|
+
// Validate webhook
|
|
226
|
+
if (base.webhook) {
|
|
227
|
+
if (!base.webhook.startsWith('http://') && !base.webhook.startsWith('https://')) {
|
|
228
|
+
throw new Error('[NextLimiter] config.webhook must be a valid URL starting with http:// or https://');
|
|
229
|
+
}
|
|
230
|
+
if (base.webhook.startsWith('http://')) {
|
|
231
|
+
console.warn('[NextLimiter] Warning: Webhook URL is using insecure http://. Consider switching to https://');
|
|
232
|
+
}
|
|
233
|
+
if (base.webhookRetries < 0 || base.webhookRetries > 10) base.webhookRetries = 3;
|
|
234
|
+
if (base.webhookTimeout < 500) base.webhookTimeout = 5000;
|
|
235
|
+
}
|
|
236
|
+
|
|
162
237
|
return base;
|
|
163
238
|
}
|
|
164
239
|
|
package/src/core/limiter.js
CHANGED
|
@@ -14,6 +14,9 @@ const { SmartDetector } = require('../smart/detector');
|
|
|
14
14
|
const { setHeaders } = require('../middleware/headers');
|
|
15
15
|
const { checkAccess } = require('./accessControl');
|
|
16
16
|
const { PrometheusFormatter } = require('../analytics/prometheus');
|
|
17
|
+
const { RuleEngine } = require('./ruleEngine');
|
|
18
|
+
const { WebhookSender } = require('../webhook/sender');
|
|
19
|
+
const { Scheduler } = require('./scheduler');
|
|
17
20
|
|
|
18
21
|
const STRATEGY_MAP = {
|
|
19
22
|
'fixed-window': fixedWindowCheck,
|
|
@@ -77,6 +80,11 @@ class Limiter extends EventEmitter {
|
|
|
77
80
|
this.emit('stats', this.getStats());
|
|
78
81
|
}, this._config.statsInterval).unref();
|
|
79
82
|
}
|
|
83
|
+
|
|
84
|
+
// Engine, Scheduler & Webhook
|
|
85
|
+
this.ruleEngine = this._config.rules ? new RuleEngine(this._config.rules, this._store, this, this._config) : null;
|
|
86
|
+
this.scheduler = this._config.schedule ? new Scheduler(this._config.schedule, this._config) : null;
|
|
87
|
+
this.webhookSender = this._config.webhook ? new WebhookSender(this._config) : null;
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
// ── Public API ─────────────────────────────────────────────────────────────
|
|
@@ -114,19 +122,54 @@ class Limiter extends EventEmitter {
|
|
|
114
122
|
}
|
|
115
123
|
// ────────────────────────────────────────────────────────────────────
|
|
116
124
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
let result;
|
|
126
|
+
let rawKey;
|
|
127
|
+
let key;
|
|
128
|
+
let failedRuleName;
|
|
129
|
+
|
|
130
|
+
if (this.ruleEngine) {
|
|
131
|
+
const ruleResult = await this.ruleEngine.check(req);
|
|
132
|
+
result = ruleResult.mostRestrictive;
|
|
133
|
+
rawKey = ruleResult.key; // composite key
|
|
134
|
+
key = ruleResult.key;
|
|
135
|
+
failedRuleName = ruleResult.failedRule ? ruleResult.failedRule.name : undefined;
|
|
136
|
+
|
|
137
|
+
if (this._config.headers) {
|
|
138
|
+
res.setHeader('X-RateLimit-Rule-Count', ruleResult.results.length);
|
|
139
|
+
if (!ruleResult.allowed && failedRuleName) {
|
|
140
|
+
res.setHeader('X-RateLimit-Failed-Rule', failedRuleName);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
rawKey = this._keyGenerator(req);
|
|
145
|
+
key = `${this._config.keyPrefix}${rawKey}`;
|
|
146
|
+
result = await this._runCheck(key);
|
|
147
|
+
}
|
|
121
148
|
|
|
122
149
|
// Emit events
|
|
123
|
-
if (result.allowed)
|
|
124
|
-
|
|
150
|
+
if (result.allowed) {
|
|
151
|
+
this.emit('allowed', rawKey, result);
|
|
152
|
+
} else {
|
|
153
|
+
this.emit('blocked', rawKey, result);
|
|
154
|
+
if (this.webhookSender) {
|
|
155
|
+
this.webhookSender.send({
|
|
156
|
+
event: 'blocked',
|
|
157
|
+
key,
|
|
158
|
+
ip: clientIp,
|
|
159
|
+
limit: result.limit,
|
|
160
|
+
count: result.limit - result.remaining,
|
|
161
|
+
timestamp: new Date().toISOString(),
|
|
162
|
+
retryAfter: result.retryAfter,
|
|
163
|
+
strategy: result.strategy,
|
|
164
|
+
...(failedRuleName ? { ruleName: failedRuleName } : {})
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
125
168
|
|
|
126
169
|
// Record analytics
|
|
127
170
|
this._analytics.record(key, result.allowed);
|
|
128
171
|
|
|
129
|
-
// Set headers
|
|
172
|
+
// Set headers (base headers overrides single rule headers if rule engine is active, that is fine)
|
|
130
173
|
if (this._config.headers) {
|
|
131
174
|
setHeaders(res, result);
|
|
132
175
|
}
|
|
@@ -307,6 +350,8 @@ class Limiter extends EventEmitter {
|
|
|
307
350
|
};
|
|
308
351
|
}
|
|
309
352
|
|
|
353
|
+
stats.activeSchedule = this.scheduler ? this.scheduler.resolve() : null;
|
|
354
|
+
|
|
310
355
|
stats.config = {
|
|
311
356
|
strategy: this._config.strategy,
|
|
312
357
|
windowMs: this._config.windowMs,
|
|
@@ -342,14 +387,14 @@ class Limiter extends EventEmitter {
|
|
|
342
387
|
* @returns {import('../core/result').RateLimitResult}
|
|
343
388
|
*/
|
|
344
389
|
_runCheck(key) {
|
|
345
|
-
let effectiveConfig = this._config;
|
|
390
|
+
let effectiveConfig = this.scheduler ? this.scheduler.resolve() : this._config;
|
|
346
391
|
|
|
347
392
|
// Apply smart penalty if relevant
|
|
348
393
|
if (this._smart) {
|
|
349
394
|
const { penalized, effectiveMax } = this._smart.check(key);
|
|
350
395
|
if (penalized) {
|
|
351
396
|
// Create a shallow config override with reduced max
|
|
352
|
-
effectiveConfig = { ...
|
|
397
|
+
effectiveConfig = { ...effectiveConfig, max: effectiveMax };
|
|
353
398
|
}
|
|
354
399
|
}
|
|
355
400
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const { resolveKeyGenerator } = require('../utils/keyGenerator');
|
|
3
|
+
const { fixedWindowCheck } = require('../strategies/fixedWindow');
|
|
4
|
+
const { slidingWindowCheck } = require('../strategies/slidingWindow');
|
|
5
|
+
const { tokenBucketCheck } = require('../strategies/tokenBucket');
|
|
6
|
+
|
|
7
|
+
const STRATEGY_MAP = {
|
|
8
|
+
'fixed-window': fixedWindowCheck,
|
|
9
|
+
'sliding-window': slidingWindowCheck,
|
|
10
|
+
'token-bucket': tokenBucketCheck,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class RuleEngine {
|
|
14
|
+
constructor(rules, store, emitter, globalConfig) {
|
|
15
|
+
this._rules = rules.map((r, i) => {
|
|
16
|
+
const strategyName = r.strategy || 'sliding-window';
|
|
17
|
+
const strategyFn = STRATEGY_MAP[strategyName];
|
|
18
|
+
if (!strategyFn) throw new Error(`[NextLimiter] Unknown strategy in rule: ${strategyName}`);
|
|
19
|
+
return {
|
|
20
|
+
...r,
|
|
21
|
+
name: r.name || `rule${i}`,
|
|
22
|
+
index: i,
|
|
23
|
+
keyGenerator: typeof r.keyBy === 'function' ? r.keyBy : resolveKeyGenerator(r.keyBy),
|
|
24
|
+
strategyFn
|
|
25
|
+
};
|
|
26
|
+
});
|
|
27
|
+
this._store = store;
|
|
28
|
+
this._emitter = emitter;
|
|
29
|
+
this._globalConfig = globalConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async check(req) {
|
|
33
|
+
const promises = this._rules.map(rule => {
|
|
34
|
+
const rawKey = rule.keyGenerator(req);
|
|
35
|
+
const fullKey = `${this._globalConfig.keyPrefix}${rule.name}:${rule.keyBy}:${rawKey}`;
|
|
36
|
+
|
|
37
|
+
const config = { max: rule.max, windowMs: rule.windowMs };
|
|
38
|
+
return Promise.resolve(rule.strategyFn(fullKey, config, this._store)).then(result => {
|
|
39
|
+
return {
|
|
40
|
+
...result,
|
|
41
|
+
key: fullKey,
|
|
42
|
+
rawKey,
|
|
43
|
+
rule
|
|
44
|
+
};
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const results = await Promise.all(promises);
|
|
49
|
+
|
|
50
|
+
let allowed = true;
|
|
51
|
+
let failedRule = null;
|
|
52
|
+
let mostRestrictive = results[0];
|
|
53
|
+
let mostRestrictiveRemaining = Infinity;
|
|
54
|
+
|
|
55
|
+
// Find the most restrictive outcome and if any caused a block.
|
|
56
|
+
for (const res of results) {
|
|
57
|
+
if (!res.allowed) {
|
|
58
|
+
allowed = false;
|
|
59
|
+
if (!failedRule) failedRule = res.rule;
|
|
60
|
+
}
|
|
61
|
+
if (res.remaining < mostRestrictiveRemaining) {
|
|
62
|
+
mostRestrictiveRemaining = res.remaining;
|
|
63
|
+
mostRestrictive = res;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!allowed) {
|
|
68
|
+
let longestRetry = -1;
|
|
69
|
+
for (const res of results) {
|
|
70
|
+
if (!res.allowed && res.retryAfter > longestRetry) {
|
|
71
|
+
longestRetry = res.retryAfter;
|
|
72
|
+
mostRestrictive = res; // prioritize the one with highest retry limit to wait for
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const compositeKey = results.map(r => r.key).join('|');
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
allowed,
|
|
81
|
+
failedRule,
|
|
82
|
+
results,
|
|
83
|
+
mostRestrictive,
|
|
84
|
+
key: compositeKey
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = { RuleEngine };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
class Scheduler {
|
|
4
|
+
constructor(schedule, baseConfig) {
|
|
5
|
+
this._schedule = schedule;
|
|
6
|
+
this._baseConfig = baseConfig;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
resolve(now = new Date()) {
|
|
10
|
+
const currentHour = this._currentHourUTC(now);
|
|
11
|
+
|
|
12
|
+
for (const entry of this._schedule) {
|
|
13
|
+
const { start, end } = this._parseHours(entry.hours);
|
|
14
|
+
if (currentHour >= start && currentHour <= end) {
|
|
15
|
+
return {
|
|
16
|
+
...this._baseConfig,
|
|
17
|
+
max: entry.max,
|
|
18
|
+
windowMs: entry.windowMs !== undefined ? entry.windowMs : this._baseConfig.windowMs,
|
|
19
|
+
strategy: entry.strategy || this._baseConfig.strategy
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return this._baseConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
_currentHourUTC(now) {
|
|
28
|
+
return now.getUTCHours();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
_parseHours(rangeStr) {
|
|
32
|
+
const match = /^\s*(\d{1,2})\s*-\s*(\d{1,2})\s*$/.exec(rangeStr);
|
|
33
|
+
if (!match) throw new Error(`[NextLimiter] Invalid schedule hours format: "${rangeStr}" (expected "start-end" e.g. "9-17")`);
|
|
34
|
+
|
|
35
|
+
const start = parseInt(match[1], 10);
|
|
36
|
+
const end = parseInt(match[2], 10);
|
|
37
|
+
|
|
38
|
+
if (start < 0 || start > 23 || end < 0 || end > 23) {
|
|
39
|
+
throw new Error(`[NextLimiter] Schedule hours must be between 0 and 23. Got: ${rangeStr}`);
|
|
40
|
+
}
|
|
41
|
+
if (end < start) {
|
|
42
|
+
throw new Error(`[NextLimiter] Schedule end hour cannot be less than start hour. Got: ${rangeStr}`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return { start, end };
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { Scheduler };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
class WebhookSender {
|
|
6
|
+
constructor(config = {}) {
|
|
7
|
+
this.webhook = config.webhook;
|
|
8
|
+
this.webhookRetries = config.webhookRetries !== undefined ? config.webhookRetries : 3;
|
|
9
|
+
this.webhookBackoff = config.webhookBackoff || 'exponential';
|
|
10
|
+
this.webhookTimeout = config.webhookTimeout || 5000;
|
|
11
|
+
this.webhookSecret = config.webhookSecret;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async send(payload) {
|
|
15
|
+
// Fire-and-forget
|
|
16
|
+
this._sendWithRetry(payload, this.webhookRetries, this._getInitialDelay()).catch(() => {
|
|
17
|
+
// Intentionally swallow errors — background fire and forget
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async _sendWithRetry(payload, attemptsLeft, delayMs) {
|
|
22
|
+
try {
|
|
23
|
+
await this._makeRequest(payload);
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (attemptsLeft > 0) {
|
|
26
|
+
await new Promise(resolve => setTimeout(resolve, delayMs));
|
|
27
|
+
const nextDelay = this._getNextDelay(delayMs);
|
|
28
|
+
await this._sendWithRetry(payload, attemptsLeft - 1, nextDelay);
|
|
29
|
+
} else {
|
|
30
|
+
throw err;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
_getInitialDelay() {
|
|
36
|
+
if (this.webhookBackoff === 'linear' || this.webhookBackoff === 'fixed') return 1000;
|
|
37
|
+
return 100; // exponential
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_getNextDelay(currentDelay) {
|
|
41
|
+
if (this.webhookBackoff === 'linear') return currentDelay + 1000;
|
|
42
|
+
if (this.webhookBackoff === 'exponential') return currentDelay * 2;
|
|
43
|
+
return currentDelay; // fixed
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
_makeRequest(payload) {
|
|
47
|
+
return new Promise((resolve, reject) => {
|
|
48
|
+
const body = JSON.stringify(payload);
|
|
49
|
+
const urlObj = new URL(this.webhook);
|
|
50
|
+
|
|
51
|
+
const options = {
|
|
52
|
+
hostname: urlObj.hostname,
|
|
53
|
+
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
|
54
|
+
path: urlObj.pathname + urlObj.search,
|
|
55
|
+
method: 'POST',
|
|
56
|
+
headers: {
|
|
57
|
+
'Content-Type': 'application/json',
|
|
58
|
+
'Content-Length': Buffer.byteLength(body)
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
if (this.webhookSecret) {
|
|
63
|
+
const sig = crypto.createHmac('sha256', this.webhookSecret).update(body).digest('hex');
|
|
64
|
+
options.headers['X-nextlimiter-signature'] = `sha256=${sig}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// We only support https natively as per requirement, though http objects won't crash here they just might fail protocol
|
|
68
|
+
const req = https.request(options, (res) => {
|
|
69
|
+
let responseBody = '';
|
|
70
|
+
res.on('data', chunk => { responseBody += chunk; });
|
|
71
|
+
res.on('end', () => {
|
|
72
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
73
|
+
resolve({ statusCode: res.statusCode, body: responseBody });
|
|
74
|
+
} else {
|
|
75
|
+
reject(new Error(`Webhook failed with status: ${res.statusCode}`));
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
req.on('error', (err) => reject(err));
|
|
81
|
+
|
|
82
|
+
req.setTimeout(this.webhookTimeout, () => {
|
|
83
|
+
req.destroy();
|
|
84
|
+
reject(new Error(`Webhook timed out after ${this.webhookTimeout}ms`));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
req.write(body);
|
|
88
|
+
req.end();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { WebhookSender };
|
package/types/index.d.ts
CHANGED
|
@@ -23,6 +23,41 @@ export interface PlanDefinition {
|
|
|
23
23
|
description?: string;
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
export interface RuleConfig {
|
|
27
|
+
keyBy: 'ip' | 'api-key' | 'user-id' | string | ((req: Request) => string);
|
|
28
|
+
max: number;
|
|
29
|
+
windowMs: number;
|
|
30
|
+
strategy?: 'sliding-window' | 'token-bucket' | 'fixed-window';
|
|
31
|
+
name?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RuleEngineResult {
|
|
35
|
+
allowed: boolean;
|
|
36
|
+
failedRule: RuleConfig | null;
|
|
37
|
+
results: RateLimitResult[];
|
|
38
|
+
mostRestrictive: RateLimitResult;
|
|
39
|
+
key: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ScheduleEntry {
|
|
43
|
+
hours: string;
|
|
44
|
+
max: number;
|
|
45
|
+
windowMs?: number;
|
|
46
|
+
strategy?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WebhookPayload {
|
|
50
|
+
event: string;
|
|
51
|
+
key: string;
|
|
52
|
+
ip: string;
|
|
53
|
+
limit: number;
|
|
54
|
+
count: number;
|
|
55
|
+
timestamp: string;
|
|
56
|
+
retryAfter: number;
|
|
57
|
+
strategy: string;
|
|
58
|
+
ruleName?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
26
61
|
export type BuiltInPlan = 'free' | 'pro' | 'enterprise';
|
|
27
62
|
export type BuiltInPreset = 'strict' | 'relaxed' | 'api' | 'auth';
|
|
28
63
|
export type Strategy = 'fixed-window' | 'sliding-window' | 'token-bucket';
|
|
@@ -105,6 +140,14 @@ export interface LimiterOptions {
|
|
|
105
140
|
|
|
106
141
|
/** ms interval to emit 'stats' event. undefined = disabled. */
|
|
107
142
|
statsInterval?: number;
|
|
143
|
+
|
|
144
|
+
rules?: RuleConfig[];
|
|
145
|
+
schedule?: ScheduleEntry[];
|
|
146
|
+
webhook?: string;
|
|
147
|
+
webhookRetries?: number;
|
|
148
|
+
webhookBackoff?: 'exponential' | 'linear' | 'fixed';
|
|
149
|
+
webhookTimeout?: number;
|
|
150
|
+
webhookSecret?: string;
|
|
108
151
|
}
|
|
109
152
|
|
|
110
153
|
// ── Rate limit result ────────────────────────────────────────────────────────
|