feat: simplify caching — TanStack Query as single source of truth (#6075)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-03 16:33:03 +02:00
1 parent a5ec880eec
commit f19033e0d5
187 files changed
+1013 -4712

No files matched your search

+7 -15
View File
@@ -1,30 +1,22 @@
import dayjs from "dayjs";
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const anchorNotesListRequestHandler = createCachedIntegrationRequestHandler<
export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
AnchorNoteSummary[],
"anchor",
AnchorNotesListInput
>({
queryKey: "anchorNotesList",
cacheDuration: dayjs.duration(15, "seconds"),
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.listNotesAsync(input);
},
});
export const anchorNoteRequestHandler = createCachedIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>(
{
queryKey: "anchorNote",
cacheDuration: dayjs.duration(15, "seconds"),
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.getNoteAsync(input.noteId);
},
export const anchorNoteRequestHandler = createIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>({
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.getNoteAsync(input.noteId);
},
);
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHandler<
export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler<
ArchiveTeamWarriorStatus,
IntegrationKindByCategory<"archiving">,
Record<string, never>
@@ -15,6 +13,4 @@ export const archiveTeamWarriorRequestHandler = createCachedIntegrationRequestHa
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatusAsync();
},
cacheDuration: dayjs.duration(5, "minute"),
queryKey: "archiveTeamWarriorStatus",
});
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandler<
export const audiobookshelfRequestHandler = createIntegrationRequestHandler<
AudiobookshelfDashboardData,
"audiobookshelf",
Record<string, never>
@@ -14,6 +12,4 @@ export const audiobookshelfRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "audiobookshelfDashboard",
});
+12 -20
View File
@@ -1,5 +1,3 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
@@ -12,7 +10,7 @@ import type {
BeszelSystemStatsRecord,
} from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
@@ -53,7 +51,7 @@ function mapToSystemRow(system: BeszelSystem, details: BeszelSystemDetails | nul
};
}
export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler<
export const beszelSystemsRequestHandler = createIntegrationRequestHandler<
BeszelSystemRow[],
"beszel" | "mock",
Record<string, never>
@@ -64,7 +62,10 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
systems.map(async (system) => {
const details = await instance.getSystemDetailsAsync(system.id).catch(() => null);
const details = await instance.getSystemDetailsAsync(system.id).catch((error) => {
logger.warn("Failed to fetch Beszel system details", { systemId: system.id, error: String(error) });
return null;
});
return mapToSystemRow(system, details);
}),
);
@@ -75,10 +76,6 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
});
return enriched;
},
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelSystems",
});
export interface BeszelAlertsData {
@@ -86,7 +83,7 @@ export interface BeszelAlertsData {
history: BeszelAlertHistory[];
}
export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
export const beszelAlertsRequestHandler = createIntegrationRequestHandler<
BeszelAlertsData,
"beszel" | "mock",
{ includeHistory: boolean; maxHistoryItems: number }
@@ -104,10 +101,6 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
});
return { alerts, history };
},
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
@@ -124,7 +117,7 @@ export interface BeszelStatsData {
containerStats: BeszelContainerStatsRecord[];
}
export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
export const beszelStatsRequestHandler = createIntegrationRequestHandler<
BeszelStatsData,
"beszel" | "mock",
{ systemId: string; timePeriod: string; includeDocker: boolean }
@@ -135,7 +128,10 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch((error) => {
logger.warn("Failed to fetch Beszel container stats", { systemId: input.systemId, error: String(error) });
return [];
})
: [];
logger.debug("beszelStats fetch completed", {
integrationId: integration.id,
@@ -147,8 +143,4 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
});
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelStats",
});
+2 -4
View File
@@ -4,9 +4,9 @@ import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler<
export const calendarMonthRequestHandler = createIntegrationRequestHandler<
CalendarEvent[],
IntegrationKindByCategory<"calendar">,
{ year: number; month: number; releaseType: RadarrReleaseType[]; showUnmonitored: boolean }
@@ -23,6 +23,4 @@ export const calendarMonthRequestHandler = createCachedIntegrationRequestHandler
input.showUnmonitored,
);
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "calendarMonth",
});
+2 -6
View File
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const coolifyRequestHandler = createCachedIntegrationRequestHandler<
export const coolifyRequestHandler = createIntegrationRequestHandler<
CoolifyInstanceInfo,
"coolify",
Record<string, never>
@@ -14,6 +12,4 @@ export const coolifyRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getInstanceInfoAsync();
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "coolifyInfo",
});
+2 -6
View File
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
export const dnsHoleRequestHandler = createIntegrationRequestHandler<
DnsHoleSummary,
IntegrationKindByCategory<"dnsHole">,
Record<string, never>
@@ -15,6 +13,4 @@ export const dnsHoleRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "dnsHoleSummary",
});
+2 -6
View File
@@ -1,5 +1,4 @@
import type { Readable } from "node:stream";
import dayjs from "dayjs";
import type { Container, ContainerInfo, ContainerStats } from "dockerode";
import type Dockerode from "dockerode";
@@ -13,7 +12,7 @@ import type { ContainerState, Port } from "@homarr/docker";
import { dockerLabels, DockerSingleton } from "@homarr/docker";
import { createDockerLogStreamProcessor, decodeDockerLogs } from "./docker-log-decode";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
const logger = createLogger({ module: "dockerRequestHandler" });
@@ -171,16 +170,13 @@ const mockContainers: {
},
];
export const dockerContainersRequestHandler = createCachedWidgetRequestHandler({
queryKey: "dockerContainersResult",
widgetKind: "dockerContainers",
export const dockerContainersRequestHandler = createWidgetRequestHandler({
async requestAsync() {
if (isDemoMode) {
return mockContainers;
}
return await getContainersWithStatsAsync();
},
cacheDuration: dayjs.duration(20, "seconds"),
});
const extractImage = (container: ContainerInfo) => extractContainerImageName(container.Image);
+2 -6
View File
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const downloadClientRequestHandler = createCachedIntegrationRequestHandler<
export const downloadClientRequestHandler = createIntegrationRequestHandler<
DownloadClientJobsAndStatus,
IntegrationKindByCategory<"downloadClient">,
{ limit: number }
@@ -15,6 +13,4 @@ export const downloadClientRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClientJobsAndStatusAsync(input);
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "downloadClientJobStatus",
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import { z } from "zod";
import { env } from "@homarr/common/env";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
import { createRequestHandler } from "@homarr/request-handler/lib/request-handler";
export type DuckDuckGoBang = z.infer<typeof duckDuckGoBangSchema>;
@@ -38,9 +36,7 @@ const normalizeBangToken = (token: string) => token.toLowerCase().trim();
const compareBangTokenAsc = (left: DuckDuckGoBang, right: DuckDuckGoBang) => left.t.localeCompare(right.t);
export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({
queryKey: "duckduckgo-bangs",
cacheDuration: dayjs.duration(1, "day"),
export const duckDuckGoBangsRequestHandler = createRequestHandler({
async requestAsync(_) {
if (env.NO_EXTERNAL_CONNECTION) {
return [];
@@ -73,7 +69,4 @@ export const duckDuckGoBangsRequestHandler = createCachedRequestHandler({
return normalized;
},
createRedisChannel() {
return createChannelWithLatestAndEvents<DuckDuckGoBang[]>("homarr:duckduckgo-bangs");
},
});
+5 -15
View File
@@ -1,5 +1,3 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
@@ -9,9 +7,9 @@ import type {
FirewallVersionSummary,
} from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler<
export const firewallCpuRequestHandler = createIntegrationRequestHandler<
FirewallCpuSummary,
IntegrationKindByCategory<"firewall">,
Record<string, never>
@@ -20,11 +18,9 @@ export const firewallCpuRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return integrationInstance.getFirewallCpuAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "firewallCpuSummary",
});
export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandler<
export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
FirewallMemorySummary,
IntegrationKindByCategory<"firewall">,
Record<string, never>
@@ -33,11 +29,9 @@ export const firewallMemoryRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallMemoryAsync();
},
cacheDuration: dayjs.duration(15, "seconds"),
queryKey: "firewallMemorySummary",
});
export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHandler<
export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
FirewallInterfacesSummary[],
IntegrationKindByCategory<"firewall">,
Record<string, never>
@@ -46,11 +40,9 @@ export const firewallInterfacesRequestHandler = createCachedIntegrationRequestHa
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallInterfacesAsync();
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "firewallInterfacesSummary",
});
export const firewallVersionRequestHandler = createCachedIntegrationRequestHandler<
export const firewallVersionRequestHandler = createIntegrationRequestHandler<
FirewallVersionSummary,
IntegrationKindByCategory<"firewall">,
Record<string, never>
@@ -59,6 +51,4 @@ export const firewallVersionRequestHandler = createCachedIntegrationRequestHandl
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallVersionAsync();
},
cacheDuration: dayjs.duration(1, "hour"),
queryKey: "firewallVersionSummary",
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
export const systemInfoRequestHandler = createIntegrationRequestHandler<
SystemHealthMonitoring,
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel">,
Record<string, never>
@@ -15,11 +13,9 @@ export const systemInfoRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSystemInfoAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "systemInfo",
});
export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
export const clusterInfoRequestHandler = createIntegrationRequestHandler<
ProxmoxClusterInfo,
"proxmox" | "mock",
Record<string, never>
@@ -28,6 +24,4 @@ export const clusterInfoRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClusterInfoAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "clusterInfo",
});
+4 -12
View File
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const immichStatsRequestHandler = createCachedIntegrationRequestHandler<
export const immichStatsRequestHandler = createIntegrationRequestHandler<
ImmichServerStats,
IntegrationKindByCategory<"photoService">,
Record<string, never>
@@ -15,11 +13,9 @@ export const immichStatsRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getServerStatsAsync();
},
cacheDuration: dayjs.duration(15, "minute"),
queryKey: "immich-server-stats",
});
export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler<
export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
{
id: string;
albumName: string;
@@ -32,11 +28,9 @@ export const immichAlbumsRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumsAsync();
},
cacheDuration: dayjs.duration(15, "minute"),
queryKey: "immich-albums",
});
export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler<
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
{ albumId: string }
@@ -45,6 +39,4 @@ export const immichAlbumRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumAsync(input.albumId);
},
cacheDuration: dayjs.duration(15, "minute"),
queryKey: "immich-album",
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { Indexer } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const indexerManagerRequestHandler = createCachedIntegrationRequestHandler<
export const indexerManagerRequestHandler = createIntegrationRequestHandler<
Indexer[],
IntegrationKindByCategory<"indexerManager">,
Record<string, never>
@@ -15,6 +13,4 @@ export const indexerManagerRequestHandler = createCachedIntegrationRequestHandle
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getIndexersAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "indexerManager",
});
@@ -1,49 +0,0 @@
import type { Duration } from "dayjs/plugin/duration";
import type { Modify } from "@homarr/common/types";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import { createIntegrationOptionsChannel } from "@homarr/redis";
import { createCachedRequestHandler } from "./cached-request-handler";
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
kind: TKind;
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
externalUrl: string | null;
};
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
queryKey: string;
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
cacheDuration: Duration;
cacheDurationForInput?: (input: TInput) => Duration | undefined;
fallbackToStaleOnError?: boolean;
retry?: { attempts?: number; delayMs?: number };
isValid?: (data: TData) => boolean;
}
export const createCachedIntegrationRequestHandler = <
TData,
TKind extends IntegrationKind,
TInput extends Record<string, unknown>,
>(
options: Options<TData, TKind, TInput>,
) => {
return {
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
createCachedRequestHandler({
queryKey: options.queryKey,
requestAsync: async (input: { options: TInput; integration: IntegrationOfKind<TKind> }) => {
return await options.requestAsync(input.integration, input.options);
},
cacheDuration: options.cacheDurationForInput?.(itemOptions) ?? options.cacheDuration,
fallbackToStaleOnError: options.fallbackToStaleOnError,
retry: options.retry,
isValid: options.isValid,
createRedisChannel(input, handlerOptions) {
return createIntegrationOptionsChannel<TData>(input.integration.id, handlerOptions.queryKey, input.options);
},
}).handler({ options: itemOptions, integration }),
};
};
@@ -1,160 +0,0 @@
import dayjs from "dayjs";
import type { Duration } from "dayjs/plugin/duration";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { createChannelWithLatestAndEvents } from "@homarr/redis";
const logger = createLogger({ module: "cachedRequestHandler" });
type FetchResult<TData> = { data: TData; timestamp: Date };
type InFlightEntry = Promise<FetchResult<unknown>>;
const inFlightFetches = new Map<string, InFlightEntry>();
const isAbortedError = (error: unknown): boolean => {
if (!(error instanceof Error)) return false;
return error.name === "AbortError" || error.name === "TimeoutError";
};
interface Options<TData, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
requestAsync: (input: TInput) => Promise<TData>;
createRedisChannel: (
input: TInput,
options: Options<TData, TInput>,
) => ReturnType<typeof createChannelWithLatestAndEvents<TData>>;
cacheDuration: Duration;
fallbackToStaleOnError?: boolean;
// Retry the upstream call on failure. attempts=1 means no retry (default).
// AbortError / TimeoutError are never retried.
retry?: { attempts?: number; delayMs?: number };
// Predicate to decide whether a fetched response is worth caching.
// Return false to skip the cache write (next call will refetch).
isValid?: (data: TData) => boolean;
}
const defaultRetry = { attempts: 1, delayMs: 0 };
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
export const createCachedRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
return {
handler: (input: TInput) => {
const channel = options.createRedisChannel(input, options);
const retryConfig = { ...defaultRetry, ...options.retry };
return {
async getCachedOrUpdatedDataAsync({ forceUpdate = false }) {
const channelData = await channel.getAsync();
const requestNewDataAsync = (): Promise<FetchResult<TData>> => {
const inflight = inFlightFetches.get(channel.name);
if (inflight) {
logger.debug("Cached request handler joining in-flight fetch", {
channel: channel.name,
queryKey: options.queryKey,
});
return inflight as Promise<FetchResult<TData>>;
}
const promise = (async (): Promise<FetchResult<TData>> => {
let lastError: unknown;
for (let attempt = 1; attempt <= retryConfig.attempts; attempt++) {
try {
const data = await options.requestAsync(input);
if (options.isValid && !options.isValid(data)) {
logger.warn("Cached request handler received invalid data, not caching", {
channel: channel.name,
queryKey: options.queryKey,
attempt,
});
return { data, timestamp: new Date() };
}
await channel.publishAndUpdateLastStateAsync(data);
return { data, timestamp: new Date() };
} catch (error) {
lastError = error;
if (isAbortedError(error) || attempt === retryConfig.attempts) {
break;
}
logger.warn("Cached request handler fetch failed, retrying", {
channel: channel.name,
queryKey: options.queryKey,
attempt,
nextDelayMs: retryConfig.delayMs,
error: error instanceof Error ? `${error.name}: ${error.message}` : String(error),
});
await sleep(retryConfig.delayMs);
}
}
if (options.fallbackToStaleOnError && channelData) {
logger.warn(
new ErrorWithMetadata(
"Cached request handler using stale cache after fetch failure",
{
channel: channel.name,
queryKey: options.queryKey,
attempts: retryConfig.attempts,
},
{ cause: lastError },
),
);
return channelData;
}
throw lastError;
})().finally(() => {
inFlightFetches.delete(channel.name);
});
inFlightFetches.set(channel.name, promise as InFlightEntry);
return promise;
};
if (forceUpdate) {
logger.debug("Cached request handler forced update", {
channel: channel.name,
queryKey: options.queryKey,
});
return await requestNewDataAsync();
}
const shouldRequestNewData =
!channelData ||
dayjs().diff(channelData.timestamp, "milliseconds") > options.cacheDuration.asMilliseconds();
if (shouldRequestNewData) {
logger.debug("Cached request handler cache miss", {
channel: channel.name,
queryKey: options.queryKey,
reason: !channelData ? "no data" : "cache expired",
});
return await requestNewDataAsync();
}
logger.debug("Cached request handler cache hit", {
channel: channel.name,
queryKey: options.queryKey,
expiresAt: dayjs(channelData.timestamp).add(options.cacheDuration).toISOString(),
});
return channelData;
},
async invalidateAsync() {
logger.debug("Cached request handler invalidating cache", {
channel: channel.name,
queryKey: options.queryKey,
});
await this.getCachedOrUpdatedDataAsync({ forceUpdate: true });
},
subscribe(callback: (data: TData) => void) {
return channel.subscribe(callback);
},
};
},
};
};
@@ -1,115 +0,0 @@
import SuperJSON from "superjson";
import { hashObjectBase64, Stopwatch } from "@homarr/common";
import { decryptSecret } from "@homarr/common/server";
import type { MaybeArray } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db } from "@homarr/db";
import { getItemsWithIntegrationsAsync, getServerSettingsAsync } from "@homarr/db/queries";
import type { WidgetKind } from "@homarr/definitions";
// This imports are done that way to avoid circular dependencies.
import type { inferSupportedIntegrationsStrict } from "../../../widgets/src";
import { reduceWidgetOptionsWithDefaultValues } from "../../../widgets/src";
import type { WidgetComponentProps } from "../../../widgets/src/definition";
import type { createCachedIntegrationRequestHandler } from "./cached-integration-request-handler";
const logger = createLogger({ module: "cachedRequestIntegrationJobHandler" });
export const createRequestIntegrationJobHandler = <
TWidgetKind extends WidgetKind,
TIntegrationKind extends inferSupportedIntegrationsStrict<TWidgetKind>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
THandler extends ReturnType<typeof createCachedIntegrationRequestHandler<any, TIntegrationKind, any>>["handler"],
>(
handler: THandler,
{
widgetKinds,
getInput,
}: {
widgetKinds: TWidgetKind[];
getInput: {
[key in TWidgetKind]: (options: WidgetComponentProps<key>["options"]) => MaybeArray<Parameters<THandler>[1]>;
};
},
) => {
return async () => {
const serverSettings = await getServerSettingsAsync(db);
const itemsForIntegration = await getItemsWithIntegrationsAsync(db, {
kinds: widgetKinds,
});
logger.debug("Found items for integration", {
widgetKinds: widgetKinds.join(","),
count: itemsForIntegration.length,
});
const distinctIntegrations: {
integrationId: string;
inputHash: string;
integration: (typeof itemsForIntegration)[number]["integrations"][number]["integration"];
input: Parameters<THandler>[1];
}[] = [];
for (const itemForIntegration of itemsForIntegration) {
const oneOrMultipleInputs = getInput[itemForIntegration.kind](
reduceWidgetOptionsWithDefaultValues(
itemForIntegration.kind,
{
enableStatusByDefault: serverSettings.board.enableStatusByDefault,
forceDisableStatus: serverSettings.board.forceDisableStatus,
},
SuperJSON.parse<Record<string, unknown>>(itemForIntegration.options),
) as never,
);
for (const { integration } of itemForIntegration.integrations) {
const inputArray = Array.isArray(oneOrMultipleInputs) ? oneOrMultipleInputs : [oneOrMultipleInputs];
for (const input of inputArray) {
const inputHash = hashObjectBase64(input);
if (
distinctIntegrations.some(
(distinctIntegration) =>
distinctIntegration.integrationId === integration.id && distinctIntegration.inputHash === inputHash,
)
) {
continue;
}
distinctIntegrations.push({ integrationId: integration.id, inputHash, integration, input });
}
}
}
for (const { integrationId, integration, input, inputHash } of distinctIntegrations) {
try {
const decryptedSecrets = integration.secrets.map((secret) => ({
...secret,
value: decryptSecret(secret.value),
}));
const innerHandler = handler(
{
...integration,
kind: integration.kind as TIntegrationKind,
decryptedSecrets,
externalUrl: integration.app?.href ?? null,
},
input,
);
const stopWatch = new Stopwatch();
await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: true });
logger.debug("Ran integration job", {
integration: integrationId,
inputHash,
elapsed: stopWatch.getElapsedInHumanWords(),
});
} catch (error) {
logger.error(
new ErrorWithMetadata("Failed to run integration job", { integrationId, inputHash }, { cause: error }),
);
}
}
};
};
@@ -1,36 +0,0 @@
import type { Duration } from "dayjs/plugin/duration";
import type { WidgetKind } from "@homarr/definitions";
import { createWidgetOptionsChannel } from "@homarr/redis";
import { createCachedRequestHandler } from "./cached-request-handler";
interface Options<TData, TKind extends WidgetKind, TInput extends Record<string, unknown>> {
// Unique key for this request handler
queryKey: string;
requestAsync: (input: TInput) => Promise<TData>;
cacheDuration: Duration;
widgetKind: TKind;
}
export const createCachedWidgetRequestHandler = <
TData,
TKind extends WidgetKind,
TInput extends Record<string, unknown>,
>(
requestHandlerOptions: Options<TData, TKind, TInput>,
) => {
return {
handler: (widgetOptions: TInput) =>
createCachedRequestHandler({
queryKey: requestHandlerOptions.queryKey,
requestAsync: async (input: TInput) => {
return await requestHandlerOptions.requestAsync(input);
},
cacheDuration: requestHandlerOptions.cacheDuration,
createRedisChannel(input, options) {
return createWidgetOptionsChannel<TData>(requestHandlerOptions.widgetKind, options.queryKey, input);
},
}).handler(widgetOptions),
};
};
@@ -0,0 +1,30 @@
import type { Modify } from "@homarr/common/types";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import { createRequestHandler } from "./request-handler";
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
kind: TKind;
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
externalUrl: string | null;
};
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
}
export const createIntegrationRequestHandler = <
TData,
TKind extends IntegrationKind,
TInput extends Record<string, unknown>,
>(
options: Options<TData, TKind, TInput>,
) => ({
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) => {
const inner = createRequestHandler<TData, { integrationId: string; options: TInput }>({
requestAsync: async (input) => options.requestAsync(integration, input.options),
});
return inner.handler({ integrationId: integration.id, options: itemOptions });
},
});
@@ -0,0 +1,60 @@
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number };
const MAX_CACHE_SIZE = 1000;
const cache = new Map<string, CacheEntry<unknown>>();
const inflight = new Map<string, Promise<CacheEntry<unknown>>>();
const DEFAULT_TTL_MS = 10_000;
const evictExpired = () => {
const now = Date.now();
for (const [key, entry] of cache) {
if (now >= entry.expiresAt) cache.delete(key);
}
};
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => ({
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = JSON.stringify(input);
const cached = cache.get(key) as CacheEntry<TData> | undefined;
if (cached && Date.now() < cached.expiresAt) {
return { data: cached.data, timestamp: cached.timestamp };
}
if (cached) cache.delete(key);
const existing = inflight.get(key);
if (existing) return existing as Promise<CacheEntry<TData>>;
const promise = options
.requestAsync(input)
.then((data) => {
if (cache.size >= MAX_CACHE_SIZE) evictExpired();
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
const entry: CacheEntry<TData> = { data, timestamp: new Date(), expiresAt: Date.now() + ttl };
cache.set(key, entry as CacheEntry<unknown>);
inflight.delete(key);
return entry;
})
.catch((err) => {
inflight.delete(key);
throw err;
});
inflight.set(key, promise as Promise<CacheEntry<unknown>>);
return promise;
},
}),
});
@@ -0,0 +1,14 @@
import { createRequestHandler } from "./request-handler";
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
}
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => ({
handler: (widgetOptions: TInput) =>
createRequestHandler<TData, TInput>({
requestAsync: options.requestAsync,
}).handler(widgetOptions),
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRelease } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler<
export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
MediaRelease[],
IntegrationKindByCategory<"mediaRelease">,
Record<string, never>
@@ -15,6 +13,4 @@ export const mediaReleaseRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMediaReleasesAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "mediaReleases",
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequest } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const mediaRequestListRequestHandler = createCachedIntegrationRequestHandler<
export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
MediaRequest[],
IntegrationKindByCategory<"mediaRequest">,
Record<string, never>
@@ -15,6 +13,4 @@ export const mediaRequestListRequestHandler = createCachedIntegrationRequestHand
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getRequestsAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "mediaRequestList",
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { MediaRequestStats } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHandler<
export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
MediaRequestStats,
IntegrationKindByCategory<"mediaRequest">,
Record<string, never>
@@ -18,6 +16,4 @@ export const mediaRequestStatsRequestHandler = createCachedIntegrationRequestHan
users: await integrationInstance.getUsersAsync(),
};
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "mediaRequestStats",
});
+2 -6
View File
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const mediaServerRequestHandler = createCachedIntegrationRequestHandler<
export const mediaServerRequestHandler = createIntegrationRequestHandler<
StreamSession[],
IntegrationKindByCategory<"mediaService">,
{
@@ -17,6 +15,4 @@ export const mediaServerRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying });
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "mediaServerSessions",
});
@@ -1,18 +1,14 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const mediaTranscodingRequestHandler = createCachedIntegrationRequestHandler<
export const mediaTranscodingRequestHandler = createIntegrationRequestHandler<
MediaTranscoding,
IntegrationKindByCategory<"mediaTranscoding">,
{ pageOffset: number; pageSize: number }
>({
queryKey: "mediaTranscoding",
cacheDuration: dayjs.duration(5, "minutes"),
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return {
@@ -1,14 +1,11 @@
import dayjs from "dayjs";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHandler({
queryKey: "minecraftServerStatusApiResult",
widgetKind: "minecraftServerStatus",
export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
@@ -17,7 +14,6 @@ export const minecraftServerStatusRequestHandler = createCachedWidgetRequestHand
);
return responseSchema.parse(await response.json());
},
cacheDuration: dayjs.duration(5, "minutes"),
});
const responseSchema = z
+2 -6
View File
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { NavidromeDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const navidromeRequestHandler = createCachedIntegrationRequestHandler<
export const navidromeRequestHandler = createIntegrationRequestHandler<
NavidromeDashboardData,
"navidrome",
Record<string, never>
@@ -14,6 +12,4 @@ export const navidromeRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "navidromeDashboard",
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const networkControllerRequestHandler = createCachedIntegrationRequestHandler<
export const networkControllerRequestHandler = createIntegrationRequestHandler<
NetworkControllerSummary,
IntegrationKindByCategory<"networkController">,
Record<string, never>
@@ -15,6 +13,4 @@ export const networkControllerRequestHandler = createCachedIntegrationRequestHan
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNetworkSummaryAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "networkControllerSummary",
});
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { Notification } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const notificationsRequestHandler = createCachedIntegrationRequestHandler<
export const notificationsRequestHandler = createIntegrationRequestHandler<
Notification[],
IntegrationKindByCategory<"notifications">,
Record<string, never>
@@ -15,6 +13,4 @@ export const notificationsRequestHandler = createCachedIntegrationRequestHandler
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNotificationsAsync();
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "notificationsJobStatus",
});
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { PaperlessNgxStats } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHandler<
export const paperlessNgxStatsRequestHandler = createIntegrationRequestHandler<
PaperlessNgxStats,
"paperlessNgx",
Record<string, never>
@@ -14,6 +12,4 @@ export const paperlessNgxStatsRequestHandler = createCachedIntegrationRequestHan
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
},
cacheDuration: dayjs.duration(15, "minute"),
queryKey: "paperless-ngx-stats",
});
@@ -127,7 +127,7 @@ const parseOwnerPackageName = (identifier: string) => {
const removeTrailingSlash = (url: string) => url.replace(/\/$/, "");
const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`);
const buildUrl = (baseUrl: string, path: `/${string}`) => new URL(`${baseUrl}${path}`).toString();
const getGithubApi = (baseUrl: string, userAgent: string, token?: string) =>
new Octokit({
+7 -16
View File
@@ -1,19 +1,10 @@
import dayjs from "dayjs";
import { createWidgetOptionsChannel } from "@homarr/redis";
import { createCachedRequestHandler } from "./lib/cached-request-handler";
import type { ReleaseResponse, ReleasesRepositoryRequest } from "./release-providers";
import { getLatestMatchingReleaseAsync } from "./release-providers";
import { createRequestHandler } from "./lib/request-handler";
export const releasesRequestHandler = {
handler: (itemOptions: ReleasesRepositoryRequest) =>
createCachedRequestHandler<ReleaseResponse, ReleasesRepositoryRequest>({
requestAsync: async (input) => await getLatestMatchingReleaseAsync(input),
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "repositoriesReleases",
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<ReleaseResponse>("releases", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
export const releasesRequestHandler = createRequestHandler<ReleaseResponse, ReleasesRepositoryRequest>({
async requestAsync(input) {
return await getLatestMatchingReleaseAsync(input);
},
cacheTtlMs: 5 * 60 * 1000,
});
+2 -6
View File
@@ -1,18 +1,15 @@
import type { FeedData, FeedEntry } from "@extractus/feed-extractor";
import { extract } from "@extractus/feed-extractor";
import dayjs from "dayjs";
import { z } from "zod/v4";
import type { Modify } from "@homarr/common/types";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
const logger = createLogger({ module: "rssFeedsRequestHandler" });
export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({
queryKey: "rssFeedList",
widgetKind: "rssFeed",
export const rssFeedsRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { url: string; count: number }) {
const result = (await extract(input.url, {
getExtraEntryFields: (feedEntry) => {
@@ -31,7 +28,6 @@ export const rssFeedsRequestHandler = createCachedWidgetRequestHandler({
entries: result.entries?.map((entry) => ({ ...entry, feedUrl: input.url })).slice(0, input.count) ?? [],
};
},
cacheDuration: dayjs.duration(5, "minutes"),
});
const attemptGetImageFromEntry = (feedUrl: string, entry: object) => {
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const smartHomeEntityStateRequestHandler = createCachedIntegrationRequestHandler<
export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler<
string,
IntegrationKindByCategory<"smartHomeServer">,
{ entityId: string }
@@ -20,6 +18,4 @@ export const smartHomeEntityStateRequestHandler = createCachedIntegrationRequest
return result.data.state;
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "smartHome-entityState",
});
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHandler<
export const speedtestTrackerRequestHandler = createIntegrationRequestHandler<
SpeedtestTrackerDashboardData,
"speedtestTracker",
Record<string, never>
@@ -14,6 +12,4 @@ export const speedtestTrackerRequestHandler = createCachedIntegrationRequestHand
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(10, "minutes"),
queryKey: "speedtestTrackerDashboard",
});
+2 -6
View File
@@ -1,14 +1,11 @@
import dayjs from "dayjs";
import { z } from "zod/v4";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const fetchStockPriceHandler = createCachedWidgetRequestHandler({
queryKey: "fetchStockPriceResult",
widgetKind: "stockPrice",
export const fetchStockPriceHandler = createWidgetRequestHandler({
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
const response = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
@@ -42,7 +39,6 @@ export const fetchStockPriceHandler = createCachedWidgetRequestHandler({
shortName: firstResult.meta.shortName,
};
},
cacheDuration: dayjs.duration(5, "minutes"),
});
const dataSchema = z
+20 -30
View File
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createWidgetOptionsChannel } from "@homarr/redis";
import { createCachedRequestHandler } from "./lib/cached-request-handler";
import { createRequestHandler } from "./lib/request-handler";
export interface Station {
id: string;
@@ -34,40 +32,32 @@ export interface Timetable {
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
export const timetableSearchStationsRequestHandler = {
handler: (itemOptions: { baseUrl: string; query: string }) =>
createCachedRequestHandler<Station[], { baseUrl: string; query: string }>({
async requestAsync(input) {
return await searchStationsAsync(input.baseUrl, input.query);
},
queryKey: "timetableSearchStations",
cacheDuration: dayjs.duration(1, "day"),
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<Station[]>("timetable", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
export const timetableSearchStationsRequestHandler = createRequestHandler<
Station[],
{ baseUrl: string; query: string }
>({
async requestAsync(input) {
return await searchStationsAsync(input.baseUrl, input.query);
},
cacheTtlMs: 24 * 60 * 60 * 1000,
});
export const timetableGetTimetableRequestHandler = {
handler: (itemOptions: { baseUrl: string; stationId: string; limit: number }) =>
createCachedRequestHandler<Timetable, { baseUrl: string; stationId: string; limit: number }>({
async requestAsync(input) {
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
},
queryKey: "timetableGetTimetable",
cacheDuration: dayjs.duration(1, "minute"),
createRedisChannel(input, handlerOptions) {
return createWidgetOptionsChannel<Timetable>("timetable", handlerOptions.queryKey, input);
},
}).handler(itemOptions),
};
export const timetableGetTimetableRequestHandler = createRequestHandler<
Timetable,
{ baseUrl: string; stationId: string; limit: number }
>({
async requestAsync(input) {
return await getTimetableAsync(input.baseUrl, { stationId: input.stationId, limit: input.limit });
},
cacheTtlMs: 60_000,
});
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record<string, string | number>) => {
const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
for (const [key, value] of Object.entries(queryParams)) {
url.searchParams.set(key, value.toString());
}
return url;
return url.toString();
};
const searchStationsAsync = async (baseUrl: string, query: string): Promise<Station[]> => {
+2 -6
View File
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { TracearrDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const tracearrRequestHandler = createCachedIntegrationRequestHandler<
export const tracearrRequestHandler = createIntegrationRequestHandler<
TracearrDashboardData,
"tracearr",
Record<string, never>
@@ -14,6 +12,4 @@ export const tracearrRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(5, "seconds"),
queryKey: "tracearrDashboard",
});
+7 -25
View File
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const umamiRequestHandler = createCachedIntegrationRequestHandler<
export const umamiRequestHandler = createIntegrationRequestHandler<
UmamiVisitorStats,
"umami",
{ websiteId: string; timeFrame: string; eventName?: string }
@@ -14,24 +12,16 @@ export const umamiRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiVisitorStats",
});
export const umamiEventNamesRequestHandler = createCachedIntegrationRequestHandler<
string[],
"umami",
{ websiteId: string }
>({
export const umamiEventNamesRequestHandler = createIntegrationRequestHandler<string[], "umami", { websiteId: string }>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getEventNamesAsync(input.websiteId);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiEventNames",
});
export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler<
export const umamiTopPagesRequestHandler = createIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
@@ -40,11 +30,9 @@ export const umamiTopPagesRequestHandler = createCachedIntegrationRequestHandler
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopPages",
});
export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHandler<
export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler<
UmamiMetricItem[],
"umami",
{ websiteId: string; timeFrame: string; limit: number }
@@ -53,11 +41,9 @@ export const umamiTopReferrersRequestHandler = createCachedIntegrationRequestHan
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiTopReferrers",
});
export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandler<
export const umamiMultiEventRequestHandler = createIntegrationRequestHandler<
UmamiEventSeries[],
"umami",
{ websiteId: string; timeFrame: string; eventNames: string[] }
@@ -66,11 +52,9 @@ export const umamiMultiEventRequestHandler = createCachedIntegrationRequestHandl
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "umamiMultiEvent",
});
export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestHandler<
export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler<
number,
"umami",
{ websiteId: string }
@@ -79,6 +63,4 @@ export const umamiActiveVisitorsRequestHandler = createCachedIntegrationRequestH
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
},
cacheDuration: dayjs.duration(30, "seconds"),
queryKey: "umamiActiveVisitors",
});
+5 -15
View File
@@ -1,4 +1,3 @@
import dayjs from "dayjs";
import { Octokit } from "octokit";
import { compareSemVer, isValidSemVer, parseSemVer } from "semver-parser";
@@ -6,27 +5,18 @@ import { env } from "@homarr/common/env";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createChannelWithLatestAndEvents } from "@homarr/redis";
import { createCachedRequestHandler } from "@homarr/request-handler/lib/cached-request-handler";
import { createRequestHandler } from "./lib/request-handler";
import packageJson from "../../../package.json";
const logger = createLogger({ module: "updateCheckerRequestHandler" });
export const updateCheckerRequestHandler = createCachedRequestHandler({
queryKey: "homarr-update-checker",
cacheDuration: dayjs.duration(1, "day"),
fallbackToStaleOnError: true,
export const updateCheckerRequestHandler = createRequestHandler({
async requestAsync(_) {
return {
availableUpdates: await getAvailableUpdatesAsync(packageJson.version),
};
},
createRedisChannel() {
return createChannelWithLatestAndEvents<{
availableUpdates: Update[];
}>("homarr:update");
const availableUpdates = await getAvailableUpdatesAsync(packageJson.version);
return { availableUpdates };
},
cacheTtlMs: 30 * 60 * 1000,
});
interface Update {
+2 -6
View File
@@ -1,12 +1,10 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UpsSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
export const upsSummariesRequestHandler = createIntegrationRequestHandler<
UpsSummary[],
IntegrationKindByCategory<"ups">,
Record<string, never>
@@ -15,6 +13,4 @@ export const upsSummariesRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getUpsSummariesAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "upsSummaries",
});
+2 -6
View File
@@ -1,11 +1,9 @@
import dayjs from "dayjs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler<
export const uptimeKumaRequestHandler = createIntegrationRequestHandler<
UptimeKumaDashboardData,
"uptimeKuma",
Record<string, never>
@@ -14,6 +12,4 @@ export const uptimeKumaRequestHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
},
cacheDuration: dayjs.duration(1, "minute"),
queryKey: "uptimeKumaDashboard",
});
+2 -9
View File
@@ -1,15 +1,13 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
const logger = createLogger({ module: "vpnRequestHandler" });
export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
export const vpnSummaryHandler = createIntegrationRequestHandler<
VpnSummary | null,
IntegrationKindByCategory<"vpn">,
Record<string, never>
@@ -19,9 +17,6 @@ export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
} catch (error) {
// The VPN control server is unreachable (e.g. the container is down).
// Return null so the widget can render an "unavailable" status instead of
// surfacing a hard error, and so it recovers automatically once it is back up.
logger.warn("Failed to fetch VPN summary, marking as unavailable", {
integrationId: integration.id,
integrationKind: integration.kind,
@@ -30,6 +25,4 @@ export const vpnSummaryHandler = createCachedIntegrationRequestHandler<
return null;
}
},
cacheDuration: dayjs.duration(5, "minutes"),
queryKey: "vpnSummary",
});
+2 -6
View File
@@ -1,14 +1,11 @@
import dayjs from "dayjs";
import { z } from "zod";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createCachedWidgetRequestHandler } from "./lib/cached-widget-request-handler";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const weatherRequestHandler = createCachedWidgetRequestHandler({
queryKey: "weatherAtLocation",
widgetKind: "weather",
export const weatherRequestHandler = createWidgetRequestHandler({
async requestAsync(input: { latitude: number; longitude: number }) {
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
@@ -34,7 +31,6 @@ export const weatherRequestHandler = createCachedWidgetRequestHandler({
}),
} satisfies Weather;
},
cacheDuration: dayjs.duration(1, "minute"),
});
const atLocationOutput = z.object({