Files
pmomusic/pmoapp/webapp/src/composables/apiCache.ts

272 lines
6.7 KiB
TypeScript
Raw Normal View History

/**
* Cache API centralisé pour les requêtes HTTP
*
* Fonctionnalités:
* - Cache mémoire avec TTL configurable
* - Dédupplication des requêtes en cours (une seule requête pour plusieurs callers)
* - Invalidation par pattern (ex: invalidate('renderers/*'))
* - Subscribe aux changements de données pour reactivity
*/
export interface CacheEntry<T> {
data: T;
timestamp: number;
etag?: string;
}
export interface ApiCacheOptions {
ttl?: number;
staleWhileRevalidate?: boolean;
}
interface PendingRequest {
promise: Promise<unknown>;
subscribers: Set<(data: unknown) => void>;
}
/**
* Classe principale du cache API
*/
class ApiCacheService {
private cache = new Map<string, CacheEntry<unknown>>();
private pendingRequests = new Map<string, PendingRequest>();
private subscriptions = new Map<string, Set<(data: unknown) => void>>();
private options: Required<ApiCacheOptions> = {
ttl: 2000,
staleWhileRevalidate: true,
};
configure(options: Partial<ApiCacheOptions>) {
this.options = { ...this.options, ...options };
}
private makeKey(endpoint: string, params?: Record<string, string | number | boolean>): string {
if (!params) return endpoint;
const sorted = Object.entries(params).sort(([a], [b]) => a.localeCompare(b));
const query = sorted.map(([k, v]) => `${k}=${v}`).join('&');
return `${endpoint}?${query}`;
}
private isFresh(key: string): boolean {
const entry = this.cache.get(key);
if (!entry) return false;
return Date.now() - entry.timestamp < this.options.ttl;
}
get<T>(endpoint: string, params?: Record<string, string | number | boolean>): T | null {
const key = this.makeKey(endpoint, params);
const entry = this.cache.get(key) as CacheEntry<T> | undefined;
if (!entry) return null;
if (!this.isFresh(key)) {
return this.options.staleWhileRevalidate ? entry.data : null;
}
return entry.data;
}
set<T>(endpoint: string, data: T, params?: Record<string, string | number | boolean>, etag?: string): void {
const key = this.makeKey(endpoint, params);
this.cache.set(key, {
data,
timestamp: Date.now(),
etag,
});
this.notifySubscribers(key, data);
}
async fetch<T>(
endpoint: string,
params: Record<string, string | number | boolean> | undefined,
fetcher: () => Promise<T>,
options: { force?: boolean; ttl?: number } = {}
): Promise<T> {
const key = this.makeKey(endpoint, params);
const { force = false, ttl } = options;
if (!force && this.isFresh(key)) {
const cached = this.get<T>(endpoint, params);
if (cached) return cached;
}
const existing = this.pendingRequests.get(key);
if (existing) {
return existing.promise as Promise<T>;
}
let resolvePromise!: (value: unknown) => void;
let rejectPromise!: (reason: unknown) => void;
const promise = new Promise<unknown>((resolve, reject) => {
resolvePromise = resolve;
rejectPromise = reject;
});
this.pendingRequests.set(key, {
promise,
subscribers: new Set(),
});
try {
const data = await fetcher();
if (ttl) {
const originalTtl = this.options.ttl;
this.options.ttl = ttl;
this.set(endpoint, data, params);
this.options.ttl = originalTtl;
} else {
this.set(endpoint, data, params);
}
resolvePromise(data);
const pending = this.pendingRequests.get(key);
if (pending) {
pending.subscribers.forEach(cb => cb(data));
}
} catch (error) {
rejectPromise(error);
throw error;
} finally {
this.pendingRequests.delete(key);
}
return Promise.reject(new Error('Unreachable'));
}
subscribe<T>(endpoint: string, params: Record<string, string | number | boolean>, callback: (data: T) => void): () => void {
const key = this.makeKey(endpoint, params);
if (!this.subscriptions.has(key)) {
this.subscriptions.set(key, new Set());
}
this.subscriptions.get(key)!.add(callback as (data: unknown) => void);
const cached = this.get<T>(endpoint, params);
if (cached) {
callback(cached);
}
return () => {
const subs = this.subscriptions.get(key);
if (subs) {
subs.delete(callback as (data: unknown) => void);
if (subs.size === 0) {
this.subscriptions.delete(key);
}
}
};
}
invalidate(pattern: string): void {
const keysToDelete: string[] = [];
if (pattern.includes('*')) {
const prefix = pattern.replace('*', '');
this.cache.forEach((_, key) => {
if (key.startsWith(prefix)) {
keysToDelete.push(key);
}
});
} else {
if (this.cache.has(pattern)) {
keysToDelete.push(pattern);
}
}
keysToDelete.forEach(key => {
this.cache.delete(key);
this.subscriptions.delete(key);
});
}
clear(): void {
this.cache.clear();
this.subscriptions.clear();
}
async invalidateAndFetch<T>(
endpoint: string,
params: Record<string, string | number | boolean>,
fetcher: () => Promise<T>
): Promise<T> {
this.invalidate(this.makeKey(endpoint, params));
return this.fetch(endpoint, params, fetcher, { force: true });
}
getStats() {
let fresh = 0;
let stale = 0;
const now = Date.now();
this.cache.forEach((entry) => {
if (now - entry.timestamp < this.options.ttl) {
fresh++;
} else {
stale++;
}
});
return {
total: this.cache.size,
fresh,
stale,
pending: this.pendingRequests.size,
subscriptions: this.subscriptions.size,
};
}
private notifySubscribers(key: string, data: unknown) {
const subs = this.subscriptions.get(key);
if (subs) {
subs.forEach(cb => {
try {
cb(data);
} catch (e) {
console.error('[ApiCache] Error in subscriber:', e);
}
});
}
}
}
export const apiCache = new ApiCacheService();
export function useApiCache() {
return {
fetch<T>(
endpoint: string,
params: Record<string, string | number | boolean> | undefined,
fetcher: () => Promise<T>,
options?: { force?: boolean; ttl?: number }
): Promise<T> {
return apiCache.fetch(endpoint, params, fetcher, options);
},
subscribe<T>(
endpoint: string,
params: Record<string, string | number | boolean>,
callback: (data: T) => void
): () => void {
return apiCache.subscribe(endpoint, params, callback);
},
invalidate(pattern: string): void {
apiCache.invalidate(pattern);
},
clear(): void {
apiCache.clear();
},
getStats() {
return apiCache.getStats();
},
};
}