62 lines
2.4 KiB
TypeScript
62 lines
2.4 KiB
TypeScript
import { loadImage } from '@napi-rs/canvas';
|
|
|
|
async function boundedBytes(response: Response) {
|
|
const reader = response.body?.getReader();
|
|
if (!reader) return null;
|
|
const chunks: Uint8Array[] = []; let size = 0;
|
|
try {
|
|
while (true) {
|
|
const { value, done } = await reader.read();
|
|
if (done) return Buffer.concat(chunks);
|
|
size += value.byteLength;
|
|
if (size > 1024 * 1024) { await reader.cancel(); return null; }
|
|
chunks.push(value);
|
|
}
|
|
} finally { reader.releaseLock(); }
|
|
}
|
|
|
|
type Avatar = Awaited<ReturnType<typeof loadImage>>;
|
|
type Options = {
|
|
request?: typeof fetch;
|
|
now?: () => number;
|
|
maxEntries?: number;
|
|
maxPending?: number;
|
|
successTtlMs?: number;
|
|
failureTtlMs?: number;
|
|
};
|
|
/** Hash-based Discord URLs invalidate naturally; bound both cached images and downloads. */
|
|
export function createAvatarCache({ request = ((url, init) => fetch(url, init)) as typeof fetch, now = Date.now, maxEntries = 128, maxPending = 4, successTtlMs = 3600000, failureTtlMs = 30000 }: Options = {}) {
|
|
const cache = new Map<string, { image: Avatar | null; expires: number }>();
|
|
const pending = new Map<string, Promise<Avatar | null>>();
|
|
return async (url: string): Promise<Avatar | null> => {
|
|
const cached = cache.get(url);
|
|
if (cached && cached.expires > now()) {
|
|
cache.delete(url); cache.set(url, cached);
|
|
return cached.image;
|
|
}
|
|
cache.delete(url);
|
|
const existing = pending.get(url);
|
|
if (existing) return existing;
|
|
if (pending.size >= maxPending) return null;
|
|
const job = (async () => {
|
|
let image: Avatar | null = null;
|
|
try {
|
|
const response = await request(url, { redirect: 'error', signal: AbortSignal.timeout(3000) });
|
|
if (response.ok) {
|
|
const bytes = await boundedBytes(response);
|
|
if (bytes) {
|
|
const decoded = await loadImage(Buffer.from(bytes));
|
|
if (decoded.width <= 256 && decoded.height <= 256) image = decoded;
|
|
}
|
|
}
|
|
} catch { /* A brief negative cache keeps an unavailable CDN out of the hot path. */ }
|
|
cache.set(url, { image, expires: now() + (image ? successTtlMs : failureTtlMs) });
|
|
while (cache.size > maxEntries) cache.delete(cache.keys().next().value!);
|
|
return image;
|
|
})().finally(() => { pending.delete(url); });
|
|
pending.set(url, job);
|
|
return job;
|
|
};
|
|
}
|
|
export const cachedAvatar = createAvatarCache();
|