feat(widgets): consolidate integration metadata + shared response caching (#6691)
This commit is contained in:
1 parent
7d1d9062ed
commit
8d8dec5749
206 files changed
+3893
-1946
No files matched your search
@@ -5,7 +5,6 @@ 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 { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
@@ -44,10 +43,12 @@ const requestedVariables = [
|
||||
] as const;
|
||||
|
||||
export const airQualityRequestHandler = createWidgetRequestHandler({
|
||||
// Coordinates are a public, continuous key space. Keep this cache process-local and bounded.
|
||||
cacheTtlMs: 15 * 60 * 1000,
|
||||
fallbackToStaleOnError: true,
|
||||
staleIfErrorTtlMs: 24 * 60 * 60 * 1000,
|
||||
async requestAsync(input: { latitude: number; longitude: number }) {
|
||||
requestTimeoutMs: 10_000,
|
||||
async requestAsync(input: { latitude: number; longitude: number }, signal: AbortSignal) {
|
||||
const url = new URL("https://air-quality-api.open-meteo.com/v1/air-quality");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
@@ -56,9 +57,7 @@ export const airQualityRequestHandler = createWidgetRequestHandler({
|
||||
url.searchParams.set("forecast_days", FORECAST_DAYS.toString());
|
||||
url.searchParams.set("timezone", "auto");
|
||||
|
||||
const response = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
const response = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
if (!response.ok) throw new ResponseError(response);
|
||||
|
||||
const json: unknown = await response.json();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -207,6 +207,7 @@ const demoDockerEndpoint = {
|
||||
} satisfies DockerEndpointStatus;
|
||||
|
||||
export const dockerContainersRequestHandler = createWidgetRequestHandler({
|
||||
cacheNamespace: "docker-containers",
|
||||
async requestAsync({ endpointIds }: { endpointIds?: string[] }) {
|
||||
if (isDemoMode()) {
|
||||
const includesDemoEndpoint =
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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";
|
||||
|
||||
@@ -9,6 +9,8 @@ export const downloadClientRequestHandler = createIntegrationRequestHandler<
|
||||
IntegrationKindByCategory<"downloadClient">,
|
||||
{ limit: number }
|
||||
>({
|
||||
cacheNamespace: "downloads:jobs-and-status",
|
||||
cacheTtlMs: 10 * 1000,
|
||||
async requestAsync(integration, input) {
|
||||
const integrationInstance = await createIntegrationAsync(integration);
|
||||
return await integrationInstance.getClientJobsAndStatusAsync(input);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 { 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,22 +1,63 @@
|
||||
import { createKeyedFingerprint } from "@homarr/common/server";
|
||||
import type { Modify } from "@homarr/common/types";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
import type { IntegrationKind } from "@homarr/definitions";
|
||||
import { invalidateIntegrationResponseCacheAsync } from "@homarr/redis";
|
||||
|
||||
import { createIntegrationSharedCacheAsync, hashSharedCacheInput } from "./shared-cache";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import type { SharedCacheAdapter } from "./request-handler";
|
||||
|
||||
type IntegrationOfKind<TKind extends IntegrationKind> = Omit<Integration, "kind"> & {
|
||||
kind: TKind;
|
||||
decryptedSecrets: Modify<Pick<IntegrationSecret, "kind" | "value">, { value: string }>[];
|
||||
decryptedSecrets: Modify<
|
||||
Pick<IntegrationSecret, "kind" | "value"> & Partial<Pick<IntegrationSecret, "updatedAt">>,
|
||||
{ value: string }
|
||||
>[];
|
||||
externalUrl: string | null;
|
||||
};
|
||||
|
||||
interface Options<TData, TKind extends IntegrationKind, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput) => Promise<TData>;
|
||||
requestAsync: (integration: IntegrationOfKind<TKind>, input: TInput, signal: AbortSignal) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
const getIntegrationCacheIdentity = <TKind extends IntegrationKind, TInput extends Record<string, unknown>>({
|
||||
integration,
|
||||
options,
|
||||
}: {
|
||||
integration: IntegrationOfKind<TKind>;
|
||||
options: TInput;
|
||||
}) => {
|
||||
const secretRevisions = (integration.decryptedSecrets ?? [])
|
||||
.map((secret) => ({
|
||||
kind: secret.kind,
|
||||
fingerprint: createKeyedFingerprint(JSON.stringify([secret.kind, secret.value])),
|
||||
updatedAt: secret.updatedAt?.toISOString(),
|
||||
}))
|
||||
.toSorted((left, right) => {
|
||||
const kindComparison = left.kind.localeCompare(right.kind);
|
||||
if (kindComparison !== 0) return kindComparison;
|
||||
return left.fingerprint.localeCompare(right.fingerprint);
|
||||
});
|
||||
|
||||
return hashSharedCacheInput({
|
||||
integration: {
|
||||
id: integration.id,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
externalUrl: integration.externalUrl,
|
||||
secretRevisions,
|
||||
},
|
||||
options,
|
||||
});
|
||||
};
|
||||
|
||||
export const createIntegrationRequestHandler = <
|
||||
TData,
|
||||
TKind extends IntegrationKind,
|
||||
@@ -24,17 +65,46 @@ export const createIntegrationRequestHandler = <
|
||||
>(
|
||||
options: Options<TData, TKind, TInput>,
|
||||
) => {
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync:
|
||||
| ((
|
||||
input: { integration: IntegrationOfKind<TKind>; options: TInput },
|
||||
cacheIdentity: string,
|
||||
) => Promise<SharedCacheAdapter<TData>>)
|
||||
| undefined;
|
||||
if (cacheNamespace) {
|
||||
getSharedCacheAsync = async ({ integration }, cacheIdentity) =>
|
||||
await createIntegrationSharedCacheAsync({
|
||||
namespace: cacheNamespace,
|
||||
integrationId: integration.id,
|
||||
cacheIdentity,
|
||||
cacheVersion: options.cacheVersion,
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
requestAsync: async ({ integration, options: itemOptions }, signal) =>
|
||||
options.requestAsync(integration, itemOptions, signal),
|
||||
getCacheKey: getIntegrationCacheIdentity,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
return {
|
||||
invalidateCache: inner.invalidateCache,
|
||||
/** Clears this handler's L1 and every shared response-cache namespace for these integrations. */
|
||||
invalidateCacheAsync: async (integrationIds: readonly string[]) => {
|
||||
inner.invalidateCache();
|
||||
if (!cacheNamespace) return;
|
||||
await Promise.all(
|
||||
[...new Set(integrationIds)].map(async (integrationId) => {
|
||||
await invalidateIntegrationResponseCacheAsync(integrationId);
|
||||
}),
|
||||
);
|
||||
},
|
||||
handler: (integration: IntegrationOfKind<TKind>, itemOptions: TInput) =>
|
||||
inner.handler({ integration, options: itemOptions }),
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Integration, IntegrationSecret } from "@homarr/db/schema";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./integration-request-handler";
|
||||
import type { CacheEntry, SharedCacheAdapter } from "./request-handler";
|
||||
import { createRequestHandler } from "./request-handler";
|
||||
import { createWidgetRequestHandler } from "./widget-request-handler";
|
||||
|
||||
@@ -52,6 +53,134 @@ describe("createRequestHandler", () => {
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
|
||||
it("uses a fresh shared cache entry before calling the upstream service", async () => {
|
||||
const timestamp = new Date();
|
||||
const requestAsync = vi.fn(async () => "upstream");
|
||||
const sharedCache = {
|
||||
generation: "shared-1",
|
||||
isShared: true,
|
||||
getAsync: vi.fn(async () => ({
|
||||
data: "shared",
|
||||
timestamp,
|
||||
expiresAt: Date.now() + 1_000,
|
||||
staleUntil: Date.now() + 1_000,
|
||||
})),
|
||||
setAsync: vi.fn(async () => undefined),
|
||||
acquireRefreshLockAsync: vi.fn(async () => "lock"),
|
||||
renewRefreshLockAsync: vi.fn(async () => true),
|
||||
releaseRefreshLockAsync: vi.fn(async () => undefined),
|
||||
};
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync: async () => sharedCache,
|
||||
});
|
||||
|
||||
await expect(handler.handler({ id: "same" }).getDataAsync()).resolves.toMatchObject({ data: "shared", timestamp });
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
expect(sharedCache.acquireRefreshLockAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renews the refresh lock while the upstream request is still running", async () => {
|
||||
vi.useFakeTimers();
|
||||
const upstreamRequest = deferred<string>();
|
||||
const renewRefreshLockAsync = vi.fn(async () => true);
|
||||
const releaseRefreshLockAsync = vi.fn(async () => undefined);
|
||||
const sharedCache: SharedCacheAdapter<string> = {
|
||||
generation: "shared-1",
|
||||
isShared: true,
|
||||
getAsync: async () => null,
|
||||
setAsync: async () => undefined,
|
||||
acquireRefreshLockAsync: async () => "lock",
|
||||
renewRefreshLockAsync,
|
||||
releaseRefreshLockAsync,
|
||||
};
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync: async () => await upstreamRequest.promise,
|
||||
getSharedCacheAsync: async () => sharedCache,
|
||||
});
|
||||
|
||||
const pending = handler.handler({ id: "same" }).getDataAsync();
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(renewRefreshLockAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
upstreamRequest.resolve("upstream");
|
||||
await expect(pending).resolves.toMatchObject({ data: "upstream" });
|
||||
expect(releaseRefreshLockAsync).toHaveBeenCalledWith("lock");
|
||||
});
|
||||
|
||||
it("waits out a refresh lock held by another process instead of stampeding the upstream", async () => {
|
||||
vi.useFakeTimers();
|
||||
const timestamp = new Date();
|
||||
let sharedEntry: CacheEntry<string> | null = null;
|
||||
const requestAsync = vi.fn(async () => "upstream");
|
||||
const sharedCache = {
|
||||
generation: "shared-1",
|
||||
isShared: true,
|
||||
getAsync: vi.fn(async () => sharedEntry),
|
||||
setAsync: vi.fn(async () => undefined),
|
||||
// A different process/pod already holds the refresh lock.
|
||||
acquireRefreshLockAsync: vi.fn(async () => null),
|
||||
renewRefreshLockAsync: vi.fn(async () => true),
|
||||
releaseRefreshLockAsync: vi.fn(async () => undefined),
|
||||
};
|
||||
const handler = createRequestHandler<string, { id: string }>({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync: async () => sharedCache,
|
||||
});
|
||||
|
||||
const pending = handler.handler({ id: "same" }).getDataAsync();
|
||||
|
||||
// Poll well past the old fixed wait window while the lock is still held and the
|
||||
// shared cache is empty: the waiter must keep waiting, not fetch upstream itself.
|
||||
await vi.advanceTimersByTimeAsync(20_000);
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
|
||||
// The lock holder finishes and publishes the entry to the shared cache.
|
||||
sharedEntry = {
|
||||
data: "shared",
|
||||
timestamp,
|
||||
expiresAt: Date.now() + 1_000,
|
||||
staleUntil: Date.now() + 1_000,
|
||||
};
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
await expect(pending).resolves.toMatchObject({ data: "shared", timestamp });
|
||||
expect(requestAsync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deduplicates while the shared cache is being resolved", async () => {
|
||||
const sharedCacheRequest = deferred<SharedCacheAdapter<string>>();
|
||||
const upstreamRequest = deferred<string>();
|
||||
const getSharedCacheAsync = vi.fn(async () => await sharedCacheRequest.promise);
|
||||
const requestAsync = vi.fn(async () => await upstreamRequest.promise);
|
||||
const handler = createRequestHandler({
|
||||
cacheTtlMs: 1_000,
|
||||
requestAsync,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
|
||||
const first = handler.handler({ id: "same" }).getDataAsync();
|
||||
const second = handler.handler({ id: "same" }).getDataAsync();
|
||||
expect(getSharedCacheAsync).toHaveBeenCalledTimes(1);
|
||||
|
||||
sharedCacheRequest.resolve({
|
||||
generation: "shared-1",
|
||||
isShared: false,
|
||||
getAsync: async () => undefined,
|
||||
setAsync: async () => undefined,
|
||||
acquireRefreshLockAsync: async () => undefined,
|
||||
renewRefreshLockAsync: async () => undefined,
|
||||
releaseRefreshLockAsync: async () => undefined,
|
||||
});
|
||||
await vi.waitFor(() => expect(requestAsync).toHaveBeenCalledTimes(1));
|
||||
upstreamRequest.resolve("value");
|
||||
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
});
|
||||
|
||||
it("keeps in-flight deduplication but retains no payload when ttl is zero", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<{ call: number }>();
|
||||
@@ -193,7 +322,7 @@ describe("createRequestHandler", () => {
|
||||
});
|
||||
|
||||
describe("createIntegrationRequestHandler", () => {
|
||||
it("keys requests by integration id and options without retaining secret values", async () => {
|
||||
it("does not deduplicate requests across rotated integration secrets", async () => {
|
||||
let calls = 0;
|
||||
const request = deferred<string>();
|
||||
const handler = createIntegrationRequestHandler<string, "sonarr", { page: number }>({
|
||||
@@ -203,11 +332,16 @@ describe("createIntegrationRequestHandler", () => {
|
||||
},
|
||||
});
|
||||
const first = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
|
||||
const duplicate = handler.handler(createIntegration("first-secret"), { page: 1 }).getDataAsync();
|
||||
const second = handler.handler(createIntegration("rotated-secret"), { page: 1 }).getDataAsync();
|
||||
expect(calls).toBe(1);
|
||||
expect(calls).toBe(2);
|
||||
|
||||
request.resolve("value");
|
||||
await expect(Promise.all([first, second])).resolves.toMatchObject([{ data: "value" }, { data: "value" }]);
|
||||
await expect(Promise.all([first, duplicate, second])).resolves.toMatchObject([
|
||||
{ data: "value" },
|
||||
{ data: "value" },
|
||||
{ data: "value" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,16 +1,114 @@
|
||||
interface Options<TData, TInput extends Record<string, unknown>> {
|
||||
requestAsync: (input: TInput) => Promise<TData>;
|
||||
requestAsync: (input: TInput, signal: AbortSignal) => Promise<TData>;
|
||||
getCacheKey?: (input: TInput) => string;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
getSharedCacheAsync?: (input: TInput, cacheIdentity: string) => 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>, refreshLockToken: string) => Promise<boolean | void>;
|
||||
acquireRefreshLockAsync: () => Promise<string | null | undefined>;
|
||||
renewRefreshLockAsync: (token: string) => Promise<boolean | undefined>;
|
||||
releaseRefreshLockAsync: (token: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const MAX_CACHE_SIZE = 1000;
|
||||
const MAX_INFLIGHT_REQUESTS = 100;
|
||||
const MAX_UPSTREAM_SETTLEMENT_GRACE_MS = 60_000;
|
||||
const DEFAULT_TTL_MS = 10_000;
|
||||
const DEFAULT_STALE_IF_ERROR_TTL_MS = 5 * 60_000;
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
||||
const SHARED_REFRESH_POLL_MS = 100;
|
||||
const SHARED_REFRESH_LOCK_RETRY_MS = 1_000;
|
||||
const SHARED_REFRESH_STALE_WAIT_MS = 15_000;
|
||||
const SHARED_REFRESH_MAX_WAIT_MS = 30_000;
|
||||
// Must remain comfortably below REFRESH_LOCK_TTL_SECONDS in shared-cache.ts.
|
||||
const SHARED_REFRESH_LOCK_RENEW_MS = 5_000;
|
||||
|
||||
class RequestHandlerTimeoutError extends Error {
|
||||
constructor() {
|
||||
super("Request handler deadline exceeded");
|
||||
this.name = RequestHandlerTimeoutError.name;
|
||||
}
|
||||
}
|
||||
|
||||
class RequestHandlerOverloadedError extends Error {
|
||||
constructor() {
|
||||
super("Request handler concurrency limit exceeded");
|
||||
this.name = RequestHandlerOverloadedError.name;
|
||||
}
|
||||
}
|
||||
|
||||
const throwIfDeadlineExceeded = (deadlineAt: number) => {
|
||||
if (Date.now() >= deadlineAt) throw new RequestHandlerTimeoutError();
|
||||
};
|
||||
|
||||
const delayAsync = async (durationMs: number, deadlineAt: number) => {
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, Math.min(durationMs, remainingMs));
|
||||
timer.unref?.();
|
||||
});
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
};
|
||||
|
||||
const waitForSettlementGraceAsync = async (settledAsync: Promise<void>, durationMs: number) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(resolve, durationMs);
|
||||
timer.unref?.();
|
||||
void settledAsync.then(() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const startRefreshLockRenewal = <TData>(sharedCache: SharedCacheAdapter<TData>, token: string) => {
|
||||
let isOwned = true;
|
||||
let isStopped = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const schedule = () => {
|
||||
timer = setTimeout(() => {
|
||||
void sharedCache.renewRefreshLockAsync(token).then(
|
||||
(renewed) => {
|
||||
if (renewed !== true) {
|
||||
isOwned = false;
|
||||
return;
|
||||
}
|
||||
if (!isStopped) schedule();
|
||||
},
|
||||
() => {
|
||||
isOwned = false;
|
||||
},
|
||||
);
|
||||
}, SHARED_REFRESH_LOCK_RENEW_MS);
|
||||
timer.unref?.();
|
||||
};
|
||||
|
||||
schedule();
|
||||
return {
|
||||
isOwned: () => isOwned,
|
||||
stop: () => {
|
||||
isStopped = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
||||
const now = Date.now();
|
||||
@@ -22,12 +120,94 @@ const evictExpired = <TData>(cache: Map<string, CacheEntry<TData>>) => {
|
||||
export const createRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0) {
|
||||
throw new Error("Request timeout must be a positive finite duration");
|
||||
}
|
||||
|
||||
const cache = new Map<string, CacheEntry<TData>>();
|
||||
const inflight = new Map<string, Promise<CacheEntry<TData>>>();
|
||||
const sharedCacheResolutions = new Map<string, Promise<SharedCacheAdapter<TData>>>();
|
||||
const upstreamSettlements = new Map<string, Promise<void>>();
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
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);
|
||||
cache.delete(key);
|
||||
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 getFromMemory = (key: string) => {
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return undefined;
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
return entry;
|
||||
};
|
||||
|
||||
const startRequestWithDeadline = (input: TInput, deadlineAt: number, upstreamKey: string) => {
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
if (upstreamSettlements.has(upstreamKey) || upstreamSettlements.size >= MAX_INFLIGHT_REQUESTS) {
|
||||
throw new RequestHandlerOverloadedError();
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutError = new RequestHandlerTimeoutError();
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
let upstreamRequest: Promise<TData>;
|
||||
try {
|
||||
upstreamRequest = Promise.resolve(options.requestAsync(input, controller.signal));
|
||||
} catch (error) {
|
||||
upstreamRequest = Promise.reject(error);
|
||||
}
|
||||
const settledAsync = upstreamRequest.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
upstreamSettlements.set(upstreamKey, settledAsync);
|
||||
void settledAsync.then(() => {
|
||||
if (upstreamSettlements.get(upstreamKey) === settledAsync) upstreamSettlements.delete(upstreamKey);
|
||||
});
|
||||
|
||||
const resultAsync = new Promise<TData>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, remainingMs);
|
||||
timer.unref?.();
|
||||
|
||||
void upstreamRequest.then(resolve, reject).finally(() => clearTimeout(timer));
|
||||
});
|
||||
|
||||
return { resultAsync, settledAsync };
|
||||
};
|
||||
|
||||
const resolveSharedCacheAsync = (input: TInput, cacheIdentity: string, requestGeneration: number) => {
|
||||
if (!options.getSharedCacheAsync) return undefined;
|
||||
|
||||
const resolutionKey = `${requestGeneration}:${cacheIdentity}`;
|
||||
const existing = sharedCacheResolutions.get(resolutionKey);
|
||||
if (existing) return existing;
|
||||
if (sharedCacheResolutions.size >= MAX_INFLIGHT_REQUESTS) {
|
||||
throw new RequestHandlerOverloadedError();
|
||||
}
|
||||
|
||||
let promise: Promise<SharedCacheAdapter<TData>>;
|
||||
promise = options.getSharedCacheAsync(input, cacheIdentity).finally(() => {
|
||||
if (sharedCacheResolutions.get(resolutionKey) === promise) sharedCacheResolutions.delete(resolutionKey);
|
||||
});
|
||||
sharedCacheResolutions.set(resolutionKey, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const scheduleExpiry = (candidateExpiryAt?: number) => {
|
||||
if (candidateExpiryAt !== undefined && expiryTimerAt !== undefined && expiryTimerAt <= candidateExpiryAt) return;
|
||||
if (expiryTimer) clearTimeout(expiryTimer);
|
||||
@@ -53,72 +233,149 @@ export const createRequestHandler = <TData, TInput extends Record<string, unknow
|
||||
invalidateCache: () => {
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
inflight.clear();
|
||||
if (expiryTimer) clearTimeout(expiryTimer);
|
||||
expiryTimer = undefined;
|
||||
expiryTimerAt = undefined;
|
||||
},
|
||||
handler: (input: TInput) => ({
|
||||
async getDataAsync(): Promise<{ data: TData; timestamp: Date }> {
|
||||
const deadlineAt = Date.now() + requestTimeoutMs;
|
||||
const ttl = options.cacheTtlMs ?? DEFAULT_TTL_MS;
|
||||
const key = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
|
||||
let cached = cache.get(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) {
|
||||
return { data: cached.data, timestamp: cached.timestamp };
|
||||
const baseKey = options.getCacheKey?.(input) ?? JSON.stringify(input);
|
||||
const requestGeneration = generation;
|
||||
let sharedCache: SharedCacheAdapter<TData> | undefined;
|
||||
const sharedCacheResolution = ttl > 0 ? resolveSharedCacheAsync(input, baseKey, requestGeneration) : undefined;
|
||||
if (sharedCacheResolution) {
|
||||
sharedCache = await sharedCacheResolution;
|
||||
throwIfDeadlineExceeded(deadlineAt);
|
||||
}
|
||||
|
||||
const key = `${sharedCache?.generation ?? "local"}:${baseKey}`;
|
||||
const inflightKey = `${requestGeneration}:${key}`;
|
||||
const existing = inflight.get(inflightKey);
|
||||
if (existing) return existing;
|
||||
|
||||
let cached = getFromMemory(key);
|
||||
const now = Date.now();
|
||||
if (cached && now < cached.expiresAt) return cached;
|
||||
if (cached && now >= cached.staleUntil) {
|
||||
cache.delete(key);
|
||||
scheduleExpiry();
|
||||
cached = undefined;
|
||||
}
|
||||
if (upstreamSettlements.has(inflightKey) || inflight.size >= MAX_INFLIGHT_REQUESTS) {
|
||||
if (options.fallbackToStaleOnError && cached) return cached;
|
||||
throw new RequestHandlerOverloadedError();
|
||||
}
|
||||
|
||||
const existing = inflight.get(key);
|
||||
if (existing) return existing;
|
||||
|
||||
const requestGeneration = generation;
|
||||
let cleanupAfterUpstreamAsync: Promise<void> | undefined;
|
||||
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 returnStaleAt = Date.now() + SHARED_REFRESH_STALE_WAIT_MS;
|
||||
const waitDeadlineAt = Math.min(deadlineAt, Date.now() + SHARED_REFRESH_MAX_WAIT_MS);
|
||||
let retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS;
|
||||
while (lockToken === null && sharedCache.isShared) {
|
||||
await delayAsync(SHARED_REFRESH_POLL_MS, waitDeadlineAt);
|
||||
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;
|
||||
if (Date.now() >= returnStaleAt && options.fallbackToStaleOnError && stale) {
|
||||
storeInMemory(key, stale, requestGeneration);
|
||||
return stale;
|
||||
}
|
||||
if (Date.now() >= retryLockAt) {
|
||||
lockToken = await sharedCache.acquireRefreshLockAsync();
|
||||
retryLockAt = Date.now() + SHARED_REFRESH_LOCK_RETRY_MS;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const refreshLockRenewal =
|
||||
lockToken && sharedCache ? startRefreshLockRenewal(sharedCache, lockToken) : undefined;
|
||||
let upstreamSettledAsync: Promise<void> | undefined;
|
||||
try {
|
||||
const request = startRequestWithDeadline(input, deadlineAt, inflightKey);
|
||||
upstreamSettledAsync = request.settledAsync;
|
||||
const data = await request.resultAsync;
|
||||
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);
|
||||
if (ttl > 0) {
|
||||
let canStoreInMemory = true;
|
||||
if (sharedCache?.isShared) {
|
||||
canStoreInMemory = false;
|
||||
if (lockToken && refreshLockRenewal?.isOwned() !== false) {
|
||||
const stored = await sharedCache.setAsync(entry, lockToken);
|
||||
if (stored !== false || !sharedCache.isShared) canStoreInMemory = true;
|
||||
}
|
||||
}
|
||||
cache.set(key, entry);
|
||||
scheduleExpiry(entry.staleUntil);
|
||||
if (canStoreInMemory) storeInMemory(key, entry, requestGeneration);
|
||||
}
|
||||
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 {
|
||||
const cleanupAsync = async () => {
|
||||
refreshLockRenewal?.stop();
|
||||
if (lockToken) await sharedCache?.releaseRefreshLockAsync(lockToken);
|
||||
};
|
||||
if (upstreamSettledAsync) {
|
||||
const settlementGraceMs = Math.min(requestTimeoutMs, MAX_UPSTREAM_SETTLEMENT_GRACE_MS);
|
||||
cleanupAfterUpstreamAsync = waitForSettlementGraceAsync(upstreamSettledAsync, settlementGraceMs).then(
|
||||
cleanupAsync,
|
||||
);
|
||||
} else {
|
||||
await cleanupAsync();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
inflight.set(key, promise);
|
||||
inflight.set(inflightKey, promise);
|
||||
const removeInflight = () => {
|
||||
const cleanupAsync = cleanupAfterUpstreamAsync ?? Promise.resolve();
|
||||
void cleanupAsync.then(
|
||||
() => {
|
||||
if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey);
|
||||
},
|
||||
() => {
|
||||
if (inflight.get(inflightKey) === promise) inflight.delete(inflightKey);
|
||||
},
|
||||
);
|
||||
};
|
||||
void promise.then(removeInflight, removeInflight);
|
||||
return promise;
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
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" });
|
||||
// The dedicated cache client rejects commands after 500 ms. This guard stays
|
||||
// above that deadline so lock outcomes are never abandoned while still pending.
|
||||
const REDIS_OPERATION_TIMEOUT_MS = 750;
|
||||
const REFRESH_LOCK_TTL_SECONDS = 15;
|
||||
const SHARED_CACHE_ENVELOPE_VERSION = 2;
|
||||
const DEFAULT_HANDLER_CACHE_VERSION = "v1";
|
||||
const namespacePattern = /^[a-z0-9][a-z0-9:-]*$/;
|
||||
const cacheVersionPattern = /^[a-z0-9][a-z0-9._-]*$/;
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const canonicalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(canonicalize);
|
||||
if (!isRecord(value)) return value;
|
||||
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).toSorted()) {
|
||||
result[key] = canonicalize(value[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));
|
||||
});
|
||||
};
|
||||
|
||||
interface SharedCacheEnvelope<TData> {
|
||||
version: typeof SHARED_CACHE_ENVELOPE_VERSION;
|
||||
entry: CacheEntry<TData>;
|
||||
}
|
||||
|
||||
const isSharedCacheEnvelope = (value: unknown): value is SharedCacheEnvelope<unknown> => {
|
||||
if (!isRecord(value) || value.version !== SHARED_CACHE_ENVELOPE_VERSION || !isRecord(value.entry)) return false;
|
||||
|
||||
const entry = value.entry;
|
||||
if (!Object.hasOwn(entry, "data") || !(entry.timestamp instanceof Date)) return false;
|
||||
const timestamp = entry.timestamp.getTime();
|
||||
return (
|
||||
Number.isFinite(timestamp) &&
|
||||
typeof entry.expiresAt === "number" &&
|
||||
Number.isFinite(entry.expiresAt) &&
|
||||
typeof entry.staleUntil === "number" &&
|
||||
Number.isFinite(entry.staleUntil) &&
|
||||
timestamp <= entry.expiresAt &&
|
||||
entry.expiresAt <= entry.staleUntil
|
||||
);
|
||||
};
|
||||
|
||||
interface IntegrationSharedCacheOptions {
|
||||
namespace: string;
|
||||
integrationId: string;
|
||||
cacheIdentity: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
interface RedisSharedCacheOptions {
|
||||
namespace: string;
|
||||
cacheKind: "integration" | "widget";
|
||||
cacheIdentity: string;
|
||||
cacheVersion: string;
|
||||
generation: string;
|
||||
metadata?: Record<string, string>;
|
||||
isShared?: boolean;
|
||||
}
|
||||
|
||||
const logRedisFailure = (operation: string, metadata: Record<string, unknown>, error: unknown) => {
|
||||
logger.warn(
|
||||
new ErrorWithMetadata(
|
||||
"Redis response cache operation failed",
|
||||
{
|
||||
...metadata,
|
||||
operation,
|
||||
},
|
||||
{ cause: error },
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const createRedisSharedCache = <TData>({
|
||||
namespace,
|
||||
cacheKind,
|
||||
cacheIdentity,
|
||||
cacheVersion,
|
||||
generation,
|
||||
metadata = {},
|
||||
isShared: initialIsShared = true,
|
||||
}: RedisSharedCacheOptions): SharedCacheAdapter<TData> => {
|
||||
const logMetadata = { cacheNamespace: namespace, cacheKind, ...metadata };
|
||||
const payloadName = `${cacheKind}-cache:payload:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
|
||||
const lockName = `${cacheKind}-cache:lock:v2:${cacheVersion}:${namespace}:${generation}:${cacheIdentity}`;
|
||||
const payloadChannel = createGetSetChannel<unknown>(payloadName, { useBoundedCacheClient: true });
|
||||
const lock = createLockChannel(lockName, { useBoundedCacheClient: true });
|
||||
let isShared = initialIsShared;
|
||||
|
||||
const runRedisOperationAsync = async <T>(operation: string, callback: () => Promise<T>, fallback: T): Promise<T> => {
|
||||
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 (isSharedCacheEnvelope(value)) return value.entry as CacheEntry<TData>;
|
||||
|
||||
logger.warn("Ignored invalid Redis response cache entry", {
|
||||
...logMetadata,
|
||||
operation: "payload-validate",
|
||||
});
|
||||
await runRedisOperationAsync("payload-remove", () => payloadChannel.removeAsync(), undefined);
|
||||
return null;
|
||||
},
|
||||
setAsync: async (entry, refreshLockToken) => {
|
||||
const retentionMs = entry.staleUntil - Date.now();
|
||||
if (retentionMs <= 0) return false;
|
||||
const envelope: SharedCacheEnvelope<TData> = {
|
||||
version: SHARED_CACHE_ENVELOPE_VERSION,
|
||||
entry,
|
||||
};
|
||||
return await runRedisOperationAsync(
|
||||
"payload-write",
|
||||
() => lock.setIfOwnedAsync(refreshLockToken, payloadName, envelope, retentionMs),
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
acquireRefreshLockAsync: async () =>
|
||||
await runRedisOperationAsync(
|
||||
"refresh-lock-acquire",
|
||||
() => lock.acquireAsync(REFRESH_LOCK_TTL_SECONDS),
|
||||
undefined,
|
||||
),
|
||||
renewRefreshLockAsync: async (token) =>
|
||||
await runRedisOperationAsync(
|
||||
"refresh-lock-renew",
|
||||
() => lock.renewAsync(token, REFRESH_LOCK_TTL_SECONDS),
|
||||
undefined,
|
||||
),
|
||||
releaseRefreshLockAsync: async (token) => {
|
||||
try {
|
||||
await withTimeoutAsync(lock.releaseAsync(token), REDIS_OPERATION_TIMEOUT_MS);
|
||||
} catch (error) {
|
||||
logRedisFailure("refresh-lock-release", logMetadata, error);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
interface WidgetSharedCacheOptions {
|
||||
namespace: string;
|
||||
cacheIdentity: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
export const createWidgetSharedCacheAsync = async <TData>({
|
||||
namespace,
|
||||
cacheIdentity,
|
||||
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
|
||||
}: WidgetSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
|
||||
if (typeof window !== "undefined") throw new Error("Widget request caching is server-only");
|
||||
if (!namespacePattern.test(namespace)) throw new Error(`Invalid widget cache namespace '${namespace}'`);
|
||||
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid widget cache version '${cacheVersion}'`);
|
||||
|
||||
const metadata = { cacheNamespace: namespace };
|
||||
let generation: WidgetCacheGeneration;
|
||||
try {
|
||||
generation = await getWidgetCacheGenerationAsync(namespace);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
}
|
||||
|
||||
return createRedisSharedCache<TData>({
|
||||
namespace,
|
||||
cacheKind: "widget",
|
||||
cacheIdentity,
|
||||
cacheVersion,
|
||||
generation: generation.value,
|
||||
isShared: generation.isShared,
|
||||
});
|
||||
};
|
||||
|
||||
export const createIntegrationSharedCacheAsync = async <TData>({
|
||||
namespace,
|
||||
integrationId,
|
||||
cacheIdentity,
|
||||
cacheVersion = DEFAULT_HANDLER_CACHE_VERSION,
|
||||
}: IntegrationSharedCacheOptions): Promise<SharedCacheAdapter<TData>> => {
|
||||
if (typeof window !== "undefined") throw new Error("Integration request caching is server-only");
|
||||
if (!namespacePattern.test(namespace)) throw new Error(`Invalid integration cache namespace '${namespace}'`);
|
||||
if (!cacheVersionPattern.test(cacheVersion)) throw new Error(`Invalid integration cache version '${cacheVersion}'`);
|
||||
|
||||
const metadata = { cacheNamespace: namespace, integrationId };
|
||||
let generation: IntegrationCacheGeneration;
|
||||
try {
|
||||
generation = await getIntegrationCacheGenerationAsync(integrationId);
|
||||
} catch (error) {
|
||||
logRedisFailure("generation-read", metadata, error);
|
||||
generation = { value: "redis-unavailable", isShared: false };
|
||||
}
|
||||
|
||||
const sharedCache = createRedisSharedCache<TData>({
|
||||
namespace,
|
||||
cacheKind: "integration",
|
||||
cacheIdentity,
|
||||
cacheVersion,
|
||||
generation: generation.value,
|
||||
metadata: { integrationId },
|
||||
isShared: generation.isShared,
|
||||
});
|
||||
return sharedCache;
|
||||
};
|
||||
@@ -1,15 +1,47 @@
|
||||
import { invalidateWidgetCache } from "@homarr/redis";
|
||||
|
||||
import { createWidgetSharedCacheAsync, hashSharedCacheInput } from "./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>;
|
||||
requestAsync: (input: TInput, signal: AbortSignal) => Promise<TData>;
|
||||
cacheTtlMs?: number;
|
||||
fallbackToStaleOnError?: boolean;
|
||||
staleIfErrorTtlMs?: number;
|
||||
requestTimeoutMs?: number;
|
||||
cacheNamespace?: string;
|
||||
cacheVersion?: string;
|
||||
}
|
||||
|
||||
export const createWidgetRequestHandler = <TData, TInput extends Record<string, unknown>>(
|
||||
options: Options<TData, TInput>,
|
||||
) => {
|
||||
const inner = createRequestHandler<TData, TInput>(options);
|
||||
return inner;
|
||||
const cacheNamespace = options.cacheNamespace;
|
||||
let getSharedCacheAsync: ((input: TInput, cacheIdentity: string) => Promise<SharedCacheAdapter<TData>>) | undefined;
|
||||
if (cacheNamespace) {
|
||||
getSharedCacheAsync = async (_input, cacheIdentity) =>
|
||||
await createWidgetSharedCacheAsync<TData>({
|
||||
namespace: cacheNamespace,
|
||||
cacheIdentity,
|
||||
cacheVersion: options.cacheVersion,
|
||||
});
|
||||
}
|
||||
|
||||
const inner = createRequestHandler<TData, TInput>({
|
||||
...options,
|
||||
cacheTtlMs: options.cacheTtlMs,
|
||||
fallbackToStaleOnError: options.fallbackToStaleOnError,
|
||||
staleIfErrorTtlMs: options.staleIfErrorTtlMs,
|
||||
requestTimeoutMs: options.requestTimeoutMs,
|
||||
getCacheKey: hashSharedCacheInput,
|
||||
getSharedCacheAsync,
|
||||
});
|
||||
return {
|
||||
invalidateCache: () => {
|
||||
inner.invalidateCache();
|
||||
if (cacheNamespace) invalidateWidgetCache(cacheNamespace);
|
||||
},
|
||||
handler: inner.handler,
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { IntegrationKindByCategory } from "@homarr/definitions";
|
||||
import { createIntegrationAsync } from "@homarr/integrations";
|
||||
import type { IMediaOrganizerIntegration, MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
import { createIntegrationAsync } from "@homarr/integrations/factory";
|
||||
import type { MissingMediaItem, QueuedMediaItem } from "@homarr/integrations/types";
|
||||
|
||||
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
|
||||
|
||||
@@ -16,8 +16,9 @@ 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 integrationInstance = await createIntegrationAsync(integration);
|
||||
const [missingResult, queueResult] = await Promise.all([
|
||||
integrationInstance.getMissingAsync(input.pageSize),
|
||||
integrationInstance.getMediaQueueAsync(input.pageSize),
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
export const minecraftServerStatusRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { domain: string; isBedrockServer: boolean }) {
|
||||
// Domains are supplied through a public procedure and form an unbounded key space.
|
||||
requestTimeoutMs: 10_000,
|
||||
async requestAsync(input: { domain: string; isBedrockServer: boolean }, signal) {
|
||||
const path = `${input.isBedrockServer ? "/bedrock" : ""}/3/${input.domain}`;
|
||||
|
||||
const response = await withTimeoutAsync(async (signal) =>
|
||||
fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal }),
|
||||
);
|
||||
const response = await fetchWithTrustedCertificatesAsync(`https://api.mcsrvstat.us${path}`, { signal });
|
||||
return responseSchema.parse(await response.json());
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,18 +10,23 @@ import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
const logger = createLogger({ module: "rssFeedsRequestHandler" });
|
||||
|
||||
export const rssFeedsRequestHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { url: string; count: number }) {
|
||||
const result = (await extract(input.url, {
|
||||
getExtraEntryFields: (feedEntry) => {
|
||||
const media = attemptGetImageFromEntry(input.url, feedEntry);
|
||||
if (!media) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
enclosure: media,
|
||||
};
|
||||
// Authorized editors can supply arbitrary URLs, so keep this cache process-local and bounded.
|
||||
async requestAsync(input: { url: string; count: number }, signal) {
|
||||
const result = (await extract(
|
||||
input.url,
|
||||
{
|
||||
getExtraEntryFields: (feedEntry) => {
|
||||
const media = attemptGetImageFromEntry(input.url, feedEntry);
|
||||
if (!media) {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
enclosure: media,
|
||||
};
|
||||
},
|
||||
},
|
||||
})) as ExtendedFeedData;
|
||||
{ signal },
|
||||
)) as ExtendedFeedData;
|
||||
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { withTimeoutAsync } from "@homarr/core/infrastructure/http/timeout";
|
||||
|
||||
import { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
export const fetchStockPriceHandler = createWidgetRequestHandler({
|
||||
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }) {
|
||||
const response = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`,
|
||||
{ signal },
|
||||
);
|
||||
});
|
||||
// Symbols are supplied through a public procedure and form an unbounded key space.
|
||||
requestTimeoutMs: 10_000,
|
||||
async requestAsync(input: { stock: string; timeRange: string; timeInterval: string }, signal) {
|
||||
const response = await fetchWithTrustedCertificatesAsync(
|
||||
`https://query1.finance.yahoo.com/v8/finance/chart/${input.stock}?range=${input.timeRange}&interval=${input.timeInterval}`,
|
||||
{ signal },
|
||||
);
|
||||
const data = dataSchema.parse(await response.json());
|
||||
|
||||
if ("error" in data) {
|
||||
|
||||
@@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
|
||||
createIntegrationAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/integrations", () => ({
|
||||
vi.mock("@homarr/integrations/factory", () => ({
|
||||
createIntegrationAsync: mocks.createIntegrationAsync,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 { createWidgetRequestHandler } from "./lib/widget-request-handler";
|
||||
|
||||
@@ -15,7 +14,10 @@ dayjs.extend(timezone);
|
||||
const HOURLY_FORECAST_LENGTH = 24;
|
||||
const DAILY_FORECAST_LENGTH = 7;
|
||||
|
||||
const requestWeatherAsync = async (input: { latitude: number; longitude: number }): Promise<Weather> => {
|
||||
const requestWeatherAsync = async (
|
||||
input: { latitude: number; longitude: number },
|
||||
signal: AbortSignal,
|
||||
): Promise<Weather> => {
|
||||
const url = new URL("https://api.open-meteo.com/v1/forecast");
|
||||
url.searchParams.set("latitude", input.latitude.toString());
|
||||
url.searchParams.set("longitude", input.longitude.toString());
|
||||
@@ -68,9 +70,7 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number
|
||||
url.searchParams.set("forecast_hours", HOURLY_FORECAST_LENGTH.toString());
|
||||
url.searchParams.set("forecast_days", DAILY_FORECAST_LENGTH.toString());
|
||||
|
||||
const res = await withTimeoutAsync(async (signal) => {
|
||||
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
});
|
||||
const res = await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
|
||||
if (!res.ok) throw new ResponseError(res);
|
||||
|
||||
const json: unknown = await res.json();
|
||||
@@ -140,7 +140,9 @@ const requestWeatherAsync = async (input: { latitude: number; longitude: number
|
||||
};
|
||||
|
||||
export const weatherRequestHandler = createWidgetRequestHandler({
|
||||
// Coordinates are a public, continuous key space. Keep this cache process-local and bounded.
|
||||
cacheTtlMs: 5 * 60 * 1000,
|
||||
requestTimeoutMs: 10_000,
|
||||
requestAsync: requestWeatherAsync,
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user