diff --git a/lib/config.test.ts b/lib/config.test.ts index 02010cb5d4..472f1aefe4 100644 --- a/lib/config.test.ts +++ b/lib/config.test.ts @@ -91,6 +91,20 @@ describe('config', () => { expect(config.isDefaultUA).toBe(true); }); + it('http cache config', async () => { + process.env.CACHE_HTTP_URL = 'https://cache.example.com'; + process.env.CACHE_HTTP_TOKEN = 'token'; + + const { config } = await import('./config'); + expect(config.httpCache).toMatchObject({ + url: 'https://cache.example.com', + token: 'token', + }); + + delete process.env.CACHE_HTTP_URL; + delete process.env.CACHE_HTTP_TOKEN; + }); + it('remote config', async () => { process.env.REMOTE_CONFIG = 'http://rsshub.test/config'; diff --git a/lib/config.ts b/lib/config.ts index b4dca3920d..fea6a950ee 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -27,6 +27,8 @@ type ConfigEnvKeys = | 'CACHE_CONTENT_EXPIRE' | 'MEMORY_MAX' | 'REDIS_URL' + | 'CACHE_HTTP_URL' + | 'CACHE_HTTP_TOKEN' // Proxy | 'PROXY_URI' | 'PROXY_URIS' @@ -284,6 +286,10 @@ export type Config = { redis: { url: string; }; + httpCache: { + url?: string; + token?: string; + }; // proxy proxyUri?: string; proxyUris?: string[]; @@ -763,7 +769,7 @@ const calculateValue = () => { allowOrigin: envs.ALLOW_ORIGIN, // cache cache: { - type: envs.CACHE_TYPE || (envs.CACHE_TYPE === '' ? '' : 'memory'), // 缓存类型,支持 'memory' 和 'redis',设为空可以禁止缓存 + type: envs.CACHE_TYPE || (envs.CACHE_TYPE === '' ? '' : 'memory'), // Cache type; supports 'memory', 'redis', and 'http'. Set to empty string to disable cache. requestTimeout: toInt(envs.CACHE_REQUEST_TIMEOUT, 60), routeExpire: toInt(envs.CACHE_EXPIRE, 5 * 60), // 路由缓存时间,单位为秒 contentExpire: toInt(envs.CACHE_CONTENT_EXPIRE, 1 * 60 * 60), // 不变内容缓存时间,单位为秒 @@ -775,6 +781,10 @@ const calculateValue = () => { redis: { url: envs.REDIS_URL || 'redis://localhost:6379/', }, + httpCache: { + url: envs.CACHE_HTTP_URL, + token: envs.CACHE_HTTP_TOKEN, + }, // proxy proxyUri: envs.PROXY_URI, proxyUris: envs.PROXY_URIS diff --git a/lib/utils/cache/http.test.ts b/lib/utils/cache/http.test.ts new file mode 100644 index 0000000000..6e663dee47 --- /dev/null +++ b/lib/utils/cache/http.test.ts @@ -0,0 +1,118 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const errorSpy = vi.fn(); +const infoSpy = vi.fn(); + +vi.mock('@/utils/logger', () => ({ + default: { + error: errorSpy, + info: infoSpy, + }, +})); + +const setHttpCacheEnv = () => { + process.env.CACHE_HTTP_URL = 'https://cache.example.com/'; + process.env.CACHE_HTTP_TOKEN = 'token'; +}; + +const clearHttpCacheEnv = () => { + delete process.env.CACHE_TYPE; + delete process.env.CACHE_HTTP_URL; + delete process.env.CACHE_HTTP_TOKEN; +}; + +describe('http cache module', () => { + afterEach(() => { + clearHttpCacheEnv(); + vi.unstubAllGlobals(); + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('requires endpoint and token', async () => { + const cache = (await import('@/utils/cache/http')).default; + + cache.init(); + + expect(cache.status.available).toBe(false); + expect(errorSpy).toHaveBeenCalledWith('HTTP cache requires CACHE_HTTP_URL and CACHE_HTTP_TOKEN.'); + }); + + it('sets, gets, refreshes, and checks hashed keys', async () => { + setHttpCacheEnv(); + const requests: Array<{ body?: string; init?: RequestInit; url: string }> = []; + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + requests.push({ + body: init?.body?.toString(), + init, + url: input.toString(), + }); + + if (init?.method === 'PUT') { + return new Response(null, { status: 204 }); + } + if (init?.method === 'HEAD') { + return new Response(null, { status: 204 }); + } + + return Response.json({ hit: true, value: 'cached' }); + }); + vi.stubGlobal('fetch', fetchMock); + + const cache = (await import('@/utils/cache/http')).default; + cache.init(); + + await cache.set('mock/key', { ok: true }, 30); + await expect(cache.get('mock/key')).resolves.toBe('cached'); + await expect(cache.get('mock/key', false)).resolves.toBe('cached'); + await expect(cache.has('mock/key')).resolves.toBe(true); + + expect(cache.status.available).toBe(true); + expect(requests[0].url).toMatch(/^https:\/\/cache\.example\.com\/v1\/cache\/rsshub%3Ahttp-cache%3A[a-f0-9]{32}$/); + expect(requests[0].init?.headers).toMatchObject({ + authorization: 'Bearer token', + 'content-type': 'application/json', + }); + expect(JSON.parse(requests[0].body || '{}')).toEqual({ + ttl: 30, + value: '{"ok":true}', + }); + expect(requests[1].url).toMatch(/\?refresh=1$/); + expect(requests[2].url).not.toContain('?refresh=1'); + expect(requests[3].init?.method).toBe('HEAD'); + }); + + it('treats a 404 response as a miss', async () => { + setHttpCacheEnv(); + vi.stubGlobal( + 'fetch', + vi.fn(() => Response.json({ hit: false }, { status: 404 })) + ); + + const cache = (await import('@/utils/cache/http')).default; + cache.init(); + + await expect(cache.get('missing')).resolves.toBe(''); + await expect(cache.has('missing')).resolves.toBe(false); + }); + + it('uses non-refreshing reads for global cache', async () => { + process.env.CACHE_TYPE = 'http'; + setHttpCacheEnv(); + const urls: string[] = []; + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + urls.push(input.toString()); + return Response.json({ hit: true, value: 'route-cache' }); + }) + ); + + const cache = (await import('@/utils/cache')).default; + + await expect(cache.globalCache.get('route/key')).resolves.toBe('route-cache'); + + expect(cache.status.available).toBe(true); + expect(urls[0]).not.toContain('?refresh=1'); + }); +}); diff --git a/lib/utils/cache/http.ts b/lib/utils/cache/http.ts new file mode 100644 index 0000000000..07e642c68a --- /dev/null +++ b/lib/utils/cache/http.ts @@ -0,0 +1,173 @@ +import { config } from '@/config'; +import logger from '@/utils/logger'; +import md5 from '@/utils/md5'; + +import type CacheModule from './base'; + +type CacheHitResponse = { + hit: true; + value: string; +}; + +const status = { available: false }; + +let baseUrl: string | undefined; +let apiToken: string | undefined; + +const toRemoteKey = (key: string) => `rsshub:http-cache:${md5(key)}`; + +const cacheUrl = (key: string, refresh = false) => { + const url = `${baseUrl}/v1/cache/${encodeURIComponent(toRemoteKey(key))}`; + return refresh ? `${url}?refresh=1` : url; +}; + +const requestSignal = () => (typeof AbortSignal.timeout === 'function' ? AbortSignal.timeout(config.requestTimeout) : undefined); + +const request = async (url: string, init: Omit = {}, headers: Record = {}) => { + if (!status.available || !apiToken) { + return null; + } + + try { + return await fetch(url, { + ...init, + headers: { + authorization: `Bearer ${apiToken}`, + ...headers, + }, + signal: requestSignal(), + }); + } catch (error) { + logger.error('HTTP cache request failed:', error); + return null; + } +}; + +const readResponseText = async (response: Response) => { + try { + return await response.text(); + } catch { + return ''; + } +}; + +const readCacheHitResponse = async (response: Response) => { + try { + return (await response.json()) as CacheHitResponse; + } catch { + return null; + } +}; + +const logUnexpectedResponse = async (operation: string, response: Response) => { + const message = await readResponseText(response); + logger.error(`HTTP cache ${operation} failed with status ${response.status}${message ? `: ${message}` : ''}`); + + if (response.status === 401) { + status.available = false; + } +}; + +export default { + init: () => { + baseUrl = config.httpCache.url?.replace(/\/+$/, ''); + apiToken = config.httpCache.token; + + if (!baseUrl || !apiToken) { + status.available = false; + logger.error('HTTP cache requires CACHE_HTTP_URL and CACHE_HTTP_TOKEN.'); + return; + } + + try { + new URL(baseUrl); + } catch { + status.available = false; + logger.error('HTTP cache URL is invalid.'); + return; + } + + status.available = true; + logger.info('HTTP cache configured.'); + }, + get: async (key: string, refresh = true) => { + if (!key) { + return null; + } + + const response = await request(cacheUrl(key, refresh), { method: 'GET' }); + if (!response) { + return null; + } + + if (response.status === 404) { + return ''; + } + + if (!response.ok) { + await logUnexpectedResponse('get', response); + return null; + } + + const data = await readCacheHitResponse(response); + if (data?.hit === true && typeof data.value === 'string') { + return data.value; + } + + logger.error('HTTP cache get returned an invalid response.'); + return null; + }, + has: async (key: string) => { + if (!key) { + return false; + } + + const response = await request(cacheUrl(key), { method: 'HEAD' }); + if (!response) { + return false; + } + + if (response.status === 204) { + return true; + } + + if (response.status === 404) { + return false; + } + + await logUnexpectedResponse('has', response); + return false; + }, + set: async (key: string, value?: string | Record, maxAge = config.cache.contentExpire) => { + if (!key) { + return; + } + + if (!value || value === 'undefined') { + value = ''; + } + if (typeof value === 'object') { + value = JSON.stringify(value); + } + + const response = await request( + cacheUrl(key), + { + method: 'PUT', + body: JSON.stringify({ + ttl: maxAge, + value, + }), + }, + { + 'content-type': 'application/json', + } + ); + + if (response && response.status !== 204) { + await logUnexpectedResponse('set', response); + } + }, + clients: {}, + status, +} as CacheModule; diff --git a/lib/utils/cache/index.ts b/lib/utils/cache/index.ts index 17e3297005..4d8a04fe6d 100644 --- a/lib/utils/cache/index.ts +++ b/lib/utils/cache/index.ts @@ -3,6 +3,7 @@ import { isWorker } from '@/utils/is-worker'; import logger from '@/utils/logger'; import type CacheModule from './base'; +import http from './http'; import memory from './memory'; import redis from './redis'; @@ -16,76 +17,96 @@ const globalCache: { set: () => null, }; -let cacheModule: CacheModule; +const noopCacheModule: CacheModule = { + init: () => null, + get: () => null, + has: () => false, + set: () => null, + status: { + available: false, + }, + clients: {}, +}; + +let cacheModule: CacheModule = noopCacheModule; if (isWorker) { // No-op cache for Cloudflare Workers - cacheModule = { - init: () => null, - get: () => null, - has: () => false, - set: () => null, - status: { - available: false, - }, - clients: {}, - }; -} else if (config.cache.type === 'redis') { - cacheModule = redis; - cacheModule.init(); - const { redisClient } = cacheModule.clients; - globalCache.get = async (key) => { - if (key && cacheModule.status.available && redisClient) { - const value = await redisClient.get(key); - return value; - } - }; - globalCache.has = async (key) => { - if (key && cacheModule.status.available && redisClient) { - const result = await redisClient.exists(key); - return result > 0; - } - return false; - }; - globalCache.set = cacheModule.set; -} else if (config.cache.type === 'memory') { - cacheModule = memory; - cacheModule.init(); - const { memoryCache } = cacheModule.clients; - globalCache.get = (key) => { - if (key && cacheModule.status.available && memoryCache) { - return memoryCache.get(key, { updateAgeOnGet: false }) as string | undefined; - } - }; - globalCache.has = (key) => { - if (key && cacheModule.status.available && memoryCache) { - return memoryCache.has(key); - } - return false; - }; - globalCache.set = (key, value, maxAge = config.cache.routeExpire) => { - if (!value || value === 'undefined') { - value = ''; - } - if (typeof value === 'object') { - value = JSON.stringify(value); - } - if (key && memoryCache) { - return memoryCache.set(key, value, { ttl: maxAge * 1000 }); - } - }; + cacheModule = noopCacheModule; } else { - cacheModule = { - init: () => null, - get: () => null, - has: () => false, - set: () => null, - status: { - available: false, - }, - clients: {}, - }; - logger.error('Cache not available, concurrent requests are not limited. This could lead to bad behavior.'); + switch (config.cache.type) { + case 'redis': { + cacheModule = redis; + cacheModule.init(); + const { redisClient } = cacheModule.clients; + globalCache.get = async (key) => { + if (key && cacheModule.status.available && redisClient) { + const value = await redisClient.get(key); + return value; + } + }; + globalCache.has = async (key) => { + if (key && cacheModule.status.available && redisClient) { + const result = await redisClient.exists(key); + return result > 0; + } + return false; + }; + globalCache.set = cacheModule.set; + break; + } + case 'http': + cacheModule = http; + cacheModule.init(); + globalCache.get = (key) => { + if (key && cacheModule.status.available) { + return cacheModule.get(key, false); + } + }; + globalCache.has = (key) => { + if (key && cacheModule.status.available) { + return cacheModule.has(key); + } + return false; + }; + globalCache.set = (key, value, maxAge = config.cache.routeExpire) => { + if (key && cacheModule.status.available) { + return cacheModule.set(key, value, maxAge); + } + }; + break; + case 'memory': { + cacheModule = memory; + cacheModule.init(); + const { memoryCache } = cacheModule.clients; + globalCache.get = (key) => { + if (key && cacheModule.status.available && memoryCache) { + return memoryCache.get(key, { updateAgeOnGet: false }) as string | undefined; + } + }; + globalCache.has = (key) => { + if (key && cacheModule.status.available && memoryCache) { + return memoryCache.has(key); + } + return false; + }; + globalCache.set = (key, value, maxAge = config.cache.routeExpire) => { + if (!value || value === 'undefined') { + value = ''; + } + if (typeof value === 'object') { + value = JSON.stringify(value); + } + if (key && memoryCache) { + return memoryCache.set(key, value, { ttl: maxAge * 1000 }); + } + }; + break; + } + default: + cacheModule = noopCacheModule; + logger.error('Cache not available, concurrent requests are not limited. This could lead to bad behavior.'); + } } // only give cache string, as the `!` condition tricky