mirror of
https://github.com/DIYgod/RSSHub.git
synced 2026-10-17 05:11:57 +08:00
3bc1cc248d
* chore: bump to msw v3 * refactor: anti-slop * test: use vi.stubEnv for REDIS_URL in redis error test * refactor: move remote debugging registration into a conditional block * chore: add code coverage upload for github
187 lines
4.9 KiB
TypeScript
187 lines
4.9 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
import { config } from '@/config';
|
|
import logger from '@/utils/logger';
|
|
import md5 from '@/utils/md5';
|
|
|
|
import type CacheModule from './base';
|
|
import { stringify } from './base';
|
|
|
|
const cacheHitSchema = z.object({
|
|
hit: z.literal(true),
|
|
value: z.string(),
|
|
});
|
|
|
|
const cacheMissSchema = z.object({
|
|
hit: z.literal(false),
|
|
});
|
|
|
|
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 = () => ('timeout' in AbortSignal ? 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 {
|
|
const payload = await response.json();
|
|
const parsed = cacheHitSchema.safeParse(payload);
|
|
return parsed.success ? parsed.data : null;
|
|
} 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) {
|
|
try {
|
|
const data = await response.json();
|
|
if (cacheMissSchema.safeParse(data).success) {
|
|
return '';
|
|
}
|
|
} catch {
|
|
// Cloudflare routing errors can also return 404, without reaching the cache service.
|
|
}
|
|
logger.error('HTTP cache get returned HTTP 404 without a cache miss response.');
|
|
return null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
await logUnexpectedResponse('get', response);
|
|
return null;
|
|
}
|
|
|
|
const data = await readCacheHitResponse(response);
|
|
if (data) {
|
|
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 <T>(key: string, value?: string | T, maxAge = config.cache.contentExpire) => {
|
|
if (!key) {
|
|
return;
|
|
}
|
|
|
|
const stored = stringify(value);
|
|
|
|
const response = await request(
|
|
cacheUrl(key),
|
|
{
|
|
method: 'PUT',
|
|
body: JSON.stringify({
|
|
ttl: maxAge,
|
|
value: stored,
|
|
}),
|
|
},
|
|
{
|
|
'content-type': 'application/json',
|
|
}
|
|
);
|
|
|
|
if (response && response.status !== 204) {
|
|
await logUnexpectedResponse('set', response);
|
|
}
|
|
},
|
|
clients: {},
|
|
status,
|
|
} satisfies CacheModule;
|