@oxog/spark
Version:
Ultra-fast, zero-dependency Node.js web framework with security hardening, memory leak protection, and enhanced error handling
171 lines (147 loc) • 4.26 kB
JavaScript
/**
* Cache middleware for @oxog/spark
* Provides response caching functionality
*/
/**
* Creates a cache middleware
* @param {Object} options - Cache configuration options
* @param {number} [options.maxAge=3600] - Cache max age in seconds
* @param {Function} [options.key] - Function to generate cache key
* @param {Function} [options.condition] - Function to determine if response should be cached
* @param {Object} [options.store] - Custom cache store (default: in-memory)
* @returns {Function} Cache middleware function
*/
function cache(options = {}) {
const {
maxAge = 3600,
key = defaultKeyGenerator,
condition = defaultCondition,
store = new Map()
} = options;
return async function cacheMiddleware(ctx, next) {
// Only cache GET and HEAD requests
if (ctx.method !== 'GET' && ctx.method !== 'HEAD') {
return next();
}
// Check if caching should be applied
if (!condition(ctx)) {
return next();
}
// Generate cache key
const cacheKey = key(ctx);
// Check if cached response exists
const cached = store.get(cacheKey);
if (cached && cached.expires > Date.now()) {
// Serve from cache
ctx.status = cached.status;
ctx.body = cached.body;
ctx.set(cached.headers);
ctx.set('X-Cache', 'HIT');
ctx.set('Age', Math.floor((Date.now() - cached.created) / 1000));
return;
}
// Not in cache, proceed with request
await next();
// Store response in cache if successful
if (ctx.status >= 200 && ctx.status < 300 && ctx.body) {
const cacheEntry = {
status: ctx.status,
body: ctx.body,
headers: filterHeaders(ctx.headers),
created: Date.now(),
expires: Date.now() + (maxAge * 1000)
};
store.set(cacheKey, cacheEntry);
// Set cache headers
ctx.set('Cache-Control', `public, max-age=${maxAge}`);
ctx.set('X-Cache', 'MISS');
}
};
}
/**
* Default cache key generator
* @param {Object} ctx - Koa context
* @returns {string} Cache key
*/
function defaultKeyGenerator(ctx) {
return ctx.url;
}
/**
* Default condition to determine if response should be cached
* @param {Object} ctx - Koa context
* @returns {boolean} Whether to cache the response
*/
function defaultCondition(ctx) {
// Don't cache if no-cache header is present
const cacheControl = ctx.get('Cache-Control');
if (cacheControl && cacheControl.includes('no-cache')) {
return false;
}
// Don't cache authenticated requests by default
if (ctx.get('Authorization')) {
return false;
}
return true;
}
/**
* Filter headers to store in cache
* @param {Object} headers - Response headers
* @returns {Object} Filtered headers
*/
function filterHeaders(headers) {
const filtered = {};
const allowedHeaders = [
'content-type',
'content-encoding',
'content-language',
'vary',
'etag',
'last-modified'
];
for (const [key, value] of Object.entries(headers)) {
if (allowedHeaders.includes(key.toLowerCase())) {
filtered[key] = value;
}
}
return filtered;
}
/**
* Memory cache store with size limits
*/
class MemoryCacheStore {
constructor(maxSize = 100) {
this.cache = new Map();
this.maxSize = maxSize;
}
get(key) {
const entry = this.cache.get(key);
if (entry && entry.expires > Date.now()) {
// Move to end (LRU)
this.cache.delete(key);
this.cache.set(key, entry);
return entry;
}
// Remove expired entry
if (entry) {
this.cache.delete(key);
}
return null;
}
set(key, value) {
// Remove oldest entry if at capacity
if (this.cache.size >= this.maxSize && !this.cache.has(key)) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
clear() {
this.cache.clear();
}
size() {
return this.cache.size;
}
}
// Export factory function and store class
cache.MemoryCacheStore = MemoryCacheStore;
module.exports = cache;