Files
Tony 3bc1cc248d chore: bump to msw v3 (#23463)
* 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
2026-10-03 06:36:19 +08:00

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;