UNPKG

@oxog/spark

Version:

Ultra-fast, zero-dependency Node.js web framework with security hardening, memory leak protection, and enhanced error handling

320 lines (266 loc) 8.81 kB
const DEFAULT_WINDOW_MS = 15 * 60 * 1000; // 15 minutes const DEFAULT_MAX_REQUESTS = 100; const DEFAULT_MESSAGE = 'Too many requests, please try again later.'; class MemoryStore { constructor() { this.store = new Map(); this.resetTimes = new Map(); } async incr(key, windowMs) { const now = Date.now(); const resetTime = this.resetTimes.get(key) || now; if (now > resetTime) { this.store.set(key, 1); this.resetTimes.set(key, now + windowMs); return { totalHits: 1, resetTime: now + windowMs }; } const current = this.store.get(key) || 0; const newCount = current + 1; this.store.set(key, newCount); return { totalHits: newCount, resetTime }; } async decrement(key) { const current = this.store.get(key) || 0; if (current > 0) { this.store.set(key, current - 1); } } async reset(key) { this.store.delete(key); this.resetTimes.delete(key); } cleanup() { const now = Date.now(); for (const [key, resetTime] of this.resetTimes) { if (now > resetTime) { this.store.delete(key); this.resetTimes.delete(key); } } } } function rateLimit(options = {}) { const opts = { windowMs: options.windowMs || DEFAULT_WINDOW_MS, max: options.max || DEFAULT_MAX_REQUESTS, message: options.message || DEFAULT_MESSAGE, statusCode: options.statusCode || 429, headers: options.headers !== false, keyGenerator: options.keyGenerator || defaultKeyGenerator, handler: options.handler || defaultHandler, onLimitReached: options.onLimitReached, skipSuccessfulRequests: options.skipSuccessfulRequests || false, skipFailedRequests: options.skipFailedRequests || false, store: options.store || new MemoryStore(), ...options }; // Start cleanup interval let cleanupInterval; if (opts.store.cleanup) { // Use smaller interval for cleanup (1/4 of window) const cleanupPeriod = Math.max(60000, Math.floor(opts.windowMs / 4)); cleanupInterval = setInterval(() => { opts.store.cleanup(); }, cleanupPeriod); // Allow cleanup interval to be stopped when server shuts down if (cleanupInterval.unref) { cleanupInterval.unref(); } } const middleware = async (ctx, next) => { const key = opts.keyGenerator(ctx); const { totalHits, resetTime } = await opts.store.incr(key, opts.windowMs); if (opts.headers) { ctx.set('X-RateLimit-Limit', opts.max); ctx.set('X-RateLimit-Remaining', Math.max(0, opts.max - totalHits)); ctx.set('X-RateLimit-Reset', new Date(resetTime).toISOString()); } if (totalHits <= opts.max) { await next(); if (opts.skipSuccessfulRequests && ctx.statusCode >= 200 && ctx.statusCode < 300) { await opts.store.decrement(key); } } else { if (opts.onLimitReached) { opts.onLimitReached(ctx, opts); } return opts.handler(ctx, opts); } if (opts.skipFailedRequests && ctx.statusCode >= 400) { await opts.store.decrement(key); } }; // Attach cleanup method to middleware for manual cleanup middleware.cleanup = () => { if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; } if (opts.store.cleanup) { opts.store.cleanup(); } }; // Attach store for external access if needed middleware.store = opts.store; return middleware; } function defaultKeyGenerator(ctx) { return ctx.ip(); } function defaultHandler(ctx, opts) { ctx.set('Retry-After', Math.round(opts.windowMs / 1000)); ctx.status(opts.statusCode).json({ error: 'Too Many Requests', message: opts.message }); } function slowDown(options = {}) { const opts = { windowMs: options.windowMs || DEFAULT_WINDOW_MS, delayAfter: options.delayAfter || 1, delayMs: options.delayMs || 1000, maxDelayMs: options.maxDelayMs || 60000, skipSuccessfulRequests: options.skipSuccessfulRequests || false, skipFailedRequests: options.skipFailedRequests || false, keyGenerator: options.keyGenerator || defaultKeyGenerator, store: options.store || new MemoryStore(), ...options }; // Start cleanup interval for slowDown store let cleanupInterval; if (opts.store.cleanup) { const cleanupPeriod = Math.max(60000, Math.floor(opts.windowMs / 4)); cleanupInterval = setInterval(() => { opts.store.cleanup(); }, cleanupPeriod); if (cleanupInterval.unref) { cleanupInterval.unref(); } } const middleware = async (ctx, next) => { const key = opts.keyGenerator(ctx); const { totalHits } = await opts.store.incr(key, opts.windowMs); if (totalHits > opts.delayAfter) { const delay = Math.min( (totalHits - opts.delayAfter) * opts.delayMs, opts.maxDelayMs ); await new Promise(resolve => setTimeout(resolve, delay)); } await next(); if (opts.skipSuccessfulRequests && ctx.statusCode >= 200 && ctx.statusCode < 300) { await opts.store.decrement(key); } if (opts.skipFailedRequests && ctx.statusCode >= 400) { await opts.store.decrement(key); } }; // Attach cleanup method middleware.cleanup = () => { if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; } if (opts.store.cleanup) { opts.store.cleanup(); } }; middleware.store = opts.store; return middleware; } class TokenBucket { constructor(capacity, refillRate, refillPeriod = 1000) { this.capacity = capacity; this.tokens = capacity; this.refillRate = refillRate; this.refillPeriod = refillPeriod; this.lastRefill = Date.now(); } consume(tokens = 1) { this.refill(); if (this.tokens >= tokens) { this.tokens -= tokens; return true; } return false; } refill() { const now = Date.now(); const timePassed = now - this.lastRefill; const tokensToAdd = Math.floor(timePassed / this.refillPeriod * this.refillRate); if (tokensToAdd > 0) { this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd); this.lastRefill = now; } } } function tokenBucket(options = {}) { const opts = { capacity: options.capacity || 10, refillRate: options.refillRate || 1, refillPeriod: options.refillPeriod || 1000, keyGenerator: options.keyGenerator || defaultKeyGenerator, message: options.message || DEFAULT_MESSAGE, statusCode: options.statusCode || 429, cleanupPeriod: options.cleanupPeriod || 300000, // 5 minutes maxBuckets: options.maxBuckets || 10000, // Maximum number of buckets to prevent memory leak ...options }; const buckets = new Map(); const lastActivity = new Map(); // Cleanup old buckets const cleanupInterval = setInterval(() => { const now = Date.now(); const keysToDelete = []; for (const [key, timestamp] of lastActivity) { if (now - timestamp > opts.cleanupPeriod) { keysToDelete.push(key); } } for (const key of keysToDelete) { buckets.delete(key); lastActivity.delete(key); } }, Math.max(60000, opts.cleanupPeriod / 4)); if (cleanupInterval.unref) { cleanupInterval.unref(); } const middleware = async (ctx, next) => { const key = opts.keyGenerator(ctx); // Prevent unlimited growth if (!buckets.has(key) && buckets.size >= opts.maxBuckets) { // Remove oldest bucket const oldestKey = lastActivity.entries().next().value?.[0]; if (oldestKey) { buckets.delete(oldestKey); lastActivity.delete(oldestKey); } } if (!buckets.has(key)) { buckets.set(key, new TokenBucket(opts.capacity, opts.refillRate, opts.refillPeriod)); } const bucket = buckets.get(key); lastActivity.set(key, Date.now()); if (bucket.consume()) { await next(); } else { ctx.status(opts.statusCode).json({ error: 'Too Many Requests', message: opts.message }); } }; // Attach cleanup method middleware.cleanup = () => { if (cleanupInterval) { clearInterval(cleanupInterval); } buckets.clear(); lastActivity.clear(); }; return middleware; } module.exports = rateLimit; module.exports.slowDown = slowDown; module.exports.tokenBucket = tokenBucket; module.exports.MemoryStore = MemoryStore;