refactor: simplify widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-24 00:57:36 +02:00
1 parent c988c35b29
commit 8f9efd0074
179 files changed
+1509 -4187

No files matched your search

+2 -4
View File
@@ -1,18 +1,16 @@
import { getWidgetServerCachePolicy } from "@homarr/definitions";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const downloadsCachePolicy = getWidgetServerCachePolicy("downloads", "getJobsAndStatuses");
export const downloadClientRequestHandler = createIntegrationRequestHandler<
DownloadClientJobsAndStatus,
IntegrationKindByCategory<"downloadClient">,
{ limit: number }
>({
cachePolicy: downloadsCachePolicy,
cacheNamespace: "downloads:jobs-and-status",
cacheTtlMs: 10 * 1000,
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClientJobsAndStatusAsync(input);
@@ -1,8 +1,9 @@
import type { Modify } from "@homarr/common/types";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import type { IntegrationKind, WidgetModuleServerCachePolicy } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./integration-shared-cache";
import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./shared-cache";
import { createRequestHandler } from "./request-handler";
import type { SharedCacheAdapter } from "./request-handler";
@@ -18,7 +19,6 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
cacheNamespace?: string;
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "integration" };
}
export const createIntegrationRequestHandler = <
@@ -28,7 +28,7 @@ export const createIntegrationRequestHandler = <
>(
options: Options<TData, TKind, TInput>,
) => {
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
const cacheNamespace = options.cacheNamespace;
let getSharedCacheAsync:
| ((input: { integration: IntegrationOfKind<TKind>; options: TInput }) => Promise<SharedCacheAdapter<TData>>)
| undefined;
@@ -45,14 +45,24 @@ export const createIntegrationRequestHandler = <
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
getCacheKey: ({ integration, options: itemOptions }) =>
`${integration.id}:${hashIntegrationCacheOptions(itemOptions)}`,
cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs,
fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs,
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
getSharedCacheAsync,
});
return {
invalidateCache: inner.invalidateCache,
/** Clears this handler's L1 and every shared response-cache namespace for these integrations. */
invalidateCacheAsync: async (integrationIds: readonly string[]) => {
inner.invalidateCache();
if (!cacheNamespace) return;
await Promise.all(
[...new Set(integrationIds)].map(async (integrationId) => {
await invalidateIntegrationResponseCacheAsync(integrationId);
}),
);
},
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
inner.handler({ integration, options: itemOptions }),
};
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import { createIntegrationRequestHandler } from "./integration-request-handler";
import type { SharedCacheAdapter } from "./request-handler";
import { createRequestHandler } from "./request-handler";
import { createWidgetRequestHandler } from "./widget-request-handler";
@@ -52,6 +53,62 @@ describe("createRequestHandler", () => {
expect(calls).toBe(1);
});
it("uses a fresh shared cache entry before calling the upstream service", async () => {
const timestamp = new Date();
const requestAsync = vi.fn(async () => "upstream");
const sharedCache = {
generation: "shared-1",
isShared: true,
getAsync: vi.fn(async () => ({
data: "shared",
timestamp,
expiresAt: Date.now() + 1_000,
staleUntil: Date.now() + 1_000,
})),
setAsync: vi.fn(async () => undefined),
acquireRefreshLockAsync: vi.fn(async () => "lock"),
releaseRefreshLockAsync: vi.fn(async () => undefined),
};
const handler = createRequestHandler({
cacheTtlMs: 1_000,
requestAsync,
getSharedCacheAsync: async () => sharedCache,
});
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "shared", timestamp });
expect(requestAsync).not.toHaveBeenCalled();
expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled();
});
it("deduplicates while the shared cache is being resolved", async () => {
const sharedCacheRequest = deferred<SharedCacheAdapter<string>>();
const upstreamRequest = deferred<string>();
const getSharedCacheAsync = vi.fn(async () => await sharedCacheRequest.promise);
const requestAsync = vi.fn(async () => await upstreamRequest.promise);
const handler = createRequestHandler({
cacheTtlMs: 1_000,
requestAsync,
getSharedCacheAsync,
});
const first = handler.handler({ id: "same" }).getDataAsync();
const second = handler.handler({ id: "same" }).getDataAsync();
expect(getSharedCacheAsync).toHaveBeenCalledTimes(1);
sharedCacheRequest.resolve({
generation: "shared-1",
isShared: false,
getAsync: async () => undefined,
setAsync: async () => undefined,
acquireRefreshLockAsync: async () => undefined,
releaseRefreshLockAsync: async () => undefined,
});
await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1));
upstreamRequest.resolve("value");
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
});
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
let calls = 0;
const request = deferred<{ call: number }>();
@@ -95,26 +95,27 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input);
const sharedCache = ttl > 0 ? await options.getSharedCacheAsync?.(input) : undefined;
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
let cached = cache.get(key);
const now = Date.now();
if (cached && now < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached && now >= cached.staleUntil) {
cache.delete(key);
scheduleExpiry();
cached = undefined;
}
const existing = inflight.get(key);
const existing = inflight.get(baseKey);
if (existing) return existing;
const requestGeneration = generation;
let promise: Promise<CacheEntry<TData>>;
promise = (async () => {
let sharedCache: SharedCacheAdapter<TData> | undefined;
if (ttl > 0 && options.getSharedCacheAsync) {
sharedCache = await options.getSharedCacheAsync(input);
}
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
let cached = cache.get(key);
const now = Date.now();
if (cached && now < cached.expiresAt) return cached;
if (cached && now >= cached.staleUntil) {
cache.delete(key);
scheduleExpiry();
cached = undefined;
}
let stale = cached;
let lockToken: string | null | undefined;
@@ -179,10 +180,10 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
}
})().finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
if (inflight.get(baseKey) === promise) inflight.delete(baseKey);
});
inflight.set(key, promise);
inflight.set(baseKey, promise);
return promise;
},
}),
@@ -75,7 +75,7 @@ interface RedisSharedCacheOptions {
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
logger.warn(
new ErrorWithMetadata(
"Redis widget cache operation failed",
"Redis response cache operation failed",
{
...metadata,
operation,
@@ -121,7 +121,7 @@ const createRedisSharedCache = <TData>({
if (value === undefined || value === null) return value;
if (isCacheEntry(value)) return value as CacheEntry<TData>;
logger.warn("Ignored invalid Redis widget cache entry", {
logger.warn("Ignored invalid Redis response cache entry", {
...logMetadata,
operation: "payload-validate",
});
@@ -164,7 +164,7 @@ export const createWidgetSharedCacheAsync = async <TData>({
const metadata = { cacheNamespace: namespace };
let generation: WidgetCacheGeneration;
try {
generation = await withTimeoutAsync(getWidgetCacheGenerationAsync(namespace), REDIS_OPERATION_TIMEOUT_MS);
generation = await getWidgetCacheGenerationAsync(namespace);
} catch (error) {
logRedisFailure("generation-read", metadata, error);
generation = { value: "redis-unavailable", isShared: false };
@@ -190,7 +190,7 @@ export const createIntegrationSharedCacheAsync = async <TData>({
const metadata = { cacheNamespace: namespace, integrationId };
let generation: IntegrationCacheGeneration;
try {
generation = await withTimeoutAsync(getIntegrationCacheGenerationAsync(integrationId), REDIS_OPERATION_TIMEOUT_MS);
generation = await getIntegrationCacheGenerationAsync(integrationId);
} catch (error) {
logRedisFailure("generation-read", metadata, error);
generation = { value: "redis-unavailable", isShared: false };
@@ -1,7 +1,6 @@
import { invalidateWidgetCache } from "@homarr/redis";
import type { WidgetModuleServerCachePolicy } from "@homarr/definitions";
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./integration-shared-cache";
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
import { createRequestHandler } from "./request-handler";
import type { SharedCacheAdapter } from "./request-handler";
@@ -11,13 +10,12 @@ interface Options<TData, TInput extends Record<string, unknown>> {
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
cacheNamespace?: string;
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "shared" };
}
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
const cacheNamespace = options.cacheNamespace;
let getSharedCacheAsync: ((input: TInput) => Promise<SharedCacheAdapter<TData>>) | undefined;
if (cacheNamespace) {
getSharedCacheAsync = async (input) =>
@@ -29,9 +27,9 @@ export const createWidgetRequestHandler = <TData, TInput extends Record<string,
const inner = createRequestHandler<TData, TInput>({
...options,
cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs,
fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs,
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
getCacheKey: hashSharedCacheInput,
getSharedCacheAsync,
});
@@ -1,6 +1,6 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -18,7 +18,7 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
>({
cacheNamespace: "media-organizer:summary",
async requestAsync(integration, input) {
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
const integrationInstance = await createIntegrationAsync(integration);
const [missingResult, queueResult] = await Promise.all([
integrationInstance.getMissingAsync(input.pageSize),
integrationInstance.getMediaQueueAsync(input.pageSize),
+2 -4
View File
@@ -6,15 +6,12 @@ import { z } from "zod";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { getWidgetServerCachePolicy } from "@homarr/definitions";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
dayjs.extend(utc);
dayjs.extend(timezone);
const weatherCachePolicy = getWidgetServerCachePolicy("weather", "atLocation");
const HOURLY_FORECAST_LENGTH = 24;
const DAILY_FORECAST_LENGTH = 7;
@@ -143,7 +140,8 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number
};
export const weatherRequestHandler = createWidgetRequestHandler({
cachePolicy: weatherCachePolicy,
cacheNamespace: "weather:at-location",
cacheTtlMs: 5 * 60 * 1000,
requestAsync: requestWeatherAsync,
});