Files
homarr/packages/request-handler/src/lib/request-handler.ts
T

127 lines
4.1 KiB
TypeScript

interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
getCacheKey?: (input: TInput) => string;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
const MAX_CACHE_SIZE = 1000;
const DEFAULT_TTL_MS = 10_000;
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
const now = Date.now();
for (const [key, entry] of cache) {
if (now >= entry.staleUntil) cache.delete(key);
}
};
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const cache = new Map<string, CacheEntry<TData>>();
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
let expiryTimerAt: number | undefined;
let generation = 0;
const scheduleExpiry = (candidateExpiryAt?: number) => {
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = undefined;
expiryTimerAt = undefined;
if (cache.size === 0) return;
const nextExpiryAt = candidateExpiryAt ?? Math.min(...[...cache.values()].map(({ staleUntil }) => staleUntil));
expiryTimerAt = nextExpiryAt;
expiryTimer = setTimeout(
() => {
expiryTimer = undefined;
expiryTimerAt = undefined;
evictExpired(cache);
scheduleExpiry();
},
Math.max(0, nextExpiryAt - Date.now()),
);
expiryTimer.unref?.();
};
return {
invalidateCache: () => {
generation += 1;
cache.clear();
inflight.clear();
if (expiryTimer) clearTimeout(expiryTimer);
expiryTimer = undefined;
expiryTimerAt = undefined;
},
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
let cached = cache.get(key);
const now = Date.now();
if (cached && now < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached && now >= cached.staleUntil) {
cache.delete(key);
scheduleExpiry();
cached = undefined;
}
const existing = inflight.get(key);
if (existing) return existing;
const requestGeneration = generation;
let promise: Promise<CacheEntry<TData>>;
promise = options
.requestAsync(input)
.then((data) => {
const expiresAt = Date.now() + ttl;
const staleIfErrorTtlMs = options.fallbackToStaleOnError
? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS)
: 0;
const entry: CacheEntry<TData> = {
data,
timestamp: new Date(),
expiresAt,
staleUntil: expiresAt + staleIfErrorTtlMs,
};
if (ttl > 0 && generation === requestGeneration) {
evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, entry);
scheduleExpiry(entry.staleUntil);
}
return entry;
})
.catch((err) => {
if (
options.fallbackToStaleOnError &&
cached &&
Date.now() < cached.staleUntil &&
generation === requestGeneration
) {
return cached;
}
throw err;
})
.finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
});
inflight.set(key, promise);
return promise;
},
}),
};
};