refactor: streamline widget and integration architecture

This commit is contained in:
ajnart committed 2026-08-23 22:33:29 +02:00
1 parent ec295fe26e
commit 425cb653a1
243 files changed
+5722 -2255

No files matched your search

@@ -44,6 +44,7 @@ const requestedVariables = [
] as const;
export const airQualityRequestHandler = createWidgetRequestHandler({
cacheNamespace: "air-quality",
cacheTtlMs: 15 * 60 * 1000,
fallbackToStaleOnError: true,
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
+3 -1
View File
@@ -1,5 +1,5 @@
import type { AnchorNote, AnchorNotesListInput, AnchorNoteSummary } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
"anchor",
AnchorNotesListInput
>({
cacheNamespace: "anchor-notes:list",
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.listNotesAsync(input);
@@ -15,6 +16,7 @@ export const anchorNotesListRequestHandler = createIntegrationRequestHandler<
});
export const anchorNoteRequestHandler = createIntegrationRequestHandler<AnchorNote, "anchor", { noteId: string }>({
cacheNamespace: "anchor-notes:detail",
async requestAsync(integration, input) {
const instance = await createIntegrationAsync(integration);
return instance.getNoteAsync(input.noteId);
@@ -1,6 +1,6 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const archiveTeamWarriorRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"archiving">,
Record<string, never>
>({
cacheNamespace: "archive-team-warrior:status",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatusAsync();
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const audiobookshelfRequestHandler = createIntegrationRequestHandler<
"audiobookshelf",
Record<string, never>
>({
cacheNamespace: "audiobookshelf:stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
+2 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { BazarrBadges } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const bazarrBadgesRequestHandler = createIntegrationRequestHandler<
"bazarr",
Record<string, never>
>({
cacheNamespace: "bazarr:badges",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getBadgesAsync();
+4 -1
View File
@@ -1,5 +1,5 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type {
BeszelAlert,
BeszelAlertHistory,
@@ -57,6 +57,7 @@ export const beszelSystemsRequestHandler = createIntegrationRequestHandler<
"beszel" | "mock",
Record<string, never>
>({
cacheNamespace: "beszel:systems",
async requestAsync(integration) {
const start = performance.now();
const instance = await createIntegrationAsync(integration);
@@ -89,6 +90,7 @@ export const beszelAlertsRequestHandler = createIntegrationRequestHandler<
"beszel" | "mock",
{ includeHistory: boolean; maxHistoryItems: number }
>({
cacheNamespace: "beszel:alerts",
async requestAsync(integration, input) {
const start = performance.now();
const instance = await createIntegrationAsync(integration);
@@ -123,6 +125,7 @@ export const beszelStatsRequestHandler = createIntegrationRequestHandler<
"beszel" | "mock",
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
cacheNamespace: "beszel:stats",
// No cache — the widget polls every 5s for live updates;
// a TTL here would serve stale records between polls.
cacheTtlMs: 0,
+2 -1
View File
@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { CalendarEvent, RadarrReleaseType } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -19,6 +19,7 @@ export const calendarMonthRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"calendar">,
{ year: number; month: number; releaseType: RadarrReleaseType[]; showUnmonitored: boolean }
>({
cacheNamespace: "calendar:month",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
// Calendar component shows up to 6 days before and after the month, for example if 1. of january is sunday, it shows the last 6 days of december.
+2 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const coolifyRequestHandler = createIntegrationRequestHandler<
"coolify",
Record<string, never>
>({
cacheNamespace: "coolify:resources",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getInstanceInfoAsync();
+2 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { DnsHoleSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const dnsHoleRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"dnsHole">,
Record<string, never>
>({
cacheNamespace: "dns-hole:summary",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
+1
View File
@@ -207,6 +207,7 @@ const demoDockerEndpoint = {
} satisfies DockerEndpointStatus;
export const dockerContainersRequestHandler = createWidgetRequestHandler({
cacheNamespace: "docker-containers",
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
if (isDemoMode()) {
const includesDemoEndpoint =
+5 -1
View File
@@ -1,14 +1,18 @@
import { getWidgetServerCachePolicy } from "@homarr/definitions";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { DownloadClientJobsAndStatus } from "@homarr/integrations";
import { createIntegrationAsync } 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,
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClientJobsAndStatusAsync(input);
+5 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
@@ -14,6 +14,7 @@ export const firewallCpuRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
cacheNamespace: "firewall:cpu",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return integrationInstance.getFirewallCpuAsync();
@@ -25,6 +26,7 @@ export const firewallMemoryRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
cacheNamespace: "firewall:memory",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallMemoryAsync();
@@ -36,6 +38,7 @@ export const firewallInterfacesRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
cacheNamespace: "firewall:interfaces",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallInterfacesAsync();
@@ -47,6 +50,7 @@ export const firewallVersionRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"firewall">,
Record<string, never>
>({
cacheNamespace: "firewall:version",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getFirewallVersionAsync();
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { ProxmoxClusterInfo, SystemHealthMonitoring } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const systemInfoRequestHandler = createIntegrationRequestHandler<
Exclude<IntegrationKindByCategory<"healthMonitoring">, "proxmox" | "coolify" | "beszel" | "patchmon" | "wud">,
Record<string, never>
>({
cacheNamespace: "health-monitoring:system-info",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSystemInfoAsync();
@@ -20,6 +21,7 @@ export const clusterInfoRequestHandler = createIntegrationRequestHandler<
"proxmox" | "mock",
Record<string, never>
>({
cacheNamespace: "health-monitoring:cluster-info",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getClusterInfoAsync();
+1 -1
View File
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
+4 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { ImmichAlbum, ImmichServerStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const immichStatsRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"photoService">,
Record<string, never>
>({
cacheNamespace: "immich:server-stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getServerStatsAsync();
@@ -24,6 +25,7 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"photoService">,
{ limit?: number }
>({
cacheNamespace: "immich:albums",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
const albums = await integrationInstance.getAlbumsAsync();
@@ -48,6 +50,7 @@ export const immichAlbumRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"photoService">,
{ albumId?: string }
>({
cacheNamespace: "immich:album",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getAlbumAsync(input.albumId);
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { Indexer } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const indexerManagerRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"indexerManager">,
Record<string, never>
>({
cacheNamespace: "indexer-manager:indexers",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getIndexersAsync();
@@ -1,8 +1,10 @@
import type { Modify } from "@homarr/common/types";
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
import type { IntegrationKind } from "@homarr/definitions";
import type { IntegrationKind, WidgetModuleServerCachePolicy } from "@homarr/definitions";
import { createIntegrationSharedCacheAsync, hashIntegrationCacheOptions } from "./integration-shared-cache";
import { createRequestHandler } from "./request-handler";
import type { SharedCacheAdapter } from "./request-handler";
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
kind: TKind;
@@ -15,6 +17,8 @@ interface Options<TData, TKind extends IntegrationKind, TInput extends Record<st
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
cacheNamespace?: string;
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "integration" };
}
export const createIntegrationRequestHandler = <
@@ -24,13 +28,27 @@ export const createIntegrationRequestHandler = <
>(
options: Options<TData, TKind, TInput>,
) => {
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
let getSharedCacheAsync:
| ((input: { integration: IntegrationOfKind<TKind>; options: TInput }) => Promise<SharedCacheAdapter<TData>>)
| undefined;
if (cacheNamespace) {
getSharedCacheAsync = async ({ integration, options: itemOptions }) =>
await createIntegrationSharedCacheAsync({
namespace: cacheNamespace,
integrationId: integration.id,
cacheOptions: itemOptions,
});
}
const inner = createRequestHandler<TData, { integration: IntegrationOfKind<TKind>; options: TInput }>({
requestAsync: async ({ integration, options: itemOptions }) => options.requestAsync(integration, itemOptions),
getCacheKey: ({ integration, options: itemOptions }) =>
JSON.stringify({ integrationId: integration.id, itemOptions }),
cacheTtlMs: options.cacheTtlMs,
fallbackToStaleOnError: options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
`${integration.id}:${hashIntegrationCacheOptions(itemOptions)}`,
cacheTtlMs: options.cachePolicy?.ttlMs ?? options.cacheTtlMs,
fallbackToStaleOnError: options.cachePolicy?.staleIfErrorTtlMs !== undefined || options.fallbackToStaleOnError,
staleIfErrorTtlMs: options.cachePolicy?.staleIfErrorTtlMs ?? options.staleIfErrorTtlMs,
getSharedCacheAsync,
});
return {
@@ -0,0 +1,209 @@
import { createHash } from "node:crypto";
import { serialize } from "superjson";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import {
createGetSetChannel,
createLockChannel,
getIntegrationCacheGenerationAsync,
getWidgetCacheGenerationAsync,
} from "@homarr/redis";
import type { IntegrationCacheGeneration, WidgetCacheGeneration } from "@homarr/redis";
import type { CacheEntry, SharedCacheAdapter } from "./request-handler";
const logger = createLogger({ module: "sharedRequestCache" });
const REDIS_OPERATION_TIMEOUT_MS = 250;
const REFRESH_LOCK_TTL_SECONDS = 15;
const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/;
const canonicalize = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(canonicalize);
if (!value || typeof value !== "object") return value;
const result: Record<string, unknown> = {};
for (const key of Object.keys(value).toSorted()) {
result[key] = canonicalize((value as Record<string, unknown>)[key]);
}
return result;
};
export const hashSharedCacheInput = (options: Record<string, unknown>) => {
const serialized = JSON.stringify(canonicalize(serialize(options)));
return createHash("sha256").update(serialized).digest("hex");
};
export const hashIntegrationCacheOptions = hashSharedCacheInput;
const withTimeoutAsync = async <T>(promise: Promise<T>, durationMs: number): Promise<T> => {
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("Redis operation timed out")), durationMs);
timer.unref?.();
void promise.then(resolve, reject).finally(() => clearTimeout(timer));
});
};
const isCacheEntry = (value: unknown): value is CacheEntry<unknown> => {
if (!value || typeof value !== "object") return false;
const entry = value as Partial<CacheEntry<unknown>>;
return (
entry.timestamp instanceof Date &&
typeof entry.expiresAt === "number" &&
Number.isFinite(entry.expiresAt) &&
typeof entry.staleUntil === "number" &&
Number.isFinite(entry.staleUntil)
);
};
interface IntegrationSharedCacheOptions {
namespace: string;
integrationId: string;
cacheOptions: Record<string, unknown>;
}
interface RedisSharedCacheOptions {
namespace: string;
cacheKind: "integration" | "widget";
cacheIdentity: string;
generation: string;
metadata?: Record<string, string>;
isShared?: boolean;
}
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
logger.warn(
new ErrorWithMetadata(
"Redis widget cache operation failed",
{
...metadata,
operation,
},
{ cause: error },
),
);
};
const createRedisSharedCache = <TData>({
namespace,
cacheKind,
cacheIdentity,
generation,
metadata = {},
isShared: initialIsShared = true,
}: RedisSharedCacheOptions): SharedCacheAdapter<TData> => {
const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata };
const payloadChannel = createGetSetChannel<unknown>(
`${cacheKind}-cache:payload:v1:${namespace}:${generation}:${cacheIdentity}`,
);
const lock = createLockChannel(`${cacheKind}-cache:lock:v1:${namespace}:${generation}:${cacheIdentity}`);
let isShared = initialIsShared;
const runRedisOperationAsync = async <T>(operation: string, callback: () => Promise<T>, fallback: T): Promise<T> => {
if (!isShared) return fallback;
try {
return await withTimeoutAsync(callback(), REDIS_OPERATION_TIMEOUT_MS);
} catch (error) {
isShared = false;
logRedisFailure(operation, logMetadata, error);
return fallback;
}
};
return {
generation,
get isShared() {
return isShared;
},
getAsync: async () => {
const value = await runRedisOperationAsync("payload-read", () => payloadChannel.getAsync(), undefined);
if (value === undefined || value === null) return value;
if (isCacheEntry(value)) return value as CacheEntry<TData>;
logger.warn("Ignored invalid Redis widget cache entry", {
...logMetadata,
operation: "payload-validate",
});
await runRedisOperationAsync("payload-remove", () => payloadChannel.removeAsync(), undefined);
return null;
},
setAsync: async (entry) => {
const retentionMs = entry.staleUntil - Date.now();
if (retentionMs <= 0) return;
await runRedisOperationAsync(
"payload-write",
() => payloadChannel.setAsync(entry, { ttlMs: retentionMs }),
undefined,
);
},
acquireRefreshLockAsync: async () =>
await runRedisOperationAsync(
"refresh-lock-acquire",
() => lock.acquireAsync(REFRESH_LOCK_TTL_SECONDS),
undefined,
),
releaseRefreshLockAsync: async (token) => {
await runRedisOperationAsync("refresh-lock-release", () => lock.releaseAsync(token), undefined);
},
};
};
interface WidgetSharedCacheOptions {
namespace: string;
cacheInput: Record<string, unknown>;
}
export const createWidgetSharedCacheAsync = async <TData>({
namespace,
cacheInput,
}: 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}'`);
const metadata = { cacheNamespace: namespace };
let generation: WidgetCacheGeneration;
try {
generation = await withTimeoutAsync(getWidgetCacheGenerationAsync(namespace), REDIS_OPERATION_TIMEOUT_MS);
} catch (error) {
logRedisFailure("generation-read", metadata, error);
generation = { value: "redis-unavailable", isShared: false };
}
return createRedisSharedCache<TData>({
namespace,
cacheKind: "widget",
cacheIdentity: hashSharedCacheInput(cacheInput),
generation: generation.value,
isShared: generation.isShared,
});
};
export const createIntegrationSharedCacheAsync = async <TData>({
namespace,
integrationId,
cacheOptions,
}: 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}'`);
const metadata = { cacheNamespace: namespace, integrationId };
let generation: IntegrationCacheGeneration;
try {
generation = await withTimeoutAsync(getIntegrationCacheGenerationAsync(integrationId), REDIS_OPERATION_TIMEOUT_MS);
} catch (error) {
logRedisFailure("generation-read", metadata, error);
generation = { value: "redis-unavailable", isShared: false };
}
const optionsHash = hashIntegrationCacheOptions(cacheOptions);
const sharedCache = createRedisSharedCache<TData>({
namespace,
cacheKind: "integration",
cacheIdentity: `${integrationId}:${optionsHash}`,
generation: generation.value,
metadata: { integrationId },
isShared: generation.isShared,
});
return sharedCache;
};
@@ -4,13 +4,35 @@ interface Options<TData, TInput extends Record<string, unknown>> {
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
getSharedCacheAsync?: (input: TInput) => Promise<SharedCacheAdapter<TData>>;
}
type CacheEntry<TData> = { data: TData; timestamp: Date; expiresAt: number; staleUntil: number };
export interface CacheEntry<TData> {
data: TData;
timestamp: Date;
expiresAt: number;
staleUntil: number;
}
export interface SharedCacheAdapter<TData> {
generation: string;
isShared: boolean;
getAsync: () => Promise<CacheEntry<TData> | null | undefined>;
setAsync: (entry: CacheEntry<TData>) => Promise<void>;
acquireRefreshLockAsync: () => Promise<string | null | undefined>;
releaseRefreshLockAsync: (token: string) => Promise<void>;
}
const MAX_CACHE_SIZE = 1000;
const DEFAULT_TTL_MS = 10_000;
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
const SHARED_REFRESH_WAIT_MS = 750;
const SHARED_REFRESH_POLL_MS = 75;
const delayAsync = async (durationMs: number) =>
await new Promise<void>((resolve) => {
setTimeout(resolve, durationMs);
});
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
const now = Date.now();
@@ -28,6 +50,17 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
let expiryTimerAt: number | undefined;
let generation = 0;
const storeInMemory = (key: string, entry: CacheEntry<TData>, requestGeneration: number) => {
if (generation !== requestGeneration || Date.now() >= entry.staleUntil) return;
evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, entry);
scheduleExpiry(entry.staleUntil);
};
const scheduleExpiry = (candidateExpiryAt?: number) => {
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
if (expiryTimer) clearTimeout(expiryTimer);
@@ -61,7 +94,9 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
handler: (input: TInput) => ({
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
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();
@@ -79,44 +114,73 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
const requestGeneration = generation;
let promise: Promise<CacheEntry<TData>>;
promise = options
.requestAsync(input)
.then((data) => {
const expiresAt = Date.now() + ttl;
promise = (async () => {
let stale = cached;
let lockToken: string | null | undefined;
if (sharedCache?.isShared) {
const sharedEntry = await sharedCache.getAsync();
if (sharedEntry && Date.now() < sharedEntry.expiresAt) {
storeInMemory(key, sharedEntry, requestGeneration);
return sharedEntry;
}
if (sharedEntry && Date.now() < sharedEntry.staleUntil) stale = sharedEntry;
lockToken = await sharedCache.acquireRefreshLockAsync();
if (lockToken === null) {
const waitUntil = Date.now() + SHARED_REFRESH_WAIT_MS;
while (Date.now() < waitUntil) {
await delayAsync(SHARED_REFRESH_POLL_MS);
const refreshedEntry = await sharedCache.getAsync();
if (refreshedEntry && Date.now() < refreshedEntry.expiresAt) {
storeInMemory(key, refreshedEntry, requestGeneration);
return refreshedEntry;
}
if (refreshedEntry && Date.now() < refreshedEntry.staleUntil) stale = refreshedEntry;
}
lockToken = await sharedCache.acquireRefreshLockAsync();
if (lockToken === null && options.fallbackToStaleOnError && stale) {
storeInMemory(key, stale, requestGeneration);
return stale;
}
}
}
try {
const data = await options.requestAsync(input);
const completedAt = Date.now();
const staleIfErrorTtlMs = options.fallbackToStaleOnError
? Math.max(0, options.staleIfErrorTtlMs ?? DEFAULT_STALE_IF_ERROR_TTL_MS)
: 0;
const entry: CacheEntry<TData> = {
data,
timestamp: new Date(),
expiresAt,
staleUntil: expiresAt + staleIfErrorTtlMs,
timestamp: new Date(completedAt),
expiresAt: completedAt + ttl,
staleUntil: completedAt + ttl + staleIfErrorTtlMs,
};
if (ttl > 0 && generation === requestGeneration) {
evictExpired(cache);
if (cache.size >= MAX_CACHE_SIZE) {
const oldest = cache.keys().next().value;
if (oldest !== undefined) cache.delete(oldest);
}
cache.set(key, entry);
scheduleExpiry(entry.staleUntil);
if (ttl > 0) {
storeInMemory(key, entry, requestGeneration);
if (sharedCache?.isShared) await sharedCache.setAsync(entry);
}
return entry;
})
.catch((err) => {
} catch (error) {
if (
options.fallbackToStaleOnError &&
cached &&
Date.now() < cached.staleUntil &&
stale &&
Date.now() < stale.staleUntil &&
generation === requestGeneration
) {
return cached;
storeInMemory(key, stale, requestGeneration);
return stale;
}
throw err;
})
.finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
});
throw error;
} finally {
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
}
})().finally(() => {
if (inflight.get(key) === promise) inflight.delete(key);
});
inflight.set(key, promise);
return promise;
@@ -1,15 +1,45 @@
import { invalidateWidgetCache } from "@homarr/redis";
import type { WidgetModuleServerCachePolicy } from "@homarr/definitions";
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./integration-shared-cache";
import { createRequestHandler } from "./request-handler";
import type { SharedCacheAdapter } from "./request-handler";
interface Options<TData, TInput extends Record<string, unknown>> {
requestAsync: (input: TInput) => Promise<TData>;
cacheTtlMs?: number;
fallbackToStaleOnError?: boolean;
staleIfErrorTtlMs?: number;
cacheNamespace?: string;
cachePolicy?: WidgetModuleServerCachePolicy & { scope: "shared" };
}
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
options: Options<TData, TInput>,
) => {
const inner = createRequestHandler<TData, TInput>(options);
return inner;
const cacheNamespace = options.cachePolicy?.namespace ?? options.cacheNamespace;
let getSharedCacheAsync: ((input: TInput) => Promise<SharedCacheAdapter<TData>>) | undefined;
if (cacheNamespace) {
getSharedCacheAsync = async (input) =>
await createWidgetSharedCacheAsync<TData>({
namespace: cacheNamespace,
cacheInput: input,
});
}
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,
getCacheKey: hashSharedCacheInput,
getSharedCacheAsync,
});
return {
invalidateCache: () => {
inner.invalidateCache();
if (cacheNamespace) invalidateWidgetCache(cacheNamespace);
},
handler: inner.handler,
};
};
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -16,6 +16,7 @@ export const mediaOrganizerRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"mediaOrganizer">,
{ pageSize: number }
>({
cacheNamespace: "media-organizer:summary",
async requestAsync(integration, input) {
const integrationInstance = (await createIntegrationAsync(integration)) as unknown as IMediaOrganizerIntegration;
const [missingResult, queueResult] = await Promise.all([
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { MediaRelease } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const mediaReleaseRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"mediaRelease">,
Record<string, never>
>({
cacheNamespace: "media-release:list",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMediaReleasesAsync();
@@ -1,7 +1,7 @@
import { z } from "zod/v4";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { MediaRequest, MediaRequestStatus } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -28,6 +28,7 @@ export const mediaRequestListRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"mediaRequest">,
MediaRequestListInput
>({
cacheNamespace: "media-requests:list",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
const requests = await integrationInstance.getRequestsAsync();
@@ -8,7 +8,7 @@ const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { MediaRequestStats } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"mediaRequest">,
Record<string, never>
>({
cacheNamespace: "media-requests:stats",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
const [stats, users] = await Promise.all([
+2 -1
View File
@@ -1,6 +1,6 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -11,6 +11,7 @@ export const mediaServerRequestHandler = createIntegrationRequestHandler<
showOnlyPlaying: boolean;
}
>({
cacheNamespace: "media-server:sessions",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getCurrentSessionsAsync({ showOnlyPlaying: input.showOnlyPlaying });
@@ -6,7 +6,7 @@ const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const mediaTranscodingRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"mediaTranscoding">,
{ pageOffset: number; pageSize: number }
>({
cacheNamespace: "media-transcoding:summary",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
const [queue, workers, statistics] = await Promise.all([
@@ -6,6 +6,7 @@ import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({
cacheNamespace: "minecraft-server-status",
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
+2 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { NavidromeDashboardData } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const navidromeRequestHandler = createIntegrationRequestHandler<
"navidrome",
Record<string, never>
>({
cacheNamespace: "navidrome:dashboard",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { NetworkControllerSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const networkControllerRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"networkController">,
Record<string, never>
>({
cacheNamespace: "network-controller:status",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNetworkSummaryAsync();
@@ -1,6 +1,6 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import type { Notification } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const notificationsRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"notifications">,
Record<string, never>
>({
cacheNamespace: "notifications:list",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getNotificationsAsync();
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { PaperlessNgxStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const paperlessNgxStatsRequestHandler = createIntegrationRequestHandler<
"paperlessNgx",
Record<string, never>
>({
cacheNamespace: "paperless-ngx:stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
+2 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { PatchMonStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const patchmonStatsRequestHandler = createIntegrationRequestHandler<
"patchmon",
Record<string, never>
>({
cacheNamespace: "patchmon:stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();
@@ -10,6 +10,7 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler";
const logger = createLogger({ module: "rssFeedsRequestHandler" });
export const rssFeedsRequestHandler = createWidgetRequestHandler({
cacheNamespace: "rss-feeds",
async requestAsync(input: { url: string; count: number }) {
const result = (await extract(input.url, {
getExtraEntryFields: (feedEntry) => {
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { EntityState } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -27,6 +27,7 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
IntegrationKindByCategory<"smartHomeServer">,
{ entityId: string }
>({
cacheNamespace: "smart-home:entity-state",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
const result = await integrationInstance.getEntityStateAsync(input.entityId);
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { SpeedtestTrackerDashboardData } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const speedtestTrackerRequestHandler = createIntegrationRequestHandler<
"speedtestTracker",
Record<string, never>
>({
cacheNamespace: "speedtest-tracker:dashboard",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
@@ -6,6 +6,7 @@ import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
export const fetchStockPriceHandler = createWidgetRequestHandler({
cacheNamespace: "stock-price",
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
const response = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
vi.mock("@homarr/integrations/factory", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
+2 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { TracearrDashboardData } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const tracearrRequestHandler = createIntegrationRequestHandler<
"tracearr",
Record<string, never>
>({
cacheNamespace: "tracearr:dashboard",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
+2 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { TraefikDashboardData } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const traefikRequestHandler = createIntegrationRequestHandler<
"traefik",
Record<string, never>
>({
cacheNamespace: "traefik:summary",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
+7 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const umamiRequestHandler = createIntegrationRequestHandler<
"umami",
{ websiteId: string; timeFrame: string; eventName?: string }
>({
cacheNamespace: "umami:stats",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getVisitorStatsAsync(input.websiteId, input.timeFrame, input.eventName);
@@ -15,6 +16,7 @@ export const umamiRequestHandler = createIntegrationRequestHandler<
});
export const umamiEventNamesRequestHandler = createIntegrationRequestHandler<string[], "umami", { websiteId: string }>({
cacheNamespace: "umami:event-names",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getEventNamesAsync(input.websiteId);
@@ -26,6 +28,7 @@ export const umamiTopPagesRequestHandler = createIntegrationRequestHandler<
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
cacheNamespace: "umami:top-pages",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopPagesAsync(input.websiteId, input.timeFrame, input.limit);
@@ -37,6 +40,7 @@ export const umamiTopReferrersRequestHandler = createIntegrationRequestHandler<
"umami",
{ websiteId: string; timeFrame: string; limit: number }
>({
cacheNamespace: "umami:top-referrers",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getTopReferrersAsync(input.websiteId, input.timeFrame, input.limit);
@@ -48,6 +52,7 @@ export const umamiMultiEventRequestHandler = createIntegrationRequestHandler<
"umami",
{ websiteId: string; timeFrame: string; eventNames: string[] }
>({
cacheNamespace: "umami:multi-event",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getMultiEventTimeSeriesAsync(input.websiteId, input.timeFrame, input.eventNames);
@@ -59,6 +64,7 @@ export const umamiActiveVisitorsRequestHandler = createIntegrationRequestHandler
"umami",
{ websiteId: string }
>({
cacheNamespace: "umami:active-visitors",
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getActiveVisitorsAsync(input.websiteId);
+2 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { UpsSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const upsSummariesRequestHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"ups">,
Record<string, never>
>({
cacheNamespace: "ups:summaries",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getUpsSummariesAsync();
+2 -1
View File
@@ -1,4 +1,4 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -8,6 +8,7 @@ export const uptimeKumaRequestHandler = createIntegrationRequestHandler<
"uptimeKuma",
Record<string, never>
>({
cacheNamespace: "uptime-kuma:monitors",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getDashboardDataAsync();
+2 -1
View File
@@ -1,5 +1,5 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { VpnSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
@@ -9,6 +9,7 @@ export const vpnSummaryHandler = createIntegrationRequestHandler<
IntegrationKindByCategory<"vpn">,
Record<string, never>
>({
cacheNamespace: "vpn:summary",
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getSummaryAsync();
+4 -1
View File
@@ -6,12 +6,15 @@ 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;
@@ -140,7 +143,7 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number
};
export const weatherRequestHandler = createWidgetRequestHandler({
cacheTtlMs: 5 * 60 * 1000,
cachePolicy: weatherCachePolicy,
requestAsync: requestWeatherAsync,
});
+2 -1
View File
@@ -1,9 +1,10 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations/factory";
import type { WudStats } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
export const wudStatsRequestHandler = createIntegrationRequestHandler<WudStats, "wud", Record<string, never>>({
cacheNamespace: "wud:stats",
async requestAsync(integration) {
const integrationInstance = await createIntegrationAsync(integration);
return await integrationInstance.getStatsAsync();