forked from lunanting/cangku
261 lines
7.3 KiB
JavaScript
261 lines
7.3 KiB
JavaScript
const crypto = require('crypto');
|
|
const jwt = require('jsonwebtoken');
|
|
const redis = require('../config/redis');
|
|
const dataPageCache = require('../services/dataPageCache');
|
|
|
|
const CACHE_BUSTER_KEYS = new Set([
|
|
'_',
|
|
'_t',
|
|
't',
|
|
'timestamp',
|
|
'cacheBust',
|
|
'cacheBuster',
|
|
'nocache',
|
|
]);
|
|
const DEFAULT_REDIS_TTL_SECONDS = Math.max(
|
|
Number(process.env.PAGE_RESPONSE_CACHE_REDIS_TTL_SECONDS) || 300,
|
|
30
|
|
);
|
|
const MAX_RECENT_EVENTS = 200;
|
|
const pageCacheStats = {
|
|
total: 0,
|
|
hits: 0,
|
|
misses: 0,
|
|
stores: 0,
|
|
bypasses: 0,
|
|
invalidations: 0,
|
|
labels: new Map(),
|
|
recentEvents: [],
|
|
lastResetAt: new Date().toISOString(),
|
|
};
|
|
|
|
function hashValue(value) {
|
|
if (!value) return '';
|
|
return crypto.createHash('sha256').update(String(value)).digest('hex');
|
|
}
|
|
|
|
function normalizeQuery(query = {}) {
|
|
return Object.keys(query || {})
|
|
.filter((key) => !CACHE_BUSTER_KEYS.has(key))
|
|
.sort()
|
|
.reduce((acc, key) => {
|
|
acc[key] = query[key];
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
function buildUserScope(req) {
|
|
if (req.user) {
|
|
return {
|
|
sub: req.user.sub || req.user.id || '',
|
|
role: req.user.role || '',
|
|
};
|
|
}
|
|
|
|
return {
|
|
authHash: hashValue(req.headers.authorization || ''),
|
|
};
|
|
}
|
|
|
|
function verifyAuthHeaderIfPresent(req) {
|
|
if (req.user) {
|
|
return true;
|
|
}
|
|
|
|
const authHeader = String(req.headers.authorization || '');
|
|
if (!authHeader) {
|
|
return true;
|
|
}
|
|
|
|
if (!authHeader.startsWith('Bearer ')) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
req.user = jwt.verify(authHeader.substring(7), process.env.CUBEJS_SECRET);
|
|
return true;
|
|
} catch (_error) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function getNamespacePrefix(namespace) {
|
|
return `page-response:${namespace}:`;
|
|
}
|
|
|
|
function buildCacheKey(req, namespace, version) {
|
|
return dataPageCache.buildHashedCacheKey(
|
|
getNamespacePrefix(namespace),
|
|
{
|
|
method: req.method,
|
|
path: `${req.baseUrl || ''}${req.path || ''}`,
|
|
query: normalizeQuery(req.query),
|
|
user: buildUserScope(req),
|
|
acceptLanguage: req.headers['accept-language'] || '',
|
|
},
|
|
version
|
|
);
|
|
}
|
|
|
|
function isCacheBypassed(req) {
|
|
const cacheControl = String(req.headers['cache-control'] || '').toLowerCase();
|
|
return (
|
|
cacheControl.includes('no-cache')
|
|
|| cacheControl.includes('no-store')
|
|
|| req.query?._refresh === '1'
|
|
|| req.query?.refresh === '1'
|
|
);
|
|
}
|
|
|
|
function defaultShouldCacheBody(body) {
|
|
if (body === undefined) return false;
|
|
if (body?.success === false) return false;
|
|
return true;
|
|
}
|
|
|
|
function recordPageCacheEvent(namespace, hit, details = {}) {
|
|
pageCacheStats.total += 1;
|
|
if (hit === 'redis') pageCacheStats.hits += 1;
|
|
if (hit === 'miss') pageCacheStats.misses += 1;
|
|
if (hit === 'store') pageCacheStats.stores += 1;
|
|
if (hit === 'bypass') pageCacheStats.bypasses += 1;
|
|
if (hit === 'invalidate') pageCacheStats.invalidations += 1;
|
|
|
|
const labelStats = pageCacheStats.labels.get(namespace) || {
|
|
label: namespace,
|
|
total: 0,
|
|
hits: 0,
|
|
misses: 0,
|
|
stores: 0,
|
|
bypasses: 0,
|
|
invalidations: 0,
|
|
lastHit: null,
|
|
lastSeenAt: null,
|
|
};
|
|
labelStats.total += 1;
|
|
labelStats.lastHit = hit;
|
|
labelStats.lastSeenAt = new Date().toISOString();
|
|
if (hit === 'redis') labelStats.hits += 1;
|
|
if (hit === 'miss') labelStats.misses += 1;
|
|
if (hit === 'store') labelStats.stores += 1;
|
|
if (hit === 'bypass') labelStats.bypasses += 1;
|
|
if (hit === 'invalidate') labelStats.invalidations += 1;
|
|
pageCacheStats.labels.set(namespace, labelStats);
|
|
|
|
pageCacheStats.recentEvents.unshift({
|
|
namespace,
|
|
hit,
|
|
path: details.path || '',
|
|
timestamp: new Date().toISOString(),
|
|
});
|
|
if (pageCacheStats.recentEvents.length > MAX_RECENT_EVENTS) {
|
|
pageCacheStats.recentEvents.length = MAX_RECENT_EVENTS;
|
|
}
|
|
}
|
|
|
|
function getPageDataCacheStats() {
|
|
return {
|
|
total: pageCacheStats.total,
|
|
hits: pageCacheStats.hits,
|
|
misses: pageCacheStats.misses,
|
|
stores: pageCacheStats.stores,
|
|
bypasses: pageCacheStats.bypasses,
|
|
invalidations: pageCacheStats.invalidations,
|
|
hitRate: pageCacheStats.total ? Number((pageCacheStats.hits * 100 / pageCacheStats.total).toFixed(2)) : 0,
|
|
lastResetAt: pageCacheStats.lastResetAt,
|
|
labels: Array.from(pageCacheStats.labels.values()).sort((a, b) => b.total - a.total),
|
|
recentEvents: pageCacheStats.recentEvents,
|
|
};
|
|
}
|
|
|
|
function resetPageDataCacheStats() {
|
|
pageCacheStats.total = 0;
|
|
pageCacheStats.hits = 0;
|
|
pageCacheStats.misses = 0;
|
|
pageCacheStats.stores = 0;
|
|
pageCacheStats.bypasses = 0;
|
|
pageCacheStats.invalidations = 0;
|
|
pageCacheStats.labels.clear();
|
|
pageCacheStats.recentEvents = [];
|
|
pageCacheStats.lastResetAt = new Date().toISOString();
|
|
}
|
|
|
|
function createPageDataCache(options = {}) {
|
|
const namespace = options.namespace || 'default';
|
|
const version = options.version || 'v1';
|
|
const redisTtlSeconds = Math.max(Number(options.redisTtlSeconds) || DEFAULT_REDIS_TTL_SECONDS, 30);
|
|
const enabled = options.enabled !== false;
|
|
const invalidateOnMutations = options.invalidateOnMutations !== false;
|
|
const shouldHandleRequest = typeof options.shouldHandleRequest === 'function'
|
|
? options.shouldHandleRequest
|
|
: () => true;
|
|
const shouldCacheBody = typeof options.shouldCacheBody === 'function'
|
|
? options.shouldCacheBody
|
|
: defaultShouldCacheBody;
|
|
|
|
return async (req, res, next) => {
|
|
if (!enabled || !shouldHandleRequest(req)) {
|
|
recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl });
|
|
return next();
|
|
}
|
|
|
|
if (req.method !== 'GET') {
|
|
if (invalidateOnMutations) {
|
|
res.on('finish', () => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
dataPageCache.deleteByPrefix(getNamespacePrefix(namespace)).catch((error) => {
|
|
console.warn(`[PageDataCache] ${namespace} invalidation failed:`, error.message);
|
|
});
|
|
recordPageCacheEvent(namespace, 'invalidate', { path: req.originalUrl });
|
|
}
|
|
});
|
|
}
|
|
return next();
|
|
}
|
|
|
|
if (!verifyAuthHeaderIfPresent(req) || isCacheBypassed(req)) {
|
|
recordPageCacheEvent(namespace, 'bypass', { path: req.originalUrl });
|
|
res.set('X-Page-Cache', 'bypass');
|
|
return next();
|
|
}
|
|
|
|
const cacheKey = buildCacheKey(req, namespace, version);
|
|
const cachedPayload = await redis.get(cacheKey);
|
|
if (cachedPayload?.body !== undefined) {
|
|
recordPageCacheEvent(namespace, 'redis', { path: req.originalUrl });
|
|
res.set('X-Page-Cache', 'redis');
|
|
return res.status(cachedPayload.statusCode || 200).json(cachedPayload.body);
|
|
}
|
|
|
|
recordPageCacheEvent(namespace, 'miss', { path: req.originalUrl });
|
|
res.set('X-Page-Cache', 'miss');
|
|
|
|
const originalJson = res.json.bind(res);
|
|
res.json = (body) => {
|
|
if (res.statusCode >= 200 && res.statusCode < 300 && shouldCacheBody(body, req)) {
|
|
redis.setex(cacheKey, redisTtlSeconds, {
|
|
statusCode: res.statusCode,
|
|
body,
|
|
cachedAt: new Date().toISOString(),
|
|
}).then((stored) => {
|
|
if (stored) {
|
|
recordPageCacheEvent(namespace, 'store', { path: req.originalUrl });
|
|
}
|
|
}).catch((error) => {
|
|
console.warn(`[PageDataCache] ${namespace} store failed:`, error.message);
|
|
});
|
|
}
|
|
|
|
return originalJson(body);
|
|
};
|
|
|
|
return next();
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
createPageDataCache,
|
|
getPageDataCacheStats,
|
|
resetPageDataCacheStats,
|
|
};
|