Release v0.19.0. See the `## [0.19.0] — 2026-09-29` section of [CHANGELOG.md](https://github.com/ralksta/immich-folio/blob/dev/CHANGELOG.md) for the full list. Highlights: client proofing links (#683), custom content pages (#738), asset picker search (#740), concurrent-edit detection (#742), env-locked settings (#739), an accessibility baseline (#741), three security fixes with advisories (#737, #755, #759), and a pre-release quality pass (#780–#797). --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: ralksta <ralksta@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Ralf <rohlenbo79@gmail.com> Co-authored-by: Paul Thies <110185070+ImScheinox@users.noreply.github.com> Co-authored-by: lancetm714 <108419425+lancetm714@users.noreply.github.com> Co-authored-by: Lance Martin <lancetaylor95@gmail.com>
119 lines
3.4 KiB
TypeScript
119 lines
3.4 KiB
TypeScript
/**
|
|
* Simple in-memory LRU cache for Immich API responses.
|
|
* Avoids hammering the Immich server with identical requests.
|
|
*
|
|
* Entries carry two deadlines. `staleAt` is the normal TTL — past it, get()
|
|
* reports a miss and callers refetch. `hardExpiresAt` is the point past which
|
|
* the data is too old to show at all. Between the two, the entry survives as a
|
|
* fallback that getStale() hands out when Immich is unreachable, so a
|
|
* restarting server does not turn the gallery into an error page.
|
|
*/
|
|
|
|
interface CacheEntry<T> {
|
|
data: T;
|
|
staleAt: number;
|
|
hardExpiresAt: number;
|
|
}
|
|
|
|
const DEFAULT_STALE_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
|
|
|
|
/**
|
|
* The entries live on globalThis, not in this module.
|
|
*
|
|
* Next bundles each route separately, so every route handler gets its own
|
|
* instance of this module. A module-level Map meant the admin panel's
|
|
* "Clear cache", a save's invalidation and the Immich webhook each cleared a
|
|
* cache no page ever read from, and the diagnostics page reported the size
|
|
* of its own, always empty, copy ("0 items"). The process is shared, so one
|
|
* store on globalThis is the cache every instance reads and clears. The key is
|
|
* versioned so a dev server's hot reload never hands a new build entries in
|
|
* an older shape.
|
|
*/
|
|
const STORE_KEY = Symbol.for('immich-folio.immich-cache.v2');
|
|
|
|
function sharedStore(): Map<string, CacheEntry<unknown>> {
|
|
const holder = globalThis as unknown as Record<symbol, Map<string, CacheEntry<unknown>>>;
|
|
holder[STORE_KEY] ??= new Map();
|
|
return holder[STORE_KEY];
|
|
}
|
|
|
|
class MemoryCache {
|
|
private store = sharedStore();
|
|
private maxEntries = 200;
|
|
|
|
get size(): number {
|
|
return this.store.size;
|
|
}
|
|
|
|
/** Move an entry to the newest end of the LRU order. */
|
|
private touch(key: string, entry: CacheEntry<unknown>): void {
|
|
this.store.delete(key);
|
|
this.store.set(key, entry);
|
|
}
|
|
|
|
/** Fresh data only. Returns null once the TTL has elapsed. */
|
|
get<T>(key: string): T | null {
|
|
const entry = this.store.get(key);
|
|
if (!entry) return null;
|
|
|
|
const now = Date.now();
|
|
|
|
if (now > entry.hardExpiresAt) {
|
|
this.store.delete(key);
|
|
return null;
|
|
}
|
|
|
|
// Past the TTL the entry is no longer fresh, but it is deliberately kept
|
|
// so getStale() can still use it.
|
|
if (now > entry.staleAt) return null;
|
|
|
|
this.touch(key, entry);
|
|
return entry.data as T;
|
|
}
|
|
|
|
/**
|
|
* Fresh *or* stale data. Only for the upstream-unreachable path — prefer
|
|
* get() everywhere else.
|
|
*/
|
|
getStale<T>(key: string): T | null {
|
|
const entry = this.store.get(key);
|
|
if (!entry) return null;
|
|
|
|
if (Date.now() > entry.hardExpiresAt) {
|
|
this.store.delete(key);
|
|
return null;
|
|
}
|
|
|
|
this.touch(key, entry);
|
|
return entry.data as T;
|
|
}
|
|
|
|
set<T>(key: string, data: T, ttlMs: number, staleMaxAgeMs = DEFAULT_STALE_MAX_AGE_MS): void {
|
|
if (this.store.has(key)) {
|
|
this.store.delete(key);
|
|
} else if (this.store.size >= this.maxEntries) {
|
|
const firstKey = this.store.keys().next().value;
|
|
if (firstKey) this.store.delete(firstKey);
|
|
}
|
|
|
|
const now = Date.now();
|
|
this.store.set(key, {
|
|
data,
|
|
staleAt: now + ttlMs,
|
|
// Measured from write time, and never shorter than the TTL itself.
|
|
hardExpiresAt: now + Math.max(ttlMs, staleMaxAgeMs),
|
|
});
|
|
}
|
|
|
|
delete(key: string): void {
|
|
this.store.delete(key);
|
|
}
|
|
|
|
clear(): void {
|
|
this.store.clear();
|
|
}
|
|
}
|
|
|
|
export const cache = new MemoryCache();
|
|
export { DEFAULT_STALE_MAX_AGE_MS };
|