Some checks are pending
Server Tests / Mocha – Unit & Utility Tests (push) Waiting to run
143 lines
4.7 KiB
TypeScript
143 lines
4.7 KiB
TypeScript
import { Injectable } from '@angular/core';
|
|
import { from, Observable, of } from 'rxjs';
|
|
import { catchError, switchMap } from 'rxjs/operators';
|
|
import { AppConfigService } from './app-config.service';
|
|
import { AuthService } from './auth.service';
|
|
|
|
/** Shape of every entry stored in Cache Storage. */
|
|
export interface BrowserCacheEntry<T> {
|
|
data: T;
|
|
cachedAt: number; // epoch ms
|
|
}
|
|
|
|
/**
|
|
* Generic browser-side Cache Storage wrapper.
|
|
*
|
|
* Each logical cache is identified by a **cacheName** (e.g. `'agm-jobs-list-v1'`).
|
|
* Within that cache, individual entries are keyed by an arbitrary **key** string
|
|
* (typically a serialised set of query params).
|
|
*
|
|
* Usage:
|
|
* ```ts
|
|
* // Read
|
|
* this.browserCache.get<MyModel[]>('my-cache-v1', key, 60_000).subscribe(data => { ... });
|
|
*
|
|
* // Write
|
|
* this.browserCache.put('my-cache-v1', key, data);
|
|
*
|
|
* // Invalidate
|
|
* this.browserCache.invalidate('my-cache-v1');
|
|
* ```
|
|
*
|
|
* All operations are no-ops when the Cache Storage API is unavailable
|
|
* (e.g. in unit tests or older browsers).
|
|
*/
|
|
@Injectable({ providedIn: 'root' })
|
|
export class BrowserCacheService {
|
|
|
|
private readonly supported = typeof caches !== 'undefined';
|
|
private readonly fallbackMaxAgeMs = 60_000;
|
|
|
|
constructor(
|
|
private readonly appConfig: AppConfigService,
|
|
private readonly authSvc: AuthService
|
|
) {}
|
|
|
|
private ttlStorageKey(cacheName: string): string {
|
|
const userId = this.authSvc.user?._id || 'anonymous';
|
|
return `browser-cache-ttl:${userId}:${cacheName}`;
|
|
}
|
|
|
|
private get defaultMaxAgeMs(): number {
|
|
return this.appConfig.settings?.browserListCacheTtlMs || this.fallbackMaxAgeMs;
|
|
}
|
|
|
|
getTtl(cacheName: string): number {
|
|
const storedValue = localStorage.getItem(this.ttlStorageKey(cacheName));
|
|
if (storedValue === null) {
|
|
return this.defaultMaxAgeMs;
|
|
}
|
|
|
|
const parsedValue = Number(storedValue);
|
|
return Number.isFinite(parsedValue) && parsedValue >= 0
|
|
? parsedValue
|
|
: this.defaultMaxAgeMs;
|
|
}
|
|
|
|
setTtl(cacheName: string, ttlMs: number): number {
|
|
const normalizedValue = Number.isFinite(ttlMs) && ttlMs >= 0
|
|
? Math.floor(ttlMs)
|
|
: this.defaultMaxAgeMs;
|
|
localStorage.setItem(this.ttlStorageKey(cacheName), String(normalizedValue));
|
|
return normalizedValue;
|
|
}
|
|
|
|
/**
|
|
* Build the pseudo-URL used as the cache key inside a named Cache bucket.
|
|
* We prefix with a fixed path so it looks like a valid Request URL.
|
|
*/
|
|
private entryUrl(cacheName: string, key: string): string {
|
|
return `/browser-cache/${encodeURIComponent(cacheName)}?${key}`;
|
|
}
|
|
|
|
/**
|
|
* Retrieve a cached value.
|
|
*
|
|
* @param cacheName Name of the Cache Storage bucket (e.g. `'agm-jobs-list-v1'`).
|
|
* @param key Entry key — typically serialised query params.
|
|
* @param maxAgeMs Maximum age in milliseconds before the entry is treated as stale.
|
|
* Defaults to `appConfig.browserListCacheTtlMs` or 60 000.
|
|
* @returns The cached value, or `null` when unavailable / stale / missing.
|
|
*/
|
|
get<T>(cacheName: string, key: string, maxAgeMs?: number): Observable<T | null> {
|
|
if (!this.supported) return of(null);
|
|
|
|
const effectiveMaxAgeMs = maxAgeMs ?? this.getTtl(cacheName);
|
|
|
|
return from(caches.open(cacheName)).pipe(
|
|
switchMap(cache => from(cache.match(this.entryUrl(cacheName, key)))),
|
|
switchMap(response => {
|
|
if (!response) return of(null);
|
|
return from(response.json() as Promise<BrowserCacheEntry<T>>);
|
|
}),
|
|
switchMap((entry: BrowserCacheEntry<T> | null) => {
|
|
if (!entry) return of(null);
|
|
if (Date.now() - entry.cachedAt > effectiveMaxAgeMs) return of(null);
|
|
return of(entry.data);
|
|
}),
|
|
catchError(() => of(null))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Store a value in Cache Storage.
|
|
* Fire-and-forget — errors are silently swallowed so they never block the caller.
|
|
*
|
|
* @param cacheName Name of the Cache Storage bucket.
|
|
* @param key Entry key.
|
|
* @param data Value to store (must be JSON-serialisable).
|
|
*/
|
|
put<T>(cacheName: string, key: string, data: T): void {
|
|
if (!this.supported) return;
|
|
|
|
const entry: BrowserCacheEntry<T> = { data, cachedAt: Date.now() };
|
|
caches.open(cacheName)
|
|
.then(cache => cache.put(
|
|
this.entryUrl(cacheName, key),
|
|
new Response(JSON.stringify(entry), { headers: { 'Content-Type': 'application/json' } })
|
|
))
|
|
.catch(() => { /* silent */ });
|
|
}
|
|
|
|
/**
|
|
* Delete an entire Cache Storage bucket, invalidating all its entries.
|
|
* Typically called after a mutation (create / update / delete).
|
|
*
|
|
* @param cacheName Name of the Cache Storage bucket to delete.
|
|
*/
|
|
invalidate(cacheName: string): void {
|
|
if (!this.supported) return;
|
|
caches.delete(cacheName).catch(() => { /* silent */ });
|
|
}
|
|
}
|