navis.js 3.1.0 → 5.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.
- package/README.md +30 -2
- package/examples/v4-features-demo.js +171 -0
- package/examples/v5-features-demo.js +167 -0
- package/package.json +1 -1
- package/src/auth/authenticator.js +223 -0
- package/src/cache/cache.js +157 -0
- package/src/cache/redis-cache.js +174 -0
- package/src/core/advanced-router.js +186 -0
- package/src/core/app.js +264 -176
- package/src/core/graceful-shutdown.js +77 -0
- package/src/errors/error-handler.js +157 -0
- package/src/health/health-checker.js +120 -0
- package/src/index.js +69 -0
- package/src/middleware/cache-middleware.js +105 -0
- package/src/middleware/compression.js +97 -0
- package/src/middleware/cors.js +86 -0
- package/src/middleware/rate-limiter.js +159 -0
- package/src/middleware/security.js +107 -0
- package/src/validation/validator.js +301 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Health Check Middleware
|
|
3
|
+
* v5: Liveness and readiness probes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class HealthChecker {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.checks = options.checks || {};
|
|
9
|
+
this.livenessPath = options.livenessPath || '/health/live';
|
|
10
|
+
this.readinessPath = options.readinessPath || '/health/ready';
|
|
11
|
+
this.enabled = options.enabled !== false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Add a health check
|
|
16
|
+
* @param {string} name - Check name
|
|
17
|
+
* @param {Function} checkFn - Async function that returns true/false or throws
|
|
18
|
+
*/
|
|
19
|
+
addCheck(name, checkFn) {
|
|
20
|
+
this.checks[name] = checkFn;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Remove a health check
|
|
25
|
+
* @param {string} name - Check name
|
|
26
|
+
*/
|
|
27
|
+
removeCheck(name) {
|
|
28
|
+
delete this.checks[name];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Run all checks
|
|
33
|
+
* @param {boolean} includeReadiness - Include readiness checks
|
|
34
|
+
* @returns {Promise<Object>} - Health status
|
|
35
|
+
*/
|
|
36
|
+
async runChecks(includeReadiness = true) {
|
|
37
|
+
const results = {};
|
|
38
|
+
let allHealthy = true;
|
|
39
|
+
|
|
40
|
+
for (const [name, checkFn] of Object.entries(this.checks)) {
|
|
41
|
+
try {
|
|
42
|
+
const result = await checkFn();
|
|
43
|
+
results[name] = {
|
|
44
|
+
status: result === false ? 'unhealthy' : 'healthy',
|
|
45
|
+
timestamp: new Date().toISOString(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (result === false) {
|
|
49
|
+
allHealthy = false;
|
|
50
|
+
}
|
|
51
|
+
} catch (error) {
|
|
52
|
+
results[name] = {
|
|
53
|
+
status: 'unhealthy',
|
|
54
|
+
error: error.message,
|
|
55
|
+
timestamp: new Date().toISOString(),
|
|
56
|
+
};
|
|
57
|
+
allHealthy = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
status: allHealthy ? 'healthy' : 'unhealthy',
|
|
63
|
+
checks: results,
|
|
64
|
+
timestamp: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Create health check middleware
|
|
70
|
+
* @returns {Function} - Middleware function
|
|
71
|
+
*/
|
|
72
|
+
middleware() {
|
|
73
|
+
return async (req, res, next) => {
|
|
74
|
+
if (!this.enabled) {
|
|
75
|
+
return next();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const path = req.path || req.url;
|
|
79
|
+
|
|
80
|
+
// Liveness probe (always returns 200 if service is running)
|
|
81
|
+
if (path === this.livenessPath) {
|
|
82
|
+
res.statusCode = 200;
|
|
83
|
+
res.headers = res.headers || {};
|
|
84
|
+
res.headers['Content-Type'] = 'application/json';
|
|
85
|
+
res.body = {
|
|
86
|
+
status: 'alive',
|
|
87
|
+
timestamp: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Readiness probe (checks all health checks)
|
|
93
|
+
if (path === this.readinessPath) {
|
|
94
|
+
const healthStatus = await this.runChecks(true);
|
|
95
|
+
res.statusCode = healthStatus.status === 'healthy' ? 200 : 503;
|
|
96
|
+
res.headers = res.headers || {};
|
|
97
|
+
res.headers['Content-Type'] = 'application/json';
|
|
98
|
+
res.body = healthStatus;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
next();
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Create health checker
|
|
109
|
+
* @param {Object} options - Health checker options
|
|
110
|
+
* @returns {HealthChecker} - Health checker instance
|
|
111
|
+
*/
|
|
112
|
+
function createHealthChecker(options = {}) {
|
|
113
|
+
return new HealthChecker(options);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
module.exports = {
|
|
117
|
+
HealthChecker,
|
|
118
|
+
createHealthChecker,
|
|
119
|
+
};
|
|
120
|
+
|
package/src/index.js
CHANGED
|
@@ -22,6 +22,41 @@ const { LazyInit, createLazyInit } = require('./utils/lazy-init');
|
|
|
22
22
|
const LambdaHandler = require('./core/lambda-handler');
|
|
23
23
|
const { coldStartTracker } = require('./middleware/cold-start-tracker');
|
|
24
24
|
|
|
25
|
+
// v4: Advanced Features
|
|
26
|
+
const AdvancedRouter = require('./core/advanced-router');
|
|
27
|
+
const { validate, ValidationError } = require('./validation/validator');
|
|
28
|
+
const {
|
|
29
|
+
authenticateJWT,
|
|
30
|
+
authenticateAPIKey,
|
|
31
|
+
authorize,
|
|
32
|
+
optionalAuth,
|
|
33
|
+
AuthenticationError,
|
|
34
|
+
AuthorizationError,
|
|
35
|
+
} = require('./auth/authenticator');
|
|
36
|
+
const { rateLimit, RateLimiter } = require('./middleware/rate-limiter');
|
|
37
|
+
const {
|
|
38
|
+
AppError,
|
|
39
|
+
NotFoundError,
|
|
40
|
+
BadRequestError,
|
|
41
|
+
UnauthorizedError,
|
|
42
|
+
ForbiddenError,
|
|
43
|
+
ConflictError,
|
|
44
|
+
InternalServerError,
|
|
45
|
+
errorHandler,
|
|
46
|
+
asyncHandler,
|
|
47
|
+
notFoundHandler,
|
|
48
|
+
} = require('./errors/error-handler');
|
|
49
|
+
|
|
50
|
+
// v5: Enterprise Features
|
|
51
|
+
const Cache = require('./cache/cache');
|
|
52
|
+
const RedisCache = require('./cache/redis-cache');
|
|
53
|
+
const cache = require('./middleware/cache-middleware');
|
|
54
|
+
const cors = require('./middleware/cors');
|
|
55
|
+
const security = require('./middleware/security');
|
|
56
|
+
const compress = require('./middleware/compression');
|
|
57
|
+
const { HealthChecker, createHealthChecker } = require('./health/health-checker');
|
|
58
|
+
const gracefulShutdown = require('./core/graceful-shutdown');
|
|
59
|
+
|
|
25
60
|
module.exports = {
|
|
26
61
|
// Core
|
|
27
62
|
NavisApp,
|
|
@@ -52,6 +87,40 @@ module.exports = {
|
|
|
52
87
|
LambdaHandler,
|
|
53
88
|
coldStartTracker,
|
|
54
89
|
|
|
90
|
+
// v4: Advanced Features
|
|
91
|
+
AdvancedRouter,
|
|
92
|
+
validate,
|
|
93
|
+
ValidationError,
|
|
94
|
+
authenticateJWT,
|
|
95
|
+
authenticateAPIKey,
|
|
96
|
+
authorize,
|
|
97
|
+
optionalAuth,
|
|
98
|
+
AuthenticationError,
|
|
99
|
+
AuthorizationError,
|
|
100
|
+
rateLimit,
|
|
101
|
+
RateLimiter,
|
|
102
|
+
AppError,
|
|
103
|
+
NotFoundError,
|
|
104
|
+
BadRequestError,
|
|
105
|
+
UnauthorizedError,
|
|
106
|
+
ForbiddenError,
|
|
107
|
+
ConflictError,
|
|
108
|
+
InternalServerError,
|
|
109
|
+
errorHandler,
|
|
110
|
+
asyncHandler,
|
|
111
|
+
notFoundHandler,
|
|
112
|
+
|
|
113
|
+
// v5: Enterprise Features
|
|
114
|
+
Cache,
|
|
115
|
+
RedisCache,
|
|
116
|
+
cache,
|
|
117
|
+
cors,
|
|
118
|
+
security,
|
|
119
|
+
compress,
|
|
120
|
+
HealthChecker,
|
|
121
|
+
createHealthChecker,
|
|
122
|
+
gracefulShutdown,
|
|
123
|
+
|
|
55
124
|
// Utilities
|
|
56
125
|
response: {
|
|
57
126
|
success,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cache Middleware
|
|
3
|
+
* v5: Response caching middleware
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const crypto = require('crypto');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create cache middleware
|
|
10
|
+
* @param {Object} options - Cache options
|
|
11
|
+
* @returns {Function} - Middleware function
|
|
12
|
+
*/
|
|
13
|
+
function cache(options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
ttl = 3600, // 1 hour in seconds
|
|
16
|
+
keyGenerator = (req) => {
|
|
17
|
+
// Default: method + path + query string
|
|
18
|
+
const queryStr = JSON.stringify(req.query || {});
|
|
19
|
+
return `${req.method}:${req.path}:${queryStr}`;
|
|
20
|
+
},
|
|
21
|
+
cacheStore = null, // Must be provided
|
|
22
|
+
skipCache = (req, res) => {
|
|
23
|
+
// Skip cache for non-GET requests or if status >= 400
|
|
24
|
+
return req.method !== 'GET' || (res.statusCode && res.statusCode >= 400);
|
|
25
|
+
},
|
|
26
|
+
vary = [], // Vary headers
|
|
27
|
+
} = options;
|
|
28
|
+
|
|
29
|
+
if (!cacheStore) {
|
|
30
|
+
throw new Error('cacheStore is required');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return async (req, res, next) => {
|
|
34
|
+
// Generate cache key
|
|
35
|
+
const cacheKey = keyGenerator(req);
|
|
36
|
+
|
|
37
|
+
// Check if should skip cache
|
|
38
|
+
if (skipCache(req, res)) {
|
|
39
|
+
return next();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Try to get from cache
|
|
43
|
+
try {
|
|
44
|
+
const cached = await (cacheStore.get ? cacheStore.get(cacheKey) : cacheStore.get(cacheKey));
|
|
45
|
+
|
|
46
|
+
if (cached) {
|
|
47
|
+
// Set cache headers
|
|
48
|
+
res.headers = res.headers || {};
|
|
49
|
+
res.headers['X-Cache'] = 'HIT';
|
|
50
|
+
res.headers['Cache-Control'] = `public, max-age=${ttl}`;
|
|
51
|
+
|
|
52
|
+
// Set Vary headers
|
|
53
|
+
if (vary.length > 0) {
|
|
54
|
+
res.headers['Vary'] = vary.join(', ');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Return cached response
|
|
58
|
+
res.statusCode = cached.statusCode || 200;
|
|
59
|
+
res.body = cached.body;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
} catch (error) {
|
|
63
|
+
// Cache error - continue without cache
|
|
64
|
+
console.error('Cache get error:', error);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Cache miss - continue to handler
|
|
68
|
+
res.headers = res.headers || {};
|
|
69
|
+
res.headers['X-Cache'] = 'MISS';
|
|
70
|
+
|
|
71
|
+
// Store original end/finish to capture response
|
|
72
|
+
const originalBody = res.body;
|
|
73
|
+
const originalStatusCode = res.statusCode;
|
|
74
|
+
|
|
75
|
+
// Wrap response to cache it
|
|
76
|
+
const originalFinish = res.finish || (() => {});
|
|
77
|
+
res.finish = async function(...args) {
|
|
78
|
+
// Only cache successful GET requests
|
|
79
|
+
if (req.method === 'GET' && res.statusCode < 400) {
|
|
80
|
+
try {
|
|
81
|
+
const cacheValue = {
|
|
82
|
+
statusCode: res.statusCode,
|
|
83
|
+
body: res.body,
|
|
84
|
+
headers: res.headers,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
if (cacheStore.set) {
|
|
88
|
+
await cacheStore.set(cacheKey, cacheValue, ttl * 1000);
|
|
89
|
+
} else {
|
|
90
|
+
cacheStore.set(cacheKey, cacheValue, ttl * 1000);
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('Cache set error:', error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return originalFinish.apply(this, args);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
next();
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = cache;
|
|
105
|
+
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Response Compression Middleware
|
|
3
|
+
* v5: Gzip and Brotli compression support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const zlib = require('zlib');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Compression middleware
|
|
10
|
+
* @param {Object} options - Compression options
|
|
11
|
+
* @returns {Function} - Middleware function
|
|
12
|
+
*/
|
|
13
|
+
function compress(options = {}) {
|
|
14
|
+
const {
|
|
15
|
+
level = 6, // Compression level (1-9)
|
|
16
|
+
threshold = 1024, // Minimum size to compress (bytes)
|
|
17
|
+
algorithm = 'gzip', // 'gzip' or 'brotli'
|
|
18
|
+
filter = (req, res) => {
|
|
19
|
+
// Default: compress JSON and text responses
|
|
20
|
+
const contentType = res.headers?.['content-type'] || '';
|
|
21
|
+
return contentType.includes('application/json') ||
|
|
22
|
+
contentType.includes('text/') ||
|
|
23
|
+
contentType.includes('application/javascript');
|
|
24
|
+
},
|
|
25
|
+
} = options;
|
|
26
|
+
|
|
27
|
+
return async (req, res, next) => {
|
|
28
|
+
// Store original body setter
|
|
29
|
+
const originalBody = res.body;
|
|
30
|
+
const originalEnd = res.end || (() => {});
|
|
31
|
+
|
|
32
|
+
// Wrap response to compress before sending
|
|
33
|
+
res.end = function(...args) {
|
|
34
|
+
// Check if should compress
|
|
35
|
+
if (!filter(req, res)) {
|
|
36
|
+
return originalEnd.apply(this, args);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Get response body
|
|
40
|
+
let body = res.body;
|
|
41
|
+
if (typeof body === 'object') {
|
|
42
|
+
body = JSON.stringify(body);
|
|
43
|
+
} else if (typeof body !== 'string') {
|
|
44
|
+
body = String(body);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Check threshold
|
|
48
|
+
if (Buffer.byteLength(body, 'utf8') < threshold) {
|
|
49
|
+
return originalEnd.apply(this, args);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Check if client supports compression
|
|
53
|
+
const acceptEncoding = req.headers['accept-encoding'] || req.headers['Accept-Encoding'] || '';
|
|
54
|
+
const supportsGzip = acceptEncoding.includes('gzip');
|
|
55
|
+
const supportsBrotli = acceptEncoding.includes('br');
|
|
56
|
+
|
|
57
|
+
// Choose compression algorithm
|
|
58
|
+
let compressed;
|
|
59
|
+
let encoding;
|
|
60
|
+
|
|
61
|
+
if (algorithm === 'brotli' && supportsBrotli) {
|
|
62
|
+
try {
|
|
63
|
+
compressed = zlib.brotliCompressSync(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: level } });
|
|
64
|
+
encoding = 'br';
|
|
65
|
+
} catch (error) {
|
|
66
|
+
// Fallback to gzip if brotli fails
|
|
67
|
+
compressed = zlib.gzipSync(body, { level });
|
|
68
|
+
encoding = 'gzip';
|
|
69
|
+
}
|
|
70
|
+
} else if (supportsGzip) {
|
|
71
|
+
compressed = zlib.gzipSync(body, { level });
|
|
72
|
+
encoding = 'gzip';
|
|
73
|
+
} else {
|
|
74
|
+
// No compression support
|
|
75
|
+
return originalEnd.apply(this, args);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Set compression headers
|
|
79
|
+
res.headers = res.headers || {};
|
|
80
|
+
res.headers['Content-Encoding'] = encoding;
|
|
81
|
+
res.headers['Vary'] = 'Accept-Encoding';
|
|
82
|
+
|
|
83
|
+
// Update content length
|
|
84
|
+
res.headers['Content-Length'] = compressed.length.toString();
|
|
85
|
+
|
|
86
|
+
// Update body with compressed data
|
|
87
|
+
res.body = compressed;
|
|
88
|
+
|
|
89
|
+
return originalEnd.apply(this, args);
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
next();
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = compress;
|
|
97
|
+
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CORS Middleware
|
|
3
|
+
* v5: Cross-Origin Resource Sharing support
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CORS middleware
|
|
8
|
+
* @param {Object} options - CORS options
|
|
9
|
+
* @returns {Function} - Middleware function
|
|
10
|
+
*/
|
|
11
|
+
function cors(options = {}) {
|
|
12
|
+
const {
|
|
13
|
+
origin = '*',
|
|
14
|
+
methods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
|
15
|
+
allowedHeaders = ['Content-Type', 'Authorization', 'X-Requested-With'],
|
|
16
|
+
exposedHeaders = [],
|
|
17
|
+
credentials = false,
|
|
18
|
+
maxAge = 86400, // 24 hours
|
|
19
|
+
preflightContinue = false,
|
|
20
|
+
} = options;
|
|
21
|
+
|
|
22
|
+
// Normalize origin
|
|
23
|
+
const allowedOrigins = Array.isArray(origin) ? origin : [origin];
|
|
24
|
+
const isWildcard = allowedOrigins.includes('*');
|
|
25
|
+
|
|
26
|
+
return async (req, res, next) => {
|
|
27
|
+
const requestOrigin = req.headers.origin || req.headers.Origin;
|
|
28
|
+
|
|
29
|
+
// Determine allowed origin
|
|
30
|
+
let allowedOrigin = null;
|
|
31
|
+
if (isWildcard) {
|
|
32
|
+
allowedOrigin = '*';
|
|
33
|
+
} else if (requestOrigin && allowedOrigins.includes(requestOrigin)) {
|
|
34
|
+
allowedOrigin = requestOrigin;
|
|
35
|
+
} else if (allowedOrigins.length === 1 && allowedOrigins[0] !== '*') {
|
|
36
|
+
allowedOrigin = allowedOrigins[0];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Handle preflight requests
|
|
40
|
+
if (req.method === 'OPTIONS') {
|
|
41
|
+
res.headers = res.headers || {};
|
|
42
|
+
|
|
43
|
+
if (allowedOrigin) {
|
|
44
|
+
res.headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
res.headers['Access-Control-Allow-Methods'] = methods.join(', ');
|
|
48
|
+
res.headers['Access-Control-Allow-Headers'] = allowedHeaders.join(', ');
|
|
49
|
+
res.headers['Access-Control-Max-Age'] = maxAge.toString();
|
|
50
|
+
|
|
51
|
+
if (credentials) {
|
|
52
|
+
res.headers['Access-Control-Allow-Credentials'] = 'true';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (exposedHeaders.length > 0) {
|
|
56
|
+
res.headers['Access-Control-Expose-Headers'] = exposedHeaders.join(', ');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!preflightContinue) {
|
|
60
|
+
res.statusCode = 204;
|
|
61
|
+
res.body = null;
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Set CORS headers for all responses
|
|
67
|
+
res.headers = res.headers || {};
|
|
68
|
+
|
|
69
|
+
if (allowedOrigin) {
|
|
70
|
+
res.headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (credentials && allowedOrigin && allowedOrigin !== '*') {
|
|
74
|
+
res.headers['Access-Control-Allow-Credentials'] = 'true';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (exposedHeaders.length > 0) {
|
|
78
|
+
res.headers['Access-Control-Expose-Headers'] = exposedHeaders.join(', ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
next();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
module.exports = cors;
|
|
86
|
+
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rate Limiting Middleware
|
|
3
|
+
* v4: In-memory rate limiting with configurable windows
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class RateLimiter {
|
|
7
|
+
constructor(options = {}) {
|
|
8
|
+
this.windowMs = options.windowMs || 60000; // 1 minute default
|
|
9
|
+
this.max = options.max || 100; // 100 requests default
|
|
10
|
+
this.store = new Map(); // In-memory store
|
|
11
|
+
this.skipSuccessfulRequests = options.skipSuccessfulRequests || false;
|
|
12
|
+
this.skipFailedRequests = options.skipFailedRequests || false;
|
|
13
|
+
this.keyGenerator = options.keyGenerator || ((req) => {
|
|
14
|
+
// Default: IP address
|
|
15
|
+
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
|
16
|
+
req.headers['x-real-ip'] ||
|
|
17
|
+
'unknown';
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
// Cleanup old entries periodically
|
|
21
|
+
this.cleanupInterval = setInterval(() => {
|
|
22
|
+
this._cleanup();
|
|
23
|
+
}, this.windowMs);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Rate limit middleware
|
|
28
|
+
* @returns {Function} - Middleware function
|
|
29
|
+
*/
|
|
30
|
+
middleware() {
|
|
31
|
+
const self = this; // Capture 'this' reference
|
|
32
|
+
|
|
33
|
+
return async (req, res, next) => {
|
|
34
|
+
const key = self.keyGenerator(req);
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
|
|
37
|
+
// Get or create rate limit entry
|
|
38
|
+
let entry = self.store.get(key);
|
|
39
|
+
|
|
40
|
+
if (!entry || now - entry.resetTime > self.windowMs) {
|
|
41
|
+
// Create new entry or reset expired one
|
|
42
|
+
entry = {
|
|
43
|
+
count: 0,
|
|
44
|
+
resetTime: now + self.windowMs,
|
|
45
|
+
};
|
|
46
|
+
self.store.set(key, entry);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Increment count
|
|
50
|
+
entry.count++;
|
|
51
|
+
|
|
52
|
+
// Set rate limit headers
|
|
53
|
+
res.headers = res.headers || {};
|
|
54
|
+
res.headers['X-RateLimit-Limit'] = self.max.toString();
|
|
55
|
+
res.headers['X-RateLimit-Remaining'] = Math.max(0, self.max - entry.count).toString();
|
|
56
|
+
res.headers['X-RateLimit-Reset'] = new Date(entry.resetTime).toISOString();
|
|
57
|
+
|
|
58
|
+
// Check if limit exceeded
|
|
59
|
+
if (entry.count > self.max) {
|
|
60
|
+
res.statusCode = 429;
|
|
61
|
+
res.body = {
|
|
62
|
+
error: 'Too many requests',
|
|
63
|
+
retryAfter: Math.ceil((entry.resetTime - now) / 1000),
|
|
64
|
+
};
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Track response status for skip options
|
|
69
|
+
const originalStatusCode = res.statusCode;
|
|
70
|
+
const originalFinish = res.finish || (() => {});
|
|
71
|
+
|
|
72
|
+
// Wrap response finish to track status
|
|
73
|
+
res.finish = function(...args) {
|
|
74
|
+
const statusCode = res.statusCode || 200;
|
|
75
|
+
|
|
76
|
+
if (self.skipSuccessfulRequests && statusCode < 400) {
|
|
77
|
+
entry.count = Math.max(0, entry.count - 1);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (self.skipFailedRequests && statusCode >= 400) {
|
|
81
|
+
entry.count = Math.max(0, entry.count - 1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return originalFinish.apply(this, args);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
next();
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cleanup expired entries
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
_cleanup() {
|
|
96
|
+
const now = Date.now();
|
|
97
|
+
for (const [key, entry] of this.store.entries()) {
|
|
98
|
+
if (now - entry.resetTime > this.windowMs) {
|
|
99
|
+
this.store.delete(key);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reset rate limit for a key
|
|
106
|
+
* @param {string} key - Rate limit key
|
|
107
|
+
*/
|
|
108
|
+
reset(key) {
|
|
109
|
+
this.store.delete(key);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get rate limit info for a key
|
|
114
|
+
* @param {string} key - Rate limit key
|
|
115
|
+
* @returns {Object|null} - Rate limit info
|
|
116
|
+
*/
|
|
117
|
+
get(key) {
|
|
118
|
+
const entry = this.store.get(key);
|
|
119
|
+
if (!entry) return null;
|
|
120
|
+
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
if (now - entry.resetTime > this.windowMs) {
|
|
123
|
+
this.store.delete(key);
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
count: entry.count,
|
|
129
|
+
remaining: Math.max(0, this.max - entry.count),
|
|
130
|
+
resetTime: entry.resetTime,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Destroy rate limiter (cleanup)
|
|
136
|
+
*/
|
|
137
|
+
destroy() {
|
|
138
|
+
if (this.cleanupInterval) {
|
|
139
|
+
clearInterval(this.cleanupInterval);
|
|
140
|
+
}
|
|
141
|
+
this.store.clear();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Create rate limit middleware
|
|
147
|
+
* @param {Object} options - Rate limit options
|
|
148
|
+
* @returns {Function} - Middleware function
|
|
149
|
+
*/
|
|
150
|
+
function rateLimit(options = {}) {
|
|
151
|
+
const limiter = new RateLimiter(options);
|
|
152
|
+
return limiter.middleware();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
rateLimit,
|
|
157
|
+
RateLimiter,
|
|
158
|
+
};
|
|
159
|
+
|