mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-09-01 14:57:14 +08:00
feat: add HTTP cache (#21814)
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
+11
-1
@@ -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
|
||||
|
||||
Vendored
+118
@@ -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');
|
||||
});
|
||||
});
|
||||
Vendored
+173
@@ -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<RequestInit, 'headers'> = {}, headers: Record<string, string> = {}) => {
|
||||
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<string, any>, 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;
|
||||
Vendored
+87
-66
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user