fix: harden widget request caching

This commit is contained in:
ajnart committed 2026-08-26 14:57:53 +02:00
1 parent c81088d876
commit 47724f44eb
26 files changed
+838 -209

No files matched your search

@@ -15,9 +15,14 @@ import type { IntegrationCacheGeneration, WidgetCacheGeneration } from "@homarr/
import type { CacheEntry, SharedCacheAdapter } from "./request-handler";
const logger = createLogger({ module: "sharedRequestCache" });
const REDIS_OPERATION_TIMEOUT_MS = 250;
// The dedicated cache client rejects commands after 500 ms. This guard stays
// above that deadline so lock outcomes are never abandoned while still pending.
const REDIS_OPERATION_TIMEOUT_MS = 750;
const REFRESH_LOCK_TTL_SECONDS = 15;
const SHARED_CACHE_ENVELOPE_VERSION = 2;
const DEFAULT_HANDLER_CACHE_VERSION = "v1";
const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/;
const cacheVersionPattern = /^[a-z0-9][a-z0-9._-]*$/;
const isRecord = (value: unknown): value is Record<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
@@ -47,27 +52,40 @@ const withTimeoutAsync = async <T>(promise: Promise<T>, durationMs: number): Pro
});
};
const isCacheEntry = (value: unknown): value is CacheEntry<unknown> => {
if (!isRecord(value)) return false;
interface SharedCacheEnvelope<TData> {
version: typeof SHARED_CACHE_ENVELOPE_VERSION;
entry: CacheEntry<TData>;
}
const isSharedCacheEnvelope = (value: unknown): value is SharedCacheEnvelope<unknown> => {
if (!isRecord(value) || value.version !== SHARED_CACHE_ENVELOPE_VERSION || !isRecord(value.entry)) return false;
const entry = value.entry;
if (!Object.hasOwn(entry, "data") || !(entry.timestamp instanceof Date)) return false;
const timestamp = entry.timestamp.getTime();
return (
value.timestamp instanceof Date &&
typeof value.expiresAt === "number" &&
Number.isFinite(value.expiresAt) &&
typeof value.staleUntil === "number" &&
Number.isFinite(value.staleUntil)
Number.isFinite(timestamp) &&
typeof entry.expiresAt === "number" &&
Number.isFinite(entry.expiresAt) &&
typeof entry.staleUntil === "number" &&
Number.isFinite(entry.staleUntil) &&
timestamp <= entry.expiresAt &&
entry.expiresAt <= entry.staleUntil
);
};
interface IntegrationSharedCacheOptions {
namespace: string;
integrationId: string;
cacheOptions: Record<string, unknown>;
cacheIdentity: string;
cacheVersion?: string;
}
interface RedisSharedCacheOptions {
namespace: string;
cacheKind: "integration" | "widget";
cacheIdentity: string;
cacheVersion: string;
generation: string;
metadata?: Record<string, string>;
isShared?: boolean;
@@ -90,15 +108,16 @@ const createRedisSharedCache = <TData>({
namespace,
cacheKind,
cacheIdentity,
cacheVersion,
generation,
metadata = {},
isShared: initialIsShared = true,
}: RedisSharedCacheOptions): SharedCacheAdapter<TData> => {
const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata };
const payloadChannel = createGetSetChannel<CacheEntry<TData>>(
`${cacheKind}-cache:payload:v1:${namespace}:${generation}:${cacheIdentity}`,
);
const lock = createLockChannel(`${cacheKind}-cache:lock:v1:${namespace}:${generation}:${cacheIdentity}`);
const payloadName = `${cacheKind}-cache:payload:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
const lockName = `${cacheKind}-cache:lock:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
const payloadChannel = createGetSetChannel<unknown>(payloadName, { useBoundedCacheClient: true });
const lock = createLockChannel(lockName, { useBoundedCacheClient: true });
let isShared = initialIsShared;
const runRedisOperationAsync = async <T>(operation: string, callback: () => Promise<T>, fallback: T): Promise<T> => {
@@ -120,7 +139,7 @@ const createRedisSharedCache = <TData>({
getAsync: async () => {
const value = await runRedisOperationAsync("payload-read", () => payloadChannel.getAsync(), undefined);
if (value === undefined || value === null) return value;
if (isCacheEntry(value)) return value;
if (isSharedCacheEnvelope(value)) return value.entry as CacheEntry<TData>;
logger.warn("Ignored invalid Redis response cache entry", {
...logMetadata,
@@ -129,12 +148,16 @@ const createRedisSharedCache = <TData>({
await runRedisOperationAsync("payload-remove", () => payloadChannel.removeAsync(), undefined);
return null;
},
setAsync: async (entry) => {
setAsync: async (entry, refreshLockToken) => {
const retentionMs = entry.staleUntil - Date.now();
if (retentionMs <= 0) return;
await runRedisOperationAsync(
if (retentionMs <= 0) return false;
const envelope: SharedCacheEnvelope<TData> = {
version: SHARED_CACHE_ENVELOPE_VERSION,
entry,
};
return await runRedisOperationAsync(
"payload-write",
() => payloadChannel.setAsync(entry, { ttlMs: retentionMs }),
() => lock.setIfOwnedAsync(refreshLockToken, payloadName, envelope, retentionMs),
undefined,
);
},
@@ -151,22 +174,29 @@ const createRedisSharedCache = <TData>({
undefined,
),
releaseRefreshLockAsync: async (token) => {
await runRedisOperationAsync("refresh-lock-release", () => lock.releaseAsync(token), undefined);
try {
await withTimeoutAsync(lock.releaseAsync(token), REDIS_OPERATION_TIMEOUT_MS);
} catch (error) {
logRedisFailure("refresh-lock-release", logMetadata, error);
}
},
};
};
interface WidgetSharedCacheOptions {
namespace: string;
cacheInput: Record<string, unknown>;
cacheIdentity: string;
cacheVersion?: string;
}
export const createWidgetSharedCacheAsync = async <TData>({
namespace,
cacheInput,
cacheIdentity,
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
}: WidgetSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
if (typeof window !== "undefined") throw new Error("Widget request caching is server-only");
if (!namespacePattern.test(namespace)) throw new Error(`Invalid widget cache namespace '${namespace}'`);
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid widget cache version '${cacheVersion}'`);
const metadata = { cacheNamespace: namespace };
let generation: WidgetCacheGeneration;
@@ -180,7 +210,8 @@ export const createWidgetSharedCacheAsync = async <TData>({
return createRedisSharedCache<TData>({
namespace,
cacheKind: "widget",
cacheIdentity: hashSharedCacheInput(cacheInput),
cacheIdentity,
cacheVersion,
generation: generation.value,
isShared: generation.isShared,
});
@@ -189,10 +220,12 @@ export const createWidgetSharedCacheAsync = async <TData>({
export const createIntegrationSharedCacheAsync = async <TData>({
namespace,
integrationId,
cacheOptions,
cacheIdentity,
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
}: IntegrationSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
if (typeof window !== "undefined") throw new Error("Integration request caching is server-only");
if (!namespacePattern.test(namespace)) throw new Error(`Invalid integration cache namespace '${namespace}'`);
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid integration cache version '${cacheVersion}'`);
const metadata = { cacheNamespace: namespace, integrationId };
let generation: IntegrationCacheGeneration;
@@ -203,11 +236,11 @@ export const createIntegrationSharedCacheAsync = async <TData>({
generation = { value: "redis-unavailable", isShared: false };
}
const optionsHash = hashIntegrationCacheOptions(cacheOptions);
const sharedCache = createRedisSharedCache<TData>({
namespace,
cacheKind: "integration",
cacheIdentity: `${integrationId}:${optionsHash}`,
cacheIdentity,
cacheVersion,
generation: generation.value,
metadata: { integrationId },
isShared: generation.isShared,