perf: implement Phase 1 homepage diet via Next.js Cache Components
P1-0: V8 heap cap (HOMARR_MAX_OLD_SPACE_SIZE, default 512MB)
P1-1: Module graph cuts
- Dynamic integration imports (55 static → lazy loaders)
- Conditional DB driver loading (only configured driver loaded)
- Replace jsdom with linkedom for SVG sanitization
- Delete eager widget barrel (55 import * as → 0)
- Extract createRetryableLoader to @homarr/common
P1-2: Enable cacheComponents + restructure root layout
- Suspense boundaries around session-dependent providers
- ColorSchemeScript replaces server-side cookie reads
- Board/manage/auth layouts wrapped in Suspense
P1-3: Cache read paths
- Cache tag registry + invalidation helpers
- Cache invalidation wired into all mutation routers
- Translation deep-merge memoized per locale
P1-4: Prerendered shells + loading.tsx files
P1-5: Shared integration data cache (60s TTL, stale-while-revalidate)
P1-6: Redis-backed Next.js cache handler (@homarr/next-cache-handler)
P1-7: Redis optional (memory channel fallback), TTL on pubSub:last:*,
Redis clients consolidated from 6 to 2
This commit is contained in:
86 files changed
+2219
-1232
No files matched your search
@@ -0,0 +1,78 @@
|
||||
// ponytail: module-level cache — one snapshot per integration per query key.
|
||||
// Ceiling: stale data for up to `ttlMs` (60s default). Upgrade: Redis-backed with pub/sub invalidation.
|
||||
|
||||
interface CachedEntry<T> {
|
||||
data: T;
|
||||
timestamp: number;
|
||||
pending?: Promise<T>;
|
||||
}
|
||||
|
||||
const cache = new Map<string, CachedEntry<unknown>>();
|
||||
const DEFAULT_TTL_MS = 60_000;
|
||||
const MAX_ENTRIES = 200;
|
||||
|
||||
const cacheKey = (integrationId: string, queryKey: string) => `${integrationId}:${queryKey}`;
|
||||
|
||||
export const getCachedIntegrationData = async <T>(
|
||||
integrationId: string,
|
||||
queryKey: string,
|
||||
fetcher: () => Promise<T>,
|
||||
ttlMs = DEFAULT_TTL_MS,
|
||||
): Promise<T> => {
|
||||
const key = cacheKey(integrationId, queryKey);
|
||||
const existing = cache.get(key) as CachedEntry<T> | undefined;
|
||||
const now = Date.now();
|
||||
|
||||
if (existing) {
|
||||
if (now - existing.timestamp < ttlMs) {
|
||||
return existing.data;
|
||||
}
|
||||
if (existing.pending) {
|
||||
return existing.pending;
|
||||
}
|
||||
}
|
||||
|
||||
const pending = fetcher()
|
||||
.then((data) => {
|
||||
cache.set(key, { data, timestamp: Date.now() });
|
||||
|
||||
if (cache.size > MAX_ENTRIES) {
|
||||
let oldest: string | undefined;
|
||||
let oldestTime = Infinity;
|
||||
for (const [k, v] of cache) {
|
||||
if (v.timestamp < oldestTime) {
|
||||
oldestTime = v.timestamp;
|
||||
oldest = k;
|
||||
}
|
||||
}
|
||||
if (oldest) cache.delete(oldest);
|
||||
}
|
||||
|
||||
return data;
|
||||
})
|
||||
.catch((error) => {
|
||||
const entry = cache.get(key) as CachedEntry<T> | undefined;
|
||||
if (entry) {
|
||||
delete entry.pending;
|
||||
if (entry.timestamp > 0) return entry.data;
|
||||
}
|
||||
cache.delete(key);
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
existing.pending = pending;
|
||||
return existing.data;
|
||||
}
|
||||
|
||||
cache.set(key, { data: undefined as T, timestamp: 0, pending });
|
||||
return pending;
|
||||
};
|
||||
|
||||
export const invalidateIntegrationDataCache = (integrationId: string) => {
|
||||
for (const [key] of cache) {
|
||||
if (key.startsWith(`${integrationId}:`)) {
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
Reference in new issue
Block a user