import type { MiddlewareHandler } from 'hono'; import xxhash from 'xxhash-wasm'; import { config } from '@/config'; import RequestInProgressError from '@/errors/types/request-in-progress'; import type { Data } from '@/types'; import cacheModule from '@/utils/cache/index'; import { hasItemFilters } from '@/utils/route-limit'; const bypassList = new Set(['/', '/robots.txt', '/logo.png', '/favicon.ico']); const { h64ToString } = await xxhash(); // only give cache string, as the `!` condition tricky // XXH64 is used to shrink key size // plz, write these tips in comments! const middleware: MiddlewareHandler = async (ctx, next) => { if (!cacheModule.status.available || bypassList.has(ctx.req.path)) { await next(); return; } const requestPath = ctx.req.path; const format = `:${ctx.req.query('format') || config.format}`; const limit = ctx.req.query('limit') ? `:${ctx.req.query('limit')}` : ''; // Routes may select entries before fetching details according to this option. const sortedQuery = ctx.req.query('sorted'); const sorted = sortedQuery === undefined ? '' : sortedQuery === 'false' ? ':sorted=false' : ':sorted=true'; const filtered = hasItemFilters(ctx) ? ':filtered' : ''; const requestKey = h64ToString(requestPath + format + limit + sorted + filtered); const key = 'rsshub:koa-redis-cache:' + requestKey; const controlKey = 'rsshub:path-requested:' + requestKey; let value = await cacheModule.globalCache.get(key); // Only atomic backends can coordinate fetchers. HTTP/KV may return stale // control keys after a completed request, while their feed cache stays useful. let isRequesting = false; let ownsClaim = false; if (!value && cacheModule.globalCache.supportsAtomicClaims) { ownsClaim = await cacheModule.globalCache.claim(controlKey, config.cache.requestTimeout); isRequesting = !ownsClaim; } if (isRequesting) { let retryTimes = process.env.NODE_ENV === 'test' ? 1 : 10; let bypass = false; while (retryTimes > 0) { // eslint-disable-next-line no-await-in-loop await new Promise((resolve) => setTimeout(resolve, process.env.NODE_ENV === 'test' ? 3000 : 6000)); // eslint-disable-next-line no-await-in-loop if ((await cacheModule.globalCache.get(controlKey)) !== '1') { bypass = true; break; } retryTimes--; } if (!bypass) { throw new RequestInProgressError('This path is currently fetching, please come back later!'); } value = await cacheModule.globalCache.get(key); } if (value) { ctx.status(200); ctx.header('RSSHub-Cache-Status', 'HIT'); ctx.set('data', JSON.parse(value)); await next(); return; } if (isRequesting) { // waited out a stale claim without finding a cache entry, take over the fetch ownsClaim = await cacheModule.globalCache.claim(controlKey, config.cache.requestTimeout); if (!ownsClaim) { throw new RequestInProgressError('This path is currently fetching, please come back later!'); } } // let routers control cache ctx.set('cacheKey', key); if (ownsClaim) { ctx.set('cacheControlKey', controlKey); } try { await next(); const data: Data = ctx.get('data'); if (ctx.res.headers.get('Cache-Control') !== 'no-cache' && data) { data.lastBuildDate = new Date().toUTCString(); ctx.set('data', data); const body = JSON.stringify(data); await cacheModule.globalCache.set(key, body, config.cache.routeExpire); } } finally { // Release after writing the feed, including failures after the route ran. if (ownsClaim) { await cacheModule.globalCache.set(controlKey, '0', config.cache.requestTimeout); } } }; export default middleware;