perf: optimize web data resources
This commit is contained in:
@@ -0,0 +1,149 @@
|
||||
export type CacheKey = readonly string[];
|
||||
|
||||
interface CacheEntry<T> {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
updatedAt: number;
|
||||
expiresAt: number;
|
||||
promise?: Promise<T>;
|
||||
}
|
||||
|
||||
export interface QueryOptions<T> {
|
||||
key: CacheKey;
|
||||
ttlMs: number;
|
||||
fetcher: () => Promise<T>;
|
||||
staleWhileRevalidate?: boolean;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const cache = new Map<string, CacheEntry<unknown>>();
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
|
||||
function keyToString(key: CacheKey): string {
|
||||
return JSON.stringify(key);
|
||||
}
|
||||
|
||||
function isPrefix(key: CacheKey, prefix: CacheKey): boolean {
|
||||
return prefix.every((part, index) => key[index] === part);
|
||||
}
|
||||
|
||||
function notify(serializedKey: string): void {
|
||||
listeners.get(serializedKey)?.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function notifyPrefix(prefix: CacheKey): void {
|
||||
for (const serializedKey of listeners.keys()) {
|
||||
const parsedKey = JSON.parse(serializedKey) as string[];
|
||||
if (isPrefix(parsedKey, prefix)) notify(serializedKey);
|
||||
}
|
||||
}
|
||||
|
||||
function startFetch<T>(serializedKey: string, ttlMs: number, fetcher: () => Promise<T>): Promise<T> {
|
||||
const now = Date.now();
|
||||
const existing = cache.get(serializedKey) as CacheEntry<T> | undefined;
|
||||
const promise = fetcher()
|
||||
.then((data) => {
|
||||
cache.set(serializedKey, {
|
||||
data,
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
notify(serializedKey);
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
cache.set(serializedKey, {
|
||||
data: existing?.data,
|
||||
error,
|
||||
updatedAt: existing?.updatedAt ?? now,
|
||||
expiresAt: existing?.data === undefined ? now : existing.expiresAt,
|
||||
});
|
||||
notify(serializedKey);
|
||||
throw error;
|
||||
});
|
||||
|
||||
cache.set(serializedKey, {
|
||||
...existing,
|
||||
updatedAt: existing?.updatedAt ?? now,
|
||||
expiresAt: existing?.expiresAt ?? now,
|
||||
promise,
|
||||
});
|
||||
notify(serializedKey);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function query<T>({
|
||||
key,
|
||||
ttlMs,
|
||||
fetcher,
|
||||
staleWhileRevalidate = true,
|
||||
force = false,
|
||||
}: QueryOptions<T>): Promise<T> {
|
||||
const serializedKey = keyToString(key);
|
||||
const entry = cache.get(serializedKey) as CacheEntry<T> | undefined;
|
||||
const now = Date.now();
|
||||
|
||||
if (!force && entry?.promise) return entry.promise;
|
||||
if (!force && entry?.data !== undefined && entry.expiresAt > now) return Promise.resolve(entry.data);
|
||||
|
||||
if (!force && staleWhileRevalidate && entry?.data !== undefined) {
|
||||
void startFetch(serializedKey, ttlMs, fetcher).catch((error) => {
|
||||
console.debug('[data-client] Background refresh failed', error);
|
||||
});
|
||||
return Promise.resolve(entry.data);
|
||||
}
|
||||
|
||||
return startFetch(serializedKey, ttlMs, fetcher);
|
||||
}
|
||||
|
||||
export function prefetch<T>(options: QueryOptions<T>): void {
|
||||
void query(options).catch((error) => {
|
||||
console.debug('[data-client] Prefetch failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
export function peek<T>(key: CacheKey): T | undefined {
|
||||
return (cache.get(keyToString(key)) as CacheEntry<T> | undefined)?.data;
|
||||
}
|
||||
|
||||
export function getEntry<T>(key: CacheKey): CacheEntry<T> | undefined {
|
||||
return cache.get(keyToString(key)) as CacheEntry<T> | undefined;
|
||||
}
|
||||
|
||||
export function set<T>(key: CacheKey, data: T, ttlMs: number): void {
|
||||
cache.set(keyToString(key), {
|
||||
data,
|
||||
updatedAt: Date.now(),
|
||||
expiresAt: Date.now() + ttlMs,
|
||||
});
|
||||
notify(keyToString(key));
|
||||
}
|
||||
|
||||
export function invalidate(prefix: CacheKey): void {
|
||||
for (const serializedKey of Array.from(cache.keys())) {
|
||||
const parsedKey = JSON.parse(serializedKey) as string[];
|
||||
if (isPrefix(parsedKey, prefix)) {
|
||||
cache.delete(serializedKey);
|
||||
notify(serializedKey);
|
||||
}
|
||||
}
|
||||
notifyPrefix(prefix);
|
||||
}
|
||||
|
||||
export function clearAll(): void {
|
||||
cache.clear();
|
||||
for (const serializedKey of listeners.keys()) notify(serializedKey);
|
||||
}
|
||||
|
||||
export function subscribe(key: CacheKey, listener: Listener): () => void {
|
||||
const serializedKey = keyToString(key);
|
||||
const keyListeners = listeners.get(serializedKey) ?? new Set<Listener>();
|
||||
keyListeners.add(listener);
|
||||
listeners.set(serializedKey, keyListeners);
|
||||
return () => {
|
||||
keyListeners.delete(listener);
|
||||
if (keyListeners.size === 0) listeners.delete(serializedKey);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user